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
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`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
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
+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
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.
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
## 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
Ordered after the voice backend unless the owner asks first.
+9 -4
View File
@@ -156,15 +156,20 @@ from the TTS panel:
profile.
- **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
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
kept in `localStorage`. Each button speaks the line with the current profile, so a change can be
auditioned without touching the real queue.
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
provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`, `VESKSLOP_TTS_ECHO`,
`VESKSLOP_TTS_WORDS`, and `VESKSLOP_TTS_WORD_GAP_MS`. The wrapper contract and the registry
profile keys travel as `profile` (with `highpass`, `echo`, `words`, `wordGapMs`, `vox`, and
`voxClean`) to the command provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`,
`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
[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-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);
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
@@ -2058,7 +2062,9 @@ function effectOptions() {
highpass: Number($("#tts-highpass").value),
echo: Number($("#tts-echo").value),
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) {
@@ -2734,6 +2740,12 @@ function init() {
$(`#${id}`).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", () => {
const panel = document.querySelector(".vk-profile-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" />
<span id="tts-word-gap-value" class="vk-mono vk-dim">120ms</span>
</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 class="vk-profile-section">
<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"),
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
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(
await audio.speak(text, {
provider,
@@ -576,7 +592,9 @@ function buildServer(): McpServer {
highpass,
echo,
words,
wordGapMs
wordGapMs,
vox,
voxClean
})
)
);
+13 -3
View File
@@ -30,6 +30,10 @@ export interface TtsPreferences {
words: boolean;
/** Silence between words in milliseconds, 0-500. */
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]> = {
@@ -146,7 +150,9 @@ const DEFAULTS: WebPreferences = {
highpass: null,
echo: null,
words: false,
wordGapMs: null
wordGapMs: null,
vox: false,
voxClean: false
},
audio: { duck: true },
favourites: { users: [], channels: [] },
@@ -340,7 +346,9 @@ export class PreferencesStore {
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
echo: normalizeRange(patch.tts?.echo, 0, 100),
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),
echo: normalizeRange(raw?.tts?.echo, 0, 100),
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: {
duck: raw?.audio?.duck !== false
+9 -1
View File
@@ -34,6 +34,10 @@ export interface TtsOptions {
words?: boolean;
/** Silence between words in milliseconds, 0 to 500. */
wordGapMs?: number;
/** NetTTS-style VOX prosody (Flextalk voices only). */
vox?: boolean;
/** VOX without the staccato wrapper. */
voxClean?: boolean;
}
export interface TtsEngine {
@@ -348,6 +352,8 @@ export class Tts {
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
.replace("{words}", options.words ? "1" : "")
.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];
@@ -371,7 +377,9 @@ export class Tts {
...(options.words ? { VESKSLOP_TTS_WORDS: "1" } : {}),
...(options.wordGapMs !== undefined
? { 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"]
});
+8 -2
View File
@@ -508,7 +508,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
highpass: tts?.highpass ?? null,
echo: tts?.echo ?? null,
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 wordGapMs =
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, {
provider: provider ?? undefined,
@@ -767,7 +771,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
...(Number.isFinite(highpass) ? { highpass } : {}),
...(Number.isFinite(echo) ? { echo } : {}),
...(words ? { words: true } : {}),
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {})
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
...(vox ? { vox: true } : {}),
...(voxClean ? { voxClean: true } : {})
});
deps.getPuppet()?.appendAction({
type: "audio",