feat: add Native Speed control to Try it out and Read aloud

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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-26 22:01:08 +02:00
parent 65e2784d2d
commit 01a2097c5e
9 changed files with 48 additions and 10 deletions

View File

@ -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 ## [1.9.2] — 2026-06-26
### Fixed ### Fixed

View File

@ -1 +1 @@
1.9.2 1.9.3

View File

@ -470,6 +470,14 @@ async def tts_preview(request: Request):
_overrides[_k] = int(_v) if _k == "seed" else float(_v) _overrides[_k] = int(_v) if _k == "seed" else float(_v)
except (TypeError, ValueError): except (TypeError, ValueError):
pass 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: if _overrides:
from core.config import _tts_extra_params from core.config import _tts_extra_params
settings = dict(settings) settings = dict(settings)

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.9.1-3"> <link rel="stylesheet" href="/static/style.css?v=1.9.3">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -308,7 +308,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.9.1-3"></script> <script src="/static/loader.js?v=1.9.3"></script>
</body> </body>
</html> </html>

View File

@ -81,14 +81,14 @@ function splitTextIntoChunks(text, maxLen = 800) {
return chunks.length ? chunks : [text]; 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 chunks = splitTextIntoChunks(text);
const prog = $('preview-chunk-progress'); const prog = $('preview-chunk-progress');
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}`; } if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}`; }
const blobs = []; const blobs = [];
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}`; 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…'; if (prog) prog.textContent = 'Merging…';
const merged = await mergeWavBlobs(blobs); const merged = await mergeWavBlobs(blobs);

View File

@ -851,8 +851,10 @@ function readerGenParams() {
const out = {}; const out = {};
const seed = $('reader-seed')?.value.trim(); const seed = $('reader-seed')?.value.trim();
const temp = $('reader-temp')?.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 (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp); 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; return Object.keys(out).length ? out : null;
} }
@ -1104,6 +1106,7 @@ async function readerSaveLibrary() {
chunkMode: readerState.chunkMode, chunkMode: readerState.chunkMode,
seed: $('reader-seed')?.value.trim() || '', seed: $('reader-seed')?.value.trim() || '',
temperature: $('reader-temp')?.value.trim() || '', temperature: $('reader-temp')?.value.trim() || '',
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
normalize: readerState.normalize, normalize: readerState.normalize,
sentenceCount: readerState.sentences.length, sentenceCount: readerState.sentences.length,
pageCount: readerState.pages.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-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
if ($('reader-seed')) $('reader-seed').value = rec.seed || ''; if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
if ($('reader-temp')) $('reader-temp').value = rec.temperature || ''; 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; readerState.normalize = rec.normalize !== false;
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize; if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend); if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);

View File

@ -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); } if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob(); 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); const mode = effectiveTtsPlaybackMode(modeOverride);
if (backend !== 'streaming' || mode === 'buffered') { 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'}; return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
} }
try { try {
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'}; return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
} catch (e) { } catch (e) {
if (mode === 'streaming') throw 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'}; 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); 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 () => { $('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 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; 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; $('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($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
if($('effects-apply-btn')) $('effects-apply-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 { try {
const audio = $('preview-audio'); const audio = $('preview-audio');
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function'; const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
const source = useChunked const source = useChunked
? await generateChunkedTts(voice, text, backend, instruct) ? await generateChunkedTts(voice, text, backend, instruct, _extra)
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona); : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra);
previewBlob = source.blob; previewBlob = source.blob;
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text; window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
audio.src = source.url; audio.src = source.url;

View File

@ -49,6 +49,10 @@
<label>Temperature <span class="note">(01)</span></label> <label>Temperature <span class="note">(01)</span></label>
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off"> <input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
</div> </div>
<div class="field reader-ctl reader-ctl-sm">
<label>Native Speed <span class="note">(0.52)</span></label>
<input type="number" id="reader-tts-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off">
</div>
<label class="btn-primary reader-import-btn" title="Import a PDF or text file" style="margin-left:auto"> <label class="btn-primary reader-import-btn" title="Import a PDF or text file" style="margin-left:auto">
<span class="mdi mdi-file-upload-outline"></span> Import <span class="mdi mdi-file-upload-outline"></span> Import
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none"> <input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">

View File

@ -71,6 +71,10 @@
<span class="mdi mdi-alert-outline"></span> The selected backend has <strong>weak style support</strong> — this instruction may be ignored. Switch to a <em>style-aware</em> backend (CustomVoice, VoiceDesign) for full effect. <span class="mdi mdi-alert-outline"></span> The selected backend has <strong>weak style support</strong> — this instruction may be ignored. Switch to a <em>style-aware</em> backend (CustomVoice, VoiceDesign) for full effect.
</span> </span>
</div> </div>
<div class="field" style="margin-top:10px;display:flex;align-items:center;gap:12px">
<label style="white-space:nowrap;font-weight:500">Native Speed <span class="note">(0.52)</span></label>
<input type="number" id="preview-native-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off" style="width:80px">
</div>
<div class="btn-row" style="gap:20px;margin-top:8px"> <div class="btn-row" style="gap:20px;margin-top:8px">
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating"> <label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating">
<input type="checkbox" id="preview-persona-toggle"> <input type="checkbox" id="preview-persona-toggle">