diff --git a/docs/test-audio.md b/docs/test-audio.md new file mode 100644 index 0000000..51066c3 --- /dev/null +++ b/docs/test-audio.md @@ -0,0 +1,64 @@ +# Shared Test Audio — pod-wide connectivity check + +> Spec for the `frontend/src/livekit/useBeat.ts` + `PodView.tsx` test-audio +> behavior and the additive `BEAT_STOP` data message. Satisfies the +> documentation-first gate for those files. + +## Why + +The **Test audio** button is PodMan's pre-flight check that the LiveKit audio +path works for the whole pod — the same path the urgent Gemini-TTS voice +escalation rides on. Today the beat is published correctly but its on/off state +is **local to the publisher**: teammates can't see it's playing and can't stop +it. This makes it a shared, pod-wide toggle so a judge sees the state flip on +every screen at once. + +## Behavior + +- Any participant clicks **Test audio** → they publish the `podman-beat` audio + track (Web Audio, `lib/beat.ts`). Everyone auto-subscribes and hears it. +- The shared on/off state is **derived from the track's presence**, not a synced + flag — so it self-syncs across joins/leaves and can't drift from reality. The + publisher is the **owner**. +- Anyone can stop it: + - Owner clicks **Stop audio** → unpublishes its own track directly. + - Non-owner clicks **Stop (``'s)** → sends `BEAT_STOP`; the owner + unpublishes. (LiveKit forbids unpublishing another participant's track, so a + request is the only way.) +- The Status card shows `publishing` / ` playing` / `ready`, and the + waveform animates (`active`) for everyone while the test is live. + +## State derivation (source of truth = the track) + +`useBeat(room)` returns `{ on, by, mine }`, recomputed from the presence of a +track named `podman-beat` across `localParticipant` + `remoteParticipants` on +these events: `LocalTrackPublished/Unpublished`, `TrackPublished/Unpublished`, +`TrackSubscribed/Unsubscribed`, `ParticipantConnected/Disconnected`. Owner +disconnect and late-join sync therefore need no extra messaging. + +## Contract (additive) + +`shared/src/messages.ts` — one new message on the existing `podman.intervention` +data topic: + + | { type: 'BEAT_STOP' } // any participant → owner: stop the shared beat + +Additive to the `DataMessage` union; existing consumers ignore unknown types. +**No backend / API change.** + +## Known limitation (LiveKit constraint) + +A client can only unpublish **its own** tracks, so a non-owner's **Stop** is a +`BEAT_STOP` _request_ the owner must honor. If the owner disconnects **uncleanly** +(crash / network drop), the SFU keeps the track published until it times the +participant out — during that window the beat keeps playing and non-owners can't +stop it. A clean disconnect clears it immediately via `ParticipantDisconnected`. +Demo mitigation: have the same person who starts the test also stop it. + +## Files + +- `shared/src/messages.ts` — `BEAT_STOP` message (additive). +- `frontend/src/livekit/useBeat.ts` — `useBeat(room)` hook. +- `frontend/src/components/PodView.tsx` — button label, status line, waveform + `active` driven by the hook. +- `frontend/src/lib/beat.ts` — unchanged (existing Web-Audio beat source). diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index 61e7083..6287176 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-react'; import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client'; import type { Pod, PodActivityEvent, PodActivityKind, PodActivitySource } from '@podman/shared'; -import { startBeat, type BeatHandle } from '../lib/beat.js'; +import { useBeat } from '../livekit/useBeat.js'; import { useInterventions, primeSpeech } from '../livekit/useInterventions.js'; import { usePodActivity } from '../hooks/use-pod-activity.js'; import LiveWaveform from '@/components/ruixen/live-waveform'; @@ -109,7 +109,6 @@ export function PodView({ }) { const [participants, setParticipants] = useState([]); const [sharing, setSharing] = useState(false); - const [playingBeat, setPlayingBeat] = useState(false); const [note, setNote] = useState(null); const [audioBlocked, setAudioBlocked] = useState(false); const [leftStreamOpen, setLeftStreamOpen] = useState(() => @@ -119,10 +118,10 @@ export function PodView({ readStoredBool('podman.teamStreamOpen', true), ); const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room); + const { beat, toggleBeat: runBeat } = useBeat(room); const activity = usePodActivity(team.id, me); const audioRef = useRef(null); - const beatRef = useRef(null); const screenTrackRef = useRef(null); const onLeaveRef = useRef(onLeave); onLeaveRef.current = onLeave; @@ -179,8 +178,6 @@ export function PodView({ useEffect(() => { return () => { - beatRef.current?.stop(); - beatRef.current = null; screenTrackRef.current?.stop(); screenTrackRef.current = null; }; @@ -194,22 +191,10 @@ export function PodView({ localStorage.setItem('podman.teamStreamOpen', String(rightStreamOpen)); }, [rightStreamOpen]); - async function toggleBeat() { - if (!room) return; + async function onToggleBeat() { setNote(null); try { - if (playingBeat) { - if (beatRef.current) await room.localParticipant.unpublishTrack(beatRef.current.track); - beatRef.current?.stop(); - beatRef.current = null; - setPlayingBeat(false); - } else { - await room.startAudio().catch(() => {}); - const handle = startBeat(); - beatRef.current = handle; - await room.localParticipant.publishTrack(handle.track, { name: 'podman-beat' }); - setPlayingBeat(true); - } + await runBeat(); } catch (e) { setNote(`Audio test failed: ${(e as Error).message}`); } @@ -328,9 +313,9 @@ export function PodView({
-
diff --git a/frontend/src/livekit/useBeat.ts b/frontend/src/livekit/useBeat.ts new file mode 100644 index 0000000..5c4e4da --- /dev/null +++ b/frontend/src/livekit/useBeat.ts @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RoomEvent, type Room } from 'livekit-client'; +import { DATA_TOPIC, type DataMessage } from '@podman/shared'; +import { startBeat, type BeatHandle } from '../lib/beat.js'; + +/** Name of the test-audio track; its presence in the room IS the shared state. */ +export const BEAT_TRACK = 'podman-beat'; + +export interface BeatState { + /** Is the test audio playing anywhere in the pod? */ + on: boolean; + /** Display name of the participant who started it (the owner). */ + by: string | null; + /** Do I own the beat track (so I can stop it directly)? */ + mine: boolean; +} + +const OFF: BeatState = { on: false, by: null, mine: false }; + +/** + * Shared, pod-wide test audio. One participant publishes the `podman-beat` + * track; everyone hears it and sees the same on/off state, derived directly + * from the track's presence (self-syncing across joins/leaves). Any participant + * can stop it: non-owners send BEAT_STOP and the owner unpublishes. + */ +export function useBeat(room: Room | null) { + const [beat, setBeat] = useState(OFF); + const beatRef = useRef(null); + + const stopLocal = useCallback(async () => { + const handle = beatRef.current; + if (!handle) return; + beatRef.current = null; + try { + await room?.localParticipant.unpublishTrack(handle.track); + } finally { + handle.stop(); + } + }, [room]); + + // Derive the shared state from the podman-beat track across all participants. + useEffect(() => { + if (!room) { + setBeat(OFF); + return; + } + const recompute = () => { + const lp = room.localParticipant; + const localPub = [...lp.trackPublications.values()].find((p) => p.trackName === BEAT_TRACK); + if (localPub) { + setBeat({ on: true, by: lp.name || lp.identity, mine: true }); + return; + } + for (const p of room.remoteParticipants.values()) { + const pub = [...p.trackPublications.values()].find((tp) => tp.trackName === BEAT_TRACK); + if (pub) { + setBeat({ on: true, by: p.name || p.identity, mine: false }); + return; + } + } + setBeat(OFF); + }; + + recompute(); + const events = [ + RoomEvent.LocalTrackPublished, + RoomEvent.LocalTrackUnpublished, + RoomEvent.TrackPublished, + RoomEvent.TrackUnpublished, + RoomEvent.TrackSubscribed, + RoomEvent.TrackUnsubscribed, + RoomEvent.ParticipantConnected, + RoomEvent.ParticipantDisconnected, + ] as const; + events.forEach((e) => room.on(e, recompute)); + return () => { + events.forEach((e) => room.off(e, recompute)); + }; + }, [room]); + + // The owner honors stop requests from any participant. + useEffect(() => { + if (!room) return; + const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => { + if (topic !== DATA_TOPIC) return; + let msg: DataMessage; + try { + msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage; + } catch { + return; + } + if (msg.type === 'BEAT_STOP') void stopLocal(); + }; + room.on(RoomEvent.DataReceived, onData); + return () => { + room.off(RoomEvent.DataReceived, onData); + }; + }, [room, stopLocal]); + + // Tear down my own track on unmount (e.g. leaving the pod). + const stopLocalRef = useRef(stopLocal); + stopLocalRef.current = stopLocal; + const startingRef = useRef(false); + const unmountedRef = useRef(false); + useEffect(() => { + return () => { + unmountedRef.current = true; + void stopLocalRef.current(); + }; + }, []); + + const toggleBeat = useCallback(async () => { + if (!room) return; + // `beatRef` is the synchronous source of truth for "do I own it" — `beat.mine` + // lags behind the LiveKit track events that recompute it, so gate on the ref. + if (beatRef.current) { + await stopLocal(); + return; + } + if (beat.on) { + // Someone else owns it — can't unpublish their track, so ask them to stop. + await room.startAudio().catch(() => {}); + await room.localParticipant.publishData( + new TextEncoder().encode(JSON.stringify({ type: 'BEAT_STOP' } satisfies DataMessage)), + { reliable: true, topic: DATA_TOPIC }, + ); + return; + } + // Start it. Guard against rapid double-clicks publishing two tracks before the + // LocalTrackPublished event has had a chance to update state. + if (startingRef.current) return; + startingRef.current = true; + try { + await room.startAudio().catch(() => {}); // unlock playback from this gesture + if (unmountedRef.current) return; + const handle = startBeat(); + beatRef.current = handle; + await room.localParticipant.publishTrack(handle.track, { name: BEAT_TRACK }); + if (unmountedRef.current) await stopLocal(); // left mid-publish — clean up + } catch (e) { + beatRef.current?.stop(); + beatRef.current = null; + throw e; + } finally { + startingRef.current = false; + } + }, [room, beat, stopLocal]); + + return { beat, toggleBeat }; +} diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 4762506..3967b1d 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -10,7 +10,9 @@ export type DataMessage = | { type: 'HERMES_MESSAGE'; message: HermesMessage } | { type: 'VOICE_CUE'; text: string } | { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string } - | { type: 'GIT_REPORT'; report: LocalGitReport }; + | { type: 'GIT_REPORT'; report: LocalGitReport } + /** Any participant → the current test-audio owner: stop publishing the shared beat. */ + | { type: 'BEAT_STOP' }; /** A targeted teammate/project-channel notification from the Hermes action layer. */ export interface HermesMessage {