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:
parent
03147f028d
commit
4ae6b4fbbd
1 changed files with 13 additions and 100 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue