fix(audio): mute the host's voice when SayPi is the one speaking (#602) - #603
Merged
Conversation
With a SayPi voice selected on pi.ai, both voices played at once — ours and Pi's own over the top. Only visible since #597; before that we produced no audio, so the missing mute looked like the whole bug. AudioOutputMachine already owns the decision: on the audio element's loadstart it sees a source belonging to neither the selected voice nor the active provider, and skips it. It never got the chance, because AudioModule had no element bound at all. Measured live on pi.ai: the tracked element is removed, cleanupAudioElement nulls it, and recovery re-arms listenForAudioElementSwap — an observer that only fires for newly ADDED subtrees. pi.ai replaces its player rather than reusing it, so the replacement was already in the document and nothing was ever added afterwards to react to. The observer waited forever, no loadstart reached the machine, and the page ended up with two undecorated <audio> elements, one of them audibly playing pi.ai/api/chat/voice. Two changes, both in service of one rule — when a SayPi voice is selected, SayPi is the TTS provider, so the host is silent and we speak: - recovery takes up an element that is already there, and only waits for an insertion when there genuinely is none; - the mute is held as an invariant from the active provider rather than reacted to per playback event, so a track the host started before we bound to it can't stay audible for its whole length. Gated on offscreen playback: where there is no offscreen document (Firefox, Safari) SayPi shares that element and muting it would mute us, so skip-on-loadstart remains the whole mechanism there. The decisions live in src/audio/hostAudio.ts because AudioModule.js can't be imported by a test — the same reason audioElementRemoval.ts exists — and the wiring is pinned by the source-scanning guards beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KgkAqzyjN5xtxR5fKxRPR2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #602.
The symptom
With a SayPi voice selected on pi.ai, both voices play at once — ours, and Pi's own over the top. Only visible since #597; before that we produced no audio at all, so the missing mute looked like the whole bug.
Why
AudioOutputMachinealready owns the decision. On the audio element'sloadstartit sees a source belonging to neither the selected voice nor the active provider, and skips it.It never got the chance, because
AudioModulehad no element bound at all. Measured live on pi.ai:…and afterwards the page held two
<audio>elements, one audibly playingpi.ai/api/chat/voice, neither carryingsaypi-audio-main.The recovery path was the gap: losing the element re-armed
listenForAudioElementSwap(), an observer that fires only for newly added subtrees. pi.ai replaces its player rather than reusing it, so the replacement was already in the document and nothing was ever added afterwards to react to. The observer waited forever and the binding was lost for the life of the page.The change
Both parts serve one rule — when a SayPi voice is selected, SayPi is the TTS provider, so the host is silent and we speak:
Muting rather than pausing is deliberate — the host's player keeps its own state and its UI doesn't fight us over it.
The decisions live in
src/audio/hostAudio.tsbecauseAudioModule.jscan't be imported by a test (the same reasonaudioElementRemoval.tsexists); the wiring is pinned by the source-scanning guards beside it.Verified on the live host
Layer 4 (CDP), real pi.ai, signed in, a SayPi voice selected — the fix does what the unit tests can only imply:
Sampled every 1.5s across a full turn, Pi's own player reads
id=saypi-audio-main, bound=true, muted=true, src=https://pi.ai/api/chat/voicethroughout — including one sample where it was actively playing while muted, which is exactly the race the invariant exists for: the host started that track without aloadstartwe could have skipped.tsc --noEmit, Jest and 2676 Vitest tests green.🤖 Generated with Claude Code
https://claude.ai/code/session_01KgkAqzyjN5xtxR5fKxRPR2