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:
parent
1a2a85c797
commit
cc1d21ade0
2 changed files with 102 additions and 16 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue