Skip to content

fix(audio): make the idle level meter follow the operator's audio-source pick - #217

Merged
andrescera merged 1 commit into
mainfrom
fix/idle-meter-follows-audio-source-picker
Jul 25, 2026
Merged

fix(audio): make the idle level meter follow the operator's audio-source pick#217
andrescera merged 1 commit into
mainfrom
fix/idle-meter-follows-audio-source-picker

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

The Live page's Audio source selection now drives the idle audio-level meter.
config.asrc is resolved to an ALSA device and pushed to cerastream as the additive
reload-configaudio.meter_device field (engine schema 0.9.0).

Backend-only. No frontend change: the picker, the meter component and the wire payload
they exchange are all untouched — the meter simply starts reporting the card the picker
is already showing.

Depends on CERALIVE/cerastream#72
for the engine half, but does NOT need it merged to land safely: the send is gated on the
engine advertising schema ≥ 0.9.0, so against today's engine this PR is a no-op.

Why

Found live during board QA. The operator selected the RØDE HDMI-to-USB-C, SourceSection
showed the RØDE, and the meter reported the DJI Mic Mini — or "Meter unavailable". The
engine picked its own idle card with no input from CeraUI, so with two healthy cards the
one whose vendor string sorts first simply won. Selecting an audio source changed nothing
about the meter, and there was no way for the operator to correct it.

How

  • resolveMeterPreference(asrc) (modules/streaming/audio.ts) turns the picker value
    into the device the meter should prefer, or null for "engine, choose for yourself".
    It reuses the SAME audioDevices map, alias reverse-lookup and hw:CARD= wrapping that
    resolveAudioMode already uses for start, so the meter and the program leg can never
    disagree about which card a pick names. It is deliberately NOT resolveAudioMode: this
    is the IDLE meter, it has no notion of network-embedded program audio, and it must keep
    following the card the picker is showing. null covers AUDIO_SOURCE_AUTO, both
    pipeline pseudo-sources, an unset asrc, and anything that resolves to no card.
  • The audio-meter-bridge delivers it, because it already holds the ONE long-lived
    IDLE connection to the engine — cerastream-backend's client only exists while
    streaming. It pushes on every connect (the engine keeps no preference across a restart),
    and syncAudioMeterPreference() re-pushes from streaming.setConfig when asrc changed
    and from updateAudioDevices, since a re-enumeration can change which card an
    unchanged pick resolves to.
  • reload-config, not switch-audio. switch-audio is stream-only and answers
    not_streaming exactly when the idle meter is running. reload-config already carries
    an audio section and is idle-safe on the engine side.
  • rawRequest behind supportsMeterDevicePreference. The published
    @ceralive/cerastream client Zod-strips the additive meter_device key, so the typed
    reloadConfig() would silently drop it — the same constraint audio.mode and
    video_passthrough already work around. The gate is fail-safe false, so a pre-0.9.0
    engine is sent nothing and keeps auto-picking: exactly its current behaviour.
  • Absent ≠ null. Absent leaves the engine's preference unchanged (so reloadAudioDelay's
    delay-only reload can never clear it); null means Auto. Not interchangeable.
  • A refused push never breaks the meter — logged and swallowed, the previous preference
    stands, and the next config change or reconnect re-pushes.

