diff --git a/CURRENT.md b/CURRENT.md index e74ecb7..a75a9ad 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -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///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 diff --git a/TODO.md b/TODO.md index 2dd7996..6e82aa5 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/docs/voice.md b/docs/voice.md index 445be0f..0774976 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -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). diff --git a/poc/public/app.js b/poc/public/app.js index aaed2ec..93e3515 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -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) { diff --git a/poc/public/index.html b/poc/public/index.html index 8bbc9ef..0688ef8 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -556,6 +556,19 @@ 120ms +
+ + +
diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 4c77289..6b7d83d 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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 }) ) ); diff --git a/poc/src/preferences.ts b/poc/src/preferences.ts index 83369e2..cd7a776 100644 --- a/poc/src/preferences.ts +++ b/poc/src/preferences.ts @@ -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 = { @@ -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 diff --git a/poc/src/tts.ts b/poc/src/tts.ts index 68f9c11..0c3d31b 100644 --- a/poc/src/tts.ts +++ b/poc/src/tts.ts @@ -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"] }); diff --git a/poc/src/webui.ts b/poc/src/webui.ts index 85e9002..807a5b7 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -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",