Goal (user-stated, 2026-08-24): turn the reverse-engineered Babyface Pro FS proprietary-mode knowledge into a kernel-grade Linux sound driver — meeting ALSA/kernel standards — to propose upstream (sound/usb, linux-sound / linux-usb lists). The user-space TuxMix stack stays as the reference implementation + validation harness (and the mixer GUI).
Standalone driver (sound/usb/snd-usb-babyface-pro.c, modeled on
snd-usb-caiaq), NOT an snd-usb-audio extension. Reasons, verified on
hardware:
- The proprietary-mode PCM runs on interrupt endpoints (interface
5, ep 0x01/0x82, bmAttributes 0x03) —
snd-usb-audio's PCM engine is 100% isochronous and has no interrupt path; ISO URBs on those endpoints fail with EINVAL. Adding interrupt-PCM to snd-usb-audio would re-architect its core for one device (unacceptable upstream). - The only in-tree precedent for interrupt audio streaming is
snd-usb-caiaq(andsnd-usb-line6) — the review model. - The class-compliant mode (other PID) keeps working via snd-usb-audio untouched; our driver only owns interface 5 (probe returns -ENODEV for the others; the standard MIDI interface 2 stays with snd-usb-audio).
TuxMix GUI/TUI (mixer control, RmeDevice trait)
│
tuxmix-core (the TotalMix-class mixer model)
│
tuxmix-usb (proprietary USB: vendor requests + interrupt streams)
│
tuxmix-sys (C ABI, cdylib)
│
libasound_module_pcm_tuxmix.so ← PipeWire via spa-alsa (sink/source)
tools/kernel/snd-usb-babyface-pro.c — first working skeleton:
| Feature | Status |
|---|---|
| Probe / card registration (card "Babyface Pro FS") | ✅ |
| Cold init sequence (cap_coldplug) + session arm at probe | ✅ |
| Interrupt stream (ep 0x01/0x82, 256 frames/URB, 8 URBs/direction) | ✅ |
| PCM playback + capture (S32_LE, 24 msbits, 9 rates 32-192 kHz) | ✅ |
| Rate switch = SET_INTERFACE(5, alt) (3 bandwidth classes) | ✅ 32/44.1/48/64/88.2 = alt1, 96/128 = alt2, 176.4/192 = alt3 — measured exact (48/96/192 kHz frame rates) |
| Controls: 6 output masters (0-0x4000 raw, 0 dB = 0x2000) + mutes | ✅ per-output names ("AN1/2" / "PH3/4" / … Playback Volume+Switch) — the 8-bit register is the REAL volume (0.5 dB/step, 0xF3 = 0 dB) |
| System volume = PipeWire SOFTWARE volume | ✅ DONE 2026-08-26 (cap_sysvol2.pcap: the Windows volume is a host-side stream gain, zero USB writes): the masters are named per output so SPA finds no "Master" element → the sink falls back to software volume; wpctl set-volume no longer moves any hardware register (verified). See “System-volume model — CORRECTED AGAIN” |
| Controls: 4 preamp gains — TWO laws | ✅ AN1/2 mic: 0-65 dB in 1 dB steps, packed `(fine << 5) |
| Controls: 2× phantom + 2× PAD (0x17 0x003F + 0x21 commit) | ✅ (LEDs + relay clicks) |
| Controls: 84 crosspoints (6 out × 14 src) — output order corrected | ✅ (Phones = block 0); AN1/2's low-map requirement found + fixed 2026-09-14, see below |
| Controls: pitch, loopback ×6, AN1>2, link, width, FX send, MS | ✅ |
| Loopback record staging | ✅ NO STAGING NEEDED (2026-08-26): the record is 1:1 with the master on Windows (aligned captures) AND Linux (live, S16 tone: −20 dBFS → −20.0). The “fixed 2^-5 tap”/×32 were artifacts of mktone.py's broken 4-byte tone (chain ran −48 dB low → record quantizer → coarse square). ×32 REVERTED; mktone.py → S16 |
| Front-panel readback (0x17 poll at 50 Hz → read-only button/wheel/IN/OUT/MIX/DIM ALSA controls, babyfacepro-ctl.c) | ✅ hardware-validated 2026-08-26 (all six flash codes + wheel; consume-on-get bug fixed — controls hold the last state) |
| Multi-channel PCM (2-12 ch, full 14-word frame) | ✅ (12-ch capture + playback verified; marker words skipped) |
| Default mixer state at probe (playback → all outputs at unity; hardware inputs not routed; analog masters -20 dB, digital 0 dB) | ✅ (inputs-off default 2026-09-28, see "Power-on defaults") |
| DSP EQ (eq.c): 4 strips × 3-band bell/shelf + low cut, 64-byte bulk coeff blocks on ep 0x0A | ✅ HARDWARE-VALIDATED 2026-08-27 on the mic (bell ±6 dB @ 200 Hz, +6 dB @ 3 kHz, low cut 100/300 Hz on/off; eq_selftest ~1 LSB vs the captures). Fixed-point Q27 (CORDIC + exp2, no FPU). NOTE: the loopback taps the record bus POST-EQ, so the input EQ is not measurable on the loopback chain (ear-validated instead) |
| Preamp state sync from 0x17 readback at probe | ✅ |
| Mixer-state persistence across interface re-probes (usbfs claim → detach → re-probe restores 48V/gains/crosspoints/pitch/flags) | ✅ 2026-08-24 |
| PM: suspend/resume with full cached-state restore (cold init + mixer re-apply) | ✅ |
| checkpatch | ✅ 0 errors / 0 warnings |
| Packaging: DKMS (survives kernel upgrades, no manual rebuild) | ✅ 2026-09-07 — tools/kernel/dkms.conf + aur/snd-usb-babyface-pro-dkms/PKGBUILD; tries plain make first, falls back to LLVM=1 CC=clang for clang-built kernels (CachyOS). Hardware-validated: built, installed, MOK-signed, and reloaded live via dkms build/install + modprobe on the dev box — all recent controls (Clock Source, Ref Level, Phase, Split, Trim) confirmed present via amixer after the DKMS-managed reload |
| Latency profiles discoverable (not just in a README footnote) | ✅ 2026-09-12 — tools/kernel/snd-usb-babyface-pro.conf, a fully-commented modprobe.d template installed by the DKMS package to /usr/lib/modprobe.d/. Shipped commented on purpose: the driver's compiled-in defaults are unchanged (the tree is under upstream review), and 16/16 is NOT safe to default to — it drops ~1 capture sample / 7 s. README's blockquote used to recommend exactly that lossy profile; corrected to 32/8 (0.67 ms), which was re-verified live on 2026-09-12: 20 s full-duplex at period 32, 0 xruns either direction, capture file byte-exact (7,680,044 = 48000×2×4×20 + header) |
Hardware test results (2026-08-24, live card):
- 440 Hz playback heard on PH3/4; master mute/unmute verified by ear.
- Voice capture on AN1 verified (48V + gain 35 → RMS ≈ −25 dB, envelope tracks speech).
- Phantom P48 LED follows the control; PAD relay clicks on both edges.
- Gain control changes the level; rate classes exact to the frame.
- Latency tuning (the whole point of the kernel driver): defaults
are 256 frames/URB × 8 URBs — the RME TotalMix 256-sample parity.
Module params
frames_per_urb(8..1024, mult. of 8) /nurbs(1..16) tune it down; the round-trip is measured phase-anchored withtools/kernel/looplat.c.
MEASURED 2026-08-24 (32 frames/URB × 3 URBs, live loopback, phase-
anchored with tools/kernel/looplat.c): the true loopback round-trip
latency is 138-170 frames = 2.9-3.5 ms at 48 kHz — a 32-frame
(0.67 ms) step = exactly one URB, so the latency is quantized to the
URB boundary and does NOT depend on the app's buffer/period size.
(Earlier "2.21 ms" readings were a start-phase artifact of the naive
first-impulse method.) Full sweep tools/kernel/latency-sweep.sh:
| channels | period (frames) | buffer (frames) | latency | xruns |
|---|---|---|---|---|
| 2 | 192-8192 | 384-16384 | 2.88-3.54 ms | 0 |
| 12 | 32-8192 | 64-16384 | 2.88-3.54 ms | 0 |
REVISED 2026-08-25 (evening, real-audio full-duplex under PipeWire):
- The min-PERIOD_BYTES clamp (
4 × 12 × frames_per_urb) inflated the 2-ch period floor to 1536 frames (32 ms). Constraint is nowPERIOD_SIZE ≥ frames_per_urb(frames), so the 2-ch floor = one URB at any channel count. - The DSP engages with URBs down to 16 frames (the old "256-frame URBs only" note was wrong). Validated sweep (48 kHz, tone via PipeWire, full-duplex playback+capture):
| frames/URB | nurbs | period (2ch) | result |
|---|---|---|---|
| 256/128/64 | 8 | = fpu | 100 % stable |
| 32 | 8 | 0.67 ms | 100 % stable |
| 16 | 8 | 0.33 ms | light cutouts |
| 16 | 16 | 0.33 ms | pb rock-solid; cap ~1 drop/7 s (soak) |
| 8 | 16 | 0.17 ms | ~90 % (slight crackle) |
Floor: frames_per_urb=16, nurbs=16 → period 16 = 0.33 ms @ 48 kHz (monitoring-grade; period 32 = 0.67 ms is the zero-glitch floor for recording — see the soak evidence below). The loopback of each output lands on the record words 2×out (AN1/2 → words 0/1 = capture ch0/1; the words 12/13 = ch10/11 are a separate fixed-gain playback tap used as the latency anchor — see the Loopback section of PROTOCOL.md).
REVISED 2026-09-06 (empirical, from TuxMix's VU-metering work):
the "ch10/11 = a separate, independent fixed-gain playback tap"
framing above doesn't hold as stated. Test: routed a live mic signal
(AN1) into the PH3/4 output bus via its own crosspoint (amixer 'AN1',14 → 100%), engaged Loopback,1 (PH3/4), and captured all 12
channels (arecord -D hw:0 -f S32_LE -c 12 -r 48000) while tapping
the mic. Per-100ms-block peak analysis across an 8s capture: ch2
and ch3 track ch0 (AN1) exactly, sample-for-sample, at a fixed +3dB
offset, every single block — solid confirmation of the "words
2×out" formula for PH3/4 (output pair 1, 2×1 = words 2/3). ch11
tracked the same signal just as tightly — not independent of the
looped-back output at all in this test. ch10 showed only a much
weaker, inconsistent correlation (near noise floor, one early
transient), unlike ch2/3/11's rock-solid match. Only one output
pair/source combination was tested (PH3/4 loopback, AN1 as source) —
not enough to fully re-derive what ch10/11 actually is, just enough
to say "independent fixed-gain latency anchor" isn't it. Worth a
proper re-investigation (try looping a different output pair, and/
or actual software playback instead of an input→output loopback, to
see whether ch11 tracks the specific looped bus or something more
global like "whatever's currently on the primary monitor path").
2. Multi-channel PCM — DONE (see the status table above:
channels_max = 12, 12-ch capture + playback hardware-verified,
marker words skipped). This entry was stale — left listed as an
open gap for a while after the table above had already marked it
done, caught 2026-09-07 while cross-checking the two against each
other. Originally described as "the full 14-channel frame"; what
actually shipped is 12 (AN1-4 + the 8 ADAT words) — SPDIF isn't a
separately addressable pair of PCM channels in the captured
protocol, so 12 is the real ceiling here, not a shortfall against
14.
3. Full control set: crosspoints (the TotalMix matrix ~hundreds of
controls), EQ bulk uploads (0x0A), pitch/varispeed (0x1B DDS quads),
loopback/MS/AN1>2/width flags — ALL DONE. 2026-09-06: the
remaining 4 (stereo split, ref level, clock source keepalive, phase
invert) are now done too, hardware-validated on the real card
(round-trip via amixer + survives an unbind/rebind cycle):
- Clock source (
Sample Clock Sourceenum, Internal/Optical In) — matches the name TuxMix's ALSA backend already looked for, zero Rust-side changes needed. Along the way, fixed 3 call sites that hardcoded the keepalive word to0x0001("always Internal") — composed from tracked state now (bf_settings_write), or Optical would have silently reverted on the next pitch change or PM resume. - Ref level (
Instrument Ref Levelenum, +4dBu/-10dBV/Boost) — a single shared switch for the Instrument pair, not per-channel (no separate bits exist for IN3 vs IN4). Boost's distinguishing 0x21 commit value (0x0003) is a one-shot, not a persisted register bit —bf_preamp_state_writenow re-asserts it on every preamp write (any phantom/PAD toggle included), or Boost would silently degrade to plain -10dBV the next time anything else touched the shared preamp byte. - Phase invert (
<mic> Phase Switch, AN1-4) — bitwise-NOT of the crosspoint value, all 6 outputs + the AN1/2 low-map shadow. Known limitation, same class as TuxMix's ownusb.rs::set_phase(not fixed there either): this negates the current crosspoint value once, at toggle time — a later fader move on the same [out][mic] slot writes the plain value, silently un-inverting phase. Making the crosspoint hot path itself phase-aware would close this properly; out of scope for this pass (touches all 84 crosspoint controls' write path), flagged rather than silently shipped. - Stereo split (
<PBx> Stereo Split Switch) — fixed constants (no fader dependency, unlike phase), AN1/2 destination only, same scope as CUE/mute/solo's own low-map reach. - New
BF_REG_LOWMAP_BASE_L/_Rconstants generalize the low-map addressing MS-proc/width already used ad-hoc with hardcoded addresses (unchanged, just now named). sh selftests.sh: laws + module build +checkpatchall still pass.- Input Trim (
<name> Trim Volume, AN1-4) added same day, the last of the 5 upstream follow-ups — closes the list. Two curves combine (matchingtuxmix-usb's own already-shipped Rust reference,cap_trim2/3/4.pcap): the low map holds the trim ALONE on the MASTER curve, the standard map holds fader+trim SUMMED on the FADER curve (reused the existing staticbf_fader_raw_to_db2/bf_fader_db2_to_rawhelpers already written for the front-panel wheel, forward-declared rather than moved). Always writes all 8 registers for the pair; the pair base is derived (mic & ~1) so the write lands correctly regardless of which channel's control triggered it. Two known limitations kept, not hidden: (1) exposed as 2 independent per-channel ALSA controls even though the hardware register is genuinely shared per pair — matchestuxmix-usb's own per-channelInputChannel::trimmodel exactly, not a new problem; (2) same composition gap as Phase — a later fader move on the same crosspoint slot silently drops trim from the combined register (out of scope, same reasoning). Hardware- validated: round-trip viaamixer(including a negative dB value), no dmesg errors, correct persistence across unbind/rebind.sh selftests.sh: laws + module build +checkpatchall pass. Limitation (1) turned out to be a real bug, fixed 2026-09-13: because the two per-channel controls trackedchip->trim[]independently,bf_state_apply_flags- which replays a pair from its EVEN index - replayed the wrong value whenever the user had set trim on the ODD channel (AN2 or AN4). Reproduced on hardware with a temporarydev_infoinbf_trim_apply(same technique as the 2026-09-07 phantom root-cause): setAN2 Trim Volume = -12, unbind/rebind, and the trace showedapply mic=0 trim_db2=0with the cache holding[0,-12,0,0]- i.e. the -12 dB was silently dropped on the wire while the control still claimed it. Note the ALSA controls CANNOT reveal this: they read back from the cache, which was always restored correctly; only the wire write was wrong. Fixed by makingbf_trim_putmirror the value into both entries of the pair andsnd_ctl_notifythe sibling control (newchip->trim_kctl[4], same idiom aspanel_kctl), so the cache can no longer claim a per-channel split the hardware cannot represent. Re-verified with the same trace on the fixed build:apply mic=0 trim_db2=-24 cache=[-12,-12,0,0]after rebind, both controls reading -12, dmesg clean. Trace removed afterwards. - dB TLV metadata added 2026-09-13 on the three volume families
that lacked it (only the 6 output masters had any): the 84
crosspoints (
DECLARE_TLV_DB_LINEAR(TLV_DB_GAIN_MUTE, 600)- the fader law is exactly linear in amplitude,BF_FADER_TOP= 0x2d41 = 2 xBF_FADER_0DB= 0x16a0, so +6 dB), the 4 preamp gains and the 4 trims (DECLARE_TLV_DB_SCALE, both controls' values already being dB). Verified live:amixer cgetreportsaccess=rw---R--withdBscale-min=0.00dB,step=1.00dB(gain),dBscale-min=-65.00dB(trim) anddBlinear-...,max=6.00dB(crosspoints). FX Send deliberately has no TLV: its level law was never calibrated to dB (PROTOCOL.md only records "a level sweep").
- Front panel — DONE (2026-08-26,
panel.c): the 0x17 readback is polled at 50 Hz in a delayed_work and mirrored into read-only ALSA controls (Front Panel Button/Wheel/In/Out/Mix/Dim). What remains: TuxMix user-space translating those controls into mixer writes (the host is in the loop, like TotalMix). - PM hardening: full suspend/resume with mixer-state restore
(device has no readback for faders — mirror TotalMix's re-apply).
Root-caused 2026-09-07 (not a driver bug): the anomaly flagged
2026-09-06 —
Phantom Power Mic 1reading back "on" after an unbind/rebind where it should have been "off" — reproduced consistently (2/2) with temporarydev_infotracing added atbf_state_save, the probe-time0x17readback, and right afterbf_state_restorereturns. The trace showedchip->preampstaying correctly0x0000(off) through every stage of the driver's OWN save/restore path, and confirmedbf_panel_set_phantom(the front-panel SET-press handler, the other candidate) never fired. The actual cause is external to this driver entirely: udev's stock90-alsa-restore.rulesrunsalsactl restoreon every card (re)appearance — including every unbind/rebind — from/var/lib/alsa/asound.state, which still hadPhantom Power Mic 1(index 0) stored astruefrom earlier the same session (set viaamixerduring testing, never persisted with a matchingalsactl store). Confirmed the fix:sudo alsactl storeto sync the state file, then re-ran the same unbind/rebind cycle for both an off case and an on case — both restored correctly, proving the driver's ownbf_state_save/bf_state_restoremechanism was never broken. Practical implication:--mixer-restoretesting shouldalsactl storefirst, or a stale system-wide state file will look exactly like a driver bug —regress.shalready does exactly this (see its own--mixer-restoresection, dated 2026-08-26, same root cause documented there already). The 2026-09-06 flag was raised from an ad-hoc manualamixertest that skipped that step, not fromregress.shitself; cross-checking existing knowledge before treating this as a fresh mystery would have caught it immediately. A real end user hitting this would only be affected by their OWN staleasound.state, same as any other ALSA device — not specific to this driver. Diagnosticdev_infocalls were removed after confirming the root cause (not left in the shipped driver). - PipeWire: the kernel card should replace the tuxmix ALSA plugin
as the system sink/source (PW resamples; the mixer stays in TuxMix).
Status 2026-08-24 (retested): the PW SINK works (tone heard via
alsa_output.usb-RME_...-05.stereo-fallback); the PW SOURCE also carries the signal — an earlier "source broken" report was a FALSE POSITIVE: the mic chain was silent (no 48V ⇒ the Triton Fethead is dead, so zero signal) and the "direct capture signal" was a startup burst of non-audio words (incl. the 48000 rate word), not voice. With 48V + gain set, pw-cat -r from the kernel source and arecord on hw:3 record the SAME audio (back-to-back A/B within 1 dB, mic fluctuation excepted). The kernel card is usable as the PW sink/source as-is. - Front-panel worker + upstream: poll the 0x17 readback in a kernel worker (MIX/SELECT/wheel state) once the mixer is feature complete; then move into sound/usb/ (Kconfig entry — see tools/kernel/Kconfig), MAINTAINERS entry, docs, and submit to linux-sound/linux-usb. Optionally ask RME for protocol docs (clean-room RE is fine, vendor input de-risks the rest).
- **Automated regression suite (2026-08-25,
tools/kernel/regress.shpcmxrun.c)**. IMPORTANT (found 2026-09-13): the suite was silently unrunnable from 2026-09-01 to 2026-09-13. The v2-review fix that switched the PCM from S24_LE to S32_LE (db15277) never touched the test tooling, which kept asking the driver for S24_LE on ahw:device - a format it no longer accepts. The failure did not look like a format error:regress.sh's device-busy probe is itself anaplay -f S24_LE, so the suite aborted with "FATAL: hw:0,0 still busy after destroying the RME nodes", which reads as a PipeWire problem. Fixed 2026-09-13 acrossregress.sh(4 aplay/arecord call sites),pcmxrun.candlooplat.c. The scaling constants had to move with the format: S24_LE is 24 bits RIGHT-justified in 4 bytes (full scale 2^23) while S32_LE with 24 msbits is LEFT-justified (full scale 2^31), sopcmxrun's tone amplitude and its dBFS divisor both became 2^31, andlooplat's impulse/threshold constants went 0x400000 -> 0x40000000. Confirmation the conversion is right: the signal tap reads -24.1 dB again, exactly the historical baseline. Consequence for the RFC: the "40/40" figure quoted in UPSTREAM.md and in the v1/v2/v3 cover letters predates the S32_LE switch - the suite had never been run against the code actually submitted as v3, nor against the 5 controls added 2026-09-06. It has now: 40 pass / 0 fail on 2026-09-13 (--dur 1 --mixer-restore --disconnect-test), covering the 9 rates x 4 periods sweep, 30 start/stop cycles, mixer-cache restore across unbind/rebind and a mid-stream disconnect. What it does: full-duplex sweep (9 rates × periods ≥ max(fpu,32)) with a mic-free signal-integrity check (the device playback-tap on capture ch10/11), start/stop stress, and --mixer-restore (48V+gain survive unbind/rebind). It caught two real driver bugs, both fixed: (a) playback overrunning the app ring at small buffers (hw_ptr past appl_ptr → spurious XRUN) — the copy is now clamped to the written frames; (b) capture dying silently at 176.4/192 kHz with frames_per_urb < 32 (alt-3 packets are 1024 B → -EOVERFLOW babble) — min_fpu per alt (8/16/32) is enforced in hw_params. 38/38 PASS at the 256×8 and 16×16 profiles.
- USB error-path hardening (2026-08-25,
15d06fc): consecutive URB errors (bad status or failed resubmit) now stop the stream and wake the substreams with XRUN (apps get -EPIPE and re-arm) instead of resubmitting into a silently dead stream; disconnect stops the running substreams with DISCONNECTED so blocked apps wake promptly (verified: arecord exits rc=1 immediately on an unbind mid-stream). Suite is now 40/40 with--disconnect-test. - Source split (2026-08-25): the 2600-line monolith became
snd-usb-babyface-pro.h(shared state + externs) +main.c(probe/disconnect/PM/entry),protocol.c(vendor requests, cold init, rate table),pcm.c(interrupt-URB stream + PCM ops),mixer.c(controls),state.c(mixer-state persistence). Reproducible viatools/kernel/split_driver.py; checkpatch 0/0 on every file; the 40/40 suite passes on the split build (byte-identical glue — onlystatic→externon cross-file symbols). Makefile:snd-usb-babyface-pro-objs := main.o protocol.o pcm.o mixer.o state.o. - Soak / robustness evidence (2026-08-25,
pcmxrunlong runs): 10 min full-duplex at the 256×8 default with a mixer storm (gains, masters, crosspoints changed mid-stream) — 0 xruns, tap stable at −24.1 dB, dmesg clean. 15 min of 48↔96 kHz rate switching (16 cycles) — 0 xruns, dmesg clean. 5 min soaks at 16×16: period 32/64 = 0 xruns; period 16 = 0 playback + ~42 capture overruns (the 0.33 ms ring drops ~1 buffer per 7 s under any scheduler hiccup — fine for monitoring, not for clean recording). Conclusion: the driver is glitch-free at period ≥ 32 across the matrix; period 16 is the aggressive monitoring floor. - Suspend/resume (2026-08-25, tested on this laptop, deep S3): a full suspend→resume cycle with a distinctive mixer state set (48V ON, gain 35, master 8192) — the card survives in place (no USB drop on this box), the state is intact after resume, dmesg is clean, and full-duplex 48k/96k runs 0-xrun afterwards. The re-probe path (USB drop → disconnect + probe with mixer-state cache) is covered separately by the suite's --mixer-restore.
- Physical hot-unplug/replug (2026-08-25, live card): unplugged the USB cable mid-stream (aplay on hw:3,0). The disconnect path fired cleanly ("disconnect: stopping PCM substreams"), the app exited without hanging, and on replug the card auto-re-probed (new USB device number, same card 3) with 48V + gain restored from the cache — no kernel errors, full-duplex 0-xrun after. Only the master volume is re-owned by the system afterwards (alsactl/asound.state + WirePlumber — see the re-probe note).
- Upstream-prep pass (2026-08-28): a real kernel bug found and
fixed —
bf_eq_get's Low Cut Slope case returned the raw stored dB value instead of the ALSA enum index, confirmed broken via rawamixer cget/ssetindependent of any userspace code, now fixed and re-verified live.panel.c's boot-window SELECT re-assert had an invertedtime_is_before_jiffies/time_is_after_jiffiescheck (re-asserted the power-on state after the 3 s window instead of during it).sparse(C=1/C=2) run for the first time: 8 real warnings (missingstaticon two file-local arrays,intvssnd_pcm_state_tonbabyface_pcm_stop_both, two frame pointers that should have been__le32*notu32*) — all fixed,sparseandW=1now both clean. USB autosuspend (previously untested, nothing paused the panel poll/keepalive for it) explicitly disabled viausb_disable_autosuspend()/usb_enable_autosuspend()rather than left as a live untested path; the front-panel poll interval is now a module param (panel_poll_ms, 10–1000 ms, default unchanged) instead of hardcoded. Six-file → two-file squash:main.c/protocol.c/pcm.c/state.cmerged intobabyfacepro.c(core driver);mixer.c/panel.c/eq.cmerged intobabyfacepro-ctl.c(ALSA control surface);snd-usb-babyface-pro.hrenamedbabyfacepro.h— matching the upstream target layout inUPSTREAM.md. All of the above build + build +sparse/W=1/checkpatchclean and were live-tested (module reload,amixer,dmesg) on the physical unit. - Runtime buffer reconfiguration (roadmap — NOT yet implemented):
switch the latency profile on the fly (e.g. 256-sample default
↔ 16-frame low-latency floor) without unloading/reloading the
module, the way the Windows Fireface USB Settings panel changes the
buffer size while the device is running. Today
frames_per_urb/nurbsare module params read once inprobe()— the URB pool is sized from them at probe and is never reallocated, so changing a sysfs param at runtime does nothing to a live stream (only the next probe sees it); switching profiles currently meansrmmod/insmod(or unbind/rebind), which cuts audio and re-runs the mixer-state restore. To close this gap: expose a reconfiguration entry point (card sysfs attribute or ALSA control) that tears down and rebuilds the URB pool (usb_kill_urb/usb_free_urb→ realloc at the newframes_per_urb/nurbs), re-applies thePERIOD_SIZE ≥ frames_per_urbconstraint on the open substream (watch out: a stream already running at period 16 would violate a largerframes_per_urb— the constraint must be re-negotiated), and stops/ resumes any active stream cleanly (XRUN or suspend, not a full card reset). Deliberately a post-merge follow-up, not an RFC blocker - it touches the streaming core right before submission. - Front-panel state concurrency (fixed 2026-09-28).
The read-only panel controls are fed by the 0x17 poll work, which
decodes into
chip->panel_*; the ALSAgetcallbacks read those same fields from another context, andpanel_selectalso has a second writer in the SELECT control'sput(). Every access to the shared fields (panel_button/wheel/in/out/select/mix/dim) now goes throughREAD_ONCE()/WRITE_ONCE(), so the lock-free sharing is defined and tear-free instead of a plain data race. The locking is unchanged: a lock cannot be held across the panel handlers, which already takechip->mutex(an ABBA withsnd_ctl_notify). - Idle cost of the front-panel poll (measured 2026-09-28).
While bound, the 0x17 poll runs at ~47 Hz (
panel_poll_ms, default 20). On the reference unit the RME's xHCI IRQ line (0000:07:00.4) goes from ~28 IRQ/s with the module unloaded to ~72 IRQ/s bound and idle - ~44 extra interrupts/s from the driver. That is negligible for CPU and small for idle power, andusb_disable_autosuspend()deliberately keeps the device active so the poll can run. A lowerpanel_poll_ms(or an adaptive backoff) would trim it, but the OUT wheel decode is timing-sensitive (accel window 62 ms, fast-poll 5 ms for 200 ms after a click), so a backoff needs care - not worth the risk for the measured gain.
The AN1/2 output's own crosspoint fader had no audible effect on the signal, for every source, the whole time the matrix has existed. Found while trying to hardware-verify the crosspoint fader's TLV curve (the 2026-09-13 dB-linear TLV added to the crosspoints had never been checked against a real signal, only against the register-value arithmetic) - which is exactly why that verification mattered.
- Symptom: sweeping a crosspoint fader from off (raw 0) through 0 dB to +6 dB, with a real signal (a generated tone routed via PB1, and separately AN1's own preamp) into the AN1/2 output, produced NO change in the captured level - constant at every fader position, including "off". The exact same test against the PH3/4 (Phones) output, same code, same tone, same measurement, gave a clean monotonic response matching the fader law to within 0.6 dB (-62.6 dB near "off" to -12.0 dB at +6 dB, expected +6.02 dB span).
- Root cause:
bf_xpoint_put()(the crosspoint ALSA control),babyface_write_default_mixer()(the probe-time unity default) andbabyface_restore_state()(the re-probe/resume replay) all wrote only the "standard" crosspoint map (BF_REG_CROSS_BASE_L/R + BF_REG_CROSS_STRIDE*blk + idx). For the AN1/2 output specifically, PROTOCOL.md's own "Scene load" capture already showed the vendor software always writes a SECOND register, the "low map" (BF_REG_LOWMAP_BASE_L/R + idx, no block multiplier - it only exists for this one output), at the same value, every time. The driver's own comment called it "only a shadow" and skipped it in these three places - wrongly. The low map is what the AN1/2 submix actually sums from; the standard map alone reaches a register the hardware doesn't act on for this output. Every other output (Phones, AS1/2, ADAT×3) has no low map at all, which is presumably how the wrong assumption generalized from "5 of 6 outputs" to "all 6". - Not a fresh mistake in unfamiliar territory:
bf_split_apply(),bf_phase_apply()andbf_trim_apply()already wrote both maps correctly for their own AN1/2-only features (stereo split, phase, trim) - the pattern was right there to copy.tuxmix-usb's own generic fader path (usb.rs::set_volume) already keeps the low map "in sync... TotalMix writes both" forOutput::An12; TuxMix'sapply_scene(its state-restore path) does too. The kernel's plain crosspoint fader control was the one place, across both codebases, this was missed. - The fix: a new exported helper,
bf_xpoint_write(), writes the low map first (whenout == 0) and then the standard map, replacing the triplicated write sequence in all three call sites. Verified hardware-fixed with the same tone-sweep methodology that found the bug: off/0 dB/+3 dB/+6 dB now read -55.5/-18.5/-14.5/-12.0 dB on AN1/2, matching the already-working Phones result almost exactly. Confirmed the fix survives a real unbind/rebind with a non-default crosspoint value, both the cached control value and the actual audio level.regress.sh --mixer-restore --disconnect-test: 40/40. - Practical impact: every source ever routed into the default
output (AN1/2) via its crosspoint fader, rather than left at the
probe-time unity default, was silently inaudible. The probe-time
default itself (unity into every source) happened to look correct
by accident, because
babyface_write_default_mixer()never touched the low map either - meaning AN1/2 has been running on whatever the low map held frombf_cold_init's register clear (0x0000, i.e. silence) this whole time, and the audible AN1/2 signal any tester actually heard came from sources whose low map got a real value via a DIFFERENT, AN1/2-specific control (phase/trim/split) rather than from the general-purpose fader. Any earlier "AN1/2 works fine" observation almost certainly never moved this specific fader with a real signal in the loop - which was itself exactly the gap the 2026-09-06 test (AN1 into PH3/4) left open, and this investigation's starting point (see the "v4 verification" thread below). - PROTOCOL.md's "writing either (or both) is safe" claim about the two maps is corrected in place.
- Masters come up at -20 dB, not at TotalMix's 0 dB - on the two
analog outputs only (AN1/2, PH3/4), narrowed 2026-09-15 after
David Fredman pointed out the original blanket six-output default
reached the four digital outputs too (AS1/2, ADAT3/4, ADAT5/6,
ADAT7/8, all carried over the single optical port). Nothing
downstream of a digital output can be damaged by a loud signal the
way a speaker or a pair of headphones can, so there is no hazard to
mitigate there, only a feed that would otherwise arrive 20 dB quiet
for no reason a receiving device could infer - those four keep
TotalMix's own 0 dB default. On the two analog outputs the six
playback channels SUM, and the default is re-applied on every fresh
module load, before udev's
alsactl restorecan put the user's own levels back. The -20 dB value is the exact 8-bit/16-bit pair (0xcb/0x0333) that the hardware's own DIM button writes, captured incap_dim2.pcap, so it is measured rather than chosen. Consequence: DIM applies an absolute -20 dB on Phones, so with the new default it does nothing audible there until the Phones master is raised above -20 dB. That is how the hardware has always behaved; it is simply now visible from the first second. Verified live (2026-09-15): all 6 masters read back correctly split (819/819/8192/8192/8192/8192) both on a genuinely freshalsactlstate (no prior entry for this card) and after armmod/insmodcycle with the state file back in sync -regress.sh --mixer-restore --disconnect-test: 40/40. Chasing this down on hardware hit the samealsactl restoretrap the 2026-09-07 phantom-power incident already named: a stale system-wideasound.stateentry (819 for every master, cached during earlier testing before this scope change existed) silently overwrote the driver's freshly-computed correct defaults about two seconds after every probe, for over half an hour of otherwise-inexplicable results, untilsudo alsactl storeresynced it. Same lesson as before:--mixer-restoretesting (and any power-on-default check) needs a synced state file first, or a stale one looks exactly like a driver bug. - The routing default routes the playback only, not the inputs
(changed 2026-09-28). A fresh probe with no saved state used to route
all 14 sources into every output at unity, summing a live mic or line
input straight into the main out and the headphones the moment the
module loaded - the hazard issue #4 raised, and the "mic audible in
the phones" surprise.
babyface_write_default_mixer()now routes the six playback channels (PB1-6,BF_SRC_PB1) to every output at unity and leaves the hardware inputs (AN1-4, AS1/2, ADAT) out of every output; raising an input's crosspoint in a mixer is what monitors it. The defaults still land beforealsactl restore, but a fresh load can no longer blast a live input into the outputs. - DIM's scope is Phones-only, and that is confirmed faithful to the hardware, not a driver limitation - with one real open question. PROTOCOL.md's own capture states plainly: "DIM only ever touched the Phones (out 1) in this capture." But the same section also documents that "Main Out" - what DIM actually targets - is a TotalMix-side software setting, reassignable, and DIM is itself described there as "also a configurable hotkey (Speaker B, Talkback...)". That means the protocol is very likely capable of writing the DIM burst to a different output's master registers when TotalMix's Main Out is reassigned - we simply have never captured that case, only the default (Main Out = Phones). David Fredman raised this (2026-09-14, issue #4): on a monitor setup where speakers live on a different output, DIM currently does nothing audible. Not fixed: guessing which output to target without a capture showing what a reassigned Main Out actually writes risks introducing behavior no evidence supports, which is worse than the current honest gap. The right next step is a fresh Windows capture (set Main Out = AN1/2 in TotalMix, press DIM, see what changes) - flagged as an open protocol question in PROTOCOL.md rather than guessed at in code.
- The front-panel DIM button is reported, not acted on (changed
2026-09-17, issue #4). Each press increments the read-only
DIM Button Press Countcontrol and sends a change event; the driver no longer togglesDim Switchitself. The fixed action dimmed Phones to an absolute -20 dB, which raised quieter levels and missed setups whose speakers are on AN1/2. The RME manual (p. 91) describes DIM as a 20 dB reduction from the current level on the selected output, so the action is left to a mixer application such as TuxMix.Dim Switchkeeps its old behaviour. The notes below describe the earlier wiring. - The front-panel DIM button acts (wired 2026-09-13, confirmed on
the physical unit the same day): two presses gave two
Dim Switchand twoFront Panel Dimevents, clean toggle round-trip, no deadlock between the control path and the panel work. Note the press movedFront Panel Dim, NOTFront Panel Button- which does not match the description in issue #4 ("pressing it changes Front Panel Button and nothing else"). Not chased, since it does not affect the fix, but worth knowing before relying onFront Panel Buttonto observe a DIM press. - Card naming is model-neutral:
card->driver=BabyfacePro, shortnameBabyface Pro, idBabyfacePro. The FS and the 2015 non-FS share VID:PID, bcdDevice and iProduct shape - the FS's owniProductis "Babyface Pro (73055480)", with no "FS" in it - so there is nothing to branch on at probe.card->driveris what alsa-lib and UCM match on and is effectively frozen by a kernel merge. The id is derived caiaq-style from the shortname rather than left to the core, which gave the last word only (hw:FS, thenhw:Pro).
LOCKED OUT 2026-08-26 (80c5fff): BabyfaceUsb::open() (tuxmix-usb)
refuses with KernelDriverBound when snd-usb-babyface-pro owns the
interface (a scan of the driver's sysfs dir) — libusb's
USBDEVFS_DISCONNECT_CLAIM would unbind the kernel driver and the card
vanishes mid-session. The conflict is no longer just documented: it
is refused up front.
Stream error paths hardened (80c5fff): a failed stream START now
wakes the apps with an XRUN and re-counts stream_users (the old
err: path left them RUNNING with no URBs — hang); the trigger's
stream_users ++/-- is under chip->lock (the two substreams have
separate ALSA locks — a lost increment stopped the stream mid-run);
broken SNDRV_PCM_INFO_PAUSE removed (PAUSE_RELEASE reset hw_ptr to 0).
tools/kernel/selftests.sh runs the law selftests + the build +
checkpatch without the card; the hardware regress stays regress.sh.
A userspace client can claim the proprietary interface via usbfs
(USBDEVFS_DISCONNECT_CLAIM), which silently detaches the kernel
bound driver — the ALSA card vanishes from /proc/asound/cards for the
duration (no USB disconnect/reset logged; confirmed by kprobe on
usb_unbind_interface: device_release_driver_internal ← usbdev_ioctl ← ioctl, from the pipewire process). Observed trigger:
pw-cat -r --target <sink> (record-from-sink) — PipeWire claims
iface 5 for the record duration, then releases → re-probe → card back
(~3 s later). Normal playback (pw-cat -p) and recording from the
SOURCE do NOT trigger it. Confirmed again 2026-08-24: merely opening
the KDE sound-settings panel (plasma-pa → wireplumber inspecting the
device) kills the card the same way. The TuxMix user-space daemon
(rusb/libusb) claims the same interface — run it OR the kernel driver,
not both.
ROOT CAUSE (fully identified 2026-08-24): the claim does NOT come
from PipeWire's core — it comes from the TuxMix user-space ALSA
plugin (libasound_module_pcm_tuxmix.so → libtuxmix_sys.so with
vendored libusb), which PipeWire loads in-process for the "tuxmix"
sink/source nodes (~/.config/pipewire/pipewire.conf.d/50-tuxmix.conf
/etc/alsa/conf.d/50-tuxmix.conf). When the sound panel touches the TuxMix device, libusb's auto-detach doesUSBDEVFS_DISCONNECT_CLAIMon interface 5 (ftrace:usbdev_ioctl cmd=0x8108551bfrom the pipewire data-loop thread) and then streams the device itself (SETINTERFACE + SUBMITURB + REAPURBNDELAY burst). The freeze the user sees = the window where the kernel card is disconnected.
FIX (applied): with the kernel driver active, disable the tuxmix
PipeWire nodes — mv 50-tuxmix.conf 50-tuxmix.conf.disabled + restart
pipewire. The kernel card is then the only Babyface device; the TuxMix
GUI/TUI still work (they use libtuxmix_sys directly, not the plugin).
Keep ONE stack wired into the sound system at a time.
Fix (implemented): the driver now saves the full mixer state at
disconnect (module-global cache keyed by device serial, bf_saved)
and restores it at the next probe (48V/PAD, 4 gains, 6 masters+
mutes, 84 crosspoints, pitch, loopback/AN1>2/link/width/FX/MS —
verified live: 48V + gain + a crosspoint set to 3000 all survived an
unbind/rebind cycle). Master VOLUME still gets overridden after the
restore by external agents, which is normal system behavior — pinned
down 2026-08-25: udev runs alsactl --export restore on every card
add, and asound.state DOES hold a state.FS block, so alsactl
writes back the last-saved playback volume (the stale 983 seen in the
replug test — the driver itself had restored 8192 at probe, rc=1);
WirePlumber then applies its own volume policy on node activation.
Phantom/gains/crosspoints have no
system equivalent and persist untouched.
Test the full cycle:
amixer -c 3 cset numid=13 1; amixer -c 3 cset numid=17 65 # 48V AN1 + gain
SINK=$(pw-dump | python3 -c '...' ) # kernel sink id
timeout 3 pw-cat -r --target $SINK ... # kills the card
sleep 4; amixer -c 3 sget ... # state restoredSUPERSEDED for the system volume (2026-08-26): the "sound panel volume drives the Phones master" model below was REVERTED — the Windows system volume is a host-side stream gain (cap_sysvol2.pcap), so the kernel sink now uses PipeWire SOFTWARE volume (the per-output master names leave no "Master" element for SPA → automatic software fallback, verified). The TLV work below still stands (the masters are plain mixer controls with a correct dB law for the GUI).
Three fixes landed together to make the kernel sink behave like a normal sound card in PipeWire:
- Stream start = full cold-init + state restore. (Superseded 2026-09-26: a session start no longer runs the cold init, see below.) The firmware only validates a session preceded by the complete cold-init; without it the outputs stay silent. The init wipes the mixer registers, so the cached state (preamp, gains, masters, crosspoints, pitch) is re-applied right after the arm — the session starts at the user's levels and the output is not muted at stream start.
- Rate change = stop URBs + restart instead of -EBUSY. A
hw_paramsat a different rate while streaming used to fail with -EBUSY, which killed the PW sink whenever another stream (e.g. a 44.1k capture) ran. Now the driver stops the URBs, re-points the bandwidth class and lets the stream work restart at the new rate (PW resamples through the brief rate step). Zero EBUSY since. - dB TLV on the output masters (
bf_master_tlv). The master law is 20·log10(v/0x2000) (0x2000 = 0 dB, 0x4000 = +6 dB — the raw value IS the linear amplitude), declared as a DB_RANGE with two DB_LINEAR items. Before the TLV, WirePlumber could not map its volume to the control and applied a software volume ~0.03 on top → the output was ~30 dB down / inaudible. With the TLV (control access gainsTLV_READ), WirePlumber drives the hardware master 1:1:wpctl set-volumemoves the Phones register (1.0 → 0x4000 = +6 dB, 0.5 → ~0x07F9 ≈ −12 dB) andsoftVolumesstays [1.0, 1.0] — no software volume at all. The data path is untouched (loopback RMS identical at sink volume 0.5 and 1.0).
Measured parity (loopback AN1/2 OUT → capture ch10/11, 440 Hz sine at −8 dBFS source, 48 kHz): aplay direct = −9.38 dBFS RMS; pw-cat via the kernel sink at volume 1.0 = −9.55 dBFS RMS (≈0.2 dB conversion residue, S32→S24). Loopback round-trip latency still 2.88 ms / 0 xruns (looplat, period 192 and 512). The sound panel volume (Phones master, control index 0) now controls the headphones end-to-end.
Known follow-up (Windows-side calibration, not a driver bug): WirePlumber maps its slider to the hardware law with its own curve (0.5 → −12 dB, 0.25 → −30 dB on the Phones register); the exact dB-per-position alignment with TotalMix's fader is a Windows calibration task.
System-volume model — CORRECTED AGAIN 2026-08-26 (cap_sysvol2.pcap): Windows volume = HOST-SIDE stream gain, zero USB writes
CONFIRMED 2026-08-26 by cap_sysvol2.pcap (clean re-extraction,
tools/usbdump/sysvol_amp.py): the Windows "Speakers" volume is a
HOST-SIDE software gain in the WDM driver. The capture (60 s,
continuous stereo tone at RMS 0.25 ≈ −12 dBFS, volume 100% → 0% →
100%) shows: ZERO control-transfer records in the whole 60 s while
the OUT ep 0x01 amplitude ramps down 0.250 → 0.0004 (t=0-24 s), holds
≈0 (t=25-30 s), ramps back up 0.0004 → 0.250 (t=31-53 s) — a smooth
host-side fader ramp, no register writes, no TotalMix fader movement.
(An earlier amplitude reading of "3.9G → 0" was an extraction bug: the
24-bit samples were not sign-extended.)
The first capture (cap_sysvol) agrees: t=0-40 s the amplitude dips with no writes at all; the 0x03E0/0x0004 AN1/2-master writes at the END were the USER manually moving the AN1/2 hardware fader (user- confirmed), NOT the volume slider. The earlier "volume = output master register" and "volume = PB playback fader" readings both conflated the user's own fader move with the slider.
So Windows does NOT move any mixer register for the system volume — it attenuates the WDM stream host-side (per WDM device: Speakers, Analog 3+4, SPDIF/ADAT each have their own host-side volume). Only the app's stream is attenuated; the mixer stays untouched (the mic monitored through the same output keeps its level).
Implication for the kernel driver: the current behavior (system volume = the PH3/4 hardware master, a mixer register) is NOT the Windows model. To match Windows, the system volume should be PipeWire's SOFTWARE volume (host-side, on the sink stream) — configure WirePlumber to not bind the sink volume to the Phones master control (software volume), so the mixer registers stay untouched and the mic monitoring isn't yanked by the OS volume. The Phones master stays a plain mixer control (TuxMix GUI). The earlier "playback faders" plan is moot — Windows doesn't write those either.
The Windows WDM mapping (Speakers=PB1→AN1/2, Analog 3+4=PB2→PH3/4, SPDIF/ADAT=PB3→AS1/2) is still the reference for the per-WDM-device host-side volumes.
Finding (hardware + ear-verified): the PH3/4 (Phones) output bus mixes L+R into both channels — a stereo (panning) playback arrives centred on both ears; the AN1/2 output bus is stereo. Proven with a pure usbfs session (lr_test.c, the RE's exact TotalMix init): with L=440 Hz / R=880 Hz on PB1 routed canonically (PB1 L → L-reg 12, PB1 R → R-reg 13 of each block), the AN1/2 record words 0/1 carry L/R separated (~20 dB) but the Phones record words 2/3 carry L+R mixed on BOTH words. The user's ears confirm (a L→R pan tone stays centred on the headphones). The kernel driver's routing is correct — this is a device behavior of the Phones (the manual's "output channels 3/4").
Default-mixer fix included with this note: the probe-time default now writes the crosspoints with the source idx_l/idx_r on the canonical block (like the stream-start restore) instead of the raw index on both bases — the old default left the "cross" registers (L-reg idx_r / R-reg idx_l) at 0 dB, which would put PB1 R on the L side and PB1 L on the R side. It does NOT change the Phones mono (device behavior) but is the correct TotalMix-style default.
Open: how TotalMix on Windows delivers stereo Phones (if it does) — quick Windows check: play a hard-panned tone with TotalMix; if stereo, a targeted capture of the session-start/assign writes is needed (the RE init does not produce stereo Phones). See WINDOWS-CAPTURE-PLAN Capture 17.
The user reported mono on the Phones (Reaper). Root cause found + fixed: the "cross" crosspoint registers of every output block (L-reg at the odd stereo indices 5,7,…23 and R-reg at the even 4,6,…22) were never zeroed by the driver.
- The probe-time default originally wrote ALL 24 raw indices on BOTH the L and R bases (PB1 R → the L side, PB1 L → the R side → L+R on both channels = mono).
- The first fix wrote the source idx_l/idx_r on the canonical block but LEFT the cross registers alone — and the 0x16 cold-init clear covers only 0x00-0x3D, so the stale cross values (0x16A0 from the old default, or whatever the previous session left) PERSISTED → still mono.
- The fix (
bf_crosspoint_clear_cross) explicitly zeroes the 20 cross registers per block, in both the probe default and the stream-start restore. Ear-verified: an alternating hard-L / hard-R tone now plays on ONE ear at a time (was centred on both). The loopback record of the Phones bus went from L+R-on-both-words to clean L/R separation (lr_test.c, 20 dB).
Also resolved along the way: the RE's usbfs init (loopback3.c /
lr_test.c) has the same gap — its 0x16 0x00-0x3D clear does not
cover the cross registers either, so stale values pollute the RE
measurements (the earlier "Phones is mono at the device level"
conclusion was this artifact). Windows TotalMix is stereo on the
Phones by default (user-confirmed with a stereo test video; and the
stereo/mono toggle per Bus is host-side — zero USB writes,
cap_stereo.pcap). Capture 17 is now RESOLVED (no capture needed).
Root cause of "no sound on Phones" = INVERTED MUTE CONTROL
(ddf1bd2): Master Playback Switch returned the internal muted flag
directly, so the ALSA convention was inverted — writing 1 (the
panel/WirePlumber "unmute") actually MUTED the output, and the default
showed as "off". The Phones output stayed muted; it only came alive
while a volume write landed (bf_master_put clears muted), then the
next stream start's restore re-muted it. Fixed: the control now
follows ALSA semantics (1 = enabled).
PipeWire crackle = an xrun stop/start loop — timer-based scheduling
(tsched) mis-estimates the device position between the interrupt-URB
completions (5.25/5.37 ms jitter) and the sink fell into a ~0.55 s
STOP/START recovery cycle (each restart = cold-init + restore = a
crackle). Fixed with a WirePlumber rule
(tools/alsa/wireplumber-rme.conf →
~/.config/wireplumber/wireplumber.conf.d/51-rme.conf):
api.alsa.disable-tsched = true + api.alsa.period-size = 2048.
Verified: 0 trigger STOPs during a 20 s pw-cat tone (was ~18), the tone
is clean, aplay direct was always clean (the driver was fine).
2026-08-25 evening: the shipped config defaults to period-size = 256 (TotalMix parity, 5.33 ms @ 48 kHz, light on CPU) and sets
node.description/node.nick = "Babyface Pro FS" (the USB product
string "Babyface Pro (73055480)" would otherwise leak into the
PipeWire UI name). The validated low-latency DAW profile is
period-size = 16 with the module loaded frames_per_urb=16 nurbs=16 (0.33 ms @ 48 kHz) — the ALSA period is floored at
frames_per_urb, so the module param must match.
Bitwig on PipeWire went silent whenever a heavy plugin that produced
xruns was added or removed, and stayed silent until something
else touched the device (the front-panel wheel was enough). Bitwig on
raw ALSA never did. With dynamic debug on, each add/remove logged five
stream stopped / stream started pairs within a second: every xrun
recovery (prepare + START) tore the USB session down and ran the full
cold-init again. Upstream issue #5 is the same symptom from a buffer
size change; the reporter found raw ALSA working in the broken state.
The session now lives from the first prepare to the last hw_free,
the pattern of the FireWire audio drivers. hw_params counts a
substream (stream_setup[], stream_users), prepare starts the
session if it is down, START/STOP only gate the URB handlers' copy, and
the OUT handler sends silence whenever no running substream fed it
(before, a set-up but stopped playback substream replayed the URB's old
contents). stream_work is left with the persistent-URB-error path.
Because the URBs now outlive a substream closed while the other
direction runs, the handlers read chip->subs[] under RCU and close
calls synchronize_rcu().
Every session start ran bf_cold_init() (the 0x16 clear of registers 0x00-0x3d, the varispeed quad, the rate/settings write, keepalives), then babyface_restore_state() and bf_state_apply_flags() to put back what the clear had wiped. Timed per phase on the hardware at 48 kHz, that is 1.06 s per start: clear 135 ms, the rest of the cold init 31 ms, trigger pair 4 ms, URBs and arm 2 ms, state restore 805 ms, flags 85 ms. The WirePlumber probe after hotplug opens the device several times and pays this each time. These times were measured through a chain of three USB hubs, where each control write took about 2.25 ms; on a direct port a write takes about 0.25 ms (the probe's cold init, 74 writes, in 18 ms), the same spacing as in the Windows capture.
The RME Windows driver sends no init at a stream start (cap_audio.pcap, tools/usbdump/PROTOCOL.md): the trigger pair 0x10 0x8000 + 0x1D, the ISO endpoints, the arm 0x14 0xC000. The 0x16 clears appear only in the cold-plug capture. A session start now does the same, preceded by the rate write (bf_clock_write(): family register and settings word, which is also what Windows sends on a rate change). The cold init and the full restore stay at probe and resume, where the device state is unknown; both now also re-apply the flags, which the next session start used to do.
Checked with a patch cable from the PH3/4 jack into IN3/IN4, only PH3/4 live and no input routed to any output, one session per measurement: the tone arrives at the same level with the old and the new start (-29.4 dBFS both channels) at 48, 44.1 and 32 kHz and after a rate change between sessions; a master change made while no session runs shows up in the next session (+10.0 dB, and back). All nine rates measure correct through ALSA (ratecheck, within +0.003 %). A session start takes about 1 ms on a direct port (9-13 ms through the hub chain).
One thing the cold init did cover: a session triggered less than about 15 ms after the previous one stopped comes up with the outputs silent (and nothing on the FX send), for the whole session. On a direct port, with back-to-back pcmxrun runs and a minimum time from the stop to the trigger: 12 ms left 18-20 of 20 sessions silent, 14 ms 4-9 of 20, and 16 ms or more none, at 32, 44.1 and 48 kHz alike. Through the hub chain the slower control writes before the trigger hid part of that window. Sending the Windows session stop 0x13 0xC000 at the stop does not help. The 1 s cold init always covered this window. babyface_stream_start() now waits until BF_SESSION_GAP_MS (50 ms) have passed since the last stop; only a session restarted at once pays it.
Putting the PC into S3 leaves the Babyface Pro showing its standalone state. Like the rest of RME's units it runs standalone — its own stored routing and clock — whenever no computer is actively driving it, and a suspended host is exactly that. Nothing the driver sends changes this: the standalone/interface switch is firmware, and there is no "stay in interface mode" request to send to a host that is asleep for the duration.
Whether the unit stays powered (and so shows standalone) or goes dark depends on the USB port: one that keeps its 5 V through S3 leaves the unit on with no host. On this box the USB link survives the suspend too — dmesg shows PM: suspend entry (deep) / PM: suspend exit with no usb 3-1: ... disconnect and no re-enumeration — so the driver's suspend()/resume() callbacks run, not the disconnect + re-probe pair, and the host-side state is re-applied on resume (validated 2026-08-25, item 12; the flags/EQ re-apply later folded into resume() is not separately re-measured).
The unit's standalone routing and levels come from its own stored configuration, which the driver does not manage, so they need not match the Linux session's — set them from TotalMix (Windows/macOS) if the standalone behaviour matters.
| Protocol | Kernel equivalent |
|---|---|
| Vendor requests (0x12/0x17/0x1A/0x1B/…: crosspoints, masters, preamp, gains, DDS pitch) | snd_kcontrol set |
| 14×32-bit frame stream, 24-bit in bytes 1-3, interrupt ep 0x01/0x82 | PCM + interrupt URBs (caiaq-style) |
Stream init/trigger/arm (streaming_init, 0x10 0x8000+0x1D, 0x14 0xC000; never 0x13 mid-run) |
probe init + trigger-time stream start (workqueue) |
| 48V/PAD state (0x17 wIdx 0x003F + 0x21 commit) | boolean controls (works with no stream — verified) |
| Sample rate = SET_INTERFACE(5, alt) | hw_params → set_interface + stream restart |
| Front panel (0x17 readback, host-driven) | read-only ALSA controls — DONE 2026-08-26 (babyfacepro-ctl.c); the translation to mixer writes is TuxMix user-space |
| Loopback/MS-proc/AN1>2/width/split flags | boolean/route controls — TBD |
| EQ = bulk OUT ep 0x0A coefficient uploads | BYTES controls + bulk URBs — TBD |
| Reverb/echo = host-side | out of scope for the kernel (TuxMix user-space) |
cd tools/kernel
make LLVM=1 -C /lib/modules/$(uname -r)/build M=$(pwd) modules # CachyOS = clang
sudo insmod snd-usb-babyface-pro.ko
# reload after a change (interface 5 is bound):
echo "3-1:1.5" | sudo tee /sys/bus/usb/drivers/snd-usb-babyface-pro/unbind
sudo rmmod snd_usb_babyface_pro && sudo insmod snd-usb-babyface-pro.ko
aplay -D hw:3,0 -f S32_LE -c 2 -r 48000 /tmp/tone.raw
arecord -t raw -D hw:3,0 -f S32_LE -c 2 -r 48000 -d 3 /tmp/cap.rawThe user-space TuxMix stays the reference/validation suite forever (everything was validated on real hardware — the kernel driver must reproduce the same writes, verifiable against PROTOCOL.md).