Skip to content

Lin standalone 516 - #518

Draft
baconpaul wants to merge 13 commits into
free-audio:nextfrom
baconpaul:lin-standalone-516
Draft

baconpaul wants to merge 13 commits into
free-audio:nextfrom
baconpaul:lin-standalone-516

Conversation

@baconpaul

Copy link
Copy Markdown
Collaborator

No description provided.

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.
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.
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.
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.
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.
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.
- 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.
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.
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).
- 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.
@baconpaul
baconpaul marked this pull request as draft August 17, 2026 16:40
@baconpaul

Copy link
Copy Markdown
Collaborator Author

Convert to draft since i want to see ci and do a review

…orts

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 <name|fragment|index>, 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.
^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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant