From 7c0d3158a3fb14fb10e2e458ce122e56cd4aff0e Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 09:57:47 -0400 Subject: [PATCH 01/13] LIN-1: Fix the plugin-timer busy spin on Linux The timerfd went into a level-triggered epoll and was never read, so after the first expiry epoll_wait returned immediately forever and any CLAP with a redraw timer pinned a core at 100%. Drain the fd when we dispatch. Also: period_ms landed entirely in tv_nsec, so every period >= 1000ms was an unchecked EINVAL and that timer never fired at all; split it across tv_sec/tv_nsec and clamp a 0ms request to 1ms. And check timerfd_create, timerfd_settime and epoll_ctl, closing the fd rather than leaking it when registration fails. Measured on a 16ms timer over 500ms: 1,757,889 epoll_wait wakeups before, 32 after. --- src/detail/standalone/linux/x11_gui.cpp | 53 ++++++++++++++++++++----- src/detail/standalone/linux/x11_gui.h | 2 +- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 7733c88c..79dd0b65 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "x11_gui.h" #include @@ -94,14 +96,21 @@ void X11Gui::runloop() if (tfd != fdToTimerId.end()) { - if (plugin->_ext._timer) + // 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 (plugin && plugin->_ext._timer) { plugin->_ext._timer->on_timer(plugin->_plugin, tfd->second); } } if (pfd != registeredFds.end()) { - if (plugin->_ext._posixfd) + if (plugin && plugin->_ext._posixfd) { plugin->_ext._posixfd->on_fd(plugin->_plugin, fd, pfd->second); } @@ -182,21 +191,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; - epoll_event event; + 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{}; 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; } diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index 432ad596..6dbb9ce1 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -17,7 +17,7 @@ struct X11Gui void runloop(); void shutdown(); - bool register_timer(int period_ms, clap_id *tid); + 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); From 8faa97334b39ff3b4a0d7de6464af93e99c069f6 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:00:51 -0400 Subject: [PATCH 02/13] LIN-6: Surface audio errors on Linux displayAudioError was never wired on Linux, so every RtAudio failure was silent and the app simply ran with no sound. Add a small Linux frontend support unit with reportError(), wired into the host's displayAudioError. It always writes to stderr - LOGINFO compiles out in release builds, and a standalone which can't open a device has to leave the user something - and additionally puts up a message box via zenity or kdialog when the session has one. Callable from any thread, since RtAudio reports errors from the stream thread: the dialog is spawned with posix_spawn onto a detached reaper thread rather than being run by the caller. Repeated identical messages are dialogged once and at most three dialogs are ever shown, so a device which errors on every callback can't bury the desktop; stderr still gets all of it. --- cmake/wrap_standalone.cmake | 7 + .../standalone/linux/linux_frontend.cpp | 185 ++++++++++++++++++ src/detail/standalone/linux/linux_frontend.h | 24 +++ src/wrapasstandalone.cpp | 15 ++ 4 files changed, 231 insertions(+) create mode 100644 src/detail/standalone/linux/linux_frontend.cpp create mode 100644 src/detail/standalone/linux/linux_frontend.h diff --git a/cmake/wrap_standalone.cmake b/cmake/wrap_standalone.cmake index c3b18cd1..4dcdef34 100644 --- a/cmake/wrap_standalone.cmake +++ b/cmake/wrap_standalone.cmake @@ -175,6 +175,13 @@ function(target_add_standalone_wrapper) target_sources(${SA_TARGET} PRIVATE ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/wrapasstandalone.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) + 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) diff --git a/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp new file mode 100644 index 00000000..f45d1487 --- /dev/null +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -0,0 +1,185 @@ +#include "linux_frontend.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/standalone/standalone_details.h" + +extern char **environ; + +namespace freeaudio::clap_wrapper::standalone::linux_standalone +{ +namespace +{ +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. + 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(); +} +} // namespace + +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..71c4e854 --- /dev/null +++ b/src/detail/standalone/linux/linux_frontend.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +/* + * 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); +} // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 147d5968..3f8ecea9 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -7,6 +7,10 @@ #include "detail/standalone/linux/x11_gui.h" #endif +#if LIN +#include "detail/standalone/linux/linux_frontend.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) @@ -49,6 +53,17 @@ int main(int argc, char **argv) return 3; } +#if LIN + // Without this every RtAudio failure is silent and the app just runs with no + // sound. reportError writes stderr always, and puts up a zenity/kdialog box + // if the desktop has one. + freeaudio::clap_wrapper::standalone::getStandaloneHost()->displayAudioError = + [](const std::string &msg) + { + freeaudio::clap_wrapper::standalone::linux_standalone::reportError("Unable to configure audio", msg); + }; +#endif + std::string pid{PLUGIN_ID}; int pindex{PLUGIN_INDEX}; From aa57059dc192961edfb84f88961d7d486b7d8f50 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:03:03 -0400 Subject: [PATCH 03/13] LIN-4: Make the Linux standalone killable, and shut it down in order The no-GUI path was 'while (true) sleep(1s)', ignoring standaloneHost->running entirely - the comment conceded the only way out was a signal - and there were no signal handlers, so ^C bypassed shutdown and any state save with it. - SIGINT/SIGTERM/SIGHUP now request an orderly exit which the runloops poll, so ^C unwinds through shutdown()/mainFinish(). A second signal _exit()s in case that path is itself stuck. - The runloop honours the window closing, the host stopping, and the quit request; the idle path does the same instead of looping forever. - epoll_wait now checks for failure and treats EINTR as 'go round again', which is what the handlers deliver now that they don't set SA_RESTART. - SIGPIPE is ignored: a dead X11 or audio-server socket should be an error we report, not a death sentence delivered mid-shutdown. - With no X11 GUI compiled in, main waits on the same quit condition rather than mainWait(), which cannot see the signal. Verified: SIGINT and SIGTERM both exit 0 through the normal path. --- .../standalone/linux/linux_frontend.cpp | 54 ++++++++++++ src/detail/standalone/linux/linux_frontend.h | 15 ++++ src/detail/standalone/linux/x11_gui.cpp | 85 ++++++++++++------- src/detail/standalone/linux/x11_gui.h | 6 ++ src/wrapasstandalone.cpp | 10 +++ 5 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index f45d1487..f8ac8df5 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -1,10 +1,12 @@ #include "linux_frontend.h" +#include #include #include #include #include +#include #include #include #include @@ -15,6 +17,8 @@ #include #include "detail/standalone/standalone_details.h" +#include "detail/standalone/standalone_host.h" +#include "detail/standalone/entry.h" extern char **environ; @@ -22,6 +26,19 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { namespace { +volatile sig_atomic_t quitFlag{0}; + +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; @@ -160,6 +177,43 @@ void runDialogDetached(std::vector command) } } // namespace +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 reportError(const std::string &title, const std::string &message) { LOGINFO("[ERROR] {} : {}", title, message); diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index 71c4e854..f5c91ab0 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -21,4 +21,19 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone * desktop in prompts. stderr always gets everything. */ void reportError(const std::string &title, const std::string &message); + +/* + * 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(); + +/* + * 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(); } // 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 79dd0b65..385432a8 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -13,6 +13,7 @@ #include #include "x11_gui.h" +#include "linux_frontend.h" #include namespace freeaudio::clap_wrapper::standalone::linux_standalone @@ -28,6 +29,7 @@ void X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah disp ? disp : "(DISPLAY not set)"); exit(1); } + standaloneHost = sah; sah->x11Gui = this; sah->onRequestResize = [this](int w, int h) { return resetSizeTo(w, h); }; epoll_fd = epoll_create1(EPOLL_CLOEXEC); @@ -37,23 +39,35 @@ void X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah } } +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) @@ -76,44 +90,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) - { - auto fd = events[i].data.fd; - auto tfd = fdToTimerId.find(fd); - auto pfd = registeredFds.find(fd); + if (errno == EINTR) continue; // a signal; keepRunning() will sort it out + LOGINFO("[ERROR] epoll_wait failed : {}", strerror(errno)); + break; + } - if (tfd != fdToTimerId.end()) + 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()) + { + // 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 (plugin && plugin->_ext._timer) { - // 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 (plugin && 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 && plugin->_ext._posixfd) - { - plugin->_ext._posixfd->on_fd(plugin->_plugin, fd, pfd->second); - } + plugin->_ext._posixfd->on_fd(plugin->_plugin, fd, pfd->second); } } } diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index 6dbb9ce1..117ca425 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -17,15 +17,21 @@ struct X11Gui void runloop(); void shutdown(); + // 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 unregister_fd(int fd); + freeaudio::clap_wrapper::standalone::StandaloneHost *standaloneHost{nullptr}; + Display *display{nullptr}; Window window{0}; Atom wmDeleteMessage{0}; + bool runloopRunning{false}; int epoll_fd{-1}; static constexpr size_t maxEpollEvents{256}; diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 3f8ecea9..10262d62 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -15,6 +15,12 @@ // 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(); +#endif + const clap_plugin_entry *entry{nullptr}; #ifdef STATICALLY_LINKED_CLAP_ENTRY extern const clap_plugin_entry clap_entry; @@ -75,6 +81,10 @@ int main(int argc, char **argv) x11Gui.setPlugin(plugin); x11Gui.runloop(); 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(); #else freeaudio::clap_wrapper::standalone::mainWait(); #endif From 6f82730dd3d990e9ab10a6c45fff5ef4b24aace3 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:05:30 -0400 Subject: [PATCH 04/13] LIN-5: Check the Linux startup and GUI handshake Everything in the Linux startup path was taken on trust. Mirror what macOS does: - mainCreatePlugin's result is checked in main(); a null used to reach X11Gui::setPlugin and dereference there. Both this and the missing-entry case now tell the user rather than only std::cerr on a GUI app. - create(), get_size(), adjust_size() and set_parent() results are all honoured, and the reported size is sanity checked before it reaches XCreateSimpleWindow, where a 0 was a BadValue abort. - adjust_size is only called when the plugin says can_resize(), as macOS already did. - An XSetErrorHandler is installed. Xlib's default handler exits the process on any protocol error, which is a poor way for an audio app to end. - XOpenDisplay failure no longer exit(1)s. There being no display is not fatal: audio, MIDI and plugin timers all still run, so initialize() reports it and returns false. The epoll is created before the display for that reason, and a failure to add the display fd to it no longer tears the epoll down - the runloop's poll still picks X events up. - shutdown() only destroys a GUI that was actually created. Previously a plugin without X11 support, whose create() we skipped, still got a destroy() on the way out. Verified: normal GUI run maps a correctly sized window; with DISPLAY unset the standalone reports the missing display, keeps running with audio, and still exits 0 on SIGINT. --- src/detail/standalone/linux/x11_gui.cpp | 200 ++++++++++++++++++------ src/detail/standalone/linux/x11_gui.h | 9 +- src/wrapasstandalone.cpp | 23 ++- 3 files changed, 182 insertions(+), 50 deletions(-) diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 385432a8..5e57d8c3 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "x11_gui.h" #include "linux_frontend.h" @@ -18,25 +19,52 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { -void X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah) +namespace +{ +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. + 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)\n", buf, (int)e->request_code, + (int)e->minor_code); + fflush(stderr); + return 0; +} +} // namespace + +bool X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah) { - 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); - } 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)); + } + + 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 @@ -74,14 +102,22 @@ void X11Gui::runloop() { 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; @@ -141,56 +177,124 @@ 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; + + // 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; - ui->create(p, CLAP_WINDOW_API_X11, false); + if (!plugin->_ext._gui) + { + LOGINFO("Plugin provides no GUI extension; running without a window"); + return; + } - uint32_t w, h; - ui->get_size(p, &w, &h); - ui->adjust_size(p, &w, &h); + auto ui = plugin->_ext._gui; + auto pl = plugin->_plugin; - 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); + 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); + XSelectInput(display, window, InputOutput | StructureNotifyMask); + + // 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) +{ + return w > 0 && h > 0 && w <= 16384 && h <= 16384; +} + +void X11Gui::destroyGui() { - if (plugin && plugin->_ext._gui) + if (guiCreated && plugin && plugin->_ext._gui) { plugin->_ext._gui->destroy(plugin->_plugin); } + guiCreated = false; + window = 0; +} +void X11Gui::shutdown() +{ + // only destroy a GUI we got as far as creating + destroyGui(); + if (epoll_fd >= 0) { close(epoll_fd); diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index 117ca425..25d3f9f1 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -12,7 +12,9 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { struct X11Gui { - void initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *); + // false if there is no usable display; audio and timers still run, we just + // never get a window + bool initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *); void setPlugin(std::shared_ptr); void runloop(); void shutdown(); @@ -32,6 +34,8 @@ struct X11Gui 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}; @@ -40,6 +44,9 @@ struct X11Gui bool resetSizeTo(int w, int h); + static bool isSaneSize(uint32_t w, uint32_t h); + void destroyGui(); + std::map fdToTimerId; std::map timerIdToFd; diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 10262d62..4e7430aa 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -21,6 +21,15 @@ int main(int argc, char **argv) freeaudio::clap_wrapper::standalone::linux_standalone::installSignalHandlers(); #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; @@ -50,12 +59,14 @@ int main(int argc, char **argv) #if LIN && CLAP_WRAPPER_STANDALONE_X11 freeaudio::clap_wrapper::standalone::linux_standalone::X11Gui x11Gui{}; + // A false here means we have no display. 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()); #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; } @@ -75,6 +86,16 @@ int main(int argc, char **argv) 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; + } + freeaudio::clap_wrapper::standalone::mainStartAudio(); #if LIN && CLAP_WRAPPER_STANDALONE_X11 From 4951ca2d2fca641dbb75b4e23345e5fe76eae42c Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:07:13 -0400 Subject: [PATCH 05/13] LIN-7: Select the intended X11 events, and let the user resize XSelectInput was passed `InputOutput | StructureNotifyMask`. InputOutput is a window *class*, not an event mask, and happens to equal KeyPressMask - so the window asked for key presses it never reads. Ask for StructureNotify and Exposure, which is what it actually wants. ConfigureNotify was never handled, and resetSizeTo pinned min == max == the current size even for a plugin which says can_resize(), so user resize was impossible in both directions at once. Now: - ConfigureNotify runs the new size through adjust_size() and set_size(), and if the plugin snaps to a size of its own the window is made to agree. Our own resizes are tracked so they don't echo back into the plugin. - Size hints come from can_resize() plus clap_gui_resize_hints: a fixed axis is pinned min == max, a resizable one gets a floor and no practical ceiling, and preserve_aspect_ratio becomes PAspect. Verified against two-filters: WM_NORMAL_HINTS now reads min 64x64 / max 16384x16384 rather than a pinned 960x693, and an external resize of the window to 1200x800 resizes the plugin's own child window to match. --- src/detail/standalone/linux/x11_gui.cpp | 114 ++++++++++++++++++++++-- src/detail/standalone/linux/x11_gui.h | 11 +++ 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 5e57d8c3..fc7b5316 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -121,6 +121,14 @@ void X11Gui::runloop() } } break; + case ConfigureNotify: + { + if (e.xconfigure.window == window) + { + handleConfigure(e.xconfigure.width, e.xconfigure.height); + } + } + break; case ClientMessage: { // 3. Check if the message is the delete request @@ -255,7 +263,10 @@ void X11Gui::setPlugin(std::shared_ptr p) } XStoreName(display, window, plugin->_plugin->desc->name); - XSelectInput(display, window, InputOutput | StructureNotifyMask); + // 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); @@ -278,7 +289,7 @@ void X11Gui::setPlugin(std::shared_ptr p) bool X11Gui::isSaneSize(uint32_t w, uint32_t h) { - return w > 0 && h > 0 && w <= 16384 && h <= 16384; + return w > 0 && h > 0 && w <= maxWindowDim && h <= maxWindowDim; } void X11Gui::destroyGui() @@ -430,19 +441,104 @@ 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 that follows matches lastWidth/lastHeight, so this + // settles rather than ping-ponging. + lastWidth = (int)aw; + lastHeight = (int)ah; + XResizeWindow(display, window, aw, 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 25d3f9f1..4d3fdbfc 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -44,9 +44,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; From 53c1dbc7846071a508359bb6fe7dae1b8606d378 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:08:09 -0400 Subject: [PATCH 06/13] LIN-9: Implement modify_fd It returned true without doing anything, so a plugin which registered an fd for reads and later wanted writes silently never got them, while the host claimed it had obliged. Implement it as epoll_ctl(EPOLL_CTL_MOD) against the registered set, with the clap-flags-to-epoll mapping factored out and shared with register_fd. Also: the register/unregister/timer forwarders in the shared host asserted x11Gui was non-null, which in a release build is no check at all. They return false instead, which matters now that a display failure is survivable. --- src/detail/standalone/linux/x11_gui.cpp | 40 ++++++++++++++++++++--- src/detail/standalone/linux/x11_gui.h | 3 ++ src/detail/standalone/standalone_host.cpp | 13 ++++++-- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index fc7b5316..2a80901e 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -403,25 +403,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; +} + +bool X11Gui::register_fd(int fd, clap_posix_fd_flags_t iflags) +{ + if (epoll_fd < 0) return false; - epoll_event event; - event.events = flags; + 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); diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index 4d3fdbfc..ee9e5cde 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -26,8 +26,11 @@ struct X11Gui 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}; 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; From 542e3aa533f5798f1e9e3d4664d655250b152b4c Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:29:03 -0400 Subject: [PATCH 07/13] LIN-10: Loader search paths, and a real X11 dependency - CLAP_PATH was dead code: `if (cp.empty())` guarded the split, so the only way in was an empty CLAP_PATH which then had nothing to split. Inverted, and each entry is checked for existence like the Windows branch does. - getenv("HOME") was handed straight to fs::path, which is undefined behaviour when HOME is unset (a systemd unit, a bare su). Fall back to the passwd entry, and skip ~/.clap if even that has nothing. - /usr/local/lib/clap is now searched. - cmake linked a bare `X11`, so a missing libx11-dev turned up as a raw linker error. find_package(X11) with a message naming the package, and link X11::X11. - New CLAP_WRAPPER_STANDALONE_X11_GUI option (default ON) turns the X11 GUI off, giving a standalone with no window and no X11 dependency at all; audio, MIDI, plugin timers and the command line still work. Verified: the resulting binary does not link libX11, runs, and exits on a signal. The error-reporter wiring moves into linux_frontend as installAudioErrorReporter() along the way, since main() cannot see StandaloneHost's definition when the X11 GUI - and with it x11_gui.h - is compiled out. --- cmake/wrap_standalone.cmake | 28 ++++++++++++++++--- src/detail/clap/fsutil.cpp | 27 +++++++++++++++--- .../standalone/linux/linux_frontend.cpp | 6 ++++ src/detail/standalone/linux/linux_frontend.h | 6 ++++ src/wrapasstandalone.cpp | 10 ++----- 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/cmake/wrap_standalone.cmake b/cmake/wrap_standalone.cmake index 4dcdef34..910666a9 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 @@ -182,10 +188,24 @@ function(target_add_standalone_wrapper) target_sources(${salib} PRIVATE ${CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR}/src/detail/standalone/linux/linux_frontend.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) + 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_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index f8ac8df5..e03dca43 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -177,6 +177,12 @@ void runDialogDetached(std::vector command) } } // namespace +void installAudioErrorReporter() +{ + getStandaloneHost()->displayAudioError = [](const std::string &msg) + { reportError("Unable to configure audio", msg); }; +} + void installSignalHandlers() { struct sigaction sa; diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index f5c91ab0..87332337 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -22,6 +22,12 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone */ 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 diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 4e7430aa..1b45aebb 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -71,14 +71,8 @@ int main(int argc, char **argv) } #if LIN - // Without this every RtAudio failure is silent and the app just runs with no - // sound. reportError writes stderr always, and puts up a zenity/kdialog box - // if the desktop has one. - freeaudio::clap_wrapper::standalone::getStandaloneHost()->displayAudioError = - [](const std::string &msg) - { - freeaudio::clap_wrapper::standalone::linux_standalone::reportError("Unable to configure audio", msg); - }; + // 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}; From ae0d789517cdd35cd216a2d2f26b170c23f0d6c5 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:32:29 -0400 Subject: [PATCH 08/13] LIN-3: Actually reach PulseAudio/PipeWire and JACK, not just raw ALSA Two halves to the "it only ever uses ALSA" complaint. Compile time: clap-wrapper set no RTAUDIO_API_* on Linux, so which backends existed was an invisible function of which dev packages the build machine happened to have - which is how CI came to ship binaries with no PulseAudio, and so no PipeWire either. base_sdks now detects libpulse-simple and jack via pkg-config, surfaces the decision as CLAP_WRAPPER_STANDALONE_LINUX_{ALSA,PULSE,JACK} (defaulting from any RTAUDIO_API_* a consumer already set, else from detection), reports what it settled on, and warns when the build is about to ship without Pulse. libpulse-dev and libx11-dev are added to the CI Linux deps. Run time: the shared layer asks RtAudio for UNSPECIFIED, and RtAudio probes ALSA, JACK, then Pulse with first-non-empty winning. ALSA always has devices, so JACK and Pulse were never reached even when compiled in. selectAudioApi() now chooses before audio starts: Pulse (which is how you reach PipeWire - RtAudio 6.0.1 has no native PipeWire backend), then JACK, then ALSA, taking the first which actually reports an output device, and naming the choice on stderr. A backend whose server isn't running reports no devices, so an unstarted JACK is skipped rather than selected and failed. Verified on a PipeWire box built without libpulse-dev: configure warns about the missing Pulse backend, the JACK probe correctly declines (no server), and the app reports "audio api: ALSA" and runs. The command-line override lands with LIN-11. --- .github/workflows/pullreq.yml | 4 +- cmake/base_sdks.cmake | 68 ++++++++++++ .../standalone/linux/linux_frontend.cpp | 100 ++++++++++++++++++ src/detail/standalone/linux/linux_frontend.h | 36 +++++++ src/wrapasstandalone.cpp | 4 + 5 files changed, 210 insertions(+), 2 deletions(-) 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/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/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index e03dca43..fe1de2fa 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -16,6 +16,8 @@ #include #include +#include + #include "detail/standalone/standalone_details.h" #include "detail/standalone/standalone_host.h" #include "detail/standalone/entry.h" @@ -175,8 +177,106 @@ void runDialogDetached(std::vector command) }) .detach(); } + +/* + * 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. + * The probe swallows errors: 'not available' is an answer, not a failure. + */ +bool apiHasOutputDevices(RtAudio::Api api) +{ + try + { + RtAudio probe(api, [](RtAudioErrorType, const std::string &) {}); + for (auto id : probe.getDeviceIds()) + { + if (probe.getDeviceInfo(id).outputChannels > 0) return true; + } + } + catch (...) + { + } + return false; +} + +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; +} } // namespace +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(); + + if (!requestedName.empty() && lowercased(requestedName) != "auto") + { + auto api = resolveAudioApiName(requestedName); + if (api == RtAudio::Api::UNSPECIFIED) + { + std::string available; + for (auto a : compiledAudioApis()) + { + if (!available.empty()) available += ", "; + available += RtAudio::getApiName(a); + } + reportError("Unknown audio API", "This build has no audio API called '" + requestedName + + "'. Available: " + available + + ". Falling back to the default order."); + } + else + { + host->setAudioApi(api); + LOGINFO("Audio API (requested) : {}", RtAudio::getApiDisplayName(api)); + fprintf(stderr, "[INFO] audio api: %s\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; + + host->setAudioApi(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) diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index 87332337..d8308801 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -1,6 +1,18 @@ #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 @@ -37,6 +49,30 @@ void installAudioErrorReporter(); 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. + */ +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(); + /* * 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. diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 1b45aebb..4f709731 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -73,6 +73,10 @@ int main(int argc, char **argv) #if LIN // stderr always, plus a zenity/kdialog box when the session has one freeaudio::clap_wrapper::standalone::linux_standalone::installAudioErrorReporter(); + + // Before any audio starts: RtAudio would otherwise settle on raw ALSA even on + // a PipeWire box, because ALSA always has devices and it probes that first + freeaudio::clap_wrapper::standalone::linux_standalone::selectAudioApi(); #endif std::string pid{PLUGIN_ID}; From 817aff0271ccf9d6d99e482cc8e2b7fff812bf97 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:40:31 -0400 Subject: [PATCH 09/13] LIN-11: Configure the Linux standalone from the command line Building a device picker in raw Xlib is poor value, so the Linux configuration story is flags, spanning what the audio layer can actually be told: --audio-api alsa | pulse | jack | pipewire (alias for pulse) | auto --output-device a name, part of a name, or an id from --list-devices --input-device likewise --no-input output only, even for a plugin with an audio input --sample-rate --buffer-size --no-gui run with no window at all --list-apis backends this build has, and what each one can see --list-devices --list-midi-inputs --version, --help Names rather than ids are the documented way to pick a device, since RtAudio 6 ids are per-instance handles: a name matches exactly if it can and otherwise as a unique fragment, and an ambiguous or absent one lists what there is and exits 5 rather than quietly using a default. Bad usage exits 2. A rate the device doesn't offer, and a buffer size outside 16-8192, are clamped with a warning rather than silently substituted. A stray non-option argument - a launcher appending %U, say - is a warning, not a refusal to start. Device and rate choices reach the host through setStartupAudio(), which existed with no callers; unspecified values keep the defaults they had. Choosing a backend which is compiled in but has no devices now says so plainly, instead of leaving the user with RtAudio's "deviceId argument not found". The README documents all of this, including which cmake options decide the available backends and that the GUI is X11/XWayland with no native Wayland. Verified: each flag, each error path, and --no-gui (no window, audio running, ^C exits in ~260ms). --- README.md | 41 ++ cmake/wrap_standalone.cmake | 3 +- .../standalone/linux/linux_command_line.cpp | 484 ++++++++++++++++++ .../standalone/linux/linux_command_line.h | 63 +++ .../standalone/linux/linux_frontend.cpp | 18 +- src/detail/standalone/linux/x11_gui.cpp | 9 +- src/detail/standalone/linux/x11_gui.h | 6 +- src/wrapasstandalone.cpp | 31 +- 8 files changed, 643 insertions(+), 12 deletions(-) create mode 100644 src/detail/standalone/linux/linux_command_line.cpp create mode 100644 src/detail/standalone/linux/linux_command_line.h diff --git a/README.md b/README.md index 034ad78b..93ffeaa8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,47 @@ 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 +--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; all of them are bound +``` + +Device *names* are the thing to pass: the numeric ids RtAudio reports are +per-run handles, not stable identifiers. A name is matched exactly if it can +be and otherwise as a unique fragment, so `--output-device HDMI` will usually +do. + +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. + ## Licensing The `clap-wrapper` project is released under the MIT license. diff --git a/cmake/wrap_standalone.cmake b/cmake/wrap_standalone.cmake index 910666a9..a98a5a6c 100644 --- a/cmake/wrap_standalone.cmake +++ b/cmake/wrap_standalone.cmake @@ -186,7 +186,8 @@ function(target_add_standalone_wrapper) 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_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 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..0ecc733c --- /dev/null +++ b/src/detail/standalone/linux/linux_command_line.cpp @@ -0,0 +1,484 @@ +#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" +#include "RtMidi.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 +{ +std::string lower(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; +} + +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" + "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. All of them are bound.\n" + " --version\n" + " --help\n" + "\n" + "Device names are the stable way to name a device: the numeric ids are handles\n" + "which can differ between runs. A name is matched exactly if it can be, and\n" + "otherwise as a unique fragment, so --output-device HDMI is usually enough.\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 + unsigned int outs{0}, ins{0}; + try + { + RtAudio probe(api, [](RtAudioErrorType, const std::string &) {}); + for (auto id : probe.getDeviceIds()) + { + auto info = probe.getDeviceInfo(id); + if (info.outputChannels > 0) outs++; + if (info.inputChannels > 0) ins++; + } + } + catch (...) + { + } + + 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()); + } +} + +void listMidiInputs() +{ + try + { + RtMidiIn midiIn; + auto n = midiIn.getPortCount(); + fprintf(stdout, "MIDI input ports (all are bound):\n"); + if (n == 0) fprintf(stdout, " (none)\n"); + for (unsigned int i = 0; i < n; ++i) + { + fprintf(stdout, " [%u] %s\n", i, midiIn.getPortName(i).c_str()); + } + } + catch (RtMidiError &e) + { + fprintf(stderr, "[ERROR] Unable to enumerate MIDI inputs : %s\n", e.getMessage().c_str()); + } +} + +/* + * Resolve what the user typed against the devices the host's own RtAudio + * instance can see. RtAudio 6 device ids are per-instance handles rather than + * stable identifiers, which is why a name is the better thing to pass and why + * this resolves at startup rather than storing an id. + */ +bool resolveDevice(const std::vector &devices, const std::string &spec, + bool forInput, unsigned int &resolved) +{ + auto what = forInput ? "input" : "output"; + + auto complain = [&](const std::string &why) + { + fprintf(stderr, "[ERROR] %s audio device '%s': %s\n", what, spec.c_str(), why.c_str()); + fprintf(stderr, " Available %s devices:\n", what); + for (const auto &d : devices) fprintf(stderr, " [%u] %s\n", d.ID, d.name.c_str()); + }; + + if (isAllDigits(spec)) + { + auto asId = (unsigned int)strtoul(spec.c_str(), nullptr, 10); + for (const auto &d : devices) + { + if (d.ID == asId) + { + resolved = d.ID; + return true; + } + } + complain("no device with that id"); + return false; + } + + auto needle = lower(spec); + + for (const auto &d : devices) + { + if (lower(d.name) == needle) + { + resolved = d.ID; + return true; + } + } + + std::vector partial; + for (const auto &d : devices) + { + if (lower(d.name).find(needle) != std::string::npos) partial.push_back(&d); + } + + if (partial.size() == 1) + { + resolved = partial.front()->ID; + return true; + } + if (partial.empty()) + { + complain("no such device"); + return false; + } + + std::string matches; + for (auto *d : partial) + { + if (!matches.empty()) matches += ", "; + matches += "'" + d->name + "'"; + } + complain("matches more than one device: " + matches); + return false; +} +} // namespace + +bool CommandLineOptions::anyAudioOverride() const +{ + return noInput || !inputDevice.empty() || !outputDevice.empty() || sampleRate > 0 || bufferSize > 0; +} + +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-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 == "--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() && lower(opts.audioApi) != "auto" && lower(opts.audioApi) != "default" && + resolveAudioApiName(opts.audioApi) == RtAudio::Api::UNSPECIFIED) + { + std::string available; + for (auto a : compiledAudioApis()) + { + if (a == RtAudio::Api::RTAUDIO_DUMMY) continue; + if (!available.empty()) available += ", "; + available += RtAudio::getApiName(a); + } + fprintf(stderr, "[ERROR] No audio api called '%s' in this build. Available: %s\n", + opts.audioApi.c_str(), available.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 (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(); + return CommandLineResult::exitOk; + } + + return CommandLineResult::run; +} + +bool applyCommandLineOptions(const CommandLineOptions &opts) +{ + if (!opts.anyAudioOverride()) return true; + + auto host = getStandaloneHost(); + + // Anything not named on the command line keeps the default it would have had + auto [defaultIn, defaultOut, defaultRate] = host->getDefaultAudioInOutSampleRate(); + auto in = defaultIn; + auto out = defaultOut; + auto rate = (opts.sampleRate > 0) ? opts.sampleRate : defaultRate; + + if (opts.noInput) + { + // The shared layer reads a device of 0 as 'no input' + in = 0; + } + else if (!opts.inputDevice.empty()) + { + if (!resolveDevice(host->getInputAudioDevices(), opts.inputDevice, true, in)) return false; + } + + if (!opts.outputDevice.empty()) + { + if (!resolveDevice(host->getOutputAudioDevices(), opts.outputDevice, false, out)) return false; + } + + if (opts.sampleRate > 0) + { + // startAudioThreadOn falls back to the device's preferred rate if this one + // isn't offered, which is a silent substitution unless we say something + try + { + auto info = host->rtaDac->getDeviceInfo(out); + auto &rates = info.sampleRates; + if (std::find(rates.begin(), rates.end(), (unsigned int)opts.sampleRate) == rates.end()) + { + 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(), opts.sampleRate, ratesToString(rates).c_str()); + } + } + catch (...) + { + } + } + + host->setStartupAudio(in, out, rate); + + 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 shared layer treats this as the requested size and RtAudio writes + // back what it actually got + host->currentBufferSize = (uint32_t)frames; + } + + 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..825ddf4a --- /dev/null +++ b/src/detail/standalone/linux/linux_command_line.h @@ -0,0 +1,63 @@ +#pragma once + +#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 + * --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, all of which get bound + * --version + * --help + */ +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}; + + // whether anything here needs to override the host's startup defaults + bool anyAudioOverride() const; +}; + +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); + +/* + * Hand the audio choices to the standalone host. Must be called after the audio + * api is selected and before audio starts. False means a device or rate the + * user asked for doesn't exist, which is a startup error rather than something + * to paper over with a default. + */ +bool applyCommandLineOptions(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 index fe1de2fa..f7959fab 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -241,15 +241,27 @@ void selectAudioApi(const std::string &requestedName) if (!available.empty()) available += ", "; available += RtAudio::getApiName(a); } - reportError("Unknown audio API", "This build has no audio API called '" + requestedName + - "'. Available: " + available + - ". Falling back to the default order."); + fprintf(stderr, + "[ERROR] This build has no audio API called '%s'. Available: %s. Falling back to " + "the default order.\n", + requestedName.c_str(), available.c_str()); } else { host->setAudioApi(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; } } diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 2a80901e..66a99f73 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -36,7 +36,7 @@ int x11ErrorHandler(Display *d, XErrorEvent *e) } } // namespace -bool X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah) +bool X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah, bool wantWindow) { standaloneHost = sah; sah->x11Gui = this; @@ -50,6 +50,13 @@ bool X11Gui::initialize(freeaudio::clap_wrapper::standalone::StandaloneHost *sah 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); diff --git a/src/detail/standalone/linux/x11_gui.h b/src/detail/standalone/linux/x11_gui.h index ee9e5cde..f653d262 100644 --- a/src/detail/standalone/linux/x11_gui.h +++ b/src/detail/standalone/linux/x11_gui.h @@ -12,9 +12,9 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { struct X11Gui { - // false if there is no usable display; audio and timers still run, we just - // never get a window - bool 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(); diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 4f709731..57dfd877 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -9,6 +9,7 @@ #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 @@ -19,6 +20,20 @@ int main(int argc, char **argv) // 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) @@ -59,9 +74,10 @@ int main(int argc, char **argv) #if LIN && CLAP_WRAPPER_STANDALONE_X11 freeaudio::clap_wrapper::standalone::linux_standalone::X11Gui x11Gui{}; - // A false here means we have no display. 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()); + // 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) @@ -76,7 +92,14 @@ int main(int argc, char **argv) // Before any audio starts: RtAudio would otherwise settle on raw ALSA even on // a PipeWire box, because ALSA always has devices and it probes that first - freeaudio::clap_wrapper::standalone::linux_standalone::selectAudioApi(); + freeaudio::clap_wrapper::standalone::linux_standalone::selectAudioApi(clOptions.audioApi); + + // A device or rate the user asked for and which doesn't exist is a startup + // error, not something to quietly substitute a default for + if (!freeaudio::clap_wrapper::standalone::linux_standalone::applyCommandLineOptions(clOptions)) + { + return 5; + } #endif std::string pid{PLUGIN_ID}; From d0fcb806330188f1049c6e6c5d4b668906d321e6 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 10:42:31 -0400 Subject: [PATCH 10/13] LIN-5/LIN-6 follow-ups: three small robustness fixes - shutdown() keeps hold of the window id before destroyGui() clears it, so our own window is destroyed explicitly rather than left to XCloseDisplay. - The error dialog's worker thread is created inside a try: out of threads is not a reason to terminate on the way to reporting a problem, and the gate has to reopen for the next message either way. - The X11 error handler caps how much it reports. Protocol errors arrive on whichever thread talked to X and a plugin can generate them per repaint, so an unbounded handler is an unbounded log. --- .../standalone/linux/linux_frontend.cpp | 53 +++++++++++-------- src/detail/standalone/linux/x11_gui.cpp | 20 +++++-- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index f7959fab..c9204f41 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -152,30 +153,40 @@ void runDialogDetached(std::vector command) // 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. - 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 + try + { + std::thread( + [command = std::move(command)]() { - int status{0}; - while (waitpid(pid, &status, 0) < 0 && errno == EINTR) + 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)); } - } - dialogGate().release(); - }) - .detach(); + 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(); + } } /* diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 66a99f73..236e7d5b 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "x11_gui.h" @@ -25,12 +26,20 @@ 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)\n", 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; } @@ -310,6 +319,9 @@ void X11Gui::destroyGui() } 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(); @@ -318,9 +330,9 @@ void X11Gui::shutdown() close(epoll_fd); epoll_fd = -1; } - if (display && window > 0) + if (display && ourWindow > 0) { - XDestroyWindow(display, window); + XDestroyWindow(display, ourWindow); XFlush(display); } if (display) From f97d9d68298f5d89b0ec42a82cb0897be2677c3e Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 13:25:28 -0400 Subject: [PATCH 11/13] Linux standalone: follow the shared settings layer, and select MIDI ports The rebase onto next landed the reworked shared standalone: audio and MIDI configuration now lives in StandaloneHost::settings, devices and ports are identified by name rather than by RtAudio's per-instance ids, and startAudioThread() loads that settings file and calls applyAudioSettings() for itself. setStartupAudio(), which the Linux command line was written against, is gone - hence the build failure. So the Linux command line now writes into settings, which is a better fit than what it was doing before: the flags are overrides layered on top of whatever the settings file said, name resolution happens once in the shared layer, and --input-device/--output-device store the resolved device *name* instead of an id which is only meaningful to the RtAudio instance that enumerated it. (You can watch that matter: the same sound card comes out as [130] in one listing and [131] in the next.) Because startAudioThread() would reload the settings file over the top of the command line, main() now hands Linux startup to the frontend - load, select the backend, overlay the flags, applyAudioSettings(), start - which is the same sequence the Windows settings UI drives, rather than mainStartAudio(). That also has to happen after the plugin exists, since the plugin id is what names the settings file, so the startup order in main() moves accordingly. Two other things fall out of it: - selectAudioApi() records its choice in settings.audioApiName, so the shared applyAudioSettings() agrees with the backend the probe picked instead of re-deciding it. A backend named in the settings file is now honoured when no --audio-api was given. - a rate of 0 ("whatever the device is running at") is resolved from the chosen output device rather than left to startAudioThreadOnImpl, which substitutes the device's *preferred* rate - a different number on a Pulse or PipeWire graph which has been moved off its default. And now that openMidiPorts() honours a selection, LIN-11 can finish the job it had to leave out: --midi-input , repeatable, plus --no-midi for deliberately binding nothing. --list-midi-inputs marks what the current flags would open, so it doubles as a way to check a name before committing to it. --- README.md | 19 +- .../standalone/linux/linux_command_line.cpp | 341 ++++++++++++++---- .../standalone/linux/linux_command_line.h | 30 +- .../standalone/linux/linux_frontend.cpp | 27 +- src/detail/standalone/linux/linux_frontend.h | 7 +- src/wrapasstandalone.cpp | 24 +- 6 files changed, 340 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 93ffeaa8..e892e32e 100644 --- a/README.md +++ b/README.md @@ -91,16 +91,25 @@ settings window; `--help` lists everything, and the useful ones are: --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; all of them are bound +--list-midi-inputs MIDI input ports, and which ones would be opened ``` -Device *names* are the thing to pass: the numeric ids RtAudio reports are -per-run handles, not stable identifiers. A name is matched exactly if it can -be and otherwise as a unique fragment, so `--output-device HDMI` will usually -do. +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 diff --git a/src/detail/standalone/linux/linux_command_line.cpp b/src/detail/standalone/linux/linux_command_line.cpp index 0ecc733c..d2a62136 100644 --- a/src/detail/standalone/linux/linux_command_line.cpp +++ b/src/detail/standalone/linux/linux_command_line.cpp @@ -15,7 +15,6 @@ #endif #include "RtAudio.h" -#include "RtMidi.h" #ifdef __GNUC__ #pragma GCC diagnostic pop @@ -61,6 +60,12 @@ void usage(const std::string &programName) " --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" @@ -68,13 +73,16 @@ void usage(const std::string &programName) "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. All of them are bound.\n" + " --list-midi-inputs MIDI input ports, and which ones would be opened.\n" " --version\n" " --help\n" "\n" - "Device names are the stable way to name a device: the numeric ids are handles\n" - "which can differ between runs. A name is matched exactly if it can be, and\n" - "otherwise as a unique fragment, so --output-device HDMI is usually enough.\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()); } @@ -172,33 +180,48 @@ void listDevices(const std::string &requestedApi) } } -void listMidiInputs() +bool resolveMidiPort(const std::vector &ports, const std::string &spec, + std::string &resolved); + +void listMidiInputs(const CommandLineOptions &opts) { - try + 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) { - RtMidiIn midiIn; - auto n = midiIn.getPortCount(); - fprintf(stdout, "MIDI input ports (all are bound):\n"); - if (n == 0) fprintf(stdout, " (none)\n"); - for (unsigned int i = 0; i < n; ++i) - { - fprintf(stdout, " [%u] %s\n", i, midiIn.getPortName(i).c_str()); - } + std::string name; + if (resolveMidiPort(ports, spec, name)) selected.push_back(name); } - catch (RtMidiError &e) + + for (unsigned int i = 0; i < ports.size(); ++i) { - fprintf(stderr, "[ERROR] Unable to enumerate MIDI inputs : %s\n", e.getMessage().c_str()); + auto picked = std::find(selected.begin(), selected.end(), ports[i]) != selected.end(); + fprintf(stdout, " %s [%u] %s\n", picked ? "*" : " ", i, ports[i].c_str()); } } /* * Resolve what the user typed against the devices the host's own RtAudio - * instance can see. RtAudio 6 device ids are per-instance handles rather than - * stable identifiers, which is why a name is the better thing to pass and why - * this resolves at startup rather than storing an id. + * instance can see, and yield the device's *name*: that is what the settings + * layer stores and matches on, because RtAudio 6 device ids are per-instance + * enumeration handles rather than stable identifiers. Ids are still accepted as + * input, since --list-devices prints them, but they are never carried further + * than this function. */ bool resolveDevice(const std::vector &devices, const std::string &spec, - bool forInput, unsigned int &resolved) + bool forInput, std::string &resolved) { auto what = forInput ? "input" : "output"; @@ -216,7 +239,7 @@ bool resolveDevice(const std::vector &devices, const std::s { if (d.ID == asId) { - resolved = d.ID; + resolved = d.name; return true; } } @@ -230,7 +253,7 @@ bool resolveDevice(const std::vector &devices, const std::s { if (lower(d.name) == needle) { - resolved = d.ID; + resolved = d.name; return true; } } @@ -243,7 +266,7 @@ bool resolveDevice(const std::vector &devices, const std::s if (partial.size() == 1) { - resolved = partial.front()->ID; + resolved = partial.front()->name; return true; } if (partial.empty()) @@ -261,13 +284,174 @@ bool resolveDevice(const std::vector &devices, const std::s complain("matches more than one device: " + matches); return false; } -} // namespace -bool CommandLineOptions::anyAudioOverride() const +/* + * The same resolution for MIDI, against the port names RtMidi reports. The index + * accepted here is the position in that list - which is what --list-midi-inputs + * prints - and is no more stable across a reboot than an audio device id is. + */ +bool resolveMidiPort(const std::vector &ports, const std::string &spec, + std::string &resolved) { - return noInput || !inputDevice.empty() || !outputDevice.empty() || sampleRate > 0 || bufferSize > 0; + auto complain = [&](const std::string &why) + { + fprintf(stderr, "[ERROR] MIDI input '%s': %s\n", spec.c_str(), why.c_str()); + fprintf(stderr, " Available MIDI inputs:\n"); + if (ports.empty()) fprintf(stderr, " (none)\n"); + for (unsigned int i = 0; i < ports.size(); ++i) + { + fprintf(stderr, " [%u] %s\n", i, ports[i].c_str()); + } + }; + + if (isAllDigits(spec)) + { + auto idx = strtoul(spec.c_str(), nullptr, 10); + if (idx < ports.size()) + { + resolved = ports[idx]; + return true; + } + complain("no port with that index"); + return false; + } + + auto needle = lower(spec); + + for (const auto &port : ports) + { + if (lower(port) == needle) + { + resolved = port; + return true; + } + } + + std::vector partial; + for (const auto &port : ports) + { + if (lower(port).find(needle) != std::string::npos) partial.push_back(&port); + } + + if (partial.size() == 1) + { + resolved = *partial.front(); + return true; + } + if (partial.empty()) + { + complain("no such port"); + return false; + } + + std::string matches; + for (auto *port : partial) + { + if (!matches.empty()) matches += ", "; + matches += "'" + *port + "'"; + } + complain("matches more than one port: " + matches); + return false; } +/* + * 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 (!resolveDevice(host->getInputAudioDevices(), opts.inputDevice, true, settings.inputDeviceName)) + { + return false; + } + settings.audioInputUsed = true; + } + + if (!opts.outputDevice.empty()) + { + if (!resolveDevice(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) { @@ -344,6 +528,10 @@ CommandLineResult parseCommandLine(int argc, char **argv, const std::string &pro { opts.noInput = true; } + else if (arg == "--no-midi") + { + opts.noMidi = true; + } else if (arg == "--no-gui") { opts.noGui = true; @@ -360,6 +548,13 @@ CommandLineResult parseCommandLine(int argc, char **argv, const std::string &pro { 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; @@ -402,83 +597,73 @@ CommandLineResult parseCommandLine(int argc, char **argv, const std::string &pro 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(); + if (wantMidi) listMidiInputs(opts); return CommandLineResult::exitOk; } return CommandLineResult::run; } -bool applyCommandLineOptions(const CommandLineOptions &opts) +bool configureAndStartAudio(const CommandLineOptions &opts) { - if (!opts.anyAudioOverride()) return true; - auto host = getStandaloneHost(); - // Anything not named on the command line keeps the default it would have had - auto [defaultIn, defaultOut, defaultRate] = host->getDefaultAudioInOutSampleRate(); - auto in = defaultIn; - auto out = defaultOut; - auto rate = (opts.sampleRate > 0) ? opts.sampleRate : defaultRate; + // 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(); - if (opts.noInput) - { - // The shared layer reads a device of 0 as 'no input' - in = 0; - } - else if (!opts.inputDevice.empty()) - { - if (!resolveDevice(host->getInputAudioDevices(), opts.inputDevice, true, in)) return false; - } + // 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 (!opts.outputDevice.empty()) + if (!overlayCommandLine(opts)) return false; + + try { - if (!resolveDevice(host->getOutputAudioDevices(), opts.outputDevice, false, out)) return false; + host->applyAudioSettings(); } - - if (opts.sampleRate > 0) + catch (const std::exception &e) { - // startAudioThreadOn falls back to the device's preferred rate if this one - // isn't offered, which is a silent substitution unless we say something - try - { - auto info = host->rtaDac->getDeviceInfo(out); - auto &rates = info.sampleRates; - if (std::find(rates.begin(), rates.end(), (unsigned int)opts.sampleRate) == rates.end()) - { - 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(), opts.sampleRate, ratesToString(rates).c_str()); - } - } - catch (...) - { - } + // 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; } - host->setStartupAudio(in, out, rate); + if (opts.sampleRate > 0) warnIfRateSubstituted(opts.sampleRate); - if (opts.bufferSize > 0) + // 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)) { - 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 shared layer treats this as the requested size and RtAudio writes - // back what it actually got - host->currentBufferSize = (uint32_t)frames; + 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 index 825ddf4a..c2f824a5 100644 --- a/src/detail/standalone/linux/linux_command_line.h +++ b/src/detail/standalone/linux/linux_command_line.h @@ -1,6 +1,7 @@ #pragma once #include +#include /* * The Linux standalone is configured from the command line rather than from a @@ -16,12 +17,18 @@ * --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, all of which get bound + * --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 { @@ -35,8 +42,10 @@ struct CommandLineOptions int bufferSize{0}; // 0 for the shared default bool noGui{false}; - // whether anything here needs to override the host's startup defaults - bool anyAudioOverride() const; + // 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 @@ -54,10 +63,15 @@ CommandLineResult parseCommandLine(int argc, char **argv, const std::string &pro CommandLineOptions &opts); /* - * Hand the audio choices to the standalone host. Must be called after the audio - * api is selected and before audio starts. False means a device or rate the - * user asked for doesn't exist, which is a startup error rather than something - * to paper over with a default. + * 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 applyCommandLineOptions(const CommandLineOptions &opts); +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 index c9204f41..4e62d9b0 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -241,9 +241,26 @@ void selectAudioApi(const std::string &requestedName) { auto host = getStandaloneHost(); - if (!requestedName.empty() && lowercased(requestedName) != "auto") + // 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) { - auto api = resolveAudioApiName(requestedName); + 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) { std::string available; @@ -255,11 +272,11 @@ void selectAudioApi(const std::string &requestedName) fprintf(stderr, "[ERROR] This build has no audio API called '%s'. Available: %s. Falling back to " "the default order.\n", - requestedName.c_str(), available.c_str()); + wanted.c_str(), available.c_str()); } else { - host->setAudioApi(api); + adopt(api); LOGINFO("Audio API (requested) : {}", RtAudio::getApiDisplayName(api)); fprintf(stderr, "[INFO] audio api: %s\n", RtAudio::getApiDisplayName(api).c_str()); @@ -287,7 +304,7 @@ void selectAudioApi(const std::string &requestedName) if (!have(pref)) continue; if (!apiHasOutputDevices(pref)) continue; - host->setAudioApi(pref); + adopt(pref); LOGINFO("Audio API : {}", RtAudio::getApiDisplayName(pref)); fprintf(stderr, "[INFO] audio api: %s\n", RtAudio::getApiDisplayName(pref).c_str()); return; diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index d8308801..fba3517f 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -62,7 +62,11 @@ bool quitRequested(); * * 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. + * 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 = {}); @@ -78,4 +82,5 @@ std::vector compiledAudioApis(); * or a signal arrived. For the case where there is no GUI runloop to sit in. */ void waitForQuit(); + } // namespace freeaudio::clap_wrapper::standalone::linux_standalone diff --git a/src/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 57dfd877..15373a12 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -89,17 +89,6 @@ int main(int argc, char **argv) #if LIN // stderr always, plus a zenity/kdialog box when the session has one freeaudio::clap_wrapper::standalone::linux_standalone::installAudioErrorReporter(); - - // Before any audio starts: RtAudio would otherwise settle on raw ALSA even on - // a PipeWire box, because ALSA always has devices and it probes that first - freeaudio::clap_wrapper::standalone::linux_standalone::selectAudioApi(clOptions.audioApi); - - // A device or rate the user asked for and which doesn't exist is a startup - // error, not something to quietly substitute a default for - if (!freeaudio::clap_wrapper::standalone::linux_standalone::applyCommandLineOptions(clOptions)) - { - return 5; - } #endif std::string pid{PLUGIN_ID}; @@ -117,7 +106,20 @@ int main(int argc, char **argv) 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); From fb0acbef1f25a106e54cdd0cc2323d98f9a0fc17 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 13:25:51 -0400 Subject: [PATCH 12/13] Linux standalone: bound the shutdown, which ALSA can wedge for ever ^C or closing the window sometimes left a process which would not exit and had to be killed. It is a deadlock in RtAudio's ALSA backend: RtApiAlsa::callbackEvent() takes stream_.mutex and holds it across the blocking snd_pcm_readi() of a duplex stream, while RtApiAlsa::stopStream() sets the state and then wants that same mutex. If the capture side has stopped producing - which a PipeWire or dmix capture device does readily, and this box reproduces about one shutdown in five - the read never returns, the mutex is never released, and stopAudioThread() blocks in stopStream() for ever. Caught in gdb: Thread 1 futex_wait -> RtApiAlsa::stopStream -> StandaloneHost:: stopAudioThread -> mainFinish -> main Thread 26 poll -> snd_pcm_mmap_readi -> RtApiAlsa::callbackEvent Nothing on this side of RtAudio can break that, and CC-11's two second acknowledgement budget does not help: the callback is stuck before it ever looks at running. So give the whole orderly teardown a budget instead. A detached timer armed the moment the runloop returns _exit()s the process if five seconds pass without shutdownFinished() - comfortably more than the two seconds quiesceProcessing() may spend, and measured against a clean shutdown here of 60-150ms, so it never fires on the good path. The second-signal escape hatch from LIN-4 only covered a user who thinks to press ^C twice; this covers closing the window, SIGTERM from a service manager, and the first ^C. --- README.md | 6 +++ .../standalone/linux/linux_frontend.cpp | 42 +++++++++++++++++++ src/detail/standalone/linux/linux_frontend.h | 17 ++++++++ src/wrapasstandalone.cpp | 13 ++++++ 4 files changed, 78 insertions(+) diff --git a/README.md b/README.md index e892e32e..efeed827 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,12 @@ 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/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index 4e62d9b0..2a6fead2 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -30,6 +31,7 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone namespace { volatile sig_atomic_t quitFlag{0}; +std::atomic shutdownDone{false}; extern "C" void requestQuitHandler(int sig) { @@ -360,6 +362,46 @@ void waitForQuit() } } +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); diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index fba3517f..5e384993 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -83,4 +83,21 @@ std::vector compiledAudioApis(); */ 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/wrapasstandalone.cpp b/src/wrapasstandalone.cpp index 15373a12..05134244 100644 --- a/src/wrapasstandalone.cpp +++ b/src/wrapasstandalone.cpp @@ -124,15 +124,28 @@ int main(int argc, char **argv) #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 } From 4706ccce077cd420dc0ed86ab1f831178a3b50b5 Mon Sep 17 00:00:00 2001 From: Paul Walker Date: Mon, 17 Aug 2026 14:18:13 -0400 Subject: [PATCH 13/13] Linux standalone: consolidate the duplication in the Linux files No behaviour change beyond error wording; this is the pass over what the LIN series had accumulated. - resolveDevice() and resolveMidiPort() were the same fuzzy matcher written twice, ~60 lines each. One resolveByNameOrNumber(names, ids, ...) now does both: a non-empty ids means the number in a spec is an RtAudio device id, an empty one means it is a position in the list, which is how MIDI ports are numbered. Audio keeps a four-line adapter to flatten DeviceInfo. - lower() in linux_command_line.cpp and lowercased() in linux_frontend.cpp were the same function. One of them, declared in the header. - --list-apis counted each backend's devices with its own probe loop while selectAudioApi() had another in apiHasOutputDevices(). Both now go through probeApiDeviceCounts(), so what the listing reports and what the automatic selection acts on cannot disagree. - the 'Available: alsa, jack' text was built by hand at two error sites; now compiledAudioApiNames(). - handleConfigure()'s snap-back branch repeated resetSizeTo()'s body, including the lastWidth/lastHeight bookkeeping which is exactly what stops the resize echoing. It calls resetSizeTo() instead. The failure messages got less repetitive on the way past: 'nothing matches that name' rather than 'no such output audio device' under an '[ERROR] output audio device ...' prefix which had already said it. Net 30 lines fewer, and linux_command_line.cpp loses a third of its body. --- .../standalone/linux/linux_command_line.cpp | 195 ++++++------------ .../standalone/linux/linux_frontend.cpp | 46 +++-- src/detail/standalone/linux/linux_frontend.h | 13 ++ src/detail/standalone/linux/x11_gui.cpp | 8 +- 4 files changed, 116 insertions(+), 146 deletions(-) diff --git a/src/detail/standalone/linux/linux_command_line.cpp b/src/detail/standalone/linux/linux_command_line.cpp index d2a62136..987537ba 100644 --- a/src/detail/standalone/linux/linux_command_line.cpp +++ b/src/detail/standalone/linux/linux_command_line.cpp @@ -28,13 +28,6 @@ namespace freeaudio::clap_wrapper::standalone::linux_standalone { namespace { -std::string lower(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; -} - bool isAllDigits(const std::string &s) { return !s.empty() && @@ -113,22 +106,11 @@ void listApis() { 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 + // 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}; - try - { - RtAudio probe(api, [](RtAudioErrorType, const std::string &) {}); - for (auto id : probe.getDeviceIds()) - { - auto info = probe.getDeviceInfo(id); - if (info.outputChannels > 0) outs++; - if (info.inputChannels > 0) ins++; - } - } - catch (...) - { - } + 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, @@ -180,8 +162,15 @@ void listDevices(const std::string &requestedApi) } } +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); + std::string &resolved) +{ + return resolveByNameOrNumber(ports, {}, spec, "MIDI input", resolved); +} void listMidiInputs(const CommandLineOptions &opts) { @@ -213,145 +202,103 @@ void listMidiInputs(const CommandLineOptions &opts) } /* - * Resolve what the user typed against the devices the host's own RtAudio - * instance can see, and yield the device's *name*: that is what the settings - * layer stores and matches on, because RtAudio 6 device ids are per-instance - * enumeration handles rather than stable identifiers. Ids are still accepted as - * input, since --list-devices prints them, but they are never carried further - * than this function. + * 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 resolveDevice(const std::vector &devices, const std::string &spec, - bool forInput, std::string &resolved) +bool resolveByNameOrNumber(const std::vector &names, const std::vector &ids, + const std::string &spec, const std::string &what, std::string &resolved) { - auto what = forInput ? "input" : "output"; + 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 audio device '%s': %s\n", what, spec.c_str(), why.c_str()); - fprintf(stderr, " Available %s devices:\n", what); - for (const auto &d : devices) fprintf(stderr, " [%u] %s\n", d.ID, d.name.c_str()); + 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 asId = (unsigned int)strtoul(spec.c_str(), nullptr, 10); - for (const auto &d : devices) + auto asNumber = strtoul(spec.c_str(), nullptr, 10); + for (size_t i = 0; i < names.size(); ++i) { - if (d.ID == asId) + if (numberFor(i) == asNumber) { - resolved = d.name; + resolved = names[i]; return true; } } - complain("no device with that id"); + complain(ids.empty() ? "nothing has that index" : "nothing has that id"); return false; } - auto needle = lower(spec); + auto needle = lowercased(spec); - for (const auto &d : devices) + for (const auto &name : names) { - if (lower(d.name) == needle) + if (lowercased(name) == needle) { - resolved = d.name; + resolved = name; return true; } } - std::vector partial; - for (const auto &d : devices) + std::vector partial; + for (const auto &name : names) { - if (lower(d.name).find(needle) != std::string::npos) partial.push_back(&d); + if (lowercased(name).find(needle) != std::string::npos) partial.push_back(&name); } if (partial.size() == 1) { - resolved = partial.front()->name; + resolved = *partial.front(); return true; } if (partial.empty()) { - complain("no such device"); + complain("nothing matches that name"); return false; } std::string matches; - for (auto *d : partial) + for (auto *name : partial) { if (!matches.empty()) matches += ", "; - matches += "'" + d->name + "'"; + matches += "'" + *name + "'"; } - complain("matches more than one device: " + matches); + complain("matches more than one: " + matches); return false; } -/* - * The same resolution for MIDI, against the port names RtMidi reports. The index - * accepted here is the position in that list - which is what --list-midi-inputs - * prints - and is no more stable across a reboot than an audio device id is. - */ -bool resolveMidiPort(const std::vector &ports, const std::string &spec, - std::string &resolved) +// 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) { - auto complain = [&](const std::string &why) - { - fprintf(stderr, "[ERROR] MIDI input '%s': %s\n", spec.c_str(), why.c_str()); - fprintf(stderr, " Available MIDI inputs:\n"); - if (ports.empty()) fprintf(stderr, " (none)\n"); - for (unsigned int i = 0; i < ports.size(); ++i) - { - fprintf(stderr, " [%u] %s\n", i, ports[i].c_str()); - } - }; - - if (isAllDigits(spec)) - { - auto idx = strtoul(spec.c_str(), nullptr, 10); - if (idx < ports.size()) - { - resolved = ports[idx]; - return true; - } - complain("no port with that index"); - return false; - } - - auto needle = lower(spec); - - for (const auto &port : ports) - { - if (lower(port) == needle) - { - resolved = port; - return true; - } - } - - std::vector partial; - for (const auto &port : ports) - { - if (lower(port).find(needle) != std::string::npos) partial.push_back(&port); - } - - if (partial.size() == 1) - { - resolved = *partial.front(); - return true; - } - if (partial.empty()) + std::vector names; + std::vector ids; + for (const auto &d : devices) { - complain("no such port"); - return false; + names.push_back(d.name); + ids.push_back(d.ID); } - std::string matches; - for (auto *port : partial) - { - if (!matches.empty()) matches += ", "; - matches += "'" + *port + "'"; - } - complain("matches more than one port: " + matches); - return false; + return resolveByNameOrNumber(names, ids, spec, forInput ? "input audio device" : "output audio device", + resolved); } /* @@ -373,7 +320,8 @@ bool overlayCommandLine(const CommandLineOptions &opts) } else if (!opts.inputDevice.empty()) { - if (!resolveDevice(host->getInputAudioDevices(), opts.inputDevice, true, settings.inputDeviceName)) + if (!resolveAudioDevice(host->getInputAudioDevices(), opts.inputDevice, true, + settings.inputDeviceName)) { return false; } @@ -382,8 +330,8 @@ bool overlayCommandLine(const CommandLineOptions &opts) if (!opts.outputDevice.empty()) { - if (!resolveDevice(host->getOutputAudioDevices(), opts.outputDevice, false, - settings.outputDeviceName)) + if (!resolveAudioDevice(host->getOutputAudioDevices(), opts.outputDevice, false, + settings.outputDeviceName)) { return false; } @@ -576,18 +524,11 @@ CommandLineResult parseCommandLine(int argc, char **argv, const std::string &pro } } - if (!opts.audioApi.empty() && lower(opts.audioApi) != "auto" && lower(opts.audioApi) != "default" && + if (!opts.audioApi.empty() && lowercased(opts.audioApi) != "auto" && lowercased(opts.audioApi) != "default" && resolveAudioApiName(opts.audioApi) == RtAudio::Api::UNSPECIFIED) { - std::string available; - for (auto a : compiledAudioApis()) - { - if (a == RtAudio::Api::RTAUDIO_DUMMY) continue; - if (!available.empty()) available += ", "; - available += RtAudio::getApiName(a); - } fprintf(stderr, "[ERROR] No audio api called '%s' in this build. Available: %s\n", - opts.audioApi.c_str(), available.c_str()); + opts.audioApi.c_str(), compiledAudioApiNames().c_str()); return CommandLineResult::exitError; } diff --git a/src/detail/standalone/linux/linux_frontend.cpp b/src/detail/standalone/linux/linux_frontend.cpp index 2a6fead2..d4ae51c8 100644 --- a/src/detail/standalone/linux/linux_frontend.cpp +++ b/src/detail/standalone/linux/linux_frontend.cpp @@ -194,31 +194,54 @@ void runDialogDetached(std::vector command) /* * 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. - * The probe swallows errors: 'not available' is an answer, not a failure. */ 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()) { - if (probe.getDeviceInfo(id).outputChannels > 0) return true; + auto info = probe.getDeviceInfo(id); + if (info.outputChannels > 0) outputs++; + if (info.inputChannels > 0) inputs++; } } catch (...) { + // 'not available' is an answer, not a failure } - return false; } -std::string lowercased(const std::string &s) +std::string compiledAudioApiNames() { - std::string r{s}; - std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c) { return (char)std::tolower(c); }); - return r; + std::string res; + for (auto api : compiledAudioApis()) + { + if (api == RtAudio::Api::RTAUDIO_DUMMY) continue; + if (!res.empty()) res += ", "; + res += RtAudio::getApiName(api); + } + return res; } -} // namespace std::vector compiledAudioApis() { @@ -265,12 +288,7 @@ void selectAudioApi(const std::string &requestedName) auto api = resolveAudioApiName(wanted); if (api == RtAudio::Api::UNSPECIFIED) { - std::string available; - for (auto a : compiledAudioApis()) - { - if (!available.empty()) available += ", "; - available += RtAudio::getApiName(a); - } + auto available = compiledAudioApiNames(); fprintf(stderr, "[ERROR] This build has no audio API called '%s'. Available: %s. Falling back to " "the default order.\n", diff --git a/src/detail/standalone/linux/linux_frontend.h b/src/detail/standalone/linux/linux_frontend.h index 5e384993..713e7487 100644 --- a/src/detail/standalone/linux/linux_frontend.h +++ b/src/detail/standalone/linux/linux_frontend.h @@ -77,6 +77,19 @@ 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. diff --git a/src/detail/standalone/linux/x11_gui.cpp b/src/detail/standalone/linux/x11_gui.cpp index 236e7d5b..7a2bf880 100644 --- a/src/detail/standalone/linux/x11_gui.cpp +++ b/src/detail/standalone/linux/x11_gui.cpp @@ -583,11 +583,9 @@ void X11Gui::handleConfigure(int w, int h) if ((int)aw != w || (int)ah != h) { // The plugin snapped to a size of its own, so make the window agree. The - // ConfigureNotify that follows matches lastWidth/lastHeight, so this - // settles rather than ping-ponging. - lastWidth = (int)aw; - lastHeight = (int)ah; - XResizeWindow(display, window, aw, ah); + // 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