Skip to content

fix(audio): mute the host's voice when SayPi is the one speaking (#602) - #603

Merged
rosscado merged 1 commit into
mainfrom
fix/host-audio-mute
Aug 9, 2026
Merged

fix(audio): mute the host's voice when SayPi is the one speaking (#602)#603
rosscado merged 1 commit into
mainfrom
fix/host-audio-mute

Conversation

@rosscado

@rosscado rosscado commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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

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:

[AudioModule] Using offscreen audio: true
Audio Output Machine transitioned from N/A to idle
Audio element removed from the document      <-- the tracked element goes
Cleaned up audio element
Speech provided by Say, Pi                   <-- provider IS SayPi

…and afterwards the page held two <audio> elements, one audibly playing pi.ai/api/chat/voice, neither carrying saypi-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:

  1. Recovery takes up an element that is already there, and only waits for an insertion when there genuinely is none.
  2. The mute is an invariant, not a reaction. Held from the active provider and re-asserted whenever the element or the provider changes, so a track the host started before we bound to it can't stay audible for its whole length. Gated on offscreen playback: without an offscreen document (Firefox, Safari) SayPi shares that element and muting it would mute us, so skip-on-loadstart remains the whole mechanism there.

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.ts because AudioModule.js can't be imported by a test (the same reason audioElementRemoval.ts exists); 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:

Audio element removed from the document
Cleaned up audio element
[AudioModule] Rebinding to the host's audio element
Speech provided by Say, Pi
[AudioModule] Muting the host's audio element

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/voice throughout — 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 a loadstart we could have skipped.

tsc --noEmit, Jest and 2676 Vitest tests green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KgkAqzyjN5xtxR5fKxRPR2

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
@rosscado rosscado closed this Aug 9, 2026
@rosscado rosscado reopened this Aug 9, 2026
@rosscado
rosscado merged commit 0891244 into main Aug 9, 2026
5 checks passed
@rosscado
rosscado deleted the fix/host-audio-mute branch August 9, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host's native voice isn't muted when SayPi is the TTS provider — the audio element is lost and never re-bound

1 participant