From 51d42b3a16f9e7733ffd5dc8a46e545dd746a448 Mon Sep 17 00:00:00 2001 From: Fisto Date: Mon, 21 Sep 2026 11:47:38 +0200 Subject: [PATCH] Let word mode combine with the VOX preset --- CURRENT.md | 6 ++++++ docs/voice.md | 12 ++++++++---- microslopsam/setup/test-wrapper.sh | 16 ++++++++++++++++ microslopsam/setup/veskslop-tts.sh | 14 +++++++++----- poc/public/app.js | 8 ++------ poc/src/tts.ts | 4 +++- 6 files changed, 44 insertions(+), 16 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index e2378f1..6cc2ff4 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] C65 Word mode with the Half-Life preset. Word mode no longer yields to VOX prosody: with + a preset it synthesizes each word with the preset profile (per-word `\!R`/`\!%` tags), and the + VOX parser is skipped because its tags cannot survive a word split. The daemon always exports + `VESKSLOP_TTS_VOX` (0/1) so an explicit off is not overridden by the preset implication, and + the TTS panel allows preset + VOX + word mode together (word mode wins for delivery). Wrapper + tests cover preset+word mode with tags, the implied-VOX case, and the profile. - [x] C64 Correct preset application. The design's bug-fix section landed in the wrapper: Flextalk rate/pitch now ride as sticky `\!R` / `\!%` vendor tags (attributes only for MSTTS), neutral sliders emit neither, word mode tags every word, and presets write the audible profile diff --git a/docs/voice.md b/docs/voice.md index f2e00dc..314f102 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -158,8 +158,10 @@ from the TTS panel: default 0), and word mode (`tts.words`) with a gap in ms (`tts.wordGapMs`) for the radio/robot 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 + announcement energy; **clean** (`tts.voxClean`) drops the staccato wrapper. Word mode **combines + with the presets** and takes precedence over VOX prosody: each word is its own engine instance, + so the parser is skipped and the vendor tags go on every word (`\!R` rate, `\!%` pitch). VOX + 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). 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 @@ -168,8 +170,10 @@ from the TTS panel: 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 - preset is reproduced exactly. `sh microslopsam/setup/test-wrapper.sh` checks this contract with - stubbed wine and engine binaries, no Wine install needed. The preset values also show up in the + preset is reproduced exactly. The daemon always exports `VESKSLOP_TTS_VOX` (0 or 1) so an + explicit off is honored, and the panel lets a preset, VOX, and word mode be combined. + `sh microslopsam/setup/test-wrapper.sh` checks this contract with stubbed wine and engine + binaries, no Wine install needed. The preset values also show up in the voice metadata defaults, so the panel's "default" snap points match what the engine gets. - **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 diff --git a/microslopsam/setup/test-wrapper.sh b/microslopsam/setup/test-wrapper.sh index c20b55e..6c06e54 100755 --- a/microslopsam/setup/test-wrapper.sh +++ b/microslopsam/setup/test-wrapper.sh @@ -103,6 +103,22 @@ VESKSLOP_TTS_VOICE=keith-bell run_wrapper check "plain run writes both hives" "2" "$(grep -c 'Speakers\\{11260610' "$STUB_REG")" check "plain run has no preset extras" "0" "$(grep -cE 'Gender|Line Mode|Default Speaker' "$STUB_REG")" +# Word mode combines with a preset and skips the VOX parser, with tags per word. +VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_PRESET=vox VESKSLOP_TTS_VOX=1 \ + VESKSLOP_TTS_WORDS=1 VESKSLOP_TTS_WORD_GAP_MS=0 VESKSLOP_TTS_SPEED=1.25 run_wrapper +check "word mode with preset synthesizes every word" "3" "$(grep -c '^ARGV:' "$STUB_LOG")" +check "word mode skips the VOX parser" "0" "$(grep -c 'wH1' "$STUB_LOG")" +check "word mode tags every word" "3" \ + "$(sed -n 's/.*\[-t\] \[\(.*\)\] \[-o\].*/\1/p' "$STUB_LOG" | grep -c '^\\!R0.800 ')" +check "word mode keeps the preset profile" "2" "$(grep -c '"Pitch"=dword:00000003' "$STUB_REG")" +check "word mode emits no attributes" "0" "$(attr_count)" + +# The vox preset implies VOX when the variable is unset, but word mode still wins. +VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_PRESET=vox VESKSLOP_TTS_WORDS=1 \ + VESKSLOP_TTS_WORD_GAP_MS=0 run_wrapper +check "implied VOX yields to word mode" "3" "$(grep -c '^ARGV:' "$STUB_LOG")" +check "implied VOX skipped for words" "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 b0dfe04..36da672 100755 --- a/microslopsam/setup/veskslop-tts.sh +++ b/microslopsam/setup/veskslop-tts.sh @@ -16,7 +16,8 @@ # prominences, head_size, lowering # VESKSLOP_TTS_HIGHPASS high-pass Hz (40-400, default 40) # VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0) -# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk) +# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk); +# combines with a preset and wins over VOX prosody # VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500) # VESKSLOP_TTS_PRESET vox | hev (Keith Bell profile preset; vox implies VOX) # VESKSLOP_TTS_VOX 1 = VOX prosody, NetTTS-style (Flextalk) @@ -305,9 +306,11 @@ fi # SAPI4 is ANSI/CP1252. VOX mode (Flextalk only) first transforms the text into # vendor tags (\!wH1/\!wH0, \!br, \!sfNN) with setup/vox_parser.py; the parser -# works on UTF-8, so it runs before the CP1252 transliteration. +# works on UTF-8, so it runs before the CP1252 transliteration. Word mode builds +# its own cadence from single-word instances, so the parser is skipped there: the +# VOX tags cannot survive a word split. TEXT_SRC="$TEXT_IN" -if [ "$VOX" = "1" ] && [ -n "$SPEAKER_GUID" ]; then +if [ "$VOX" = "1" ] && [ -n "$SPEAKER_GUID" ] && [ "$WORDS" != "1" ]; then if command -v "$MICROSLOP_PYTHON" >/dev/null 2>&1 && [ -f "$MICROSLOP_ROOT/setup/vox_parser.py" ]; then if [ "$VOX_CLEAN" = "1" ]; then "$MICROSLOP_PYTHON" "$MICROSLOP_ROOT/setup/vox_parser.py" --clean < "$TEXT_IN" > "$TMP/vox.txt" @@ -356,8 +359,9 @@ try_run_host() { # Word mode (Flextalk): synthesize each word and merge with silence gaps, which # gives the staccato radio cadence the Half-Life VOX/HEV voices are known for. -# VOX mode takes precedence: it produces its own cadence with vendor tags. -if [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ] && [ "$VOX" != "1" ]; then +# It combines with a preset (the profile is already written) and takes precedence +# over VOX prosody, which cannot survive a word split. +if [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ]; then GAP=$WORD_GAP case "$GAP" in *[!0-9]*|"") GAP=120 ;; esac [ "$GAP" -gt 500 ] && GAP=500 diff --git a/poc/public/app.js b/poc/public/app.js index d170488..958d703 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -2031,7 +2031,6 @@ function fillProfileControls() { $("#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`; @@ -2744,17 +2743,14 @@ function init() { $("#tts-words").addEventListener("change", () => void saveSpeech()); $("#tts-vox").addEventListener("change", () => { $("#tts-vox-clean").disabled = !$("#tts-vox").checked; - $("#tts-words").disabled = $("#tts-vox").checked; - // The vox preset implies VOX; turning VOX off means "factory/custom" again. - if (!$("#tts-vox").checked && $("#tts-preset").value === "vox") $("#tts-preset").value = ""; void saveSpeech(); }); $("#tts-preset").addEventListener("change", () => { - // Picking the VOX preset turns on VOX prosody so the panel matches the engine. + // 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; - $("#tts-words").disabled = true; } void saveSpeech(); }); diff --git a/poc/src/tts.ts b/poc/src/tts.ts index 5207bb0..440bdbe 100644 --- a/poc/src/tts.ts +++ b/poc/src/tts.ts @@ -381,7 +381,9 @@ export class Tts { ...(options.wordGapMs !== undefined ? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) } : {}), - ...(options.vox ? { VESKSLOP_TTS_VOX: "1" } : {}), + // Always defined, so an explicit "off" is not overridden by the wrapper's + // preset implication (which only fires when the variable is unset). + VESKSLOP_TTS_VOX: options.vox ? "1" : "0", ...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {}), ...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {}) },