From 01a2097c5e0782afd5aa0dd4de8c35882c2ce6a1 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 26 Jun 2026 22:01:08 +0200 Subject: [PATCH] feat: add Native Speed control to Try it out and Read aloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the `speed` parameter (0.5–2.0) on the TTS `/v1/audio/speech` request so audio is generated at the target tempo natively via the faster-qwen3-tts backend rather than post-processing. - Backend: `/api/tts-preview` now extracts and forwards a `speed` override (clamped 0.1–4.0) through the same extra-params mechanism already used for seed/temperature; backends that reject it fall back cleanly via `_post_tts_with_fallback`. - Try it out: "Native Speed" number input (0.5–2, step 0.05) added to the text card; value persists in localStorage per browser; passed as `extra` through `createTtsAudioSource` and `generateChunkedTts`. - Read aloud: "Native Speed" control added to the generation controls row alongside seed/temperature; included in `readerGenParams()` and saved/restored with library documents (each book tracks its own speed). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- routes/tts.py | 8 ++++++++ static/index.html | 4 ++-- static/js/generation.js | 4 ++-- static/js/reader.js | 4 ++++ static/js/tts-preview.js | 21 ++++++++++++++++----- static/sections/s-reader.html | 4 ++++ static/sections/s-tryout.html | 4 ++++ 9 files changed, 48 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cf775..7c9bad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.9.3] — 2026-06-26 + +### Added +- **Native Speed for Try it out & Read aloud**: a **Native Speed** control (range 0.5×–2.0×, default 1.0) is now available in both the *Try it out* and *Read aloud* sections. It passes the `speed` parameter directly to the TTS generation request (natively via the faster-qwen3-tts backend), producing audio at the target tempo from the model rather than using post-processing pitch/time-shift. Try it out persists the chosen speed in localStorage (per browser); Read aloud saves it with the document in the library (each book remembers its own speed). + +--- + ## [1.9.2] — 2026-06-26 ### Fixed diff --git a/VERSION b/VERSION index 8fdcf38..77fee73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.2 +1.9.3 diff --git a/routes/tts.py b/routes/tts.py index 6371cb0..5836f1a 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -470,6 +470,14 @@ async def tts_preview(request: Request): _overrides[_k] = int(_v) if _k == "seed" else float(_v) except (TypeError, ValueError): pass + _speed_v = data.get("speed") + if _speed_v is not None and _speed_v != "": + try: + _speed_f = float(_speed_v) + if _speed_f != 1.0: + _overrides["speed"] = max(0.1, min(4.0, _speed_f)) + except (TypeError, ValueError): + pass if _overrides: from core.config import _tts_extra_params settings = dict(settings) diff --git a/static/index.html b/static/index.html index 2a4b3a2..54c909b 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@ - + @@ -308,7 +308,7 @@ - + diff --git a/static/js/generation.js b/static/js/generation.js index 2430af7..e9c99f0 100644 --- a/static/js/generation.js +++ b/static/js/generation.js @@ -81,14 +81,14 @@ function splitTextIntoChunks(text, maxLen = 800) { return chunks.length ? chunks : [text]; } -async function generateChunkedTts(voice, text, backend, instruct) { +async function generateChunkedTts(voice, text, backend, instruct, extra = null) { const chunks = splitTextIntoChunks(text); const prog = $('preview-chunk-progress'); if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}…`; } const blobs = []; for (let i = 0; i < chunks.length; i++) { if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}…`; - blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend)); + blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend, false, extra)); } if (prog) prog.textContent = 'Merging…'; const merged = await mergeWavBlobs(blobs); diff --git a/static/js/reader.js b/static/js/reader.js index 420b089..e2ae27d 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -851,8 +851,10 @@ function readerGenParams() { const out = {}; const seed = $('reader-seed')?.value.trim(); const temp = $('reader-temp')?.value.trim(); + const nspd = $('reader-tts-speed')?.value.trim(); if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10); if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp); + if (nspd !== '' && nspd != null && !isNaN(+nspd) && parseFloat(nspd) !== 1) out.speed = parseFloat(nspd); return Object.keys(out).length ? out : null; } @@ -1104,6 +1106,7 @@ async function readerSaveLibrary() { chunkMode: readerState.chunkMode, seed: $('reader-seed')?.value.trim() || '', temperature: $('reader-temp')?.value.trim() || '', + tts_speed: parseFloat($('reader-tts-speed')?.value) || 1, normalize: readerState.normalize, sentenceCount: readerState.sentences.length, pageCount: readerState.pages.length, @@ -1169,6 +1172,7 @@ async function readerOpenLibraryDoc(id) { if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode; if ($('reader-seed')) $('reader-seed').value = rec.seed || ''; if ($('reader-temp')) $('reader-temp').value = rec.temperature || ''; + if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed; readerState.normalize = rec.normalize !== false; if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize; if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend); diff --git a/static/js/tts-preview.js b/static/js/tts-preview.js index 60672d2..4822dcd 100644 --- a/static/js/tts-preview.js +++ b/static/js/tts-preview.js @@ -219,17 +219,17 @@ async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } return await r.blob(); } -async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false) { +async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) { const mode = effectiveTtsPlaybackMode(modeOverride); if (backend !== 'streaming' || mode === 'buffered') { - const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona); + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra); return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; } try { return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'}; } catch (e) { if (mode === 'streaming') throw e; - const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona); + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra); return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; } } @@ -246,6 +246,15 @@ function _onPreviewGenerated(source, voice, text, backend) { if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url); } +const _TRYOUT_SPEED_KEY = 'ttsvc_tryout_native_speed'; +(function _restoreTryoutSpeed() { + const saved = localStorage.getItem(_TRYOUT_SPEED_KEY); + if (saved) { const el = $('preview-native-speed'); if (el) el.value = saved; } +})(); +$('preview-native-speed')?.addEventListener('change', function () { + localStorage.setItem(_TRYOUT_SPEED_KEY, this.value); +}); + $('preview-btn').addEventListener('click', async () => { const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim(); const applyPersona = $('preview-persona-toggle')?.checked || false; @@ -255,12 +264,14 @@ $('preview-btn').addEventListener('click', async () => { $('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true; if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true; if($('effects-apply-btn')) $('effects-apply-btn').disabled=true; + const _nspd = parseFloat($('preview-native-speed')?.value); + const _extra = (!isNaN(_nspd) && _nspd !== 1) ? {speed: _nspd} : null; try { const audio = $('preview-audio'); const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function'; const source = useChunked - ? await generateChunkedTts(voice, text, backend, instruct) - : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona); + ? await generateChunkedTts(voice, text, backend, instruct, _extra) + : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra); previewBlob = source.blob; window._previewVoice = voice; window._previewBackend = backend; window._previewText = text; audio.src = source.url; diff --git a/static/sections/s-reader.html b/static/sections/s-reader.html index 8f56a3c..40144aa 100644 --- a/static/sections/s-reader.html +++ b/static/sections/s-reader.html @@ -49,6 +49,10 @@ +
+ + +