Progressive playback for Firefox, add Qwen3-TTS to TTS page

Firefox can't use MediaSource for streaming. Instead of buffering
the entire response before playing, progressively read chunks and
start playback after ~8KB arrives. Replaces audio src with complete
blob when download finishes for seeking support.

Update text-to-speech.html to document Qwen3-TTS as the default
5th engine with 20 cloned voices and 10-language support.
This commit is contained in:
russell@unturf.com 2026-01-27 09:20:39 -05:00
parent 1a2a85c797
commit cc1d21ade0
2 changed files with 102 additions and 16 deletions

View file

@ -175,15 +175,92 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
throw new Error(`HTTP error! status: ${response.status}`);
}
// If MSE isn't available for this format, fall back to buffered playback
// If MSE isn't available, use progressive playback:
// Start playing after first chunk arrives instead of waiting for full download
if (!canStream) {
console.warn(`MediaSource can't stream ${requestFormat}, using buffered playback with ${AUDIO_FORMAT.format}`);
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 }) };
console.log(`Using progressive playback (no MSE for ${requestFormat})`);
const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
let audio = null;
let currentBlobUrl = null;
const done = new Promise(async (resolve, reject) => {
try {
while (true) {
const { done: readerDone, value } = await reader.read();
if (readerDone) break;
chunks.push(value);
totalBytes += value.byteLength;
// Start playback after accumulating some data (~8KB)
// This gives the decoder enough to parse headers and begin
if (!audio && totalBytes > 8192) {
const partialBlob = new Blob(chunks, { type: AUDIO_FORMAT.mime });
currentBlobUrl = URL.createObjectURL(partialBlob);
audio = new Audio(currentBlobUrl);
audio.playbackRate = rate;
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
console.log(`Progressive playback started at ${totalBytes} bytes`);
}
}
// Build final complete blob
const fullBlob = new Blob(chunks, { type: AUDIO_FORMAT.mime });
const fullBlobUrl = URL.createObjectURL(fullBlob);
if (!audio) {
// Very short audio - never hit the threshold
audio = new Audio(fullBlobUrl);
audio.playbackRate = rate;
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
} else {
// Replace with complete audio to enable seeking
const currentTime = audio.currentTime;
const wasPlaying = !audio.paused;
audio.src = fullBlobUrl;
audio.playbackRate = rate;
audio.currentTime = currentTime;
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 });
} catch (error) {
console.error("Progressive playback error:", error);
reject(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 };
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();
});
return result;
}
const mediaSource = new MediaSource();

View file

@ -7,7 +7,7 @@
<meta name="theme-color" content="#43a047">
<meta name="color-scheme" content="light dark">
<title>Open-Source Text-to-Speech | uncloseai-speech | uncloseai.com</title>
<meta name="description" content="Self-hostable OpenAI-compatible text-to-speech API with 4 TTS engines, 227+ voices, and zero API keys. Learn ML by building real TTS infrastructure.">
<meta name="description" content="Self-hostable OpenAI-compatible text-to-speech API with 5 TTS engines, 20+ cloned voices, and zero API keys. Default: Qwen3-TTS with voice cloning.">
<!-- PicoCSS -->
<link rel="stylesheet" href="/css/pico.classless.min.css">
@ -108,8 +108,8 @@
<ul>
<li><strong>Zero API Keys:</strong> No registration, no tracking, no rate limits on your own infrastructure</li>
<li><strong>OpenAI-Compatible:</strong> Drop-in replacement - change one URL and you're running</li>
<li><strong>Four TTS Engines:</strong> Piper (fast), XTTS (quality), Silero (CPU-friendly), Kokoro (lightweight)</li>
<li><strong>227+ Voices:</strong> Including multilingual support across 5 languages</li>
<li><strong>Five TTS Engines:</strong> Qwen3-TTS (default, voice cloning), Piper (fast), XTTS (quality), Silero (CPU-friendly), Kokoro (lightweight)</li>
<li><strong>20+ Cloned Voices:</strong> With 10-language support including Chinese, Japanese, Korean, and European languages</li>
<li><strong>Self-Hostable:</strong> Docker compose, Makefile-driven, runs on your hardware</li>
<li><strong>AGPL v3 Licensed:</strong> Keeps TTS libre forever - even network service users get source code</li>
</ul>
@ -126,14 +126,14 @@ curl https://speech.ai.unturf.com/v1/audio/speech \
"voice": "alloy"
}' > output.mp3
# Explicitly specify model (tts-1, tts-1-hd, tts-1-silero, tts-1-kokoro)
# Explicitly specify model (tts-1-qwen, tts-1, tts-1-hd, tts-1-silero, tts-1-kokoro)
curl https://speech.ai.unturf.com/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1-hd",
"input": "High quality XTTS voice cloning.",
"model": "tts-1-qwen",
"input": "Qwen3-TTS voice cloning with 20 voices.",
"voice": "alloy"
}' > output-hd.mp3</code></pre>
}' > output-qwen.mp3</code></pre>
<h3 id="voice-auto-detection">Auto-Detection Magic</h3>
@ -218,7 +218,16 @@ make voices
# Test remote endpoint
make test</code></pre>
<h2 id="the-engines">The Four Engines</h2>
<h2 id="the-engines">The Five Engines</h2>
<h3 id="qwen">🧠 Qwen3-TTS (tts-1-qwen) — Default</h3>
<ul>
<li><strong>Quality:</strong> State-of-the-art voice cloning from 3-second samples</li>
<li><strong>Languages:</strong> 10 languages (Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian)</li>
<li><strong>Voices:</strong> 20 cloned voices (alloy, echo, fable, onyx, nova, shimmer + 14 extended)</li>
<li><strong>Use Case:</strong> Default engine for all TTS — high quality with fast 97ms first-packet latency</li>
<li><strong>Tech:</strong> 1.7B params, Apache 2.0 license, actively maintained by Alibaba</li>
</ul>
<h3 id="piper">🏃 Piper TTS (tts-1)</h3>
<ul>