speechSynthesis gives you audio you can hear and cannot send. There is no MediaStreamTrack on the other end of it, so a translated reply spoken by the browser can never be placed into an outgoing WebRTC sender. I found this building a 1:1 call room with live translation, and the way out is to stop using it for anything that has to leave the machine.
What the API actually returns
The Web Speech API speaks through the platform audio path, not through anything the page owns. You get an utterance, you get events, and you get sound out of the speakers. What you never get is a node, a stream or a track. That is not an oversight in one browser: there is no specified way to capture it, so there is nothing to feature-detect and nothing that will arrive in a later release.
It matters the moment the audio has a destination other than the local speakers. A caption is fine. A voice that the person on the other end of a call is supposed to hear is not, because WebRTC only sends what is on a track.
The route that works
Use a synthesiser that returns a file. I use Workers AI melotts, which answers with audio bytes rather than a promise to make a noise. From there the path is ordinary Web Audio: decode the bytes, connect the buffer source to a MediaStreamDestination, and take the track off that destination.
// Synthesise to a file, decode through Web Audio, and replace the
// microphone track on the sender. speechSynthesis never enters this path.
const buf = await audioCtx.decodeAudioData(await res.arrayBuffer());
const dest = audioCtx.createMediaStreamDestination();
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(dest);
src.start();
const sender = pc.getSenders().find((s) => s.track?.kind === 'audio');
await sender.replaceTrack(dest.stream.getAudioTracks()[0]);replaceTrack is the important call. It swaps the outgoing audio without renegotiating, so the far side hears the translated voice instead of the microphone with no glare, no new offer and no gap in the connection.
The consequence nobody warns you about
Because the translated voice replaces the microphone on the sender, anything that rebuilds the RTCPeerConnection has to rebuild the voice channel too. A reconnection that only restores the peer puts the raw microphone back on the wire, and the other side suddenly hears the untranslated voice mid-sentence.
That failure is silent on the side that causes it. You hear yourself normally, the call stays connected, and nothing logs an error. I only catch it now because there is a browser test that reloads the far side mid-call and fails if the microphone comes back.
What I would tell you to check first
- If the audio has to leave the machine, discard speechSynthesis at the design stage rather than after the prototype.
- Keep one function that builds the outgoing audio track, and call it from every path that constructs a peer connection.
- Write the test that reloads one side mid-call. It is the only thing that catches the regression, because the symptom is inaudible to whoever introduced it.