Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>.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`) |
Expand Down Expand Up @@ -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/`:
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/src/modules/streaming/audio-meter-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<void> {
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<unknown>;
};
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.
Expand Down Expand Up @@ -185,6 +243,9 @@ async function runAttempt(): Promise<boolean> {
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(
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/src/modules/streaming/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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).
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/modules/streaming/cerastream-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/rpc/procedures/streaming.procedure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
});

Expand Down
Loading
Loading