From 4142876b23e6ae561de0212d410b73924aecd506 Mon Sep 17 00:00:00 2001 From: Ramis Date: Sat, 27 Jun 2026 21:08:21 -0700 Subject: [PATCH] feat: unlock voice audio + terse demo-style collision alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice generation, critical escalation, and audio publish all worked, but participants heard nothing: browsers block autoplay of the agent's audio track until a user gesture. Add the canonical LiveKit unlock — listen for AudioPlaybackStatusChanged and show an "Enable sound" button that calls room.startAudio() so PodMan's voice cues are actually heard. Also make collision alerts short and direct instead of chatty AI prose: card: "Conflict: ram + yahya both on README.md (unpushed). Seen before." voice: "Conflict. ram + yahya, both on README.md." Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AaCFWMkYQmTcuPsxaaACft --- backend/src/agent/podman.ts | 20 +++++++++-------- frontend/src/components/PodView.tsx | 35 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/backend/src/agent/podman.ts b/backend/src/agent/podman.ts index a3611fc..6099780 100644 --- a/backend/src/agent/podman.ts +++ b/backend/src/agent/podman.ts @@ -71,15 +71,17 @@ export class PodMan { await recordCollision(collision); const action = preferredAction(collision, prior); - const names = collision.engineers.join(' and '); + const names = collision.engineers.join(' + '); + const shortFile = collision.file.split('/').pop() ?? collision.file; + + // Terse, demo-centered alert — short and direct, not chatty AI prose. const message = - `${names} are both editing ${collision.file}` + - (collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') + - (prior?.priorOutcome?.accepted - ? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.` - : prior - ? ` I've seen this conflict pattern before.` - : ''); + `Conflict: ${names} both on ${shortFile}` + + (collision.githubState?.unpushed ? ' (unpushed).' : '.') + + (prior ? ' Seen before.' : ''); + + // Spoken line is even shorter so the voice cue lands fast on stage. + const voiceLine = `Conflict. ${names}, both on ${shortFile}.`; const intervention: Intervention = { id: `int_${Date.now()}`, @@ -106,6 +108,6 @@ export class PodMan { topic: DATA_TOPIC, }); await publishHermesMessage(this.room, collision, intervention); - if (collision.severity === 'critical') await speak(this.room, message); + if (collision.severity === 'critical') await speak(this.room, voiceLine); } } diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index ebcb90f..9f9a11b 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -105,6 +105,7 @@ export function PodView({ const [sharing, setSharing] = useState(false); const [playingBeat, setPlayingBeat] = useState(false); const [note, setNote] = useState(null); + const [audioBlocked, setAudioBlocked] = useState(false); const [leftStreamOpen, setLeftStreamOpen] = useState(() => readStoredBool('podman.myStreamOpen', true), ); @@ -132,6 +133,11 @@ export function PodView({ }; const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove()); const onDisconnected = () => onLeaveRef.current(); + // Browsers block autoplay of incoming audio until a user gesture unlocks it. + // Surface a button whenever the room can't play sound so PodMan's voice cues + // are actually heard. + const onPlaybackChanged = () => setAudioBlocked(!room.canPlaybackAudio); + onPlaybackChanged(); room .on(RoomEvent.ParticipantConnected, refresh) @@ -139,6 +145,7 @@ export function PodView({ .on(RoomEvent.ActiveSpeakersChanged, refresh) .on(RoomEvent.TrackSubscribed, onAudio) .on(RoomEvent.TrackUnsubscribed, onAudioGone) + .on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged) .on(RoomEvent.Disconnected, onDisconnected); return () => { @@ -148,10 +155,21 @@ export function PodView({ .off(RoomEvent.ActiveSpeakersChanged, refresh) .off(RoomEvent.TrackSubscribed, onAudio) .off(RoomEvent.TrackUnsubscribed, onAudioGone) + .off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged) .off(RoomEvent.Disconnected, onDisconnected); }; }, [room, me]); + async function enableSound() { + if (!room) return; + try { + await room.startAudio(); + setAudioBlocked(!room.canPlaybackAudio); + } catch (e) { + setNote(`Could not enable sound: ${(e as Error).message}`); + } + } + useEffect(() => { return () => { beatRef.current?.stop(); @@ -340,6 +358,23 @@ export function PodView({ )} + {room && audioBlocked && ( + +
+ +
+ Sound is off + + Click to hear PodMan's voice alerts. + +
+
+ +
+ )} +