diff --git a/.github/workflows/pullreq.yml b/.github/workflows/pullreq.yml index cfdc4872..8c02f387 100644 --- a/.github/workflows/pullreq.yml +++ b/.github/workflows/pullreq.yml @@ -177,7 +177,7 @@ jobs: - name: Get Deps if: ${{ matrix.run_aptget }} - run: sudo apt-get install -y alsa alsa-tools libasound2-dev libjack-dev libgtk-3-dev + run: sudo apt-get install -y alsa alsa-tools libasound2-dev libjack-dev libpulse-dev libx11-dev libgtk-3-dev #- name: Install Ninja # if: ${{ matrix.install_ninja }} @@ -261,7 +261,7 @@ jobs: - name: Get Deps if: ${{ matrix.run_aptget }} - run: sudo apt-get install -y alsa alsa-tools libasound2-dev libjack-dev libgtk-3-dev + run: sudo apt-get install -y alsa alsa-tools libasound2-dev libjack-dev libpulse-dev libx11-dev libgtk-3-dev - name: Install Ninja if: ${{ matrix.install_ninja }} diff --git a/README.md b/README.md index 034ad78b..efeed827 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,62 @@ is a complete standalone synth you can release. See [docs/ios.md](docs/ios.md) for the full iOS instructions. +### The Linux standalone + +The Linux standalone is configured from the command line rather than from a +settings window; `--help` lists everything, and the useful ones are: + +``` +--audio-api alsa, pulse, jack, pipewire (an alias for pulse), auto +--output-device a device name, part of one, or an id from --list-devices +--input-device +--no-input output only, even for a plugin with an audio input +--sample-rate +--buffer-size +--midi-input a port name, part of one, or an index; repeatable +--no-midi bind no MIDI input at all +--no-gui run without a window; end it with ^C +--list-apis backends this build has, and what each one can see +--list-devices audio devices for the chosen (or default) api +--list-midi-inputs MIDI input ports, and which ones would be opened +``` + +Device and port *names* are the thing to pass: the numeric ids RtAudio reports +are per-run handles, not stable identifiers — the same card can be `[130]` in +one listing and `[131]` in the next. A name is matched exactly if it can be and +otherwise as a unique fragment, so `--output-device HDMI` will usually do. + +Every MIDI input port is opened unless `--midi-input` names the ones you want. + +These flags are overrides on top of the persisted standalone settings, and are +not written back to them: a flag configures one run. A device, rate or port +which was named and does not exist is a startup error (exit 5) rather than +something quietly replaced with a default. + +By default the standalone prefers PulseAudio, then JACK, then ALSA, taking the +first which actually has a device — RtAudio's own order would settle on raw +ALSA every time, since ALSA always has devices. PulseAudio is also how a +PipeWire graph is reached: RtAudio 6.0.1 has no native PipeWire backend, and +`--audio-api pipewire` is an alias for `pulse` for that reason. + +Which backends are available is a build-time decision, reported at configure +time and controlled by `CLAP_WRAPPER_STANDALONE_LINUX_ALSA`, `_PULSE` and +`_JACK`. They default to what pkg-config can find, so **install +`libpulse-dev` before configuring** or the build has no PulseAudio and hence +no PipeWire. `CLAP_WRAPPER_STANDALONE_LINUX_JACK` wants `libjack-dev`. + +The GUI is X11, which is how it appears under XWayland too; there is no +native Wayland support yet. `-DCLAP_WRAPPER_STANDALONE_X11_GUI=OFF` builds a +standalone with no window and no X11 dependency at all, and needs +`libx11-dev` when it is on. SIGINT/SIGTERM shut the standalone down in order, +and a second one exits immediately. + +Shutdown also has a five second watchdog, because it can wedge somewhere we +cannot reach: RtAudio's ALSA backend holds the stream mutex across the blocking +`snd_pcm_readi()` of a duplex stream, so if the capture side stops producing — +which a PipeWire or dmix capture device does readily — nothing can stop the +stream and the process would otherwise have to be killed by hand. + ## Licensing The `clap-wrapper` project is released under the MIT license. diff --git a/cmake/base_sdks.cmake b/cmake/base_sdks.cmake index afdb0cd3..9203aa54 100644 --- a/cmake/base_sdks.cmake +++ b/cmake/base_sdks.cmake @@ -433,6 +433,74 @@ function(guarantee_rtaudio) set(RTAUDIO_API_JACK FALSE CACHE STRING "No jack by default on macos") endif() + if (UNIX AND NOT APPLE) + # Which RtAudio backends the standalone ends up with was an invisible + # function of which dev packages the build machine happened to have - + # which is how CI came to ship Linux binaries with no PulseAudio, and so + # no PipeWire either, leaving raw ALSA as the only option. Decide it + # explicitly, surface it as options, and say what we decided. + find_package(PkgConfig QUIET) + set(_cw_pulse_avail FALSE) + set(_cw_jack_avail FALSE) + if (PKG_CONFIG_FOUND) + pkg_check_modules(CW_PULSE QUIET libpulse-simple) + pkg_check_modules(CW_JACK QUIET jack) + if (CW_PULSE_FOUND) + set(_cw_pulse_avail TRUE) + endif() + if (CW_JACK_FOUND) + set(_cw_jack_avail TRUE) + endif() + endif() + + # An RTAUDIO_API_* already in the cache is a consumer being explicit, so + # let that be the default of our option rather than overriding it + if (DEFINED RTAUDIO_API_PULSE) + set(_cw_pulse_default ${RTAUDIO_API_PULSE}) + else() + set(_cw_pulse_default ${_cw_pulse_avail}) + endif() + if (DEFINED RTAUDIO_API_JACK) + set(_cw_jack_default ${RTAUDIO_API_JACK}) + else() + set(_cw_jack_default ${_cw_jack_avail}) + endif() + if (DEFINED RTAUDIO_API_ALSA) + set(_cw_alsa_default ${RTAUDIO_API_ALSA}) + else() + set(_cw_alsa_default TRUE) + endif() + + option(CLAP_WRAPPER_STANDALONE_LINUX_ALSA + "Standalone: build the RtAudio ALSA backend" ${_cw_alsa_default}) + option(CLAP_WRAPPER_STANDALONE_LINUX_PULSE + "Standalone: build the RtAudio PulseAudio backend, which is also how you reach PipeWire" ${_cw_pulse_default}) + option(CLAP_WRAPPER_STANDALONE_LINUX_JACK + "Standalone: build the RtAudio JACK backend" ${_cw_jack_default}) + + set(RTAUDIO_API_ALSA ${CLAP_WRAPPER_STANDALONE_LINUX_ALSA} CACHE BOOL "clap-wrapper: RtAudio ALSA backend" FORCE) + set(RTAUDIO_API_PULSE ${CLAP_WRAPPER_STANDALONE_LINUX_PULSE} CACHE BOOL "clap-wrapper: RtAudio PulseAudio backend" FORCE) + set(RTAUDIO_API_JACK ${CLAP_WRAPPER_STANDALONE_LINUX_JACK} CACHE BOOL "clap-wrapper: RtAudio JACK backend" FORCE) + + if (CLAP_WRAPPER_STANDALONE_LINUX_PULSE AND NOT _cw_pulse_avail) + message(WARNING "clap-wrapper: the PulseAudio backend is enabled but pkg-config cannot find " + "libpulse-simple, so expect a link error. Install libpulse-dev (debian/ubuntu) or " + "pulseaudio-libs-devel (fedora), or configure with -DCLAP_WRAPPER_STANDALONE_LINUX_PULSE=OFF") + elseif (NOT CLAP_WRAPPER_STANDALONE_LINUX_PULSE) + message(WARNING "clap-wrapper: building the standalone with no PulseAudio backend, and so no " + "PipeWire either - it will fall back to raw ALSA. Install libpulse-dev (debian/ubuntu) " + "or pulseaudio-libs-devel (fedora) and reconfigure.") + endif() + + if (NOT CLAP_WRAPPER_STANDALONE_LINUX_JACK) + message(STATUS "clap-wrapper: building the standalone with no JACK backend. Install libjack-dev " + "(or libjack-jackd2-dev) and reconfigure if you want one.") + endif() + + message(STATUS "clap-wrapper: standalone Linux audio backends: " + "alsa=${CLAP_WRAPPER_STANDALONE_LINUX_ALSA} pulse=${CLAP_WRAPPER_STANDALONE_LINUX_PULSE} jack=${CLAP_WRAPPER_STANDALONE_LINUX_JACK}") + endif() + if (NOT "${RTAUDIO_SDK_ROOT}" STREQUAL "") # Use the provided root elseif (${CLAP_WRAPPER_DOWNLOAD_DEPENDENCIES}) diff --git a/cmake/wrap_standalone.cmake b/cmake/wrap_standalone.cmake index c3b18cd1..a98a5a6c 100644 --- a/cmake/wrap_standalone.cmake +++ b/cmake/wrap_standalone.cmake @@ -1,4 +1,10 @@ +# The Linux standalone GUI is X11, which is also how it appears under XWayland. +# Turning this off builds a standalone with no window at all - audio, MIDI, the +# command line and plugin timers all still work - and needs no X11 development +# files present. +option(CLAP_WRAPPER_STANDALONE_X11_GUI "Build the X11 GUI for the Linux standalone" ON) + function(target_add_standalone_wrapper) set(oneValueArgs TARGET @@ -175,10 +181,32 @@ function(target_add_standalone_wrapper) target_sources(${SA_TARGET} PRIVATE ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/wrapasstandalone.cpp) - message(STATUS "clap-wrapper: Using Standalone X11 gui for CLAP Wrapper") - target_link_libraries(${salib} PUBLIC X11) - target_compile_definitions(${salib} PUBLIC CLAP_WRAPPER_STANDALONE_X11) - target_sources(${salib} PRIVATE ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/detail/standalone/linux/x11_gui.cpp) + # Not the GUI: error reporting and orderly shutdown, needed with or + # without X11 + find_package(Threads REQUIRED) + target_link_libraries(${salib} PUBLIC Threads::Threads) + target_sources(${salib} PRIVATE + ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/detail/standalone/linux/linux_frontend.cpp + ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/detail/standalone/linux/linux_command_line.cpp) + + if (CLAP_WRAPPER_STANDALONE_X11_GUI) + # Rather than linking a bare 'X11' and letting a missing libx11-dev + # turn up as a raw linker error + find_package(X11) + if (NOT X11_FOUND) + message(FATAL_ERROR "clap-wrapper: the standalone X11 GUI needs the X11 development " + "files, which were not found. Install them (libx11-dev on debian/ubuntu, " + "libX11-devel on fedora, libx11 on arch) or configure with " + "-DCLAP_WRAPPER_STANDALONE_X11_GUI=OFF for a standalone with no window.") + endif() + + message(STATUS "clap-wrapper: Using Standalone X11 gui for CLAP Wrapper") + target_link_libraries(${salib} PUBLIC X11::X11) + target_compile_definitions(${salib} PUBLIC CLAP_WRAPPER_STANDALONE_X11) + target_sources(${salib} PRIVATE ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/detail/standalone/linux/x11_gui.cpp) + else() + message(STATUS "clap-wrapper: Standalone X11 gui disabled; the standalone will run without a window") + endif() set_target_properties(${SA_TARGET} PROPERTIES OUTPUT_NAME ${SA_OUTPUT_NAME}) diff --git a/src/detail/clap/fsutil.cpp b/src/detail/clap/fsutil.cpp index c012fe12..54c8faa9 100644 --- a/src/detail/clap/fsutil.cpp +++ b/src/detail/clap/fsutil.cpp @@ -25,6 +25,8 @@ #if LIN #include #include +#include +#include #endif #include "../os/osutil.h" @@ -76,7 +78,21 @@ std::vector getValidCLAPSearchPaths() #if LIN res.emplace_back("/usr/lib/clap"); - res.emplace_back(fs::path(getenv("HOME")) / fs::path(".clap")); + res.emplace_back("/usr/local/lib/clap"); + + { + // HOME is not guaranteed to be set - in a systemd unit or a bare 'su' it + // often isn't - and handing a null to fs::path is undefined behaviour, so + // fall back to the passwd entry and skip the user directory if even that + // has nothing for us. + auto home = getenv("HOME"); + if (!home || !*home) + { + auto pw = getpwuid(getuid()); + home = (pw && pw->pw_dir && *pw->pw_dir) ? pw->pw_dir : nullptr; + } + if (home) res.emplace_back(fs::path(home) / fs::path(".clap")); + } #endif #if WIN @@ -109,16 +125,19 @@ std::vector getValidCLAPSearchPaths() } auto sep = ':'; - if (cp.empty()) + // This condition used to be inverted, which made the whole of CLAP_PATH dead + // code: the only way in was an empty CLAP_PATH, which then had nothing to + // split. + if (!cp.empty()) { size_t pos; while ((pos = cp.find(sep)) != std::string::npos) { auto item = cp.substr(0, pos); cp = cp.substr(pos + 1); - res.emplace_back(item); + if (!item.empty() && fs::exists(item)) res.emplace_back(item); } - if (!cp.empty()) res.emplace_back(cp); + if (!cp.empty() && fs::exists(cp)) res.emplace_back(cp); } #endif diff --git a/src/detail/standalone/linux/linux_command_line.cpp b/src/detail/standalone/linux/linux_command_line.cpp new file mode 100644 index 00000000..987537ba --- /dev/null +++ b/src/detail/standalone/linux/linux_command_line.cpp @@ -0,0 +1,610 @@ +#include "linux_command_line.h" +#include "linux_frontend.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __GNUC__ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wall" // other peoples errors are outside my scope +#endif + +#include "RtAudio.h" + +#ifdef __GNUC__ +#pragma GCC diagnostic pop +#endif + +#include "detail/standalone/standalone_details.h" +#include "detail/standalone/standalone_host.h" +#include "detail/standalone/entry.h" + +namespace freeaudio::clap_wrapper::standalone::linux_standalone +{ +namespace +{ +bool isAllDigits(const std::string &s) +{ + return !s.empty() && + std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); +} + +void usage(const std::string &programName) +{ + fprintf(stdout, + "%s - a CLAP plugin as a standalone application.\n" + "\n" + "Usage: %s [options]\n" + "\n" + "Audio:\n" + " --audio-api alsa, pulse, jack, pipewire (an alias for pulse,\n" + " which is how a PipeWire graph is reached), or auto.\n" + " Default: the first of pulse, jack, alsa which has a\n" + " device.\n" + " --output-device Device name, part of a name, or an id from\n" + " --input-device --list-devices. Default: the system default device.\n" + " --no-input Open output only, even for a plugin with an audio\n" + " input.\n" + " --sample-rate Default: whatever the device is running at.\n" + " --buffer-size Default: 256.\n" + "\n" + "MIDI:\n" + " --midi-input Port name, part of a name, or an index from\n" + " --list-midi-inputs. Repeat it for more than one\n" + " port. Default: every port is bound.\n" + " --no-midi Bind no MIDI input at all.\n" + "\n" + "Window:\n" + " --no-gui Run without a window. Audio, MIDI and the plugin's\n" + " own timers still run; end it with ^C.\n" + "\n" + "Information:\n" + " --list-apis Audio backends this build has, and what each sees.\n" + " --list-devices Audio devices for the chosen (or default) api.\n" + " --list-midi-inputs MIDI input ports, and which ones would be opened.\n" + " --version\n" + " --help\n" + "\n" + "Device and port names are the stable way to name one: the numeric ids are\n" + "handles which can differ between runs. A name is matched exactly if it can be,\n" + "and otherwise as a unique fragment, so --output-device HDMI is usually enough.\n" + "\n" + "These options override the persisted settings for this run only; they are not\n" + "written back to the settings file.\n", + programName.c_str(), programName.c_str()); +} + +void printVersion(const std::string &programName) +{ +#ifdef CLAP_WRAPPER_VERSION + fprintf(stdout, "%s - CLAP standalone, clap-wrapper %s\n", programName.c_str(), CLAP_WRAPPER_VERSION); +#else + fprintf(stdout, "%s - CLAP standalone\n", programName.c_str()); +#endif +} + +std::string ratesToString(const std::vector &rates) +{ + std::string res; + for (auto r : rates) + { + if (!res.empty()) res += " "; + res += std::to_string(r); + } + return res.empty() ? std::string("(none reported)") : res; +} + +void listApis() +{ + fprintf(stdout, "Audio APIs in this build:\n"); + for (auto api : compiledAudioApis()) + { + if (api == RtAudio::Api::RTAUDIO_DUMMY) continue; + + // Count what each one can see: a backend with no devices is one whose server + // isn't running, which is worth saying out loud. Same probe the automatic + // selection uses, so the two cannot disagree. + unsigned int outs{0}, ins{0}; + probeApiDeviceCounts(api, outs, ins); + + fprintf(stdout, " %-8s %-12s %u output, %u input device(s)%s\n", RtAudio::getApiName(api).c_str(), + RtAudio::getApiDisplayName(api).c_str(), outs, ins, + (outs == 0 && ins == 0) ? " (nothing there - is the server running?)" : ""); + } +} + +void listDevices(const std::string &requestedApi) +{ + auto api = resolveAudioApiName(requestedApi); + + try + { + RtAudio rta(api, [](RtAudioErrorType, const std::string &msg) + { fprintf(stderr, "[ERROR] %s\n", msg.c_str()); }); + + fprintf(stdout, "Audio api: %s (%s)\n\n", RtAudio::getApiDisplayName(rta.getCurrentApi()).c_str(), + RtAudio::getApiName(rta.getCurrentApi()).c_str()); + + auto ids = rta.getDeviceIds(); + if (ids.empty()) + { + fprintf(stdout, "No devices.\n"); + return; + } + + for (auto forInput : {false, true}) + { + fprintf(stdout, "%s devices:\n", forInput ? "Input" : "Output"); + bool any{false}; + for (auto id : ids) + { + auto info = rta.getDeviceInfo(id); + auto channels = forInput ? info.inputChannels : info.outputChannels; + if (channels == 0) continue; + any = true; + + auto isDefault = forInput ? info.isDefaultInput : info.isDefaultOutput; + fprintf(stdout, " [%u] %s%s\n", info.ID, info.name.c_str(), isDefault ? " (default)" : ""); + fprintf(stdout, " %u channel(s), current rate %u, rates: %s\n", channels, + info.currentSampleRate, ratesToString(info.sampleRates).c_str()); + } + if (!any) fprintf(stdout, " (none)\n"); + } + } + catch (const std::exception &e) + { + fprintf(stderr, "[ERROR] Unable to enumerate audio devices : %s\n", e.what()); + } +} + +bool resolveByNameOrNumber(const std::vector &names, const std::vector &ids, + const std::string &spec, const std::string &what, std::string &resolved); + +// MIDI ports have no ids, so the number in a spec is the position in the list +bool resolveMidiPort(const std::vector &ports, const std::string &spec, + std::string &resolved) +{ + return resolveByNameOrNumber(ports, {}, spec, "MIDI input", resolved); +} + +void listMidiInputs(const CommandLineOptions &opts) +{ + auto ports = getStandaloneHost()->getMidiPortNames(); + + if (opts.noMidi) + fprintf(stdout, "MIDI input ports (--no-midi, so none is opened):\n"); + else if (opts.midiInputs.empty()) + fprintf(stdout, "MIDI input ports (all are opened):\n"); + else + fprintf(stdout, "MIDI input ports ('*' marks the ones --midi-input picks):\n"); + + if (ports.empty()) fprintf(stdout, " (none)\n"); + + // Resolve the selection here too, so --list-midi-inputs is also how you check + // that what you are about to pass actually matches something + std::vector selected; + for (const auto &spec : opts.midiInputs) + { + std::string name; + if (resolveMidiPort(ports, spec, name)) selected.push_back(name); + } + + for (unsigned int i = 0; i < ports.size(); ++i) + { + auto picked = std::find(selected.begin(), selected.end(), ports[i]) != selected.end(); + fprintf(stdout, " %s [%u] %s\n", picked ? "*" : " ", i, ports[i].c_str()); + } +} + +/* + * Match what the user typed against a list of names: an exact name, a unique + * fragment of one, or a number. + * + * The number means different things for the two kinds of list, so `ids` says + * which: non-empty, it holds the RtAudio device id for each name - the number + * --list-devices prints - and the spec is matched against those; empty, the + * number is a position in the list, which is how MIDI ports are numbered. + * + * What comes back is always a *name*, because that is what the settings layer + * stores and matches on: RtAudio 6 device ids are per-instance enumeration + * handles, not stable identifiers, and the same card really does come out as + * [130] in one listing and [131] in the next. Failure puts the reason and the + * available names on stderr. + */ +bool resolveByNameOrNumber(const std::vector &names, const std::vector &ids, + const std::string &spec, const std::string &what, std::string &resolved) +{ + auto numberFor = [&](size_t i) { return ids.empty() ? (unsigned long)i : (unsigned long)ids[i]; }; + + auto complain = [&](const std::string &why) + { + fprintf(stderr, "[ERROR] %s '%s': %s\n", what.c_str(), spec.c_str(), why.c_str()); + fprintf(stderr, " Available %ss:\n", what.c_str()); + if (names.empty()) fprintf(stderr, " (none)\n"); + for (size_t i = 0; i < names.size(); ++i) + { + fprintf(stderr, " [%lu] %s\n", numberFor(i), names[i].c_str()); + } + }; + + if (isAllDigits(spec)) + { + auto asNumber = strtoul(spec.c_str(), nullptr, 10); + for (size_t i = 0; i < names.size(); ++i) + { + if (numberFor(i) == asNumber) + { + resolved = names[i]; + return true; + } + } + complain(ids.empty() ? "nothing has that index" : "nothing has that id"); + return false; + } + + auto needle = lowercased(spec); + + for (const auto &name : names) + { + if (lowercased(name) == needle) + { + resolved = name; + return true; + } + } + + std::vector partial; + for (const auto &name : names) + { + if (lowercased(name).find(needle) != std::string::npos) partial.push_back(&name); + } + + if (partial.size() == 1) + { + resolved = *partial.front(); + return true; + } + if (partial.empty()) + { + complain("nothing matches that name"); + return false; + } + + std::string matches; + for (auto *name : partial) + { + if (!matches.empty()) matches += ", "; + matches += "'" + *name + "'"; + } + complain("matches more than one: " + matches); + return false; +} + +// The audio side of the above, which is the one with ids to carry +bool resolveAudioDevice(const std::vector &devices, const std::string &spec, + bool forInput, std::string &resolved) +{ + std::vector names; + std::vector ids; + for (const auto &d : devices) + { + names.push_back(d.name); + ids.push_back(d.ID); + } + + return resolveByNameOrNumber(names, ids, spec, forInput ? "input audio device" : "output audio device", + resolved); +} + +/* + * Layer the command line over whatever the settings file said. Everything lands + * in host->settings rather than in the host's live audio fields, so that the + * shared applyAudioSettings() does the API-then-device ordering exactly once and + * we are not maintaining a second copy of it here. + */ +bool overlayCommandLine(const CommandLineOptions &opts) +{ + auto host = getStandaloneHost(); + auto &settings = host->settings; + + if (opts.noInput) + { + // Leave the device name alone: not using the input is a different thing from + // forgetting which input was configured. + settings.audioInputUsed = false; + } + else if (!opts.inputDevice.empty()) + { + if (!resolveAudioDevice(host->getInputAudioDevices(), opts.inputDevice, true, + settings.inputDeviceName)) + { + return false; + } + settings.audioInputUsed = true; + } + + if (!opts.outputDevice.empty()) + { + if (!resolveAudioDevice(host->getOutputAudioDevices(), opts.outputDevice, false, + settings.outputDeviceName)) + { + return false; + } + settings.audioOutputUsed = true; + } + + if (opts.sampleRate > 0) + { + settings.sampleRate = opts.sampleRate; + } + + if (opts.bufferSize > 0) + { + constexpr int minBuffer{16}, maxBuffer{8192}; + auto frames = std::clamp(opts.bufferSize, minBuffer, maxBuffer); + if (frames != opts.bufferSize) + { + fprintf(stderr, "[WARNING] Buffer size %d is outside %d-%d frames; using %d\n", opts.bufferSize, + minBuffer, maxBuffer, frames); + } + // The requested size. RtAudio writes back what it actually granted. + settings.bufferSize = (uint32_t)frames; + } + + if (opts.noMidi) + { + // An empty list with bindAll off is the deliberate "no MIDI input" case + settings.midiBindAllPorts = false; + settings.midiPortNames.clear(); + } + else if (!opts.midiInputs.empty()) + { + auto ports = host->getMidiPortNames(); + std::vector names; + for (const auto &spec : opts.midiInputs) + { + std::string name; + if (!resolveMidiPort(ports, spec, name)) return false; + if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name); + } + settings.midiBindAllPorts = false; + settings.midiPortNames = names; + } + + return true; +} + +/* + * startAudioThreadOnImpl silently substitutes the device's preferred rate for one + * it doesn't offer. That is the right thing to do, but not silently when the user + * named the rate on the command line. + */ +void warnIfRateSubstituted(int requestedRate) +{ + auto host = getStandaloneHost(); + if (!host->audioOutputUsed || !host->isKnownDevice(host->audioOutputDeviceID)) return; + + auto info = host->deviceInfoFor(host->audioOutputDeviceID); + const auto &rates = info.sampleRates; + if (std::find(rates.begin(), rates.end(), (unsigned int)requestedRate) != rates.end()) return; + + fprintf(stderr, + "[WARNING] '%s' does not offer %d Hz (it offers %s); the device's own rate will be " + "used instead\n", + info.name.c_str(), requestedRate, ratesToString(rates).c_str()); +} +} // namespace + +CommandLineResult parseCommandLine(int argc, char **argv, const std::string &programName, + CommandLineOptions &opts) +{ + bool wantApis{false}, wantDevices{false}, wantMidi{false}; + + // --opt value and --opt=value both work; a missing value is an error rather + // than a silently empty string + auto valueFor = [&](int &i, const std::string &arg, const std::string &name, std::string &into) -> bool + { + auto eq = arg.find('='); + if (eq != std::string::npos) + { + into = arg.substr(eq + 1); + } + else if (i + 1 < argc) + { + into = argv[++i]; + } + else + { + fprintf(stderr, "[ERROR] %s needs a value\n", name.c_str()); + return false; + } + + if (into.empty()) + { + fprintf(stderr, "[ERROR] %s needs a value\n", name.c_str()); + return false; + } + return true; + }; + + auto intValueFor = [&](int &i, const std::string &arg, const std::string &name, int &into) -> bool + { + std::string s; + if (!valueFor(i, arg, name, s)) return false; + if (!isAllDigits(s)) + { + fprintf(stderr, "[ERROR] %s wants a number, not '%s'\n", name.c_str(), s.c_str()); + return false; + } + into = std::atoi(s.c_str()); + return true; + }; + + for (int i = 1; i < argc; ++i) + { + std::string arg{argv[i] ? argv[i] : ""}; + auto name = arg.substr(0, arg.find('=')); + + if (arg == "-h" || arg == "--help") + { + usage(programName); + return CommandLineResult::exitOk; + } + else if (arg == "--version") + { + printVersion(programName); + return CommandLineResult::exitOk; + } + else if (arg == "--list-apis") + { + wantApis = true; + } + else if (arg == "--list-devices") + { + wantDevices = true; + } + else if (arg == "--list-midi-inputs") + { + wantMidi = true; + } + else if (arg == "--no-input") + { + opts.noInput = true; + } + else if (arg == "--no-midi") + { + opts.noMidi = true; + } + else if (arg == "--no-gui") + { + opts.noGui = true; + } + else if (name == "--audio-api") + { + if (!valueFor(i, arg, name, opts.audioApi)) return CommandLineResult::exitError; + } + else if (name == "--input-device") + { + if (!valueFor(i, arg, name, opts.inputDevice)) return CommandLineResult::exitError; + } + else if (name == "--output-device") + { + if (!valueFor(i, arg, name, opts.outputDevice)) return CommandLineResult::exitError; + } + else if (name == "--midi-input") + { + // Repeatable, so this appends rather than assigns + std::string spec; + if (!valueFor(i, arg, name, spec)) return CommandLineResult::exitError; + opts.midiInputs.push_back(spec); + } + else if (name == "--sample-rate") + { + if (!intValueFor(i, arg, name, opts.sampleRate)) return CommandLineResult::exitError; + } + else if (name == "--buffer-size") + { + if (!intValueFor(i, arg, name, opts.bufferSize)) return CommandLineResult::exitError; + } + else if (arg.rfind("-", 0) == 0) + { + fprintf(stderr, "[ERROR] Unknown option '%s'. Try --help.\n", arg.c_str()); + return CommandLineResult::exitError; + } + else + { + // Not an option. A launcher may well have appended something; say so and + // carry on rather than refusing to start. + fprintf(stderr, "[WARNING] Ignoring argument '%s'\n", arg.c_str()); + } + } + + if (!opts.audioApi.empty() && lowercased(opts.audioApi) != "auto" && lowercased(opts.audioApi) != "default" && + resolveAudioApiName(opts.audioApi) == RtAudio::Api::UNSPECIFIED) + { + fprintf(stderr, "[ERROR] No audio api called '%s' in this build. Available: %s\n", + opts.audioApi.c_str(), compiledAudioApiNames().c_str()); + return CommandLineResult::exitError; + } + + if (opts.noInput && !opts.inputDevice.empty()) + { + fprintf(stderr, "[ERROR] --no-input and --input-device contradict each other\n"); + return CommandLineResult::exitError; + } + + if (opts.noMidi && !opts.midiInputs.empty()) + { + fprintf(stderr, "[ERROR] --no-midi and --midi-input contradict each other\n"); + return CommandLineResult::exitError; + } + + if (wantApis || wantDevices || wantMidi) + { + if (wantApis) listApis(); + if (wantApis && (wantDevices || wantMidi)) fprintf(stdout, "\n"); + if (wantDevices) listDevices(opts.audioApi); + if (wantDevices && wantMidi) fprintf(stdout, "\n"); + if (wantMidi) listMidiInputs(opts); + return CommandLineResult::exitOk; + } + + return CommandLineResult::run; +} + +bool configureAndStartAudio(const CommandLineOptions &opts) +{ + auto host = getStandaloneHost(); + + // The persisted settings are the baseline the flags override. This is the load + // startAudioThread() would do for itself; we do it here because the overrides + // have to go on top of it, and a second load down there would undo them. + host->loadStandaloneSettings(); + + // The backend has to be chosen before any device is named: a device name is + // resolved against one particular backend's enumeration. + selectAudioApi(opts.audioApi); + + if (!overlayCommandLine(opts)) return false; + + try + { + host->applyAudioSettings(); + } + catch (const std::exception &e) + { + // Enumeration itself throws when there is nothing usable attached. That is + // not a startup failure: the plugin, its GUI and MIDI all still work, so say + // so and carry on without audio. + reportError("Unable to configure audio", e.what()); + return true; + } + + if (opts.sampleRate > 0) warnIfRateSubstituted(opts.sampleRate); + + // A rate of zero means "whatever the device is running at", so ask for that + // rather than leaving it to startAudioThreadOnImpl, which substitutes the + // device's *preferred* rate - not the same number on a Pulse or PipeWire graph + // which has been moved off its default. + if (host->currentSampleRate <= 0 && host->audioOutputUsed && + host->isKnownDevice(host->audioOutputDeviceID)) + { + auto info = host->deviceInfoFor(host->audioOutputDeviceID); + auto rate = info.currentSampleRate ? info.currentSampleRate : info.preferredSampleRate; + host->currentSampleRate = (int32_t)rate; + } + + host->startMIDIThread(); + host->startAudioThreadOn(host->audioInputDeviceID, host->deviceInputChannels, + host->audioInputUsed && host->numAudioInputs > 0, host->audioOutputDeviceID, + host->deviceOutputChannels, + host->audioOutputUsed && host->numAudioOutputs > 0, host->currentSampleRate); + + return true; +} +} // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/detail/standalone/linux/linux_command_line.h b/src/detail/standalone/linux/linux_command_line.h new file mode 100644 index 00000000..c2f824a5 --- /dev/null +++ b/src/detail/standalone/linux/linux_command_line.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +/* + * The Linux standalone is configured from the command line rather than from a + * settings window: building a device picker in raw Xlib is a lot of work for a + * poor result, and a standalone on Linux is usually started from a shell or a + * script anyway. + * + * The flags cover the whole of what the audio layer can be told: + * + * --audio-api alsa | pulse | jack | pipewire (= pulse) | auto + * --input-device device name (or part of one), or an id from + * --output-device --list-devices + * --no-input open output only, even for a plugin with an input + * --sample-rate + * --buffer-size + * --midi-input port name (or part of one), repeatable + * --no-midi bind no MIDI input at all + * --no-gui run without a window + * --list-apis what this build can talk to, and what it can see + * --list-devices audio devices for the chosen (or default) api + * --list-midi-inputs MIDI input ports + * --version + * --help + * + * These are overrides layered on top of the persisted standalone settings, which + * on Linux are a hand-editable key=value file (see standalone_settings.h). What + * the command line sets is not written back: a flag configures one run. + */ +namespace freeaudio::clap_wrapper::standalone::linux_standalone +{ +struct CommandLineOptions +{ + std::string audioApi; // empty for the default preference order + std::string inputDevice; // name, part of a name, or an RtAudio device id + std::string outputDevice; // likewise + bool noInput{false}; + int sampleRate{0}; // 0 for the device's own rate + int bufferSize{0}; // 0 for the shared default + bool noGui{false}; + + // Empty means every port, which is what a standalone did before ports could be + // named at all; noMidi is the different thing of deliberately wanting none. + std::vector midiInputs; + bool noMidi{false}; +}; + +enum class CommandLineResult +{ + run, // nothing to do but start up + exitOk, // we printed what was asked for + exitError, // bad usage; the message is already on stderr +}; + +/* + * Parse argv into opts. Handles --help and the --list- flags itself, in which + * case the result is exitOk and main should return 0. + */ +CommandLineResult parseCommandLine(int argc, char **argv, const std::string &programName, + CommandLineOptions &opts); + +/* + * Select the backend, layer the command line over the persisted settings, and + * start MIDI and audio - the sequence mainStartAudio() runs on the platforms + * whose settings come from a window instead. Must be called after the plugin + * exists, since that is what names the settings file and what says how many + * audio busses there are. + * + * False means a device or port the user asked for doesn't exist, which is a + * startup error rather than something to paper over with a default. The message + * is already on stderr. + */ +bool configureAndStartAudio(const CommandLineOptions &opts); +} // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp new file mode 100644 index 00000000..d4ae51c8 --- /dev/null +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -0,0 +1,445 @@ +#include "linux_frontend.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "detail/standalone/standalone_details.h" +#include "detail/standalone/standalone_host.h" +#include "detail/standalone/entry.h" + +extern char **environ; + +namespace freeaudio::clap_wrapper::standalone::linux_standalone +{ +namespace +{ +volatile sig_atomic_t quitFlag{0}; +std::atomic shutdownDone{false}; + +extern "C" void requestQuitHandler(int sig) +{ + if (quitFlag) + { + // We already asked nicely once and we're still here, so the orderly path is + // evidently stuck. _exit is async-signal-safe; exit() is not. + _exit(128 + sig); + } + quitFlag = 1; +} + +bool isExecutable(const std::string &p) +{ + return !p.empty() && access(p.c_str(), X_OK) == 0; +} + +std::string findInPath(const std::string &exe) +{ + auto path = getenv("PATH"); + if (!path) return {}; + + std::string sp{path}; + size_t pos{0}; + while (pos <= sp.size()) + { + auto next = sp.find(':', pos); + auto dir = sp.substr(pos, (next == std::string::npos) ? std::string::npos : next - pos); + if (!dir.empty()) + { + auto cand = dir + "/" + exe; + if (isExecutable(cand)) return cand; + } + if (next == std::string::npos) break; + pos = next + 1; + } + return {}; +} + +bool hasDisplay() +{ + auto d = getenv("DISPLAY"); + auto w = getenv("WAYLAND_DISPLAY"); + return (d && *d) || (w && *w); +} + +bool preferKDialog() +{ + auto d = getenv("XDG_CURRENT_DESKTOP"); + if (!d) return false; + std::string s{d}; + return s.find("KDE") != std::string::npos || s.find("kde") != std::string::npos; +} + +/* + * The argv for whichever prompt tool this desktop has. Empty when it has + * neither, which is a perfectly ordinary state of affairs - stderr still got + * the message. + */ +std::vector dialogCommand(const std::string &title, const std::string &message) +{ + static const auto zenity = findInPath("zenity"); + static const auto kdialog = findInPath("kdialog"); + + auto useKDialog = preferKDialog() ? !kdialog.empty() : (zenity.empty() && !kdialog.empty()); + + if (useKDialog) + { + return {kdialog, "--title", title, "--error", message}; + } + if (!zenity.empty()) + { + // --no-markup so a device name containing an ampersand produces a message + // rather than a pango parse error + return {zenity, "--error", "--no-markup", "--title=" + title, "--text=" + message}; + } + return {}; +} + +/* + * Rate limiting for the dialogs. RtAudio can report the same failure on every + * callback, and a wall of modal prompts is worse than no prompt at all, so each + * distinct message is shown once, only one dialog is up at a time, and we stop + * after a handful. None of this gates stderr. + */ +struct DialogGate +{ + static constexpr int maxDialogs{3}; + + std::mutex mutex; + std::set alreadyShown; + int shown{0}; + bool inFlight{false}; + + bool claim(const std::string &key) + { + std::lock_guard g(mutex); + if (inFlight || shown >= maxDialogs) return false; + if (!alreadyShown.insert(key).second) return false; + shown++; + inFlight = true; + return true; + } + + void release() + { + std::lock_guard g(mutex); + inFlight = false; + } +}; + +DialogGate &dialogGate() +{ + static DialogGate g; + return g; +} + +void runDialogDetached(std::vector command) +{ + // The dialog outlives this call, so give it a thread of its own which reaps + // the child rather than leaving a zombie and then lets the next error + // through. posix_spawn (not fork) because we may well be on RtAudio's + // stream thread. + try + { + std::thread( + [command = std::move(command)]() + { + std::vector argv; + argv.reserve(command.size() + 1); + for (auto &c : command) argv.push_back(const_cast(c.c_str())); + argv.push_back(nullptr); + + pid_t pid{0}; + auto err = posix_spawn(&pid, argv[0], nullptr, nullptr, argv.data(), environ); + if (err != 0) + { + LOGINFO("[ERROR] Unable to spawn '{}' : {}", command[0], strerror(err)); + } + else + { + int status{0}; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) + { + } + } + dialogGate().release(); + }) + .detach(); + } + catch (const std::system_error &e) + { + // Out of threads. The message is already on stderr, which is the part that + // matters; just don't leave the gate closed against the next one. + LOGINFO("[ERROR] Unable to start the dialog thread : {}", e.what()); + dialogGate().release(); + } +} + +/* + * A backend with no output device is a backend whose server isn't running - + * an unstarted JACK, or Pulse on a box with neither PulseAudio nor PipeWire. + */ +bool apiHasOutputDevices(RtAudio::Api api) +{ + unsigned int outputs{0}, inputs{0}; + probeApiDeviceCounts(api, outputs, inputs); + return outputs > 0; +} +} // namespace + +std::string lowercased(const std::string &s) +{ + std::string r{s}; + std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + return r; +} + +void probeApiDeviceCounts(RtAudio::Api api, unsigned int &outputs, unsigned int &inputs) +{ + outputs = 0; + inputs = 0; + + try + { + RtAudio probe(api, [](RtAudioErrorType, const std::string &) {}); + for (auto id : probe.getDeviceIds()) + { + auto info = probe.getDeviceInfo(id); + if (info.outputChannels > 0) outputs++; + if (info.inputChannels > 0) inputs++; + } + } + catch (...) + { + // 'not available' is an answer, not a failure + } +} + +std::string compiledAudioApiNames() +{ + std::string res; + for (auto api : compiledAudioApis()) + { + if (api == RtAudio::Api::RTAUDIO_DUMMY) continue; + if (!res.empty()) res += ", "; + res += RtAudio::getApiName(api); + } + return res; +} + +std::vector compiledAudioApis() +{ + std::vector res; + RtAudio::getCompiledApi(res); + return res; +} + +RtAudio::Api resolveAudioApiName(const std::string &name) +{ + auto lower = lowercased(name); + if (lower.empty() || lower == "auto" || lower == "default") return RtAudio::Api::UNSPECIFIED; + + // RtAudio 6 has no native PipeWire backend; its Pulse backend is how you get + // at a PipeWire graph, and 'pipewire' is what a user will reasonably type + if (lower == "pipewire" || lower == "pw") lower = "pulse"; + + return RtAudio::getCompiledApiByName(lower); +} + +void selectAudioApi(const std::string &requestedName) +{ + auto host = getStandaloneHost(); + + // Record what we settled on where the settings layer looks for it, so that the + // shared applyAudioSettings() - which selects the API from this name - agrees + // with the choice made here instead of re-deciding it. + auto adopt = [host](RtAudio::Api api) + { + host->setAudioApi(api); + host->settings.audioApiName = host->audioApiName; + }; + + // The command line wins; then whatever the settings file asked for; then the + // preference order below. + auto wanted = requestedName; + if (wanted.empty() || lowercased(wanted) == "auto") + { + wanted = host->settings.audioApiName; + } + + if (!wanted.empty() && lowercased(wanted) != "auto") + { + auto api = resolveAudioApiName(wanted); + if (api == RtAudio::Api::UNSPECIFIED) + { + auto available = compiledAudioApiNames(); + fprintf(stderr, + "[ERROR] This build has no audio API called '%s'. Available: %s. Falling back to " + "the default order.\n", + wanted.c_str(), available.c_str()); + } + else + { + adopt(api); + LOGINFO("Audio API (requested) : {}", RtAudio::getApiDisplayName(api)); + fprintf(stderr, "[INFO] audio api: %s\n", RtAudio::getApiDisplayName(api).c_str()); + + // Say this plainly here: what the user gets otherwise is RtAudio's + // "deviceId argument not found" from somewhere deep in the open + if (!apiHasOutputDevices(api)) + { + fprintf(stderr, + "[WARNING] The %s backend reports no output devices. If it needs a server " + "running - JACK, PulseAudio, PipeWire - start it, or choose another with " + "--audio-api.\n", + RtAudio::getApiDisplayName(api).c_str()); + } + return; + } + } + + auto compiled = compiledAudioApis(); + auto have = [&compiled](RtAudio::Api a) + { return std::find(compiled.begin(), compiled.end(), a) != compiled.end(); }; + + for (auto pref : {RtAudio::Api::LINUX_PULSE, RtAudio::Api::UNIX_JACK, RtAudio::Api::LINUX_ALSA, + RtAudio::Api::LINUX_OSS}) + { + if (!have(pref)) continue; + if (!apiHasOutputDevices(pref)) continue; + + adopt(pref); + LOGINFO("Audio API : {}", RtAudio::getApiDisplayName(pref)); + fprintf(stderr, "[INFO] audio api: %s\n", RtAudio::getApiDisplayName(pref).c_str()); + return; + } + + // Nothing we prefer had a device. Leave the host unspecified and let RtAudio + // make its own choice, so a backend we didn't think of still gets a chance. + reportError("No audio backend", + "None of the audio backends this build has - which is where PulseAudio, PipeWire, " + "JACK and ALSA would appear - reported an output device. Letting RtAudio choose."); +} + +void installAudioErrorReporter() +{ + getStandaloneHost()->displayAudioError = [](const std::string &msg) + { reportError("Unable to configure audio", msg); }; +} + +void installSignalHandlers() +{ + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = requestQuitHandler; + sigemptyset(&sa.sa_mask); + // Deliberately not SA_RESTART, so a blocking epoll_wait/read sees EINTR and + // the runloop gets a chance to notice + sa.sa_flags = 0; + + for (auto sig : {SIGINT, SIGTERM, SIGHUP}) + { + if (sigaction(sig, &sa, nullptr) != 0) + { + LOGINFO("[ERROR] Unable to install handler for signal {} : {}", sig, strerror(errno)); + } + } + + // A dead X11 or audio server socket should be an error we handle, not a death + // sentence delivered mid-shutdown + signal(SIGPIPE, SIG_IGN); +} + +bool quitRequested() +{ + return quitFlag != 0; +} + +void waitForQuit() +{ + auto sah = getStandaloneHost(); + while (sah->running && !quitRequested()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +void armShutdownWatchdog(int seconds) +{ + if (seconds <= 0) return; + + try + { + std::thread( + [seconds]() + { + for (int i = 0; i < seconds * 20 && !shutdownDone; ++i) + { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + if (shutdownDone) return; + + fprintf(stderr, + "[ERROR] Shutdown did not complete within %d seconds - almost certainly the " + "audio backend refusing to release the stream - so exiting anyway.\n", + seconds); + fflush(stderr); + + // Not exit(): the wedged thread holds locks that static destructors + // would want, and we are here precisely because waiting did not work. + _exit(0); + }) + .detach(); + } + catch (const std::system_error &e) + { + // No thread to be had. Nothing to do but let the shutdown take its chances. + LOGINFO("[ERROR] Unable to start the shutdown watchdog : '{}'", e.what()); + } +} + +void shutdownFinished() +{ + shutdownDone = true; +} + +void reportError(const std::string &title, const std::string &message) +{ + LOGINFO("[ERROR] {} : {}", title, message); + + // LOGINFO is compiled out in release builds, and a standalone which fails to + // start audio has to leave the user something, so stderr unconditionally. + fprintf(stderr, "[ERROR] %s\n %s\n", title.c_str(), message.c_str()); + fflush(stderr); + + if (!hasDisplay()) return; + + // Long messages make for unusable dialogs; stderr has the whole thing. + constexpr size_t maxLen{1000}; + auto shortMessage = (message.size() > maxLen) ? message.substr(0, maxLen) + "..." : message; + + auto command = dialogCommand(title, shortMessage); + if (command.empty()) return; + + if (!dialogGate().claim(title + "\n" + shortMessage)) return; + + runDialogDetached(std::move(command)); +} +} // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h new file mode 100644 index 00000000..713e7487 --- /dev/null +++ b/src/detail/standalone/linux/linux_frontend.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include + +#ifdef __GNUC__ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wall" // other peoples errors are outside my scope +#endif + +#include "RtAudio.h" + +#ifdef __GNUC__ +#pragma GCC diagnostic pop +#endif + +/* + * Bits of the Linux standalone which are not the X11 GUI: telling the user + * something went wrong, and shutting down when the OS asks us to. These are + * compiled whether or not the X11 GUI is enabled. + */ +namespace freeaudio::clap_wrapper::standalone::linux_standalone +{ +/* + * Show the user an error. There is no toolkit here, so this always writes to + * stderr and, if the session has one of the standard desktop prompt tools + * (zenity or kdialog), also puts up a message box. + * + * Safe to call from any thread: RtAudio delivers its error callback on the + * stream thread, and the dialog is run by a detached worker rather than by the + * caller. Repeated messages are only dialogged once, and only a handful of + * dialogs are ever shown, so a device which errors continuously can't bury the + * desktop in prompts. stderr always gets everything. + */ +void reportError(const std::string &title, const std::string &message); + +/* + * Point the standalone host's audio error reporting at reportError. Without + * this every RtAudio failure is silent and the app just runs with no sound. + */ +void installAudioErrorReporter(); + +/* + * Ask for an orderly shutdown on SIGINT/SIGTERM/SIGHUP rather than dying where + * we stand: the runloops poll quitRequested(), so ^C unwinds through the normal + * path which stops audio, saves settings and destroys the plugin. A second + * signal exits immediately, in case that path is itself wedged. + */ +void installSignalHandlers(); +bool quitRequested(); + +/* + * Choose the audio backend and hand it to the host. + * + * RtAudio's own probe order is ALSA, then JACK, then Pulse, first-non-empty + * wins - and ALSA always has devices, so JACK and Pulse are never reached even + * when they are compiled in. On a stock PipeWire desktop that means the + * standalone talks to raw ALSA and never touches the PipeWire graph. So: prefer + * Pulse (which is how you reach PipeWire - RtAudio 6 has no native PipeWire + * backend), then JACK, then ALSA, taking the first which actually has an output + * device. + * + * requestedName names a backend explicitly: 'alsa', 'pulse', 'jack', + * 'pipewire' as an alias for pulse, or 'auto' for the order above. An unknown + * one is reported and falls back to that order. With no request, a backend named + * in the persisted settings is used before the order above is consulted. + * + * The chosen name is written into the host's settings, which is where the shared + * applyAudioSettings() reads the API from. + */ +void selectAudioApi(const std::string &requestedName = {}); + +// UNSPECIFIED for an empty/'auto' name, and also for one this build does not +// have +RtAudio::Api resolveAudioApiName(const std::string &name); + +// the backends this build was compiled with, for help and error text +std::vector compiledAudioApis(); + +// those backends as 'alsa, jack, pulse', which is what error text wants +std::string compiledAudioApiNames(); + +/* + * How many devices a backend can see, probed on an RtAudio of its own so the + * host's instance is left alone. Errors are swallowed: a backend whose server + * isn't running answers zero, which is an answer rather than a failure. + */ +void probeApiDeviceCounts(RtAudio::Api api, unsigned int &outputs, unsigned int &inputs); + +// tolower over a whole string, since every name here is matched case-insensitively +std::string lowercased(const std::string &s); + +/* + * Idle until the standalone is asked to stop - either the host stopped running + * or a signal arrived. For the case where there is no GUI runloop to sit in. + */ +void waitForQuit(); + +/* + * Start a detached timer which ends the process outright if the teardown which + * follows has not finished within seconds. + * + * This exists because of an RtAudio/ALSA deadlock we cannot reach from here: + * RtApiAlsa::callbackEvent() takes the stream mutex and holds it across the + * blocking snd_pcm_readi() of a duplex stream, while RtApiAlsa::stopStream() + * wants that same mutex. If the capture side has stopped producing - which a + * PipeWire or dmix capture device does readily - the read never returns, the + * mutex is never released and stopping the stream blocks for ever. Both ^C and + * closing the window then leave a process which has to be killed. + * + * So: give the orderly shutdown a fixed budget and take the exit if it overruns. + * shutdownFinished() cancels the timer. + */ +void armShutdownWatchdog(int seconds); +void shutdownFinished(); +} // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 7733c88c..7a2bf880 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -9,63 +9,139 @@ #include #include #include +#include +#include +#include +#include #include "x11_gui.h" +#include "linux_frontend.h" #include namespace freeaudio::clap_wrapper::standalone::linux_standalone { -void X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah) +namespace { - XInitThreads(); - display = XOpenDisplay(nullptr); - if (!display) - { - const char *disp = getenv("DISPLAY"); - fprintf(stderr, "XOpenDisplay failed: could not connect to display '%s'\n", - disp ? disp : "(DISPLAY not set)"); - exit(1); - } +int x11ErrorHandler(Display *d, XErrorEvent *e) +{ + // Xlib's default handler exits the process on any protocol error. A plugin + // asking X11 for something it won't do shouldn't take the audio with it. + // Errors arrive on whichever thread talked to X, and a plugin can generate + // them per repaint, so this is capped rather than unbounded. + static std::atomic reported{0}; + constexpr int maxReported{20}; + + auto n = ++reported; + if (n > maxReported) return 0; + + char buf[512]{}; + XGetErrorText(d, e->error_code, buf, sizeof(buf) - 1); + LOGINFO("[ERROR] X11 protocol error : {} (request {}.{})", buf, (int)e->request_code, + (int)e->minor_code); + fprintf(stderr, "[ERROR] X11 protocol error: %s (request %d.%d)%s\n", buf, (int)e->request_code, + (int)e->minor_code, (n == maxReported) ? " (further X11 errors will not be reported)" : ""); + fflush(stderr); + return 0; +} +} // namespace + +bool X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah, bool wantWindow) +{ + standaloneHost = sah; sah->x11Gui = this; sah->onRequestResize = [this](int w, int h) { return resetSizeTo(w, h); }; + + // The epoll is how plugin timers and fds get dispatched, so it is set up + // whether or not we end up with a display epoll_fd = epoll_create1(EPOLL_CLOEXEC); if (epoll_fd < 0) { - LOGINFO("Unable to create epoll"); + LOGINFO("[ERROR] Unable to create epoll : {}", strerror(errno)); + } + + if (!wantWindow) + { + // --no-gui. The epoll above still dispatches the plugin's timers and fds. + LOGINFO("Running without a window by request"); + return false; } + + XInitThreads(); + XSetErrorHandler(x11ErrorHandler); + + display = XOpenDisplay(nullptr); + if (!display) + { + const char *disp = getenv("DISPLAY"); + reportError("Unable to open a display", + std::string("Could not connect to the X11 display '") + + (disp ? disp : "(DISPLAY not set)") + + "'. Continuing without a window; audio and MIDI still run."); + return false; + } + + return true; +} + +bool X11Gui::keepRunning() const +{ + if (!runloopRunning) return false; + if (quitRequested()) return false; + if (standaloneHost && !standaloneHost->running) return false; + return true; } void X11Gui::runloop() { - if (!display || window == 0 || epoll_fd < 0) + runloopRunning = true; + + if (epoll_fd < 0) { - // no gui. Only way to kill us is with a signal. - while (true) + // Nothing to dispatch and nothing to draw, so just idle. This used to be a + // 'while (true) sleep' which honoured nothing at all and could only be + // ended by killing the process outright. + while (keepRunning()) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } return; } + XEvent e; struct epoll_event events[maxEpollEvents]; - bool running{true}; - while (running) + while (keepRunning()) { - while (XPending(display)) + while (display && XPending(display)) { XNextEvent(display, &e); switch (e.type) { case MapNotify: { - if (plugin && plugin->_ext._gui) + if (guiCreated && plugin && plugin->_ext._gui) { clap_window win; win.api = CLAP_WINDOW_API_X11; win.x11 = window; auto ui = plugin->_ext._gui; - ui->set_parent(plugin->_plugin, &win); - ui->show(plugin->_plugin); + if (!ui->set_parent(plugin->_plugin, &win)) + { + reportError("Plugin Error", + "The plugin failed to embed its user interface. Please contact the " + "plugin developer."); + } + else + { + ui->show(plugin->_plugin); + } + } + } + break; + case ConfigureNotify: + { + if (e.xconfigure.window == window) + { + handleConfigure(e.xconfigure.width, e.xconfigure.height); } } break; @@ -74,37 +150,49 @@ void X11Gui::runloop() // 3. Check if the message is the delete request if ((Atom)(e.xclient.data.l[0]) == wmDeleteMessage) { - running = false; + runloopRunning = false; } break; } } } + if (!keepRunning()) break; + // Poll for both X11 events and timer events - int num{0}; - if (running) num = epoll_wait(epoll_fd, events, maxEpollEvents, 50); + auto num = epoll_wait(epoll_fd, events, maxEpollEvents, 50); - if (num > 0) + if (num < 0) { - for (int i = 0; i < num; ++i) + if (errno == EINTR) continue; // a signal; keepRunning() will sort it out + LOGINFO("[ERROR] epoll_wait failed : {}", strerror(errno)); + break; + } + + for (int i = 0; i < num; ++i) + { + auto fd = events[i].data.fd; + auto tfd = fdToTimerId.find(fd); + auto pfd = registeredFds.find(fd); + + if (tfd != fdToTimerId.end()) { - auto fd = events[i].data.fd; - auto tfd = fdToTimerId.find(fd); - auto pfd = registeredFds.find(fd); + // The timerfd sits in a level-triggered epoll, so it has to be drained + // here. Skipping the read leaves it permanently readable and epoll_wait + // then returns immediately forever, spinning a core at 100%. + uint64_t expirations{0}; + auto rd = ::read(fd, &expirations, sizeof(expirations)); + (void)rd; - if (tfd != fdToTimerId.end()) + if (plugin && plugin->_ext._timer) { - if (plugin->_ext._timer) - { - plugin->_ext._timer->on_timer(plugin->_plugin, tfd->second); - } + plugin->_ext._timer->on_timer(plugin->_plugin, tfd->second); } - if (pfd != registeredFds.end()) + } + if (pfd != registeredFds.end()) + { + if (plugin && plugin->_ext._posixfd) { - if (plugin->_ext._posixfd) - { - plugin->_ext._posixfd->on_fd(plugin->_plugin, fd, pfd->second); - } + plugin->_ext._posixfd->on_fd(plugin->_plugin, fd, pfd->second); } } } @@ -113,64 +201,138 @@ void X11Gui::runloop() void X11Gui::setPlugin(std::shared_ptr p) { - this->plugin = p; - if (display && plugin->_ext._gui) + if (!p) { - auto ui = plugin->_ext._gui; - auto p = plugin->_plugin; - if (!ui->is_api_supported(p, CLAP_WINDOW_API_X11, false)) - { - LOGINFO("[ERROR] CLAP does not support X11"); - window = 0; - return; - } + // The plugin failed to instantiate. main() reports that; don't compound it + // with a null dereference here. + LOGINFO("[ERROR] setPlugin with no plugin"); + return; + } + + this->plugin = p; - ui->create(p, CLAP_WINDOW_API_X11, false); + // Without a display we run on: the plugin still gets audio, MIDI, timers and + // fd callbacks, it just has nowhere to draw + if (!display) return; - uint32_t w, h; - ui->get_size(p, &w, &h); - ui->adjust_size(p, &w, &h); + if (!plugin->_ext._gui) + { + LOGINFO("Plugin provides no GUI extension; running without a window"); + return; + } - int s = DefaultScreen(display); - window = XCreateSimpleWindow(display, RootWindow(display, s), 10, 10, w, h, 1, - BlackPixel(display, s), WhitePixel(display, s)); - XStoreName(display, window, plugin->_plugin->desc->name); - XSelectInput(display, window, InputOutput | StructureNotifyMask); + auto ui = plugin->_ext._gui; + auto pl = plugin->_plugin; + + if (!ui->is_api_supported(pl, CLAP_WINDOW_API_X11, false)) + { + reportError("Plugin Error", "The plugin does not support an X11 GUI. Continuing without a window."); + window = 0; + return; + } - // Get window clsoed notifications - wmDeleteMessage = XInternAtom(display, "WM_DELETE_WINDOW", False); - XSetWMProtocols(display, window, &wmDeleteMessage, 1); + if (!ui->create(pl, CLAP_WINDOW_API_X11, false)) + { + reportError("Plugin Error", "The plugin failed to create its user interface."); + window = 0; + return; + } + guiCreated = true; - resetSizeTo(w, h); + uint32_t w{0}, h{0}; + if (!ui->get_size(pl, &w, &h)) + { + reportError("Plugin Error", "The plugin failed to report its window size."); + destroyGui(); + return; + } - XMapWindow(display, window); + if (!isSaneSize(w, h)) + { + reportError("Plugin Error", "The plugin reported an invalid window size (" + std::to_string(w) + + " x " + std::to_string(h) + ")."); + destroyGui(); + return; + } - epoll_event event; - event.events = EPOLLIN; - event.data.fd = ConnectionNumber(display); - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, ConnectionNumber(display), &event) == -1) + if (ui->can_resize(pl)) + { + // adjust_size only means anything for a resizable GUI, and a zero back from + // it would be a BadValue abort in XCreateSimpleWindow, so it only counts if + // the answer is usable + uint32_t aw{w}, ah{h}; + if (ui->adjust_size(pl, &aw, &ah) && isSaneSize(aw, ah)) { - LOGINFO("Unable to register display epoll"); - close(epoll_fd); - epoll_fd = -1; - return; + w = aw; + h = ah; } } + + int s = DefaultScreen(display); + window = XCreateSimpleWindow(display, RootWindow(display, s), 10, 10, w, h, 1, BlackPixel(display, s), + WhitePixel(display, s)); + if (window == 0) + { + reportError("Unable to create a window", "X11 would not create the plugin window."); + destroyGui(); + return; + } + + XStoreName(display, window, plugin->_plugin->desc->name); + // StructureNotify for map and resize, Exposure so a damaged window can be + // asked to redraw. This used to pass InputOutput, which is a window class + // rather than an event mask and happens to equal KeyPressMask. + XSelectInput(display, window, StructureNotifyMask | ExposureMask); + + // Get window clsoed notifications + wmDeleteMessage = XInternAtom(display, "WM_DELETE_WINDOW", False); + XSetWMProtocols(display, window, &wmDeleteMessage, 1); + + resetSizeTo(w, h); + + XMapWindow(display, window); + + epoll_event event{}; + event.events = EPOLLIN; + event.data.fd = ConnectionNumber(display); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, ConnectionNumber(display), &event) == -1) + { + // Keep the epoll: plugin timers and fds still need it, and the runloop's + // 50ms poll picks X events up regardless, just less promptly + LOGINFO("[ERROR] Unable to register display epoll : {}", strerror(errno)); + } } -void X11Gui::shutdown() + +bool X11Gui::isSaneSize(uint32_t w, uint32_t h) { - if (plugin && plugin->_ext._gui) + return w > 0 && h > 0 && w <= maxWindowDim && h <= maxWindowDim; +} + +void X11Gui::destroyGui() +{ + if (guiCreated && plugin && plugin->_ext._gui) { plugin->_ext._gui->destroy(plugin->_plugin); } + guiCreated = false; + window = 0; +} +void X11Gui::shutdown() +{ + // destroyGui() clears `window`, so keep hold of it to tear down after + auto ourWindow = window; + + // only destroy a GUI we got as far as creating + destroyGui(); + if (epoll_fd >= 0) { close(epoll_fd); epoll_fd = -1; } - if (display && window > 0) + if (display && ourWindow > 0) { - XDestroyWindow(display, window); + XDestroyWindow(display, ourWindow); XFlush(display); } if (display) @@ -182,21 +344,47 @@ void X11Gui::shutdown() int X11Gui::nextTimerId{2112}; -bool X11Gui::register_timer(int period_ms, clap_id *tid) +bool X11Gui::register_timer(uint32_t period_ms, clap_id *tid) { - int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC); + if (epoll_fd < 0) + { + LOGINFO("[ERROR] register_timer with no epoll"); + return false; + } + + // A zero period would spin the runloop, so 'as fast as you can' becomes 1ms. + // An hour is well past any plausible redraw or housekeeping timer. + auto period = std::clamp(period_ms, (uint32_t)1, (uint32_t)(60 * 60 * 1000)); + + int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); + if (tfd < 0) + { + LOGINFO("[ERROR] timerfd_create failed : {}", strerror(errno)); + return false; + } + + // tv_nsec has to stay below 1e9, so a period of a second or more belongs in + // tv_sec. Stuffing it all into tv_nsec is EINVAL and the timer never fires. struct itimerspec ts; memset(&ts, 0, sizeof(ts)); - ts.it_interval.tv_nsec = period_ms * 1e6; // Repeat every 1s - ts.it_value.tv_nsec = period_ms * 1e6; // First expiry in 1s - timerfd_settime(tfd, 0, &ts, NULL); + ts.it_interval.tv_sec = (time_t)(period / 1000); + ts.it_interval.tv_nsec = (long)(period % 1000) * 1000000L; + ts.it_value = ts.it_interval; + + if (timerfd_settime(tfd, 0, &ts, nullptr) < 0) + { + LOGINFO("[ERROR] timerfd_settime failed for a {}ms timer : {}", period, strerror(errno)); + close(tfd); + return false; + } - epoll_event event; + epoll_event event{}; event.events = EPOLLIN; event.data.fd = tfd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, tfd, &event) == -1) { - LOGINFO("Unable to register timer epoll"); + LOGINFO("[ERROR] Unable to register timer epoll : {}", strerror(errno)); + close(tfd); return false; } @@ -234,25 +422,55 @@ bool X11Gui::unregister_timer(clap_id tid) return true; } -bool X11Gui::register_fd(int fd, clap_posix_fd_flags_t iflags) +uint32_t X11Gui::epollFlagsFor(clap_posix_fd_flags_t iflags) { - int flags{0}; + uint32_t flags{0}; if (iflags & CLAP_POSIX_FD_READ) flags = flags | EPOLLIN; if (iflags & CLAP_POSIX_FD_WRITE) flags = flags | EPOLLOUT; if (iflags & CLAP_POSIX_FD_ERROR) flags = flags | EPOLLERR; + return flags; +} - epoll_event event; - event.events = flags; +bool X11Gui::register_fd(int fd, clap_posix_fd_flags_t iflags) +{ + if (epoll_fd < 0) return false; + + epoll_event event{}; + event.events = epollFlagsFor(iflags); event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { - LOGINFO("Unable to register plugin provided fd"); + LOGINFO("[ERROR] Unable to register plugin provided fd : {}", strerror(errno)); return false; } registeredFds[fd] = iflags; return true; } +bool X11Gui::modify_fd(int fd, clap_posix_fd_flags_t iflags) +{ + if (epoll_fd < 0) return false; + + auto pos = registeredFds.find(fd); + if (pos == registeredFds.end()) + { + LOGINFO("[ERROR] modify_fd on unregistered fd {}", fd); + return false; + } + + epoll_event event{}; + event.events = epollFlagsFor(iflags); + event.data.fd = fd; + if (epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &event) == -1) + { + LOGINFO("[ERROR] epoll_ctl EPOLL_CTL_MOD failed for fd {} : {}", fd, strerror(errno)); + return false; + } + + pos->second = iflags; + return true; +} + bool X11Gui::unregister_fd(int fd) { LOGINFO("Unregistering FD: {}", fd); @@ -272,19 +490,102 @@ bool X11Gui::unregister_fd(int fd) bool X11Gui::resetSizeTo(int w, int h) { if (!display || window == 0) return false; - XResizeWindow(display, window, w, h); + if (w <= 0 || h <= 0) return false; + + XResizeWindow(display, window, (unsigned int)w, (unsigned int)h); + + // we asked for this, so don't hand the resulting ConfigureNotify back to the + // plugin as if the user had done it + lastWidth = w; + lastHeight = h; + + applySizeHints(w, h); + + return true; +} + +void X11Gui::applySizeHints(int w, int h) +{ + bool hResize{false}, vResize{false}, keepAspect{false}; + int aspectW{0}, aspectH{0}; + + if (guiCreated && plugin && plugin->_ext._gui) + { + auto ui = plugin->_ext._gui; + hResize = vResize = ui->can_resize(plugin->_plugin); + + if (hResize) + { + clap_gui_resize_hints_t rh{}; + if (ui->get_resize_hints && ui->get_resize_hints(plugin->_plugin, &rh)) + { + hResize = rh.can_resize_horizontally; + vResize = rh.can_resize_vertically; + if (rh.preserve_aspect_ratio && rh.aspect_ratio_width > 0 && rh.aspect_ratio_height > 0) + { + keepAspect = true; + aspectW = (int)rh.aspect_ratio_width; + aspectH = (int)rh.aspect_ratio_height; + } + } + } + } XSizeHints *hints = XAllocSizeHints(); + if (!hints) return; + + // A fixed axis is pinned min == max. A resizable one gets a floor and no + // practical ceiling: pinning both axes regardless of can_resize(), which is + // what this used to do, makes user resize impossible. + constexpr int minDim{64}; hints->flags = PMinSize | PMaxSize; - hints->min_width = w; - hints->max_width = w; - hints->min_height = h; - hints->max_height = h; + hints->min_width = hResize ? std::min(minDim, w) : w; + hints->max_width = hResize ? (int)maxWindowDim : w; + hints->min_height = vResize ? std::min(minDim, h) : h; + hints->max_height = vResize ? (int)maxWindowDim : h; + + if (keepAspect) + { + hints->flags |= PAspect; + hints->min_aspect.x = aspectW; + hints->min_aspect.y = aspectH; + hints->max_aspect.x = aspectW; + hints->max_aspect.y = aspectH; + } - // Apply hints to the window XSetWMNormalHints(display, window, hints); XFree(hints); +} - return true; +void X11Gui::handleConfigure(int w, int h) +{ + if (w <= 0 || h <= 0) return; + if (w == lastWidth && h == lastHeight) return; // our own resize coming back + + lastWidth = w; + lastHeight = h; + + if (!guiCreated || !plugin || !plugin->_ext._gui) return; + + auto ui = plugin->_ext._gui; + auto pl = plugin->_plugin; + if (!ui->can_resize(pl)) return; // a fixed size GUI has nothing to say here + + uint32_t aw{(uint32_t)w}, ah{(uint32_t)h}; + if (!ui->adjust_size(pl, &aw, &ah) || !isSaneSize(aw, ah)) + { + aw = (uint32_t)w; + ah = (uint32_t)h; + } + + ui->set_size(pl, aw, ah); + + if ((int)aw != w || (int)ah != h) + { + // The plugin snapped to a size of its own, so make the window agree. The + // ConfigureNotify which follows matches the size resetSizeTo() records, so + // this settles rather than ping-ponging. + resetSizeTo((int)aw, (int)ah); + } } }; // namespace freeaudio::clap_wrapper::standalone::linux_standalone \ No newline at end of file diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index 432ad596..f653d262 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -12,20 +12,33 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { struct X11Gui { - void initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *); + // false if there is no usable display, or none was wanted; audio, MIDI and + // plugin timers all still run, we just never get a window + bool initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *, bool wantWindow = true); void setPlugin(std::shared_ptr); void runloop(); void shutdown(); - bool register_timer(int period_ms, clap_id *tid); + // false once the window is closed, the host stops running, or we are signalled + bool keepRunning() const; + + bool register_timer(uint32_t period_ms, clap_id *tid); bool unregister_timer(clap_id tid); bool register_fd(int fd, clap_posix_fd_flags_t flags); + bool modify_fd(int fd, clap_posix_fd_flags_t flags); bool unregister_fd(int fd); + static uint32_t epollFlagsFor(clap_posix_fd_flags_t flags); + + freeaudio::clap_wrapper::standalone::StandaloneHost *standaloneHost{nullptr}; + Display *display{nullptr}; Window window{0}; Atom wmDeleteMessage{0}; + bool runloopRunning{false}; + // so shutdown() only destroys a GUI we actually created + bool guiCreated{false}; int epoll_fd{-1}; static constexpr size_t maxEpollEvents{256}; @@ -34,6 +47,20 @@ struct X11Gui bool resetSizeTo(int w, int h); + // WM size hints from the plugin's resize hints, rather than pinning + // min == max == current for everything + void applySizeHints(int w, int h); + // a user (or WM) resize of our window, handed on to the plugin + void handleConfigure(int w, int h); + + static constexpr uint32_t maxWindowDim{16384}; + static bool isSaneSize(uint32_t w, uint32_t h); + void destroyGui(); + + // last size we know the window to have, so our own resizes don't echo back + // into the plugin + int lastWidth{-1}, lastHeight{-1}; + std::map fdToTimerId; std::map timerIdToFd; diff --git a/src/detail/standalone/standalone_host.cpp b/src/detail/standalone/standalone_host.cpp index e30d8cc1..0a6b16b6 100644 --- a/src/detail/standalone/standalone_host.cpp +++ b/src/detail/standalone/standalone_host.cpp @@ -249,7 +249,7 @@ const char *StandaloneHost::host_get_name() bool StandaloneHost::register_timer(uint32_t period_ms, clap_id *timer_id) { #if LIN && CLAP_WRAPPER_STANDALONE_X11 - assert(x11Gui); + if (!x11Gui) return false; return x11Gui->register_timer(period_ms, timer_id); #else return false; @@ -258,7 +258,7 @@ bool StandaloneHost::register_timer(uint32_t period_ms, clap_id *timer_id) bool StandaloneHost::unregister_timer(clap_id timer_id) { #if LIN && CLAP_WRAPPER_STANDALONE_X11 - assert(x11Gui); + if (!x11Gui) return false; return x11Gui->unregister_timer(timer_id); #else return false; @@ -268,6 +268,7 @@ bool StandaloneHost::unregister_timer(clap_id timer_id) bool StandaloneHost::register_fd(int fd, clap_posix_fd_flags_t flags) { #if LIN && CLAP_WRAPPER_STANDALONE_X11 + if (!x11Gui) return false; return x11Gui->register_fd(fd, flags); #else return false; @@ -275,11 +276,17 @@ bool StandaloneHost::register_fd(int fd, clap_posix_fd_flags_t flags) } bool StandaloneHost::modify_fd(int fd, clap_posix_fd_flags_t flags) { - return true; +#if LIN && CLAP_WRAPPER_STANDALONE_X11 + if (!x11Gui) return false; + return x11Gui->modify_fd(fd, flags); +#else + return false; +#endif } bool StandaloneHost::unregister_fd(int fd) { #if LIN && CLAP_WRAPPER_STANDALONE_X11 + if (!x11Gui) return false; return x11Gui->unregister_fd(fd); #else return false; diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 147d5968..05134244 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -7,10 +7,44 @@ #include "detail/standalone/linux/x11_gui.h" #endif +#if LIN +#include "detail/standalone/linux/linux_frontend.h" +#include "detail/standalone/linux/linux_command_line.h" +#endif + // For now just a simple main. In the future this will branch out to // an [NSApplicationMain ] and so on depending on platform int main(int argc, char **argv) { +#if LIN + // Before anything else, so that a ^C during startup still unwinds through + // shutdown rather than dropping the process where it stands + freeaudio::clap_wrapper::standalone::linux_standalone::installSignalHandlers(); + + freeaudio::clap_wrapper::standalone::linux_standalone::CommandLineOptions clOptions; + { + using namespace freeaudio::clap_wrapper::standalone::linux_standalone; + switch (parseCommandLine(argc, argv, OUTPUT_NAME, clOptions)) + { + case CommandLineResult::exitOk: + return 0; + case CommandLineResult::exitError: + return 2; + case CommandLineResult::run: + break; + } + } +#endif + + auto fatalError = [](const std::string &msg) + { +#if LIN + freeaudio::clap_wrapper::standalone::linux_standalone::reportError("Unable to start", msg); +#else + std::cerr << "Clap Standalone: " << msg << std::endl; +#endif + }; + const clap_plugin_entry *entry{nullptr}; #ifdef STATICALLY_LINKED_CLAP_ENTRY extern const clap_plugin_entry clap_entry; @@ -40,30 +74,78 @@ int main(int argc, char **argv) #if LIN && CLAP_WRAPPER_STANDALONE_X11 freeaudio::clap_wrapper::standalone::linux_standalone::X11Gui x11Gui{}; - x11Gui.initialize(freeaudio::clap_wrapper::standalone::getStandaloneHost()); + // A false here means we have no display, or --no-gui was passed. That is not + // fatal: audio, MIDI and plugin timers all still run, we just never show a + // window. + x11Gui.initialize(freeaudio::clap_wrapper::standalone::getStandaloneHost(), !clOptions.noGui); #endif if (!entry) { - std::cerr << "Clap Standalone: No Entry as configured" << std::endl; + fatalError("No CLAP entry as configured. Is the plugin installed?"); return 3; } +#if LIN + // stderr always, plus a zenity/kdialog box when the session has one + freeaudio::clap_wrapper::standalone::linux_standalone::installAudioErrorReporter(); +#endif + std::string pid{PLUGIN_ID}; int pindex{PLUGIN_INDEX}; auto plugin = freeaudio::clap_wrapper::standalone::mainCreatePlugin(entry, pid, pindex, 1, (char **)argv); + if (!plugin) + { + // Everything downstream of here dereferences this, so stop now rather than + // crashing in the GUI handshake + fatalError("Unable to create the plugin" + (pid.empty() ? std::string() : " '" + pid + "'") + + ". See the log for details."); + freeaudio::clap_wrapper::standalone::mainFinish(); + return 4; + } + +#if LIN + // The command line is the settings UI on Linux, so the frontend drives the + // startup sequence rather than mainStartAudio(), which would load the settings + // file over the top of what was asked for on the command line. A device, rate + // or port the user named and which doesn't exist is a startup error, not + // something to quietly substitute a default for. + if (!freeaudio::clap_wrapper::standalone::linux_standalone::configureAndStartAudio(clOptions)) + { + freeaudio::clap_wrapper::standalone::mainFinish(); + return 5; + } +#else freeaudio::clap_wrapper::standalone::mainStartAudio(); +#endif #if LIN && CLAP_WRAPPER_STANDALONE_X11 x11Gui.setPlugin(plugin); x11Gui.runloop(); + + // Everything from here is teardown, and teardown can wedge in the audio + // backend where we cannot reach it - a standalone which will not quit when + // asked has to be killed by hand. So give the orderly path a budget. It has to + // be comfortably more than the two seconds quiesceProcessing() spends waiting + // for the audio callback to acknowledge the stop. + freeaudio::clap_wrapper::standalone::linux_standalone::armShutdownWatchdog(5); + x11Gui.shutdown(); +#elif LIN + // No GUI compiled in, so idle here until the host winds down or a signal + // arrives. mainWait() would not notice the signal. + freeaudio::clap_wrapper::standalone::linux_standalone::waitForQuit(); + freeaudio::clap_wrapper::standalone::linux_standalone::armShutdownWatchdog(5); #else freeaudio::clap_wrapper::standalone::mainWait(); #endif plugin = nullptr; freeaudio::clap_wrapper::standalone::mainFinish(); + +#if LIN + freeaudio::clap_wrapper::standalone::linux_standalone::shutdownFinished(); +#endif }