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"); + }); +});