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
|
## [1.9.2] — 2026-06-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -49,6 +49,10 @@
|
|||||||
<label>Temperature <span class="note">(0–1)</span></label>
|
<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">
|
<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.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">
|
<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">
|
||||||
|
|||||||
@ -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.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">
|
<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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user