It is a preference, not a pin, and the engine guarantees that. cerastream only moves
the named card to the head of its candidate list; its delivery-confirmation demotion
(PR #71) is unchanged, so selecting a powered-off receiver still lands on a working card
rather than a dead meter. There is deliberately no CeraUI-side "force this device" path.

How to verify

Unitbun test in apps/backend (2256 pass):

  • tests/audio-meter-bridge.test.ts — pushes the selected card on connect; "Auto" sends
    an explicit null; re-pushes after a source change; sends NOTHING to a pre-0.9.0 engine
    (and levels keep flowing); a refused reload leaves the meter running; syncing while down
    is a silent no-op; plus the schema gate table.
  • tests/audio-sources.test.tsresolveMeterPreference: alias, no-alias, every null
    case, and selector passthrough.

The three push assertions were confirmed RED against the un-wired bridge before the push
landed; the ten pre-existing bridge tests stayed green throughout.

On real hardware — Rock 5B+ 192.168.78.131, RØDE HDMI-to-USB-C attached, operator
config already at asrc: "USB audio" (untouched):

audio-meter bridge: subscribed to the engine audio-level topic
audio-meter bridge: idle-meter preference set to hw:CARD=usbaudio

and the meter then reported 41 live level events over 8 s, all card:usbaudio, 0
unavailable, with real moving audio (rms_db ≈ -31…-36, not the digital-silence floor).
The updateAudioDevices re-push path was exercised live via SIGUSR2. A preference naming a
card the engine does not enumerate was verified inert — the meter stayed on the RØDE and
never went unavailable.

Gates: bun run lint (exit 0), apps/backend bun test 2256 pass / 0 fail,
tsc --noEmit clean, check:tech-debt OK.

Two local pre-push lanes could not run here and are NOT regressions: test-fe fails
identically on a clean origin/main checkout (a local jsdom localStorage quirk — all
1319 frontend tests themselves pass), and the four e2e lanes fail at
playwright install-deps, the documented can't-run-locally caveat in docs/LOCAL-CI.md.
Cloud CI is the authority for both.

Risks

  • Ordering with cerastream#72. Until it merges and ships, the schema gate keeps this
    PR inert — the engine advertises 0.8.0 and is sent nothing. No flag day.
  • rawRequest bypasses the typed client. Deliberate and pre-existing practice
    (switch-audio, start with audio.mode); the payload is a two-key object and the
    engine validates it.
  • One extra reload-config per connect and per audio re-enumeration. Cheap, idle-safe,
    and non-blocking; a failure cannot stall the meter.
  • The diff is backend-only, so no visual or interaction change to review.

…rce pick

Selecting an audio source changed nothing about the idle meter. cerastream chose
its own ALSA card while not streaming, entirely independent of config.asrc, so on
a board carrying a RODE and a DJI Mic Mini the operator selected the RODE, the
picker showed the RODE, and the meter reported the DJI - or "Meter unavailable".
Nothing in CeraUI ever told the engine what had been chosen.

config.asrc now reaches the engine. resolveMeterPreference() turns the picker
value into the ALSA device the meter should prefer, reusing the same audioDevices
map, alias reverse-lookup and hw:CARD= wrapping resolveAudioMode already uses for
start, so the meter and the program leg cannot disagree about which card a pick
names. It is deliberately not resolveAudioMode itself: this is the IDLE meter, it
has no notion of network-embedded program audio, and it must keep following the
card the picker is showing. Auto, both pipeline pseudo-sources and an unresolvable
pick all yield null - the explicit "engine, choose for yourself" hand-back.

The audio-meter bridge delivers it, because it already holds the one long-lived
IDLE connection to the engine; cerastream-backend's client only exists while
streaming. It pushes on every connect (the engine keeps no preference across a
restart), and syncAudioMeterPreference() re-pushes from streaming.setConfig when
asrc changed and from updateAudioDevices, since a re-enumeration can change which
card an unchanged pick resolves to.

reload-config carries it rather than switch-audio, which is stream-only and
answers not_streaming exactly when the idle meter is running. The published
client Zod-strips the additive audio.meter_device key, so it goes over rawRequest
behind supportsMeterDevicePreference - fail-safe false, so an engine older than
schema 0.9.0 is sent nothing and keeps auto-picking, which is what it did before.
Absent means unchanged and null means Auto; they are not interchangeable, so a
delay-only reload can never clear the preference. A refused push is logged and
swallowed: the previous preference stands and the meter keeps running.

This is a preference, not a pin, and the engine is what guarantees it - cerastream
still demotes a selected card that delivers no samples, so choosing a powered-off
receiver can never leave the meter dead.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f7a54e80-387b-4d2a-8163-d9c7f07e80bd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/idle-meter-follows-audio-source-picker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@andrescera
andrescera merged commit 3942488 into main Jul 25, 2026
12 checks passed
@andrescera
andrescera deleted the fix/idle-meter-follows-audio-source-picker branch August 19, 2026 13:24
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