Skip to content

fix: mic selection, RT-safe denoising, and video pipeline robustness - #2

Open
perfectra1n wants to merge 11 commits into
masterfrom
fix/mic-device-selection
Open

fix: mic selection, RT-safe denoising, and video pipeline robustness#2
perfectra1n wants to merge 11 commits into
masterfrom
fix/mic-device-selection

Conversation

@perfectra1n

@perfectra1n perfectra1n commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Started as the mic-selection fix and grew as two more machines surfaced real bugs in the same session. Every fix here was diagnosed from live failures, and each commit stands alone.

Microphone selection (original scope)

  • The GUI sent the PipeWire node.description as audio.device, but target.object matches node.name only, so WirePlumber silently bound an arbitrary source while the status line echoed the configured string. The picker now works in ids and highlights the actually-configured device (row -1 when unset or unplugged).
  • The daemon resolves an unambiguous description to its node name at set-time, which also heals configs poisoned by the old GUI.
  • A device switch restarted the pipeline through the failure path: Failed health plus a 5 second backoff for doing what the user asked. The audio pipeline now has the same Outcome (Stopped vs Restart) shape the video side already learned the hard way, and switches complete in about a second.

Audio: DeepFilterNet ran inside the realtime callback

Three doc comments promised a worker thread; none existed. Inference ran in the RT_PROCESS playback callback behind a mutex, with a per-hop config snapshot (a lock and a heap allocation). Any cycle where the forward pass overran the quantum became scattered sub-hop silence: audibly choppy, only with denoise on.

The promised architecture is now built: two lock-free SPSC rings (rtrb) with a normal-priority worker between them. RT callbacks do bounded copies, atomics and a futex wake, nothing else. Level meters moved from mutexes (a priority-inversion pair with the metering poll) to atomic f32 bits. The playback side pops whole cycles through a hysteresis reader that confines silence to settling after stream start or a quantum increase, and trims stale lead after a decrease; the tests simulate the worst-case event order on purpose.

The compiler closed the loop on the old design: DfTract holds Rc and is not Send, so the old code was moving a non-Send value onto the RT thread through the FFI boundary unchecked. The denoiser is now constructed on the worker via a factory, which also puts the model load on a never-realtime thread, making the RLIMIT_RTTIME SIGKILL (exit 137, no log line) structurally impossible instead of demoted around. Review caught that the worker leaked on early error paths and could overwrite a Failed health with nominal; it is now a join guard with a regression test.

Video: robustness against real-world failure modes

  • A truncated MJPG frame (routine USB bandwidth weather) tore down the whole pipeline into a 5 second crash loop. Bad frames are now skipped and counted, with the driver's V4L2_BUF_FLAG_ERROR checked before paying for a decode; the device is only reopened after 30 consecutive failures.
  • V4L2 streams whatever the last S_FMT set, and WirePlumber probes can re-format an idle device across our power-save stop/start, so MJPG-negotiated frames arrived as raw YUYV ("Not a JPEG file"). The format is re-asserted and read back at stream start; a same-size decodable swap is adopted, anything else renegotiates.
  • video.device pointing at a v4l2loopback node was stored verbatim and failed cryptically at open. It is now refused at set-time with its identity named; open-time errors name the loopback too.
  • The GPU status seeded "not initialised" and was only written after camera open, so any earlier failure read as a GPU fault (one user spent a session diagnosing a healthy RTX 5090). It now distinguishes never-attempted from failed, and a camera unplug cannot erase a working adapter name.

Packaging

The NixOS module set no card_label, so both loopback nodes appeared everywhere as "Dummy video device" and WirePlumber elected the producerless spare as the default video source. Labels are now generated to match the device count, with the spare deprioritized via a WirePlumber rule.

Verification

fmt, clippy -D warnings, full test suite and the complexity ratchet are green (the refactors lowered several recorded floors). Live-verified on the reference machine: description-based mic switches complete in about a second with correct pw-link bindings, and the new ring path delivers passthrough audio within 2 dB of input with a consumer attached.

Switching microphones returned Err("config changed"), so every device
change published Failed health and sat in the 5-second failure backoff
with a dead mic. Worse, the audio-disabled wait loop returned Ok both
for "stop flag set" and "audio re-enabled", and run() read every Ok as
stop — so toggling audio off and on ended the audio thread until a
daemon restart. The same conflation the video pipeline's Outcome enum
fixed; the audio pipeline now carries its own.

