Fix progressive playback: add error handling, response.body check

Progressive playback was silently failing if response.body was null
or if the reader threw. Now falls back to full buffer if body isn't
streamable, logs chunk progress, and properly unblocks callers on
error instead of hanging forever.
This commit is contained in:
russell@unturf.com 2026-01-27 09:23:37 -05:00
parent cc1d21ade0
commit 03147f028d

View file

@ -178,14 +178,30 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
// If MSE isn't available, use progressive playback:
// Start playing after first chunk arrives instead of waiting for full download
if (!canStream) {
console.log(`Using progressive playback (no MSE for ${requestFormat})`);
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 currentBlobUrl = null;
let audioReady = null;
let audioReadyResolve = null;
const done = new Promise(async (resolve, reject) => {
// 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();
@ -193,16 +209,17 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
chunks.push(value);
totalBytes += value.byteLength;
console.log(`Progressive: received ${totalBytes} bytes`);
// Start playback after accumulating some data (~8KB)
// This gives the decoder enough to parse headers and begin
// Start playback after accumulating enough data for decoder headers
if (!audio && totalBytes > 8192) {
const partialBlob = new Blob(chunks, { type: AUDIO_FORMAT.mime });
currentBlobUrl = URL.createObjectURL(partialBlob);
audio = new Audio(currentBlobUrl);
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);
}
}
@ -211,12 +228,14 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
const fullBlobUrl = URL.createObjectURL(fullBlob);
if (!audio) {
// Very short audio - never hit the threshold
// 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 {
// Replace with complete audio to enable seeking
// Swap to complete blob for seeking/duration support
const currentTime = audio.currentTime;
const wasPlaying = !audio.paused;
audio.src = fullBlobUrl;
@ -225,40 +244,29 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
if (wasPlaying) audio.play().catch(() => {});
}
if (currentBlobUrl && currentBlobUrl !== fullBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
console.log(`Progressive playback complete: ${totalBytes} bytes`);
setLastTTS(text, { audio, blob: fullBlob });
resolve({ blob: fullBlob, blobUrl: fullBlobUrl });
return { blob: fullBlob, blobUrl: fullBlobUrl };
} catch (error) {
console.error("Progressive playback error:", error);
reject(error);
// If audio was never created, unblock the waiter
if (!audio) audioReadyResolve(null);
throw error;
}
});
})();
// Return immediately — audio will be set once first chunks arrive
// Use a proxy object so callers get the audio once it's ready
const result = { audio: null, blob: null, blobUrl: null, streamed: true, done };
// 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(() => {});
// Wait for audio element to be created (first chunks)
await new Promise((resolve) => {
const check = () => {
if (audio) {
result.audio = audio;
resolve();
} else {
setTimeout(check, 50);
}
};
check();
});
}).catch((err) => console.error("Progressive done error:", err));
return result;
}