From baaaba3dcd46f7d57e9a014590eac30a27abb29b Mon Sep 17 00:00:00 2001 From: Fisto Date: Mon, 21 Sep 2026 12:38:47 +0200 Subject: [PATCH] Expose the Half-Life voices and tidy the radio kit --- CURRENT.md | 6 +++++ docs/voice.md | 9 +++---- microslopsam/setup/test-wrapper.sh | 9 +++++++ microslopsam/setup/veskslop-tts.sh | 18 +++++++++++--- poc/public/app.js | 26 +++++++------------- poc/public/index.html | 38 ++++++++++++------------------ poc/src/webui.ts | 4 +++- 7 files changed, 61 insertions(+), 49 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index 2a93fed..bb29ed1 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,12 @@ 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] C68 Half-Life voices and a tidier Radio kit. `vox` and `hev` are now real voices in the + list (Keith Bell with the VOX/HEV profile; VOX implies its prosody), so the preset dropdown and + the VOX/clean toggles left the Radio kit; the clean option appears only for the VOX voice, and + every remaining field has a visible label (echo delay/level, crunch bits/Hz, word gap). The + daemon auto-enables prosody for the `vox` voice and the UI no longer sends stale `preset`/`vox` + values. Wrapper tests cover both voices and the profile they write. - [x] C67 Crunch, echo options, and streaming word mode. The wrapper gained a separate compression stage applied last (presets hl90/hl90hq/telephone/adpcm/crush plus bit-depth and rate overrides, output still 48 kHz s16le), echo delay/level knobs, and `VESKSLOP_TTS_STREAM=1` diff --git a/docs/voice.md b/docs/voice.md index 664acdc..8498633 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -173,10 +173,11 @@ from the TTS panel: word: measured first sound 0.5 s instead of 2.4 s on a ten-word line. Streaming skips the loudness measurement (`VESKSLOP_TTS_STREAM_GAIN_DB` compensates if needed) and cannot use VOX prosody, which needs the whole sentence. - A **preset** select (`tts.preset`: factory, VOX, HEV) shifts the Keith Bell speaker profile to - the Half-Life approximation; `vox` also implies VOX prosody unless VOX is explicitly off, and - overrides travel as `VESKSLOP_TTS_PRESET` / `{preset}`. Precedence is preset < profile < - expression, so the sliders still win over the preset. The wrapper writes the audible profile to + The Half-Life voices are first-class entries in the voice list: **VOX (Half-Life)** and **HEV + suit** are Keith Bell with the corresponding profile preset, and VOX carries its prosody with it + (so no separate prosody toggle). Selecting the VOX voice shows its **clean (no stutter)** option + in the profile area. The underlying `tts.preset` / `vox` / `voxClean` values remain available to + the API and MCP; precedence is preset < profile < expression, so the sliders still win. The wrapper writes the audible profile to both registry hives (plus the preset's Gender/Line Mode and a Default Speaker pointer), and for Flextalk it sends rate/pitch as the sticky `\!R` / `\!%` vendor tags rather than `ITTSAttributes`; attributes only apply to MSTTS voices. Neutral sliders emit neither, so the diff --git a/microslopsam/setup/test-wrapper.sh b/microslopsam/setup/test-wrapper.sh index d3e2b85..a218309 100755 --- a/microslopsam/setup/test-wrapper.sh +++ b/microslopsam/setup/test-wrapper.sh @@ -42,6 +42,7 @@ export MICROSLOP_BUILD="$WORK/build" export MICROSLOP_WINE="$WORK/bin/wine" export MICROSLOP_FFMPEG="${MICROSLOP_FFMPEG:-ffmpeg}" export DISPLAY=:99 +export WINEPREFIX="$WORK/prefix" export STUB_LOG="$WORK/host.log" export STUB_REG="$WORK/profile.reg" export STUB_SINE="$WORK/sine.wav" @@ -136,6 +137,14 @@ check "stream is decodable" "0" \ check "stream has a gap per word break" "2" \ "$("${MICROSLOP_FFMPEG:-ffmpeg}" -hide_banner -i "$WORK/out.wav" -af silencedetect=noise=-45dB:d=0.15 -f null - 2>&1 | grep -c silence_start)" +# The Half-Life voices carry their profile preset. +VESKSLOP_TTS_VOICE=vox VESKSLOP_TTS_VOX=1 run_wrapper +check "vox voice writes the VOX profile (both hives)" "2" "$(grep -c '"Pitch"=dword:00000003' "$STUB_REG")" +check "vox voice runs VOX prosody" "1" "$(grep -c 'wH1' "$STUB_LOG")" +VESKSLOP_TTS_VOICE=hev run_wrapper +check "hev voice writes the HEV profile (both hives)" "2" "$(grep -c '"Pitch"=dword:0000000f' "$STUB_REG")" +check "hev voice has no prosody" "0" "$(grep -c 'wH1' "$STUB_LOG")" + # Voice metadata follows the active preset. check "voices reflect hev" "1" \ "$(VESKSLOP_TTS_PRESET=hev sh "$WRAP" --voices | tr ',' '\n' | grep -c 'keith-bell|Keith Bell|male|en|expression=59+defaults=pitch:15:rate:24')" diff --git a/microslopsam/setup/veskslop-tts.sh b/microslopsam/setup/veskslop-tts.sh index b200a1a..914e6f3 100755 --- a/microslopsam/setup/veskslop-tts.sh +++ b/microslopsam/setup/veskslop-tts.sh @@ -32,6 +32,7 @@ # VESKSLOP_TTS_HUM_MS leading low hum in ms, 0 = off (default 80) # VESKSLOP_TTS_HUM_HZ hum frequency, 30-200 (default 75) # VESKSLOP_TTS_HUM_DB hum peak level, -80..-6 (default -48) +# voice `vox`/`hev` Half-Life profiles as first-class voices # VESKSLOP_TTS_PRESET vox | hev (Half-Life profiles, Keith Bell; # vox also enables VOX prosody) # @@ -44,9 +45,11 @@ ROOT=$MICROSLOP_ROOT # The Keith Bell defaults follow the active preset so the UI's "default" snap points # match the values the preset actually writes. +VOX_DEFAULTS="pitch:3:rate:17:prominences:22:head_size:52:lowering:40:expression:59" +HEV_DEFAULTS="pitch:15:rate:24:prominences:22:head_size:52:lowering:40:expression:59" case "${VESKSLOP_TTS_PRESET:-}" in - vox) KB_DEFAULTS="pitch:3:rate:17:prominences:22:head_size:52:lowering:40:expression:59" ;; - hev) KB_DEFAULTS="pitch:15:rate:24:prominences:22:head_size:52:lowering:40:expression:59" ;; + vox) KB_DEFAULTS="$VOX_DEFAULTS" ;; + hev) KB_DEFAULTS="$HEV_DEFAULTS" ;; *) KB_DEFAULTS="pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59" ;; esac @@ -54,7 +57,7 @@ esac # `expression=NN` marks a Flextalk voice with an Expression profile parameter and # its registry default. Expression is applied at voice load, so the wrapper writes # it into the Wine registry before synthesis. -VOICES="sam|Sam|male|en,mary|Mary|female|en,mike|Mike|male|en,keith-bell|Keith Bell|male|en|expression=59+defaults=$KB_DEFAULTS,kerry-watson|Kerry Watson|female|en|expression=100+defaults=pitch:65:rate:32:prominences:17:head_size:45:lowering:40:expression:100,mike-telephone|Mike (for Telephone)|male|en,mary-telephone|Mary (for Telephone)|female|en,mike-hall|Mike in Hall|male|en,mike-stadium|Mike in Stadium|male|en,mike-space|Mike in Space|male|en,mary-hall|Mary in Hall|female|en,mary-stadium|Mary in Stadium|female|en,mary-space|Mary in Space|female|en" +VOICES="sam|Sam|male|en,mary|Mary|female|en,mike|Mike|male|en,keith-bell|Keith Bell|male|en|expression=59+defaults=$KB_DEFAULTS,vox|VOX (Half-Life)|male|en|expression=59+defaults=$VOX_DEFAULTS,hev|HEV suit|male|en|expression=59+defaults=$HEV_DEFAULTS,kerry-watson|Kerry Watson|female|en|expression=100+defaults=pitch:65:rate:32:prominences:17:head_size:45:lowering:40:expression:100,mike-telephone|Mike (for Telephone)|male|en,mary-telephone|Mary (for Telephone)|female|en,mike-hall|Mike in Hall|male|en,mike-stadium|Mike in Stadium|male|en,mike-space|Mike in Space|male|en,mary-hall|Mary in Hall|female|en,mary-stadium|Mary in Stadium|female|en,mary-space|Mary in Space|female|en" if [ "${1:-}" = "--voices" ]; then echo "$VOICES" @@ -82,6 +85,13 @@ WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120} VOX=${VESKSLOP_TTS_VOX:-0} VOX_CLEAN=${VESKSLOP_TTS_VOX_CLEAN:-0} PRESET=${VESKSLOP_TTS_PRESET:-} + +# The Half-Life voices are Keith Bell with a fixed profile preset; selecting one +# stands in for VESKSLOP_TTS_PRESET. +case "$VOICE" in + vox) [ -n "$PRESET" ] || PRESET=vox ;; + hev) [ -n "$PRESET" ] || PRESET=hev ;; +esac # The VOX preset is the Half-Life announcement voice: it implies VOX prosody # unless the caller explicitly disables it. if [ "$PRESET" = "vox" ] && [ -z "${VESKSLOP_TTS_VOX:-}" ]; then @@ -96,6 +106,8 @@ case "$VOICE" in mary) ENGVOICE="Mary" ;; mike) ENGVOICE="Mike" ;; keith-bell) ENGVOICE="Keith Bell" ;; + vox) ENGVOICE="Keith Bell" ;; + hev) ENGVOICE="Keith Bell" ;; kerry-watson) ENGVOICE="Kerry Watson" ;; mike-telephone) ENGVOICE="Mike (for Telephone)" ;; mary-telephone) ENGVOICE="Mary (for Telephone)" ;; diff --git a/poc/public/app.js b/poc/public/app.js index b89288d..c84d370 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -2033,10 +2033,7 @@ function fillProfileControls() { $("#tts-stream-words").checked = state.speech.streamWords !== false; $("#tts-words").checked = state.speech.words === true; - $("#tts-preset").value = state.speech.preset ?? ""; - $("#tts-vox").checked = state.speech.vox === true; $("#tts-vox-clean").checked = state.speech.voxClean === true; - $("#tts-vox-clean").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`; @@ -2069,9 +2066,10 @@ function effectOptions() { echo: Number($("#tts-echo").value), words: $("#tts-words").checked, wordGapMs: Number($("#tts-word-gap").value), - vox: $("#tts-vox").checked, + // VOX and the Half-Life profile are part of the `vox`/`hev` voices now. + vox: false, + preset: null, voxClean: $("#tts-vox-clean").checked, - preset: $("#tts-preset").value || null, echoDelayMs: $("#tts-echo-delay").value ? Number($("#tts-echo-delay").value) : null, echoLevel: $("#tts-echo-level").value ? Number($("#tts-echo-level").value) : null, crunch: $("#tts-crunch").value || null, @@ -2084,6 +2082,10 @@ function syncVoiceControls(voices, selected) { const voice = (voices ?? []).find(entry => entry.id === selected); const pitchable = !voice || voice.supportsPitch !== false; + // The Half-Life voices carry VOX prosody; their clean toggle lives in the profile area. + const voxOptions = $("#tts-vox-options"); + if (voxOptions) voxOptions.hidden = selected !== "vox"; + $("#tts-pitch-control").hidden = !pitchable; $("#tts-pitch").disabled = !pitchable; @@ -2753,19 +2755,7 @@ function init() { $(`#${id}`).addEventListener("change", () => void saveSpeech()); } $("#tts-words").addEventListener("change", () => void saveSpeech()); - $("#tts-vox").addEventListener("change", () => { - $("#tts-vox-clean").disabled = !$("#tts-vox").checked; - void saveSpeech(); - }); - $("#tts-preset").addEventListener("change", () => { - // Picking the VOX preset turns on VOX prosody as a default. Word mode still wins - // for word-by-word delivery, and turning VOX off keeps the preset. - if ($("#tts-preset").value === "vox") { - $("#tts-vox").checked = true; - $("#tts-vox-clean").disabled = false; - } - void saveSpeech(); - }); + $("#tts-vox-clean").addEventListener("change", () => void saveSpeech()); $("#tts-vox-clean").addEventListener("change", () => void saveSpeech()); for (const id of [ "#tts-echo-delay", diff --git a/poc/public/index.html b/poc/public/index.html index 572f4ed..6d0111b 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -550,8 +550,10 @@ echo off - - + delay + + level +
crunch @@ -563,38 +565,28 @@ - - + bits + + Hz +
+ gap 120ms
- preset - - Keith Bell -
-
-
+
diff --git a/poc/src/webui.ts b/poc/src/webui.ts index f714758..ee20bed 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -765,7 +765,9 @@ 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; + // The Half-Life VOX voice carries its prosody with it. + const vox = + (req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox) || voice === "vox"; const voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean; const preset = req.body?.preset === "vox" || req.body?.preset === "hev"