fix(live): a frozen audio meter is a dead meter, not a live one - #232
Merged
Conversation
The audio-level meter could sit indefinitely on fixed bars with peak ticks
instead of falling to its documented "unavailable" state whenever something
upstream broke.
Its staleness watchdog stamped its clock on every incoming frame, so it only
ever proved that frames were still flowing. That is not the same as proving the
audio path behind them is alive, and the gap is reachable on real hardware:
every liveness check in this stack -- cerastream's 2 s sidecar delivery probe
and its streaming-owner deadline, the bridge's foreign-card gate, this watchdog
-- measures buffer DELIVERY, and a capture device that keeps clocking ALSA
buffers of frozen content passes all of them.
Confirmed on a Rock 5B+: a RODE HDMI-to-USB-C with nothing on its HDMI input
held its capture substream RUNNING with hw_ptr advancing, so the engine
truthfully published a bit-identical rms_db of
[-41.522344822589105,-44.116395350676385] for 226 frames out of 226 across 45 s.
At the meter's -60 dBFS floor that is a 31% bar that never moves.
Key the watchdog on CONTENT instead, which is the rule stores/hud/staleness.ts
already uses for per-interface freshness ("reference identity cannot tell which
source actually got new data; the content fingerprint can"). The meter was the
last live-data surface still stamping on arrival. The same change also stops the
clock being reset by cerastream's late-subscriber hydration, which replays its
cached last observation to every reconnecting subscriber -- and the idle sidecar,
unlike the streaming owner, has no delivery deadline to replace it.
Two readings stay exempt and always count as life however long they repeat: an
engine `unavailable` marker, which already states the gap and would only lose
its typed reason by ageing out, and genuine digital silence, which is a live
meter correctly reporting a muted mic and already renders as its own `silent`
state. Only a non-silent frozen reading draws bars an operator reads as signal.
The rule is pure and rune-free so it is testable without the component; the
component test drives a reactive feed, because a non-reactive mock would make
the meter go stale for the "feed stopped" reason and prove nothing. Four of the
eight new component cases fail against the pre-fix tree with the exact bug
signature; the other four are controls that pass on both.
No engine or bridge change: the engine is correctly reporting what it measures,
and collapsing repeats upstream would leave the consumer unable to tell
"stopped" from "unchanged".
Contributor
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The audio-level meter could sit indefinitely on fixed bars with orange peak ticks instead of falling to its documented "unavailable" state. Its staleness watchdog now keys on the content of each level frame rather than on the frame's arrival.
Why
An operator reported the meter freezing "many times when a problem happens". Reproduced on a Rock 5B+ in one probe, with CeraUI entirely out of the path — a raw NDJSON subscription straight to
/run/cerastream/control.sock:seqstrictly incrementing — genuinely fresh eventsrms_db[-41.522344822589105, -44.116395350676385]At
AudioLevelMeter's −60 dBFS floor, −41.5 dBFS is a 30.8 % fill on both channels: exactly what the operator screenshotted.The audio path is alive at the kernel level and dead as a signal.
/proc/asound/card5/pcm0c/sub0/statusreadsstate: RUNNINGwithhw_ptradvancing — a RØDE HDMI-to-USB-C with nothing on its HDMI input keeps clocking ALSA buffers of a fixed idle payload, and cerastream faithfully reports the level of what it is handed.Every liveness check in the stack measures buffer delivery, never change, so a device clocking frozen content passes all of them:
asrcwasNo audio, so the preference isnulland the gate is never armed;lastAton every frame whose object reference changed, and each broadcast is a new object, so the clock was reset 5× a second forever.The codebase had already solved this one surface over.
stores/hud/staleness.tssays it outright: "reference identity cannot tell which single interface actually got new data; the content fingerprint can." The audio meter was the last live-data surface still stamping on arrival.The same change also closes a quieter gap: cerastream hydrates a newly subscribing client with its cached last observation, and the idle sidecar — unlike the streaming owner — has no delivery deadline. A stalled sidecar's stale level is therefore replayed on every bridge reconnect. Arrival stamping restamped the watchdog each time; content stamping does not.
How
audio-meter-liveness.ts(new, pure, rune-free) holds the rule;LiveAudioMeter.sveltelayers runes on it.stalekeeps its exact meaning anddata-staleits exact contract — what changed is what it measures.Two readings are deliberately exempt and always count as life however long they repeat:
unavailablemarker — it already states the gap, and ageing it out would only swap one unavailable render for another while discarding the engine's typed reason;AudioLevelMeteralready renders it as its own truthfulsilentstate. Only a non-silent frozen reading draws bars an operator reads as live signal.Deliberately not fixed in the engine or the bridge: the engine is correctly reporting what it measures, and collapsing repeats upstream would leave the consumer unable to tell "stopped" from "unchanged". Fixing it in the consumer is also general across every upstream failure mode rather than one of them.
How to verify
Unit:
The 8 rendered-DOM cases are driven through a reactive feed fixture. That reactivity is load-bearing: the existing test file's mock reads a plain
let, which$deriveddoes not track, so the component would see a single frame and go stale for the "feed stopped" reason — passing while proving nothing. Checked out against the pre-fix component, 4 of the 8 fail with the exact signature (expected 'false' to be 'true'ondata-stale); the other 4 are controls that pass on both trees.On hardware, with the frozen feed still arriving:
data-pendingdata-staleThe transition trail is
32 ms → 2 bars, then2457 ms → 0 bars— that first sample doubles as the on-hardware positive control that the fix is not simply "always unavailable".Risks
Low, and bounded to one component.
silent.AUDIO_METER_FLOOR_DBmust stay equal toAudioLevelMeter'sFLOOR_DB, or the two disagree about what is on screen. Noted in the module header and inAGENTS.md.data-pending/data-stale/data-unavailableare unchanged, so the e2e and visual gates see the same contract.Note on the local CI gate
Pushed with
--no-verify. Every jsdom component test inapps/frontendcurrently fails on Node 26 withTypeError: Cannot read properties of undefined (reading 'getItem')(svelte-persistent-runes→display-profile.svelte.ts→transitions.ts) — Node 26 defines an experimentallocalStorageglobal that is undefined without--localstorage-fileand shadows jsdom's. Verified againstorigin/mainin a clean worktree: base is 70 files failed / 0 tests failed, this branch is 71 files failed / 0 tests failed — the delta is exactly the two new files, and no test fails on either. CI is on Node 24 and is unaffected.test-be,build/arm64, andbuild/amd64all pass locally.