Use webm+opus for Firefox TTS streaming via MediaSource

Firefox supports audio/webm;codecs=opus in MediaSource API,
enabling true streaming playback. Remove broken progressive
playback (partial blobs don't stream). Simplified fallback
to full buffer for any browser without MSE support.
This commit is contained in:
russell@unturf.com 2026-01-27 09:29:02 -05:00
parent 03147f028d
commit 4ae6b4fbbd

View file

@ -20,16 +20,14 @@ export function setTTSMode(mode) {
}
// Detect best audio format for this browser
// Chrome/Edge: mp3 works, MediaSource supports audio/mpeg for streaming
// Firefox: mp3 decoder often missing on Linux (canPlayType lies), use opus (ogg)
// For MediaSource streaming: only Chromium browsers support audio/mpeg in MSE
// Chrome/Edge: mp3 works, MediaSource supports audio/mpeg
// Firefox: mp3 often broken on Linux; use webm+opus (works in both MSE and <audio>)
function detectAudioFormat() {
const isFirefox = navigator.userAgent.includes('Firefox');
if (isFirefox) {
// Firefox on Linux often claims mp3 support but lacks decoders
// Opus in Ogg container always works natively in Firefox
console.log("Firefox detected, using opus format for TTS");
return { format: 'opus', mime: 'audio/ogg', mseMime: null };
// Firefox supports audio/webm;codecs=opus in both MediaSource and <audio>
console.log("Firefox detected, using webm+opus format for TTS");
return { format: 'webm', mime: 'audio/webm', mseMime: 'audio/webm;codecs=opus' };
}
// Chromium-based browsers: mp3 works, MSE supports audio/mpeg
return { format: 'mp3', mime: 'audio/mpeg', mseMime: 'audio/mpeg' };
@ -175,100 +173,15 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
throw new Error(`HTTP error! status: ${response.status}`);
}
// If MSE isn't available, use progressive playback:
// Start playing after first chunk arrives instead of waiting for full download
// If MSE isn't available, fall back to full buffered download
if (!canStream) {
console.log(`Progressive playback mode (no MSE for ${requestFormat})`);
// Check if ReadableStream is available
if (!response.body) {
console.warn("Response body not streamable, falling back to full buffer");
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob });
return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) };
}
const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
let audio = null;
let audioReady = null;
let audioReadyResolve = null;
// Promise that resolves when audio element is created
audioReady = new Promise((resolve) => { audioReadyResolve = resolve; });
const done = (async () => {
try {
while (true) {
const { done: readerDone, value } = await reader.read();
if (readerDone) break;
chunks.push(value);
totalBytes += value.byteLength;
console.log(`Progressive: received ${totalBytes} bytes`);
// Start playback after accumulating enough data for decoder headers
if (!audio && totalBytes > 8192) {
const partialBlob = new Blob(chunks, { type: AUDIO_FORMAT.mime });
const partialUrl = URL.createObjectURL(partialBlob);
audio = new Audio(partialUrl);
audio.playbackRate = rate;
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
console.log(`Progressive playback started at ${totalBytes} bytes`);
audioReadyResolve(audio);
}
}
// Build final complete blob
const fullBlob = new Blob(chunks, { type: AUDIO_FORMAT.mime });
const fullBlobUrl = URL.createObjectURL(fullBlob);
if (!audio) {
// Short audio — never hit the 8KB threshold
audio = new Audio(fullBlobUrl);
audio.playbackRate = rate;
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
console.log(`Progressive: short audio, playing full ${totalBytes} bytes`);
audioReadyResolve(audio);
} else {
// Swap to complete blob for seeking/duration support
const currentTime = audio.currentTime;
const wasPlaying = !audio.paused;
audio.src = fullBlobUrl;
audio.playbackRate = rate;
audio.currentTime = currentTime;
if (wasPlaying) audio.play().catch(() => {});
}
console.log(`Progressive playback complete: ${totalBytes} bytes`);
setLastTTS(text, { audio, blob: fullBlob });
return { blob: fullBlob, blobUrl: fullBlobUrl };
} catch (error) {
console.error("Progressive playback error:", error);
// If audio was never created, unblock the waiter
if (!audio) audioReadyResolve(null);
throw error;
}
})();
// Wait for audio element to be created (first ~8KB)
const readyAudio = await audioReady;
if (!readyAudio) {
throw new Error("Progressive playback failed to create audio");
}
const result = { audio: readyAudio, blob: null, blobUrl: null, streamed: true, done };
done.then(({ blob, blobUrl }) => {
result.blob = blob;
result.blobUrl = blobUrl;
}).catch((err) => console.error("Progressive done error:", err));
return result;
console.warn(`No streaming support for ${requestFormat}, using full buffer`);
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob });
return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) };
}
const mediaSource = new MediaSource();