diff --git a/public/src/tts-modal.js b/public/src/tts-modal.js index 1fa37d2..03b6973 100644 --- a/public/src/tts-modal.js +++ b/public/src/tts-modal.js @@ -11,7 +11,7 @@ async function populateVoiceDropdown(voiceSelection) { const cacheTimeKey = 'uncloseai_voices_cache_time'; const cacheVersionKey = 'uncloseai_voices_cache_version'; const cacheExpiration = 60000; // 1 minute in milliseconds - const currentVersion = '3'; // Increment this when voice models change (v3: 20 voices) + const currentVersion = '4'; // Increment this when voice models change (v4: 42+ built-in cloned voices) try { const cachedTime = localStorage.getItem(cacheTimeKey); diff --git a/public/src/tts.js b/public/src/tts.js index 087f66e..15710fc 100644 --- a/public/src/tts.js +++ b/public/src/tts.js @@ -35,6 +35,9 @@ function detectAudioFormat() { const AUDIO_FORMAT = detectAudioFormat(); +// In-memory TTS cache - keyed by text+voice+model, cleared when tab closes +const ttsCache = new Map(); + // Simple JavaScript text cleaning for TTS function cleanTextForTTS(text) { return text @@ -457,20 +460,43 @@ export async function generateTitleForTTS(text) { } // Handle TTS - respects streaming mode setting -// In streaming mode, returns immediately with audio playing and a `done` promise -// In buffered mode, waits for full download then returns +// Caches audio blobs in memory so replay doesn't regenerate +// Cache is keyed by text+voice+model and clears when the tab closes export async function handleTTS(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { try { + const cacheKey = `${model}:${voice}:${text}`; + + // Check cache - return cached blob as a new Audio element + if (ttsCache.has(cacheKey)) { + console.log("handleTTS: cache hit, replaying"); + const cached = ttsCache.get(cacheKey); + const audioUrl = URL.createObjectURL(cached.blob); + const audio = new Audio(audioUrl); + audio.playbackRate = rate; + setLastTTS(text, { audio, blob: cached.blob }); + return { audio, blob: cached.blob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: cached.blob, blobUrl: audioUrl }) }; + } + const mode = getTTSMode(); console.log(`handleTTS: using ${mode} mode`); + let result; if (mode === "streaming") { - return await speakTextStreaming(text, voice, rate, model); + result = await speakTextStreaming(text, voice, rate, model); } else { - const result = await speakTextDirect(text, voice, rate, model); - // Add done: already-resolved promise for consistent interface + result = await speakTextDirect(text, voice, rate, model); result.done = Promise.resolve({ blob: result.blob, blobUrl: result.blobUrl }); - return result; } + + // Cache the blob once available + if (result.done) { + result.done.then(({ blob }) => { + if (blob) { + ttsCache.set(cacheKey, { blob }); + } + }).catch(() => {}); + } + + return result; } catch (error) { console.error("Error in handleTTS:", error); throw error; diff --git a/public/text-to-speech.html b/public/text-to-speech.html index b817439..e0339d7 100644 --- a/public/text-to-speech.html +++ b/public/text-to-speech.html @@ -7,7 +7,7 @@
Test our community-hosted endpoint before self-hosting:
-# Auto-detect model from voice name
+ # Female voice (aria) - auto-detects Qwen3-TTS engine
curl https://speech.ai.unturf.com/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
- "input": "Welcome to uncloseai-speech. Four TTS engines, zero API keys.",
- "voice": "alloy"
+ "input": "Welcome to uncloseai-speech. Five TTS engines, 42 voices, zero API keys.",
+ "voice": "aria"
}' > output.mp3
-# Explicitly specify model (tts-1-qwen, tts-1, tts-1-hd, tts-1-silero, tts-1-kokoro)
+# Male voice (atlas) - explicitly specify Qwen3-TTS model
curl https://speech.ai.unturf.com/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1-qwen",
- "input": "Qwen3-TTS voice cloning with 20 voices.",
- "voice": "alloy"
+ "input": "Qwen3-TTS voice cloning with 42 built-in voices.",
+ "voice": "atlas"
}' > output-qwen.mp3
Auto-Detection Magic
@@ -148,9 +148,14 @@ client = OpenAI(
# Auto-detects the right engine for each voice
client.audio.speech.create(
- voice="alloy", # → Piper (fast CPU)
- input="Fast synthesis on CPU"
-).stream_to_file("piper.mp3")
+ voice="aria", # → Qwen3-TTS (female, voice cloning)
+ input="Voice cloning with 42 built-in voices"
+).stream_to_file("qwen.mp3")
+
+client.audio.speech.create(
+ voice="atlas", # → Qwen3-TTS (male, voice cloning)
+ input="Male voice synthesis"
+).stream_to_file("qwen-male.mp3")
client.audio.speech.create(
voice="en_50", # → Silero (native voice name)
@@ -164,13 +169,13 @@ client.audio.speech.create(
Discover Voices
- Extended /v1/voices endpoint shows all 227 voices with engine metadata:
+ Browse all available voices with engine metadata: speech.ai.unturf.com/v1/voices
curl https://speech.ai.unturf.com/v1/voices | jq .
Discover Models
- Standard OpenAI /v1/models endpoint shows available TTS models:
+ See all available TTS engines: speech.ai.unturf.com/v1/models
curl https://speech.ai.unturf.com/v1/models | jq .
@@ -187,13 +192,10 @@ cd uncloseai-speech
# See all available commands
make help
-# Deploy locally with Docker
-make local-deploy
+# Deploy with Docker (GPU)
+make deploy
-# Download voice models (Piper + XTTS samples)
-make voices
-
-# Test it
+# Test it (Qwen3-TTS model downloads automatically on first use)
make test
# Watch logs
@@ -201,22 +203,21 @@ make logs
That's it. You now have a production TTS API running locally.
-Deploy to a GPU server for XTTS voice cloning:
+Deploy with GPU support for Qwen3-TTS voice cloning:
-# Configure deployment target
-cp vars.sh.example vars.sh
-# Edit vars.sh with your server details
-
-# Deploy to remote server (syncs, builds, restarts)
+ # Build and start with GPU (requires NVIDIA Docker runtime)
make deploy
-# Download all voice models
-make voices
+# Or CPU-only (slower, but works anywhere)
+make deploy-cpu
-# Test remote endpoint
-make test
+# Test the endpoint
+make test
+
+# Test all 42+ voices
+make hydrate
https://speech.ai.unturf.com/v1docs/ directory in repository