From ffe630e673acad4a655cbe30bc483772ec85cb9a Mon Sep 17 00:00:00 2001 From: andrescera Date: Sat, 25 Jul 2026 10:24:22 -0500 Subject: [PATCH] fix(audio): make the idle level meter follow the operator's audio-source 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. --- AGENTS.md | 11 ++ apps/backend/AGENTS.md | 62 +++++++++++ .../modules/streaming/audio-meter-bridge.ts | 61 +++++++++++ apps/backend/src/modules/streaming/audio.ts | 26 +++++ .../modules/streaming/cerastream-backend.ts | 19 ++++ .../src/rpc/procedures/streaming.procedure.ts | 6 ++ .../src/tests/audio-meter-bridge.test.ts | 102 +++++++++++++++++- apps/backend/src/tests/audio-sources.test.ts | 32 +++++- 8 files changed, 316 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5b9ce59e..e0f756a49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1825,6 +1825,17 @@ snapshots) and never at a render site. Display-only — `input_id`/`device_path` `stable_id` and the kind heuristic are untouched. Full contract: `apps/backend/AGENTS.md` → ONBOARD VIDEO DISPLAY NAMES. +**The idle level meter follows the picker.** Selecting an audio source used to change +nothing about the meter: cerastream chose its own idle card, so an operator who picked +the RØDE could watch the meter report the DJI Mic Mini — or "Meter unavailable" — with +no way to correct it. `config.asrc` is now resolved by `resolveMeterPreference()` and +pushed to the engine as `reload-config` `audio.meter_device` over the always-idle +`audio-meter-bridge` connection (schema ≥ 0.9.0; an older engine is sent nothing and +keeps auto-picking). "Auto" sends an explicit `null`, handing selection back to the +engine. It is a PREFERENCE, not a pin — cerastream still demotes a selected card that +delivers no samples, so a powered-off receiver can never leave the meter dead. Full +contract: `apps/backend/AGENTS.md` → IDLE AUDIO-METER DEVICE PREFERENCE. + **There is NO operator rename.** #206 briefly shipped an alias/rename UI backed by `config.audio_device_aliases`; #207 removed it in full — UI, `setAudioDeviceAlias` RPC, oRPC contract entry, `audio-aliases.schema.ts`, and the config field — by diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 585ced9a8..7b050b5c1 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -14,6 +14,7 @@ Bun/TypeScript HTTP + WebSocket server. Serves the frontend static bundle, expos | Task | Location | |------|----------| +| Idle audio-meter device preference (operator's audio pick → engine idle meter) | `modules/streaming/audio-meter-bridge.ts` (`syncAudioMeterPreference`, `pushPreference`) + `modules/streaming/audio.ts` (`resolveMeterPreference`) + `modules/streaming/cerastream-backend.ts` (`supportsMeterDevicePreference`) | | Add/change an RPC procedure | `rpc/procedures/.procedure.ts` + `rpc/router.ts` | | Engine seam + registry (cerastream-only) | `modules/streaming/streaming-engine.ts` (`getStreamingBackend`) | | Capability contract service (engine emits, CeraUI consumes; cache + fallback ladder; `transports` + `getSupportedTransports()`) | `modules/streaming/capabilities.ts` (`getCapabilities`) | @@ -214,6 +215,66 @@ Coverage: `tests/onboard-video-display-name.test.ts` (the pure rule, both device seams, the rendered `sources` payload, and the lost row — the last two assert the serialized payload contains no `rk_hdmirx` at all). +## IDLE AUDIO-METER DEVICE PREFERENCE [EXISTS] + +The engine's ALWAYS-ON level meter (ADR-0007) used to pick its own ALSA card while +idle, entirely independent of the operator's **Audio source** selection. Found live on +a board: the operator selected the RØDE, `SourceSection` showed the RØDE, and the meter +reported the DJI Mic Mini (or "Meter unavailable") — because with two healthy cards the +engine's candidate list is ordered by enumeration and `"DJI Technology…"` sorts first. + +`config.asrc` now reaches the engine's idle meter: + +- **`resolveMeterPreference(asrc)`** (`modules/streaming/audio.ts`) turns the picker + value into the ALSA device the meter should prefer, or `null` for "engine, choose for + yourself". `null` covers `AUDIO_SOURCE_AUTO`, both pipeline pseudo-sources + (`"No audio"` / `"Pipeline default"`), an unset `asrc`, and anything that resolves to + no card. It reuses the SAME `audioDevices` map + alias reverse-lookup + `hw:CARD=` + wrapping that `resolveAudioMode` 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, which has no notion of network-embedded + program audio and must keep following the card the picker is showing. +- **The `audio-meter-bridge` delivers it**, because it already holds the ONE long-lived + IDLE connection to the engine (`cerastream-backend.ts`'s client only exists while + streaming). `pushPreference()` sends `reload-config` with + `{ audio: { meter_device } }`. +- **`syncAudioMeterPreference()`** re-pushes on change. Three call sites: the bridge's + own `runAttempt` (every fresh connect — the engine holds NO preference across a + restart), `streaming.setConfig` when `input.asrc` changed, and `updateAudioDevices` + (a re-enumeration can change which card an UNCHANGED pick resolves to). + +**Why `reload-config` and not `switch-audio`.** `switch-audio` is stream-only — it +answers `-32001 cerastream.state.not_streaming` while idle, which is exactly when the +idle meter runs. `reload-config` already carries an `audio` section and is idle-safe on +the engine side (it no-ops against an absent session). + +**Three wire states, and they are NOT interchangeable.** `audio.meter_device` ABSENT +leaves the engine's preference unchanged (so `reloadAudioDelay`'s delay-only reload can +never clear it), explicit `null` restores the engine's own delivery-based auto-pick, and +a string prefers that card. Never send `undefined` expecting "Auto". + +**Sent over `rawRequest`, gated on schema ≥ 0.9.0.** The published +`@ceralive/cerastream` client Zod-STRIPS the additive `meter_device` key, so the typed +`reloadConfig()` would silently drop it — same constraint as `audio.mode` and +`video_passthrough`. `supportsMeterDevicePreference(schemaVersion)` +(`cerastream-backend.ts`) is the fail-safe gate: an older engine is sent nothing and +keeps auto-picking, which is the exact pre-0.9.0 behaviour. + +**It is a PREFERENCE, not a pin — and the engine is what guarantees that.** cerastream +only moves the named card to the head of its candidate list; its delivery-confirmation +demotion (a card holding the ALSA handle for 2 s without clocking a sample yields to the +next candidate, cerastream PR #71 / ADR-0007 §10–§11) is unchanged. So selecting a +powered-off receiver still ends on a working card, never on a permanently dead meter. +Do NOT add a CeraUI-side "force this device" path that tries to override that. + +A failed push NEVER breaks the meter: `pushPreference` swallows and logs, the previous +preference stands, and the next config change or reconnect re-pushes. + +Coverage: `tests/audio-meter-bridge.test.ts` (push on connect, `null` for Auto, re-push +on change, nothing sent to a pre-0.9.0 engine, a refused reload leaves levels flowing, +no-op while down, plus the schema gate) and `tests/audio-sources.test.ts` +(`resolveMeterPreference` — alias, no-alias, every `null` case, selector passthrough). + ## SIM PIN AUTO-UNLOCK [EXISTS] Opt-in boot auto-unlock for a PIN-locked SIM. Two modules under `modules/modems/`: @@ -1084,6 +1145,7 @@ FIRST, reason `live.education.reason.disabledInSettings`). See root `AGENTS.md` - Don't read config files with raw `fs` — use `helpers/config-loader.ts`. - Don't drive the engine directly — route through `getStreamingBackend()`, never the `cerastreamBackend` singleton. +- Don't send the idle-meter preference through the typed `reloadConfig()` — the published client Zod-strips `audio.meter_device`; it goes over `rawRequest` behind `supportsMeterDevicePreference`. And don't send `undefined` for "Auto": absent means *unchanged*, `null` means Auto. - Don't re-add stderr regex on the cerastream path — engine errors are structured codes mapped via `cerastream-error-mapping.ts`. - Don't wire `@ceralive/cerastream` as a sibling `link:` or vendored `.tgz` — it diff --git a/apps/backend/src/modules/streaming/audio-meter-bridge.ts b/apps/backend/src/modules/streaming/audio-meter-bridge.ts index e16a24cc7..030c36066 100644 --- a/apps/backend/src/modules/streaming/audio-meter-bridge.ts +++ b/apps/backend/src/modules/streaming/audio-meter-bridge.ts @@ -47,8 +47,11 @@ import type { import { connect as defaultConnect } from "@ceralive/cerastream"; import type { AudioLevelMessage } from "@ceraui/rpc/schemas"; import { logger as defaultLogger } from "../../helpers/logger.ts"; +import { getConfig } from "../config.ts"; import { setup } from "../setup.ts"; import { broadcastMsg } from "../ui/websocket-server.ts"; +import { resolveMeterPreference } from "./audio.ts"; +import { supportsMeterDevicePreference } from "./cerastream-backend.ts"; /** Backoff bounds for the initial-connect retry. Mirrors `engine-reconnect.ts`. */ export const AUDIO_METER_CONNECT_BASE_MS = 2_000; @@ -67,6 +70,11 @@ export interface AudioMeterBridgeDeps { connectOptions: ConnectOptions; /** Re-broadcast one audio-level payload over the main authenticated WS. */ broadcast: (payload: AudioLevelMessage) => void; + /** + * The ALSA device the operator's audio-source pick resolves to, or `null` for + * "Auto" (hand selection back to the engine's own delivery-based pick). + */ + meterPreference: () => string | null; logger: AudioMeterBridgeLogger; random: () => number; setTimer: (fn: () => void, ms: number) => TimerHandle; @@ -109,6 +117,7 @@ function defaultDeps(): AudioMeterBridgeDeps { client: "ceraui-audio-meter", }, broadcast: (payload) => broadcastMsg("audio-level", payload), + meterPreference: () => resolveMeterPreference(getConfig().asrc), logger: defaultLogger, random: Math.random, setTimer: (fn, ms) => setTimeout(fn, ms), @@ -158,6 +167,55 @@ function handleEvent(event: EventParams): void { } } +/** + * Tell the engine which card the operator selected, so the idle meter follows the + * "Audio source" picker instead of picking for itself. `null` restores the + * engine's own delivery-based auto-pick ("Auto"). + * + * Sent over the RAW `reload-config` primitive: the published `@ceralive/cerastream` + * client Zod-STRIPS the additive `audio.meter_device` key, so the typed call would + * silently drop it. Gated on the engine advertising schema ≥ 0.9.0 — an older + * engine keeps auto-picking, which is exactly what it did before this existed. + * + * NEVER throws and never blocks the meter: a failed push leaves the previous + * preference in place, and the next config change or reconnect re-pushes. + */ +async function pushPreference(client: CerastreamClient): Promise { + if (!state || state.stopped) return; + const { deps } = state; + if (!supportsMeterDevicePreference(client.hello.schema_version)) { + deps.logger.debug( + `audio-meter bridge: engine schema ${client.hello.schema_version} predates audio.meter_device — leaving idle-meter selection to the engine`, + ); + return; + } + const meter_device = deps.meterPreference(); + const raw = client as unknown as { + rawRequest(method: string, params?: unknown): Promise; + }; + try { + await raw.rawRequest("reload-config", { audio: { meter_device } }); + deps.logger.debug( + `audio-meter bridge: idle-meter preference set to ${meter_device ?? "auto"}`, + ); + } catch (err) { + deps.logger.warn( + `audio-meter bridge: could not set the idle-meter preference: ${errMessage(err)}`, + ); + } +} + +/** + * Re-push the idle-meter preference after the operator changed the audio source + * (or the card set was re-enumerated). Fire-and-forget: a no-op when the bridge + * is not connected — the next connect pushes the current value anyway. + */ +export function syncAudioMeterPreference(): void { + const client = state?.stopped === false ? state.client : undefined; + if (client === undefined) return; + void pushPreference(client); +} + /** * One connect + subscribe attempt. Resolves `true` when the subscription is live * (the binding's autoReconnect then owns resilience), `false` to reschedule. @@ -185,6 +243,9 @@ async function runAttempt(): Promise { deps.logger.info( "audio-meter bridge: subscribed to the engine audio-level topic", ); + // The engine holds no preference across a restart, so every fresh + // connection re-asserts the operator's current pick. + await pushPreference(client); return true; } catch (err) { deps.logger.debug( diff --git a/apps/backend/src/modules/streaming/audio.ts b/apps/backend/src/modules/streaming/audio.ts index e6bdd1a54..a15a1d71e 100644 --- a/apps/backend/src/modules/streaming/audio.ts +++ b/apps/backend/src/modules/streaming/audio.ts @@ -29,6 +29,7 @@ import { isRealDevice } from "../system/device-detection.ts"; import { getHardwareKindCached } from "../system/hardware-kind.ts"; import { notificationBroadcast } from "../ui/notifications.ts"; import { broadcastMsg } from "../ui/websocket-server.ts"; +import { syncAudioMeterPreference } from "./audio-meter-bridge.ts"; import type { AudioDeviceDisplay, AudioDeviceIdentity, @@ -96,6 +97,27 @@ export function resolveAudioMode( return { mode: "device", device: toAlsaCaptureDevice(getAudioSrcId(asrc)) }; } +/** + * The ALSA capture device the engine's ALWAYS-IDLE level meter should prefer, + * derived from the operator's audio-source pick — or `null` for "let the engine + * choose", which is the pre-0.9.0 behaviour. + * + * `null` covers every selection that names no single real card: "Auto" (the + * explicit hand-back), both pipeline pseudo-sources, and a selection that + * resolves to nothing. Deliberately independent of `resolveAudioMode`: this is + * the IDLE meter, so it has no notion of network-embedded program audio and must + * keep following the card the picker is showing. + */ +export function resolveMeterPreference( + asrc: string | undefined, +): string | null { + if (asrc === undefined || asrc === AUDIO_SOURCE_AUTO) return null; + if (isPseudoAudioSource(asrc)) return null; + const cardId = audioDevices[asrc] ?? getAudioSrcId(asrc); + if (cardId.trim() === "") return null; + return toAlsaCaptureDevice(cardId); +} + // The engine passes `audio.device` straight to `alsasrc device=`, which needs a // real ALSA device string, not a bare card id: `alsasrc device="usbaudio"` never // opens and the engine rejects the start with `-32602 audio-device-unavailable`. @@ -347,6 +369,10 @@ export async function updateAudioDevices(dir: string = deviceDir) { // preview (a no-op while streaming — the live value stays frozen). refreshResolvedAsrcPreview(); + // A re-enumeration can also change which ALSA card the operator's pick resolves + // to (or bring it back after an unplug), so the idle meter is re-pointed too. + syncAudioMeterPreference(); + // A hotplug re-enumeration may have brought in the device a stream start is // waiting on — wake the pending probe so it re-checks now instead of after // the next poll tick, beating the QW-J timeout (QW-E ↔ QW-J interaction). diff --git a/apps/backend/src/modules/streaming/cerastream-backend.ts b/apps/backend/src/modules/streaming/cerastream-backend.ts index ceb869cd3..66bf68efa 100644 --- a/apps/backend/src/modules/streaming/cerastream-backend.ts +++ b/apps/backend/src/modules/streaming/cerastream-backend.ts @@ -302,6 +302,25 @@ export function supportsVideoPassthrough( return major > 0 || (major === 0 && (minor ?? 0) >= 5); } +/** + * Whether the engine understands the additive `reload-config` + * `audio.meter_device` field (schema ≥ 0.9.0) — the operator's audio-source pick + * threaded down to the ALWAYS-IDLE level meter. Like `audio.mode`, the published + * client Zod-STRIPS the unknown field, so a supporting engine must be driven + * through the raw `reload-config` bridge. Fail-safe `false` on an + * absent/unparseable version: an older engine keeps its own auto-pick, which is + * exactly the pre-0.9.0 behaviour. + */ +export function supportsMeterDevicePreference( + schemaVersion: string | undefined, +): boolean { + if (!schemaVersion) return false; + const [major, minor] = schemaVersion.split(".").map(Number); + if (major === undefined || Number.isNaN(major) || Number.isNaN(minor)) + return false; + return major > 0 || (major === 0 && (minor ?? 0) >= 9); +} + // Local schema extension for the raw `start` bridge: the published client's // frozen `startParamsSchema` has no `audio.mode` or `video_passthrough`, so a // start carrying either is validated here and dispatched over the raw JSON-RPC diff --git a/apps/backend/src/rpc/procedures/streaming.procedure.ts b/apps/backend/src/rpc/procedures/streaming.procedure.ts index 08d55940f..037d734cf 100644 --- a/apps/backend/src/rpc/procedures/streaming.procedure.ts +++ b/apps/backend/src/rpc/procedures/streaming.procedure.ts @@ -57,6 +57,7 @@ import { } from "../../mocks/providers/streaming.ts"; import { getConfig, saveConfig } from "../../modules/config.ts"; import { reportActiveProfile } from "../../modules/remote-control/active-profile-reporter.ts"; +import { syncAudioMeterPreference } from "../../modules/streaming/audio-meter-bridge.ts"; import { getResolvedAsrc, refreshResolvedAsrcPreview, @@ -653,6 +654,11 @@ export const setConfigProcedure = authedProcedure if (input.source !== undefined || input.asrc !== undefined) { refreshResolvedAsrcPreview(); } + // A new audio pick must reach the ALWAYS-IDLE level meter too, or the meter + // keeps reporting whichever card the engine chose for itself. + if (input.asrc !== undefined) { + syncAudioMeterPreference(); + } return { success: true, applied }; }); diff --git a/apps/backend/src/tests/audio-meter-bridge.test.ts b/apps/backend/src/tests/audio-meter-bridge.test.ts index 570d5f9eb..0fcade621 100644 --- a/apps/backend/src/tests/audio-meter-bridge.test.ts +++ b/apps/backend/src/tests/audio-meter-bridge.test.ts @@ -12,8 +12,10 @@ import { initAudioMeterBridge, settleAudioMeterBridge, stopAudioMeterBridge, + syncAudioMeterPreference, toAudioLevelMessage, } from "../modules/streaming/audio-meter-bridge.ts"; +import { supportsMeterDevicePreference } from "../modules/streaming/cerastream-backend.ts"; const silent: AudioMeterBridgeLogger = { info: () => {}, @@ -26,7 +28,7 @@ type TimerHandle = ReturnType; // A fake engine: `connect` either throws (engine down) or resolves a client whose // `subscribeEvents` captures the handler so the test can push events by hand. The // manual timer queue drives the boot-retry loop with no real time. -function harness(connectOutcomes: boolean[]) { +function harness(connectOutcomes: boolean[], schemaVersion = "0.9.0") { const timers: Array<{ fn: () => void }> = []; let idx = 0; let handler: EventHandler | undefined; @@ -35,6 +37,10 @@ function harness(connectOutcomes: boolean[]) { let subscribedTopics: readonly string[] | undefined; const broadcasts: AudioLevelMessage[] = []; + const reloads: unknown[] = []; + let preference: string | null = "hw:CARD=usbaudio"; + let reloadRejects = false; + const subscription: Subscription = { result: { topics: ["audio-level"] }, close: () => { @@ -50,7 +56,13 @@ function harness(connectOutcomes: boolean[]) { close: async () => { clientClosed = true; }, - // biome-ignore lint/suspicious/noExplicitAny: the bridge only uses connect/subscribeEvents/close. + hello: { schema_version: schemaVersion }, + rawRequest: async (_method: string, params?: unknown) => { + if (reloadRejects) throw new Error("reload refused (test)"); + reloads.push(params); + return {}; + }, + // biome-ignore lint/suspicious/noExplicitAny: the bridge uses connect/subscribeEvents/close/hello/rawRequest. } as any; const deps: AudioMeterBridgeDeps = { @@ -63,6 +75,7 @@ function harness(connectOutcomes: boolean[]) { }, connectOptions: {}, broadcast: (payload) => broadcasts.push(payload), + meterPreference: () => preference, logger: silent, random: () => 0.5, setTimer: (fn: () => void, _ms: number): TimerHandle => { @@ -84,6 +97,13 @@ function harness(connectOutcomes: boolean[]) { await settleAudioMeterBridge(); }, pendingTimers: () => timers.length, + reloads, + setPreference: (next: string | null) => { + preference = next; + }, + failReloads: () => { + reloadRejects = true; + }, state: () => ({ subscriptionClosed, clientClosed, subscribedTopics }), }; } @@ -198,3 +218,81 @@ describe("toAudioLevelMessage — envelope projection", () => { }); }); }); + +// The board bug (live QA, 2026-07-25): the operator selected the RØDE, the picker +// showed the RØDE, and the idle meter still reported the DJI — because nothing ever +// told the engine what the operator had chosen. This bridge already holds the ONE +// long-lived idle connection to the engine, so it is where the pick is delivered. +describe("audio-meter bridge — the operator's audio pick reaches the idle meter", () => { + test("pushes the selected card over reload-config as soon as it connects", async () => { + const h = harness([true]); + initAudioMeterBridge(h.deps); + await settleAudioMeterBridge(); + + expect(h.reloads).toEqual([ + { audio: { meter_device: "hw:CARD=usbaudio" } }, + ]); + }); + + test('"Auto" sends an explicit null — hand selection back to the engine', async () => { + const h = harness([true]); + h.setPreference(null); + initAudioMeterBridge(h.deps); + await settleAudioMeterBridge(); + + expect(h.reloads).toEqual([{ audio: { meter_device: null } }]); + }); + + test("re-pushes after the operator changes the audio source", async () => { + const h = harness([true]); + initAudioMeterBridge(h.deps); + await settleAudioMeterBridge(); + + h.setPreference("hw:CARD=MINI"); + syncAudioMeterPreference(); + await settleAudioMeterBridge(); + await Promise.resolve(); + + expect(h.reloads).toEqual([ + { audio: { meter_device: "hw:CARD=usbaudio" } }, + { audio: { meter_device: "hw:CARD=MINI" } }, + ]); + }); + + test("sends NOTHING to an engine older than schema 0.9.0", async () => { + const h = harness([true], "0.8.0"); + initAudioMeterBridge(h.deps); + await settleAudioMeterBridge(); + + expect(h.reloads).toEqual([]); + // The meter itself is untouched — an old engine still auto-picks and streams. + h.emit(levelEvent); + expect(h.broadcasts).toHaveLength(1); + }); + + test("a refused reload never breaks the meter", async () => { + const h = harness([true]); + h.failReloads(); + initAudioMeterBridge(h.deps); + await settleAudioMeterBridge(); + + h.emit(levelEvent); + expect(h.broadcasts).toHaveLength(1); + }); + + test("syncing while the bridge is down is a silent no-op", () => { + stopAudioMeterBridge(); + expect(() => syncAudioMeterPreference()).not.toThrow(); + }); +}); + +describe("supportsMeterDevicePreference — fail-safe schema gate", () => { + test("0.9.0 and later support it; earlier and unparseable do not", () => { + for (const v of ["0.9.0", "0.10.0", "1.0.0"]) { + expect(supportsMeterDevicePreference(v)).toBe(true); + } + for (const v of ["0.8.0", "0.4.0", "", undefined, "nonsense"]) { + expect(supportsMeterDevicePreference(v)).toBe(false); + } + }); +}); diff --git a/apps/backend/src/tests/audio-sources.test.ts b/apps/backend/src/tests/audio-sources.test.ts index e46efa2b8..cd829cef0 100644 --- a/apps/backend/src/tests/audio-sources.test.ts +++ b/apps/backend/src/tests/audio-sources.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { audioSourceSchema } from "@ceraui/rpc/schemas"; +import { AUDIO_SOURCE_AUTO, audioSourceSchema } from "@ceraui/rpc/schemas"; import { z } from "zod"; import { buildMockAudioDevices } from "../mocks/fixture-factory.ts"; @@ -12,6 +12,7 @@ import { getMockAudioDevices } from "../mocks/providers/streaming.ts"; import { deriveAudioSources, getAudioDevices, + resolveMeterPreference, setMockAudioDevicesProvider, updateAudioDevices, } from "../modules/streaming/audio.ts"; @@ -116,3 +117,32 @@ describe("updateAudioDevices — sysfs card discovery", () => { } }); }); + +// The idle level meter can only follow the picker if the picker's value is first +// turned into something `alsasrc device=` understands. `null` is the explicit +// "engine, choose for yourself" hand-back — never a silent no-op. +describe("resolveMeterPreference — picker value → idle-meter ALSA device", () => { + test("an explicit device pick resolves to its hw:CARD= form", () => { + expect(resolveMeterPreference("USB audio")).toBe("hw:CARD=usbaudio"); + }); + + test("a card with no display alias keeps its own id", () => { + expect(resolveMeterPreference("MINI")).toBe("hw:CARD=MINI"); + }); + + test("Auto, the pseudo-sources and an unset pick all hand back to the engine", () => { + for (const asrc of [ + AUDIO_SOURCE_AUTO, + "No audio", + "Pipeline default", + undefined, + ]) { + expect(resolveMeterPreference(asrc)).toBeNull(); + } + }); + + test("a value that already names an ALSA selector passes through unchanged", () => { + expect(resolveMeterPreference("hw:CARD=usbaudio")).toBe("hw:CARD=usbaudio"); + expect(resolveMeterPreference("plughw:1,0")).toBe("plughw:1,0"); + }); +});