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:
parent
65e2784d2d
commit
01a2097c5e
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<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) ── -->
|
||||
@ -308,7 +308,7 @@
|
||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -49,6 +49,10 @@
|
||||
<label>Temperature <span class="note">(0–1)</span></label>
|
||||
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
|
||||
</div>
|
||||
<div class="field reader-ctl reader-ctl-sm">
|
||||
<label>Native Speed <span class="note">(0.5–2)</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">
|
||||
<span class="mdi mdi-file-upload-outline"></span> Import
|
||||
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
||||
|
||||
@ -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>
|
||||
</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.5–2)</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">
|
||||
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating">
|
||||
<input type="checkbox" id="preview-persona-toggle">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user