speechSynthesis te da audio que puedes oír y no puedes enviar. Al otro extremo no hay ningún MediaStreamTrack, así que una respuesta traducida que pronuncia el navegador nunca puede ir dentro de un sender de WebRTC. Me lo encontré construyendo una sala de llamadas 1:1 con traducción en directo, y la salida es dejar de usarlo para cualquier cosa que tenga que salir de la máquina.
Lo que devuelve la API en realidad
La Web Speech API habla por la ruta de audio de la plataforma, no por nada que posea la página. Obtienes una utterance, obtienes eventos y obtienes sonido por los altavoces. Lo que no obtienes nunca es un nodo, un stream ni una pista. No es un descuido de un navegador concreto: no existe forma especificada de capturarlo, así que no hay nada que detectar por características ni nada que vaya a llegar en una versión posterior.
Importa en cuanto el audio tiene un destino que no son los altavoces locales. Un subtítulo va bien. Una voz que debe oír la persona al otro lado de una llamada no, porque WebRTC solo envía lo que está en una pista.
La vía que sí funciona
Usa un sintetizador que devuelva un fichero. Yo uso melotts de Workers AI, que responde con bytes de audio en lugar de con la promesa de hacer un ruido. A partir de ahí el camino es Web Audio del montón: descodifica los bytes, conecta el buffer source a un MediaStreamDestination y coge la pista de ese destino.
// 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 es la llamada importante. Cambia el audio saliente sin renegociar, así que el otro lado oye la voz traducida en lugar del micrófono sin glare, sin oferta nueva y sin un hueco en la conexión.
La consecuencia de la que nadie avisa
Como la voz traducida sustituye al micrófono en el sender, cualquier cosa que reconstruya la RTCPeerConnection tiene que reconstruir también el canal de voz. Una reconexión que solo restaura el peer devuelve el micrófono crudo al cable, y el otro lado oye de golpe la voz sin traducir a media frase.
Ese fallo es silencioso en el lado que lo provoca. Te oyes con normalidad, la llamada sigue conectada y nada registra un error. Ahora solo lo detecto porque hay un test de navegador que recarga el otro lado a mitad de llamada y falla si vuelve el micrófono.
Lo que yo miraría primero
- Si el audio tiene que salir de la máquina, descarta speechSynthesis en el diseño y no después del prototipo.
- Ten una única función que construya la pista de audio saliente, y llámala desde cada ruta que cree una conexión.
- Escribe el test que recarga un lado a mitad de llamada. Es lo único que pilla la regresión, porque el síntoma es inaudible para quien la introduce.