feat: unlock voice audio + terse demo-style collision alerts
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaCFWMkYQmTcuPsxaaACft
This commit is contained in:
@@ -71,15 +71,17 @@ export class PodMan {
|
|||||||
|
|
||||||
await recordCollision(collision);
|
await recordCollision(collision);
|
||||||
const action = preferredAction(collision, prior);
|
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 =
|
const message =
|
||||||
`${names} are both editing ${collision.file}` +
|
`Conflict: ${names} both on ${shortFile}` +
|
||||||
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
|
(collision.githubState?.unpushed ? ' (unpushed).' : '.') +
|
||||||
(prior?.priorOutcome?.accepted
|
(prior ? ' Seen before.' : '');
|
||||||
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
|
|
||||||
: prior
|
// Spoken line is even shorter so the voice cue lands fast on stage.
|
||||||
? ` I've seen this conflict pattern before.`
|
const voiceLine = `Conflict. ${names}, both on ${shortFile}.`;
|
||||||
: '');
|
|
||||||
|
|
||||||
const intervention: Intervention = {
|
const intervention: Intervention = {
|
||||||
id: `int_${Date.now()}`,
|
id: `int_${Date.now()}`,
|
||||||
@@ -106,6 +108,6 @@ export class PodMan {
|
|||||||
topic: DATA_TOPIC,
|
topic: DATA_TOPIC,
|
||||||
});
|
});
|
||||||
await publishHermesMessage(this.room, collision, intervention);
|
await publishHermesMessage(this.room, collision, intervention);
|
||||||
if (collision.severity === 'critical') await speak(this.room, message);
|
if (collision.severity === 'critical') await speak(this.room, voiceLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function PodView({
|
|||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
const [playingBeat, setPlayingBeat] = useState(false);
|
const [playingBeat, setPlayingBeat] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
||||||
const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
|
const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
|
||||||
readStoredBool('podman.myStreamOpen', true),
|
readStoredBool('podman.myStreamOpen', true),
|
||||||
);
|
);
|
||||||
@@ -132,6 +133,11 @@ export function PodView({
|
|||||||
};
|
};
|
||||||
const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove());
|
const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove());
|
||||||
const onDisconnected = () => onLeaveRef.current();
|
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
|
room
|
||||||
.on(RoomEvent.ParticipantConnected, refresh)
|
.on(RoomEvent.ParticipantConnected, refresh)
|
||||||
@@ -139,6 +145,7 @@ export function PodView({
|
|||||||
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||||
.on(RoomEvent.TrackSubscribed, onAudio)
|
.on(RoomEvent.TrackSubscribed, onAudio)
|
||||||
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
|
.on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
||||||
.on(RoomEvent.Disconnected, onDisconnected);
|
.on(RoomEvent.Disconnected, onDisconnected);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -148,10 +155,21 @@ export function PodView({
|
|||||||
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||||
.off(RoomEvent.TrackSubscribed, onAudio)
|
.off(RoomEvent.TrackSubscribed, onAudio)
|
||||||
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
|
.off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
||||||
.off(RoomEvent.Disconnected, onDisconnected);
|
.off(RoomEvent.Disconnected, onDisconnected);
|
||||||
};
|
};
|
||||||
}, [room, me]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
beatRef.current?.stop();
|
beatRef.current?.stop();
|
||||||
@@ -340,6 +358,23 @@ export function PodView({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{room && audioBlocked && (
|
||||||
|
<Alert className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Volume2Icon />
|
||||||
|
<div>
|
||||||
|
<AlertTitle>Sound is off</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Click to hear PodMan's voice alerts.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => void enableSound()}>
|
||||||
|
Enable sound
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="grid flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
<main className="grid flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<section className="flex min-w-0 flex-col gap-5">
|
<section className="flex min-w-0 flex-col gap-5">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user