docs/multiarch.md
Decision
PipeWire stays the primary and default host audio backend. Alongside it, the three stream classes innative/pipewire_backend.h move behind a backend
interface, selected by a new CORDIAL_AUDIO_HOST variable, with PipeWire as
the only implementation until that refactor is proved to change nothing.
Then, in this order: PulseAudio, ALSA, and OSS only if somebody with
FreeBSD hardware will run it.
Each backend is dlopen’d, never linked, and its headers are optional at build
time — the arrangement pipewire_backend.cpp already uses, so no new hard
dependency is added by any of this.
Why this is being decided at all
A user asked for it, in these words: “pipewire-only, no ALSA nor OSS Support, no FreeBSD support, wayland only”. The first clause is accurate. Cordial has three output paths into the engine — AAudio’s pull callback, OpenSL ES and FMOD’s JavaAudioDevice push path, and capture — and every one of them ends
in PipeWire. On a desktop running PipeWire this is invisible, because
pipewire-pulse and the ALSA plugin cover PulseAudio and ALSA clients. On a
machine running PulseAudio or bare ALSA, Cordial is silent.
Sober, which is the reference implementation this project studies, links
PulseAudio (docs/analysis/sober-input-stack.md). That is a data point about
what the people who already run Roblox on Linux have, and it is why PulseAudio
comes before ALSA rather than after it.
Why the seam comes first, on its own, changing nothing
CallbackStream is a concrete pimpl class that aaudio.cpp holds by value,
and the only polymorphism in the file today is a compile-time #ifdef with an
honest “no audio” arm. There is no interface to implement against, so the first
version of any second backend would arrive tangled with the refactor that made
room for it — and then a silent regression in PipeWire, which every current user
depends on, would be indistinguishable from a bug in the new code.
A refactor that changes nothing is the one refactor that can be proved.
cordial_pipewire_backend_test and audio_probe both exist and both pass
today; they are the control, and they must still pass with PipeWire reached
through the seam before a line of PulseAudio is written.
The one-way door, which is the real risk here
native/aaudio.cpp records it: once FMOD has been told AAudio exists, there
is no way back. A control run with CORDIAL_AUDIO=aaudio-refuse answered
supportsAAudio() true and then failed every openStream; FMOD tried twice and
abandoned audio for the rest of the process — no AudioDevice.init, no
slCreateEngine, no node of any kind, and a place that loaded and played in
silence.
Every new backend sits behind that commitment. So each one must have an
availability probe as cheap and as trustworthy as PipeWire’s pw_core_sync
round trip, and supportsAAudio() must consult it before the door closes. For
ALSA that means opening and closing a PCM at probe time, which is neither free
nor reliably repeatable. A backend that cannot answer “am I really going to
work” honestly is a backend that trades silence-with-a-reason for
silence-without-one, and AGENTS.md’s rule about stubs that lie applies exactly.
The instrument problem compounds it. getXRunCount() reads zero on output
whether the stream is perfect or underrunning every 40 ms — the file says so, and
says it is there so the counter does not become this project’s fifth constant
mistaken for a measurement. What does discriminate is CORDIAL_TRACE_AUDIO=1’s
peak meter and pw-top’s error column, and the second of those is PipeWire’s.
Under ALSA or OSS we would be shipping with fewer instruments than the path we
are copying. Whatever lands must bring its own way of telling working audio
from silent audio, or it is not finished.
What is rejected
Addingalsa/pulse/oss as values of CORDIAL_AUDIO. That variable
selects which Android API FMOD reaches Cordial through — AAudio, OpenSL, or
FMOD’s Java path — and every combination of those with a host backend is
meaningful. One variable for two orthogonal axes is a variable nobody can
document. Hence CORDIAL_AUDIO_HOST, parsed in one place, announced at startup
the way the existing one already is.
Trusting pkg_check_modules for ALSA or PulseAudio. On the machine this was
written on, pkg-config first on PATH is Homebrew’s, whose search path is its
own Cellar. For PipeWire that failed loudly and find_path rescued it. For
these two it succeeds, resolving to Homebrew headers for a library that is not
the system one — a silent wrong answer, which is worse. The find_path fallback
pattern in native/CMakeLists.txt is mandatory here, not optional.
OSS as a route to FreeBSD. It is not one, and saying so to the person who
asked is more useful than shipping it. Cordial is AOSP’s bionic linker over a
Linux syscall and epoll shim, in a GTK4/Wayland window fixed by ADR-011, and
docs/multiarch.md restricts supported targets to Linux. Audio is not what
stops FreeBSD; approximately everything else is. An OSS backend would be a
correct answer to a question nobody is actually asking.
Consequences
Verification is asymmetric, and that has to be stated in each commit rather than discovered later. On the development machine PulseAudio can be exercised againstpipewire-pulse — which is PipeWire’s reimplementation of the protocol,
not the PulseAudio daemon, and differs in latency reporting and underflow
callbacks. ALSA can be exercised against default, which routes through
PipeWire’s ALSA plugin and therefore proves the snd_pcm_* sequencing but not
that real hardware is driven; hw:0,0 is reachable only by taking the device
away from the running session. OSS cannot be exercised at all: no /dev/dsp, no
snd-pcm-oss in this kernel, and OSSv3-through-ALSA would not transfer to
OSSv4-on-FreeBSD if it existed.
So PulseAudio ships “verified against pipewire-pulse”, ALSA ships “verified
against the PipeWire ALSA plugin, and once against hardware”, and OSS ships
INFERRED and never-executed or it does not ship. AGENTS.md already requires
this; writing it down here is so that nobody has to decide it under pressure at
review time.
CORDIAL_AUDIO_SINK does not port cleanly, and one property of it is load-
bearing. Empty means “follow whatever the session calls the default sink, and
keep following it” — a standing instruction, not a snapshot, so changing the
default sink mid-game moves the stream. PulseAudio keeps that property exactly.
ALSA and OSS cannot have it: default is resolved once at snd_pcm_open,
and nothing moves a running stream. The device-name string is not portable
either — a PipeWire node.name is not a PCM name. The shell’s sink picker walks
PipeWire’s registry through a C ABI, so under another backend it goes stale
unless that ABI grows an implementation too.
The picker is part of the feature, not a follow-up. A backend that plays
sound but leaves the user’s output device menu empty or wrong is half a feature,
and the half that is missing is the one people notice.
ALSA cannot work in the Flatpak, and that is not going to be fixed
Asked directly on 2026-08-26 — should ALSA be dropped and the backends be PipeWire and PulseAudio only? — and the answer turns on a fact about the sandbox that was not written down anywhere. The Flatpak has no route to/dev/snd. packaging/io.github.luohoa97.Cordial.yml
grants --socket=pulseaudio and the native PipeWire socket, and --device=dri
and nothing else. alsa_available() proves a device by opening a PCM and
closing it, which is the right probe and which cannot succeed against a
directory that is not in the sandbox. So for everybody installing Cordial the
recommended way, CORDIAL_AUDIO_HOST=alsa is a variable that reports it did not
get what it asked for.
The grant it would need is --device=all. Flatpak has no narrower lever —
there is no --device=snd — so reaching the sound devices means every device
node in /dev, cameras and input devices included. The manifest already refuses
that exact widening for FIDO2, in a comment arguing that a permission granting
far more than the capability needs is a permission that lies. That reasoning
does not get weaker because the capability this time is audio.
ALSA stays anyway, and the reason is the users the Flatpak is not for. The
AUR package, an RPM, and cargo build all produce a Cordial with no sandbox
around it, and a machine with no sound server at all is exactly the case ALSA
exists to answer. It is opt-in, it is probed before it is trusted, it compiles
out entirely without alsa-lib-devel, and it costs nothing on a machine that
never names it. Deleting a working fallback to tidy the backend list would take
sound away from the only people who need that fallback.
What was wrong was leaving this undocumented, because the failure it produces is
the expensive kind: the variable is accepted, the probe fails, and the user gets
PipeWire with no indication that the sandbox was the reason. Anybody debugging
ALSA under the Flatpak should be sent here in the first minute rather than the
second hour.
OSS is built, and the reasoning that deferred it was about who had spoken up
This document said OSS was “an answer to nothing” and scheduled it behind everything else, on the grounds that Linux has not used OSS as its sound layer since ALSA replaced it and the remaining users are on FreeBSD. The “what would change this” section below named the condition: somebody arriving who needs it. Somebody did, on 2026-08-26. So it exists, and the old reasoning stays above rather than being quietly deleted, because it turned out to be a claim about who had asked rather than about who was there. It is the simplest backend here and the only one that works with no sound daemon at all. No client library to find, no server to connect to, no node graph:/dev/dsp, three ioctls to agree a format, then write(). PipeWire
and PulseAudio both need something running and ALSA needs a configured default
PCM, so this is the one that serves a machine with none of that.
Three things about it are worth knowing before anybody selects it:
- The device is exclusive on most drivers. While Cordial holds
/dev/dsp, nothing else on the machine gets sound. That is what the interface is, not a bug to work around, and it is why this is opt-in behindCORDIAL_AUDIO_HOST=ossand never chosen for anybody who did not ask. - Even the probe takes the device, briefly, for the same reason. It is cached so it happens once.
- OSS speaks integer PCM. The engine’s float frames are converted to
AFMT_S16_NEon the way out, on the audio thread — a multiply and a clamp per sample, allocating nothing, perpipewire_backend.h’s realtime rule.
ioctl
arguments rather than assumed, because OSS negotiates by writing through them:
a driver that only does 44100 says so there and nowhere else, and reporting the
requested rate instead is how a stream ends up playing at the wrong speed with
nothing in the log.
What has not been verified
Playback. This was written and tested on Fedora, which ships no OSS emulation and has no/dev/dsp, so oss_available() correctly answers no here
and no audio has been produced through this path by anybody yet.
What is verified on this machine: it compiles into the build, find_path
detects sys/soundcard.h, CORDIAL_AUDIO_HOST=oss selects it, the probe
reports honestly, and the fallback to PipeWire announces itself and names
CORDIAL_AUDIO_DEVICE as the escape hatch:
open() onwards is INFERRED and labelled as such here
rather than in a commit message nobody will find. The first person with a real
OSS device is the test.
What would change this
A measurement showing PipeWire’s ALSA plugin orpipewire-pulse failing for
real users in a way a native backend fixes would reorder the work. So would
somebody arriving with FreeBSD hardware and the appetite to carry the rest of
the port — at which point OSS stops being an answer to nothing.