Add the VOX prosody preference and panel controls

This commit is contained in:
Fisto
2026-09-21 11:08:22 +02:00
parent ca3fa5be1e
commit 293d131d2b
9 changed files with 101 additions and 17 deletions
+8
View File
@@ -40,6 +40,14 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C62 VOX prosody controls. The last open Veskslop-side item from the MicroslopSam design:
`tts.vox` and `tts.voxClean` preferences (per-line overrides on `/api/audio/speak` and MCP
`speak`), `VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` exports plus `{vox}` / `{vox_clean}`
placeholders for the command provider, and a Radio-kit row in the TTS panel with the checkboxes
and a help tooltip. VOX disables word mode in the panel; the wrapper keeps its documented
precedence. No other MicroslopSam work is outstanding: the engine branch is already an ancestor
of main, and the remaining contract note (HTTP `/voices` discovery) is not needed for the
command-provider path in use.
- [x] C61 Media browsing performance. The grid's merge step rescanned every scrape database per - [x] C61 Media browsing performance. The grid's merge step rescanned every scrape database per
request (about 300 ms for a page, 250 ms for stats, both refetched on every scroll append). request (about 300 ms for a page, 250 ms for stats, both refetched on every scroll append).
`MediaService` now keeps a candidate cache keyed by a scrape revision plus the normalized `MediaService` now keeps a candidate cache keyed by a scrape revision plus the normalized
+7 -3
View File
@@ -58,12 +58,16 @@ Ordered backlog. `CURRENT.md` tracks the live session state.
10. Mobile web UI: first draft at 390px with the four tabs (Users, Chat, Call, Activity) is 10. Mobile web UI: first draft at 390px with the four tabs (Users, Chat, Call, Activity) is
implemented and rendered headless. Next: move the transcript into the Call tab, put the TTS implemented and rendered headless. Next: move the transcript into the Call tab, put the TTS
box in the Call tab as in 1c, and verify with real data on a phone-width viewport. box in the Call tab as in 1c, and verify with real data on a phone-width viewport.
11. TTS VOX preference: a `tts.vox` toggle that exports `VESKSLOP_TTS_VOX` (like `words`), an
optional `VOX_CLEAN`, and a help line in the TTS panel. Requested in `TTS-DESIGN.md`; the
wrapper side is ready and verified. Inline `/rate` and `/pitch` need no UI.
## Media archive follow-ups ## Media archive follow-ups
## TTS follow-ups
1. **HTTP provider voice discovery.** `TTS-DESIGN.md` specifies `GET /voices` (id, name, language,
gender, speed/pitch ranges) for the HTTP provider; Veskslop still lists voices from espeak or
`VESKSLOP_TTS_VOICES` only. The command-provider path in use does not need it; implement when an
HTTP engine with multiple voices is deployed.
## Web UI follow-ups ## Web UI follow-ups
Ordered after the voice backend unless the owner asks first. Ordered after the voice backend unless the owner asks first.
+9 -4
View File
@@ -156,15 +156,20 @@ from the TTS panel:
profile. profile.
- **Radio kit** - high-pass (`tts.highpass`, 40-400 Hz, default 40), mono echo (`tts.echo`, 0-100, - **Radio kit** - high-pass (`tts.highpass`, 40-400 Hz, default 40), mono echo (`tts.echo`, 0-100,
default 0), and word mode (`tts.words`) with a gap in ms (`tts.wordGapMs`) for the radio/robot default 0), and word mode (`tts.words`) with a gap in ms (`tts.wordGapMs`) for the radio/robot
character. character. **VOX prosody** (`tts.vox`) routes the line through the NetTTS-style encoder first -
sentence breaks, lead-ins, weighted pauses, and Flextalk vendor tags - for Black Mesa
announcement energy; **clean** (`tts.voxClean`) drops the staccato wrapper. VOX outranks word
mode and needs `python3` on the host. Both travel to the command provider as
`VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders).
- **Test lines** - Attention, Laboratory, Critical, Health, and Radiation, plus two custom lines - **Test lines** - Attention, Laboratory, Critical, Health, and Radiation, plus two custom lines
kept in `localStorage`. Each button speaks the line with the current profile, so a change can be kept in `localStorage`. Each button speaks the line with the current profile, so a change can be
auditioned without touching the real queue. auditioned without touching the real queue.
The window is movable and recenters every time it opens, so it cannot be lost off screen. Changed The window is movable and recenters every time it opens, so it cannot be lost off screen. Changed
profile keys travel as `profile` (with `highpass`, `echo`, `words`, and `wordGapMs`) to the command profile keys travel as `profile` (with `highpass`, `echo`, `words`, `wordGapMs`, `vox`, and
provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`, `VESKSLOP_TTS_ECHO`, `voxClean`) to the command provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`,
`VESKSLOP_TTS_WORDS`, and `VESKSLOP_TTS_WORD_GAP_MS`. The wrapper contract and the registry `VESKSLOP_TTS_ECHO`, `VESKSLOP_TTS_WORDS`, `VESKSLOP_TTS_WORD_GAP_MS`, `VESKSLOP_TTS_VOX`, and
`VESKSLOP_TTS_VOX_CLEAN`. The wrapper contract and the registry
pitfalls (`wine reg import`; never `echo` for the `.reg` key path) are in pitfalls (`wine reg import`; never `echo` for the `.reg` key path) are in
[TTS-DESIGN.md](../TTS-DESIGN.md). [TTS-DESIGN.md](../TTS-DESIGN.md).
+13 -1
View File
@@ -2027,6 +2027,10 @@ function fillProfileControls() {
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off"; $("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-words").checked = state.speech.words === true; $("#tts-words").checked = state.speech.words === true;
$("#tts-vox").checked = state.speech.vox === true;
$("#tts-vox-clean").checked = state.speech.voxClean === true;
$("#tts-vox-clean").disabled = state.speech.vox !== true;
$("#tts-words").disabled = state.speech.vox === true;
const gap = Number(state.speech.wordGapMs); const gap = Number(state.speech.wordGapMs);
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120; $("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`; $("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
@@ -2058,7 +2062,9 @@ function effectOptions() {
highpass: Number($("#tts-highpass").value), highpass: Number($("#tts-highpass").value),
echo: Number($("#tts-echo").value), echo: Number($("#tts-echo").value),
words: $("#tts-words").checked, words: $("#tts-words").checked,
wordGapMs: Number($("#tts-word-gap").value) wordGapMs: Number($("#tts-word-gap").value),
vox: $("#tts-vox").checked,
voxClean: $("#tts-vox-clean").checked
}; };
} }
function syncVoiceControls(voices, selected) { function syncVoiceControls(voices, selected) {
@@ -2734,6 +2740,12 @@ function init() {
$(`#${id}`).addEventListener("change", () => void saveSpeech()); $(`#${id}`).addEventListener("change", () => void saveSpeech());
} }
$("#tts-words").addEventListener("change", () => void saveSpeech()); $("#tts-words").addEventListener("change", () => void saveSpeech());
$("#tts-vox").addEventListener("change", () => {
$("#tts-vox-clean").disabled = !$("#tts-vox").checked;
$("#tts-words").disabled = $("#tts-vox").checked;
void saveSpeech();
});
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
$("#profile-open").addEventListener("click", () => { $("#profile-open").addEventListener("click", () => {
const panel = document.querySelector(".vk-profile-panel"); const panel = document.querySelector(".vk-profile-panel");
if (panel) { if (panel) {
+13
View File
@@ -556,6 +556,19 @@
<input id="tts-word-gap" type="range" min="0" max="500" step="10" value="120" /> <input id="tts-word-gap" type="range" min="0" max="500" step="10" value="120" />
<span id="tts-word-gap-value" class="vk-mono vk-dim">120ms</span> <span id="tts-word-gap-value" class="vk-mono vk-dim">120ms</span>
</div> </div>
<div class="vk-profile-row">
<label class="vk-tts-check">
<input id="tts-vox" type="checkbox" /> VOX prosody
<span
class="vk-info"
title="NetTTS-style Black Mesa delivery for the Flextalk voices: sentence breaks, lead-ins, weighted pauses, and vendor tags. Needs python3. Overrides word mode."
>i</span
>
</label>
<label class="vk-tts-check">
<input id="tts-vox-clean" type="checkbox" /> clean (no stutter)
</label>
</div>
</section> </section>
<section class="vk-profile-section"> <section class="vk-profile-section">
<div class="vk-section-head"> <div class="vk-section-head">
+21 -3
View File
@@ -561,10 +561,26 @@ function buildServer(): McpServer {
highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"), highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"),
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"), echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"), words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms") wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms"),
vox: z.boolean().optional().describe("NetTTS-style VOX prosody (Flextalk voices)"),
voxClean: z.boolean().optional().describe("VOX without the staccato wrapper")
} }
}, },
async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) => async ({
text,
provider,
voice,
speed,
pitch,
expression,
profile,
highpass,
echo,
words,
wordGapMs,
vox,
voxClean
}) =>
jsonText( jsonText(
await audio.speak(text, { await audio.speak(text, {
provider, provider,
@@ -576,7 +592,9 @@ function buildServer(): McpServer {
highpass, highpass,
echo, echo,
words, words,
wordGapMs wordGapMs,
vox,
voxClean
}) })
) )
); );
+13 -3
View File
@@ -30,6 +30,10 @@ export interface TtsPreferences {
words: boolean; words: boolean;
/** Silence between words in milliseconds, 0-500. */ /** Silence between words in milliseconds, 0-500. */
wordGapMs: number | null; wordGapMs: number | null;
/** NetTTS-style VOX prosody (Flextalk voices only). */
vox: boolean;
/** VOX without the staccato wrapper. */
voxClean: boolean;
} }
const PROFILE_RANGES: Record<string, [number, number]> = { const PROFILE_RANGES: Record<string, [number, number]> = {
@@ -146,7 +150,9 @@ const DEFAULTS: WebPreferences = {
highpass: null, highpass: null,
echo: null, echo: null,
words: false, words: false,
wordGapMs: null wordGapMs: null,
vox: false,
voxClean: false
}, },
audio: { duck: true }, audio: { duck: true },
favourites: { users: [], channels: [] }, favourites: { users: [], channels: [] },
@@ -340,7 +346,9 @@ export class PreferencesStore {
highpass: normalizeRange(patch.tts?.highpass, 40, 400), highpass: normalizeRange(patch.tts?.highpass, 40, 400),
echo: normalizeRange(patch.tts?.echo, 0, 100), echo: normalizeRange(patch.tts?.echo, 0, 100),
words: patch.tts?.words === true, words: patch.tts?.words === true,
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500) wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500),
vox: patch.tts?.vox === true,
voxClean: patch.tts?.voxClean === true
}; };
} }
@@ -405,7 +413,9 @@ export class PreferencesStore {
highpass: normalizeRange(raw?.tts?.highpass, 40, 400), highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
echo: normalizeRange(raw?.tts?.echo, 0, 100), echo: normalizeRange(raw?.tts?.echo, 0, 100),
words: raw?.tts?.words === true, words: raw?.tts?.words === true,
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500) wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
vox: raw?.tts?.vox === true,
voxClean: raw?.tts?.voxClean === true
}, },
audio: { audio: {
duck: raw?.audio?.duck !== false duck: raw?.audio?.duck !== false
+9 -1
View File
@@ -34,6 +34,10 @@ export interface TtsOptions {
words?: boolean; words?: boolean;
/** Silence between words in milliseconds, 0 to 500. */ /** Silence between words in milliseconds, 0 to 500. */
wordGapMs?: number; wordGapMs?: number;
/** NetTTS-style VOX prosody (Flextalk voices only). */
vox?: boolean;
/** VOX without the staccato wrapper. */
voxClean?: boolean;
} }
export interface TtsEngine { export interface TtsEngine {
@@ -348,6 +352,8 @@ export class Tts {
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "") .replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
.replace("{words}", options.words ? "1" : "") .replace("{words}", options.words ? "1" : "")
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "") .replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
.replace("{vox}", options.vox ? "1" : "")
.replace("{vox_clean}", options.voxClean ? "1" : "")
) )
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile]; : ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
@@ -371,7 +377,9 @@ export class Tts {
...(options.words ? { VESKSLOP_TTS_WORDS: "1" } : {}), ...(options.words ? { VESKSLOP_TTS_WORDS: "1" } : {}),
...(options.wordGapMs !== undefined ...(options.wordGapMs !== undefined
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) } ? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
: {}) : {}),
...(options.vox ? { VESKSLOP_TTS_VOX: "1" } : {}),
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {})
}, },
stdio: ["ignore", "ignore", "pipe"] stdio: ["ignore", "ignore", "pipe"]
}); });
+8 -2
View File
@@ -508,7 +508,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
highpass: tts?.highpass ?? null, highpass: tts?.highpass ?? null,
echo: tts?.echo ?? null, echo: tts?.echo ?? null,
words: tts?.words === true, words: tts?.words === true,
wordGapMs: tts?.wordGapMs ?? null wordGapMs: tts?.wordGapMs ?? null,
vox: tts?.vox === true,
voxClean: tts?.voxClean === true
}; };
} }
@@ -756,6 +758,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words; const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
const wordGapMs = const wordGapMs =
req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined; req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined;
const vox = req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox;
const voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean;
const result = await deps.audio.speak(text, { const result = await deps.audio.speak(text, {
provider: provider ?? undefined, provider: provider ?? undefined,
@@ -767,7 +771,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
...(Number.isFinite(highpass) ? { highpass } : {}), ...(Number.isFinite(highpass) ? { highpass } : {}),
...(Number.isFinite(echo) ? { echo } : {}), ...(Number.isFinite(echo) ? { echo } : {}),
...(words ? { words: true } : {}), ...(words ? { words: true } : {}),
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}) ...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
...(vox ? { vox: true } : {}),
...(voxClean ? { voxClean: true } : {})
}); });
deps.getPuppet()?.appendAction({ deps.getPuppet()?.appendAction({
type: "audio", type: "audio",