Also publish nominal health when denoise is off by choice: a passthrough
the user asked for is healthy, and leaving the previous state in place
could show "failed: config changed" over a working microphone.
On a restart the audio thread is still SCHED_RR from rtkit, and the
DeepFilterNet load is a long continuous CPU burn — indistinguishable,
under the RLIMIT_RTTIME budget rtkit made us set, from a wedged RT
thread. The kernel's answer is SIGKILL to the whole process: no
handler, no log line, exit 137 on every microphone switch in a debug
build.

The demotion has one trap of its own: rtkit promotes threads with
SCHED_RESET_ON_FORK set, and a policy passed without that flag is a
request to clear it, which needs CAP_SYS_NICE — so a plain SCHED_OTHER
demotion fails with EPERM precisely on the thread it exists for. The
flag is read back and preserved.
The config stores a PipeWire node.name, but everything the user sees —
the GUI picker, list-microphones output — is the node.description. A
description written into audio.device fails silently: target.object
only matches names, so PipeWire falls back to some other source (the
webcam's built-in mic, on the reference machine) while the status line
goes on echoing the configured string as if it were live.

Set now accepts either and stores the name. A description shared by two
devices is deliberately left unresolved rather than guessed at: storing
the wrong sibling would be the same silent-wrong-device failure this
exists to prevent.
Two halves of one confusion. The picker sent the description it
displays as audio.device — the silent-wrong-device trap the daemon
commit describes — and never set microphone_index from daemon state,
so the ComboBox sat on row 0 showing whichever mic sorts first while
the daemon captured something else entirely.

Selections now travel as a row index into a parallel microphone-ids
model, and the highlight follows the configured id on every poll: -1
for unset or unplugged, never a wrong row.
…tions

CI failed on rustfmt and on the ratchet, and every complaint had a
structural answer rather than an entry in the ledger:

- unsafe_blocks 13 -> 14: demote_current_thread added a second inline
  sched_setscheduler block. Promotion and demotion now share one
  set_policy helper, so the syscall has a single unsafe block and a
  single SAFETY argument again.
- run_once cognitive 19 -> 23, lines 109: the disabled-wait park and the
  denoiser load move out into wait_while_disabled / load_denoiser.
  run_once lands at cognitive 13 and drops off the exceptions ledger.
- wire_controls 143 -> 145 lines: the mic-picker row lookup moves out
  into mic_id_at, next to mic_index which answers the inverse question.

cargo fmt reflowed two test asserts, and ratchet record lowered the
floors the refactor earned (run_once and PwSource::run both left the
ledger).
…line

A truncated MJPG payload is a routine USB event — bandwidth contention
on a hub is enough — and it ended in `?`: one bad frame published
"JPEG decode failed: Corrupt JPEG data: premature end of data segment"
as Failed health, sat out the five-second backoff, then tore down and
reopened the camera, renegotiating the very USB link that was already
struggling. On a machine where truncation recurs the pipeline flaps
forever with a red banner while 29 of every 30 frames were fine.
Observed live on a bandwidth-starved C920.

A bad frame is now the same non-event a failed matte is: skip it, keep
the last good picture on the virtual camera, count it as a drop, and
only reopen the device after DECODE_ERROR_LIMIT consecutive failures —
at that point the stream is broken, not unlucky.

Corruption arrives two ways and both feed one counter: the driver may
confess with V4L2_BUF_FLAG_ERROR (now surfaced on RawFrame and checked
before paying for a decode — for raw formats it is the only warning
there is, a truncated YUYV payload "decodes" into a torn picture), or
the decoder finds out the hard way.

The matte-inference error block moves out into infer_and_apply_matte
unchanged, which pays for the new lines under run_once's ratchet
ceiling and starts on the extraction its ledger note already names.
Three doc comments promised a worker thread; none existed. The denoiser
ran inside the playback stream's RT_PROCESS callback, behind a Mutex,
with a config snapshot (a lock AND a heap allocation) taken per hop —
so every cycle where the forward pass overran the quantum budget became
silence padding. Audibly choppy, only with denoise enabled, and
invisible to any "is it silent" test: the gaps are sub-hop and
scattered. Diagnosed from a machine where quantum 1024 meant two full
DF3 passes per RT callback.

The promised shape is now built. Two wait-free SPSC rings (rtrb) with a
normal-priority worker between them: the capture callback pushes raw
samples and unparks the worker, the worker chops hops and runs the
denoiser, the playback callback pops whole cycles. Nothing on the RT
path does more than a bounded memcpy and an atomic — the level meters
move from Mutex<f32> (a priority-inversion pair with the daemon's
metering poll) to atomic f32 bits.

The playback side pops through a CycleReader with deliberate
hysteresis: a normal-priority worker cannot beat the microseconds
between the capture and playback callbacks of the same graph cycle, so
delivery runs on a standing lead of processed audio, rebuilt
whole-cycles-at-a-time after stream start and quantum increases, and
trimmed after decreases so stale latency is not carried forever. The
tests simulate the worst-case event order (push, pop, then worker) on
purpose — the friendly order would pass against an implementation that
chops in production.

VirtualMic::run now takes a factory executed ON the worker thread, and
the compiler closed the loop: DfTract holds Rc<Tensor> and is !Send, so
the old code was moving a !Send value onto the RT thread through the
FFI boundary with no check. Construction on the worker also puts the
model load on a thread that is never realtime, making the recent
RLIMIT_RTTIME SIGKILL structurally impossible rather than demoted
around; health is still reported from where the load runs.
…status

Three findings from one debugging session on a second machine, all
enabled by us:

- V4L2 streams whatever the LAST S_FMT set, and Camera::open's
  negotiation can be overwritten while we hold the fd idle across
  power-save stop/start — WirePlumber's v4l2 monitor probes with its
  own S_FMT. The stored mode then lies, and MJPG-negotiated frames
  arrive as raw YUYV: "Not a JPEG file: starts with 0x00 0x0a" in a
  5-second crash loop. start() now re-asserts the format and reads back
  what was granted; a same-size decodable swap is adopted (the decoder
  dispatches per frame), anything else restarts the full negotiation.
  The policy is a pure function with hardware-free tests.

- video.device pointing at a v4l2loopback node was stored verbatim and
  failed at open with "reports no pixel format we can use". set() now
  refuses positively-virtual devices with their identity named, the
  same restraint as resolve_microphone: unknown or unplugged paths
  still store as given. Open-time gets the same classification so the
  error names the loopback when config predates the guard.

- gpu status seeded "not initialised" and was only ever written once
  Gpu::new ran — which sits AFTER camera open, so any earlier failure
  left a placeholder that reads as a GPU fault. Sent a user diagnosing
  a healthy RTX 5090. The seed now says GPU init was never reached, and
  the failure path publishes "not attempted" only when no run ever
  published an adapter — a camera unplug must not erase a working
  adapter name.
The module's modprobe line set no card_label, so every node — the one
cleanroom produces into AND the spare — presented to raw-V4L2 browsers
and PipeWire's v4l2 monitor as "Dummy video device (0x000N)". Observed
consequences: apps did not recognise the virtual camera by name (the
daemon's video.card_label only names its PipeWire node), and
WirePlumber elected the producerless spare as the system default video
source, handing anything that follows the default a dead device.

card_label is now generated to match devices= (primary label from the
new services.cleanroom.cardLabel option, defaulting to the name the
daemon's sink selection actually matches; spares clearly named
"Cleanroom Spare N"), and a WirePlumber rule drops the spares'
priority.session so a real camera always outranks them.
VirtualMic::run cognitive 47 -> 39 (the RT callbacks shed their mutex
plumbing) and video_pipeline::run_once nesting 6 -> 4 (GPU init
extracted). record only ever lowers, so this commits the improvements
as the new ceilings.
Found in review: the worker was spawned before a dozen fallible steps
in VirtualMic::run, and only the success path stopped and joined it.
The most plausible early failure — connect_rc when PipeWire is down —
is exactly the case the daemon retries every five seconds, so an outage
leaked one immortal thread per retry, each holding a loaded
DeepFilterNet session and waking every 10 ms. Worse, each orphan's
health report landed AFTER the supervisor's Failed report and replaced
it with "nominal" — a mic reported working while no node existed.

DenoiseWorker is now a join guard: Drop stops, unparks and joins, so
every `?` is covered and the join's ordering closes the health race
(the worker's report cannot land after an error it precedes). The
regression test drops the guard and asserts the shared Arc's refcount
returns to one — a parked-forever worker fails it.

Also from review: refuse a comma in services.cleanroom.cardLabel at
eval time — v4l2loopback's card_label is comma-delimited, so a comma
would silently desync the label list from devices=N.
@perfectra1n perfectra1n changed the title fix: microphone selection actually selects the microphone fix: mic selection, RT-safe denoising, and video pipeline robustness Aug 6, 2026
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