diff --git a/CURRENT.md b/CURRENT.md index 8c4e6d5..e2378f1 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] 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 + to both registry hives with Gender/Line Mode plus a Default Speaker pointer. `--voices` + reflects the active preset in the Keith Bell `defaults=` flags so the panel snap points match. + New `microslopsam/setup/test-wrapper.sh` verifies the whole contract with stubbed wine and + engine binaries (tags vs attributes, registry shape, preset extras, implied VOX, metadata); + all checks pass. - [x] C63 Half-Life presets. The wrapper now implements `VESKSLOP_TTS_PRESET=vox|hev` (Keith Bell profile: VOX pitch 3/rate 17/prominence 22/head 52/volume 70, HEV pitch 15/rate 24, expression 59; other voices untouched; preset < profile < expression; the `vox` preset implies diff --git a/docs/voice.md b/docs/voice.md index b72ca73..f2e00dc 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -164,7 +164,13 @@ from the TTS panel: 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. + expression, so the sliders still win over the preset. 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 + 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 + 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 auditioned without touching the real queue. diff --git a/microslopsam/README.md b/microslopsam/README.md index 2ea4a0e..143f66a 100644 --- a/microslopsam/README.md +++ b/microslopsam/README.md @@ -22,6 +22,8 @@ pure-native build, and keep a path to a fully portable engine. - AT&T Flextalk: Keith Bell, Kerry Watson - Test output: `build/test_sam.wav` (22.05 kHz), `build/test_Keith_Bell.wav`, `build/test_Kerry_Watson.wav` (11.025 kHz). +- Wrapper contract test (no Wine needed, stubs wine and the engine host): + `sh setup/test-wrapper.sh`. ## Build diff --git a/microslopsam/setup/test-wrapper.sh b/microslopsam/setup/test-wrapper.sh new file mode 100755 index 0000000..c20b55e --- /dev/null +++ b/microslopsam/setup/test-wrapper.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# Regression test for the Veskslop TTS wrapper contract. No Wine needed: the wine +# and engine-host binaries are stubbed, so this checks the arguments the wrapper +# builds, the registry import it generates, and the voice metadata it publishes. +# +# The bug this guards against: rate/pitch sent as ITTSAttributes (-s/-p) override +# the Flextalk speaker profile, so the VOX/HEV presets sounded too fast and not +# deep enough. Flextalk must use the sticky vendor tags (\!R / \!%), and neutral +# sliders must emit neither. +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +WRAP="$HERE/veskslop-tts.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/build" "$WORK/bin" "$WORK/out" +"${MICROSLOP_FFMPEG:-ffmpeg}" -hide_banner -loglevel error -f lavfi \ + -i "sine=frequency=200:duration=0.3" -ar 11025 -ac 1 -c:a pcm_s16le -y "$WORK/sine.wav" + +cat > "$WORK/build/sapi4host.exe" <<'STUB' +#!/bin/sh +{ printf 'ARGV:'; for a in "$@"; do printf ' [%s]' "$a"; done; printf '\n'; } >> "$STUB_LOG" +OUT="" +while [ "$#" -ge 2 ]; do + [ "$1" = "-o" ] && OUT=$2 + shift +done +cp "$STUB_SINE" "$OUT" +STUB + +cat > "$WORK/bin/wine" <<'STUB' +#!/bin/sh +[ "${1:-}" = "reg" ] && [ "${2:-}" = "import" ] && cp "$3" "$STUB_REG" +exit 0 +STUB +chmod +x "$WORK/build/sapi4host.exe" "$WORK/bin/wine" + +printf 'Attention all personnel.\n' > "$WORK/text.txt" + +export MICROSLOP_BUILD="$WORK/build" +export MICROSLOP_WINE="$WORK/bin/wine" +export MICROSLOP_FFMPEG="${MICROSLOP_FFMPEG:-ffmpeg}" +export DISPLAY=:99 +export STUB_LOG="$WORK/host.log" +export STUB_REG="$WORK/profile.reg" +export STUB_SINE="$WORK/sine.wav" + +FAIL=0 +check() { + if [ "$2" = "$3" ]; then + echo "ok - $1" + else + echo "FAIL - $1: expected [$2], got [$3]" + FAIL=1 + fi +} + +run_wrapper() { + : > "$STUB_LOG" + sh "$WRAP" "$WORK/out.wav" "$WORK/text.txt" >/dev/null +} + +# The spoken text is the -t argument; the engine host writes to the wrapper's temp file. +text_for() { + sed -n '1s/.*\[-t\] \[\(.*\)\] \[-o\].*/\1/p' "$STUB_LOG" +} + +attr_count() { + grep -oE '\[-s\]|\[-p\]' "$STUB_LOG" | wc -l | tr -d ' ' +} + +# Neutral Flextalk sliders emit neither attributes nor tags. +VESKSLOP_TTS_VOICE=keith-bell run_wrapper +check "neutral flextalk text" "Attention all personnel." "$(text_for)" +check "neutral flextalk has no attributes" "0" "$(attr_count)" + +# Adjustments ride as tags; attributes stay out even at non-neutral values. +VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_VOX=0 \ + VESKSLOP_TTS_SPEED=1.3 VESKSLOP_TTS_PITCH=3 run_wrapper +check "flextalk rate tag" "\!R0.769 \!%1.189 Attention all personnel." "$(text_for)" +check "flextalk has no attributes" "0" "$(attr_count)" + +# MSTTS has no vendor tags and keeps the attributes. +VESKSLOP_TTS_VOICE=sam VESKSLOP_TTS_SPEED=1.3 VESKSLOP_TTS_PITCH=3 run_wrapper +check "mstts text has no tags" "Attention all personnel." "$(text_for)" +check "mstts keeps attributes" "2" "$(attr_count)" + +# The vox preset implies VOX prosody when VESKSLOP_TTS_VOX is unset. +VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_PRESET=vox run_wrapper +check "vox preset implies VOX parser" "1" "$(grep -c 'wH1' "$STUB_LOG")" + +# The preset writes the full audible profile to both hives. +VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_PRESET=vox run_wrapper +check "preset writes both hives" "2" "$(grep -c 'Speakers\\{11260610' "$STUB_REG")" +check "preset pitch (both hives)" "2" "$(grep -c '"Pitch"=dword:00000003' "$STUB_REG")" +check "preset rate (both hives)" "2" "$(grep -c '"Speaking Rate"=dword:00000011' "$STUB_REG")" +check "preset gender and line mode" "4" "$(grep -cE '"Gender"=dword:00000002|"Line Mode"=dword:00000001' "$STUB_REG")" +check "preset default speaker" "2" "$(grep -c '"Default Speaker"' "$STUB_REG")" + +# A plain Flextalk run keeps the defaults and writes no preset extras. +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")" + +# 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')" + +[ "$FAIL" = "0" ] && echo "wrapper tests passed" || { echo "wrapper tests failed"; exit 1; } diff --git a/microslopsam/setup/veskslop-tts.sh b/microslopsam/setup/veskslop-tts.sh index efb1020..b0dfe04 100755 --- a/microslopsam/setup/veskslop-tts.sh +++ b/microslopsam/setup/veskslop-tts.sh @@ -33,7 +33,14 @@ ROOT=$MICROSLOP_ROOT # `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=pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59,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" +# The Keith Bell defaults follow the active preset so the UI's "default" snap points match +# the values the preset actually writes. +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" ;; + *) KB_DEFAULTS="pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59" ;; +esac +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" if [ "${1:-}" = "--voices" ]; then echo "$VOICES" @@ -153,18 +160,33 @@ if [ -f "$TEXTFILE" ]; then fi # Build the engine arguments from the (possibly command-overridden) speed/pitch. +# Flextalk: rate and pitch ride as sticky vendor tags. Setting them through +# ITTSAttributes (-s/-p) overrides the speaker profile, which is the bug that made +# presets sound "too fast and not deep enough". MSTTS has no tags, so it keeps -s/-p. SET_ARGS="" -if [ -n "$SPEED" ]; then - S=$(awk -v m="$SPEED" 'BEGIN{printf "%d", 150*m+0.5}') - [ "$S" -lt 50 ] && S=50 - [ "$S" -gt "$SMAX" ] && S=$SMAX - SET_ARGS="$SET_ARGS -s $S" -fi -if [ -n "$PITCH" ]; then - P=$(awk -v st="$PITCH" -v nat="$NAT_PITCH" 'BEGIN{printf "%d", nat*exp(log(2)*st/12)+0.5}') - [ "$P" -lt 50 ] && P=50 - [ "$P" -gt "$PMAX" ] && P=$PMAX - SET_ARGS="$SET_ARGS -p $P" +TAG_PREFIX="" +if [ -n "$SPEAKER_GUID" ]; then + if [ -n "$SPEED" ]; then + R=$(awk -v m="$SPEED" 'BEGIN{ if (m==1) exit; v=1/m; if (v<0.25) v=0.25; if (v>4.0) v=4.0; printf "%.3f", v }') + [ -n "$R" ] && TAG_PREFIX="${TAG_PREFIX}\\!R${R} " + fi + if [ -n "$PITCH" ]; then + P=$(awk -v st="$PITCH" 'BEGIN{ if (st==0) exit; v=exp(log(2)*st/12); if (v<0.5) v=0.5; if (v>2.0) v=2.0; printf "%.3f", v }') + [ -n "$P" ] && TAG_PREFIX="${TAG_PREFIX}\\!%${P} " + fi +else + if [ -n "$SPEED" ]; then + S=$(awk -v m="$SPEED" 'BEGIN{printf "%d", 150*m+0.5}') + [ "$S" -lt 50 ] && S=50 + [ "$S" -gt "$SMAX" ] && S=$SMAX + SET_ARGS="$SET_ARGS -s $S" + fi + if [ -n "$PITCH" ]; then + P=$(awk -v st="$PITCH" -v nat="$NAT_PITCH" 'BEGIN{printf "%d", nat*exp(log(2)*st/12)+0.5}') + [ "$P" -lt 50 ] && P=50 + [ "$P" -gt "$PMAX" ] && P=$PMAX + SET_ARGS="$SET_ARGS -p $P" + fi fi # Apply the Flextalk profile before synthesis. Profiles are read at voice load and @@ -227,19 +249,41 @@ if [ -n "$SPEAKER_GUID" ]; then esac done - REG_KEY="HKLM\\Software\\AT&T\\Watson\\2.0\\FlexTalk\\2.0\\Speakers\\$SPEAKER_GUID" + IS_PRESET=0 + if [ "$ENGVOICE" = "Keith Bell" ]; then + case "$PRESET" in + vox|hev) IS_PRESET=1 ;; + esac + fi + # Use printf, not echo: dash's echo interprets \2 in the key path as an # escape (STX), which corrupts the key and makes the import a silent no-op. + # NetTTS parity: the audible profile goes to both hives. Presets also write the + # informational keys from Valve's setup, and point Default Speaker at Keith Bell. { printf 'Windows Registry Editor Version 5.00\n\n' - printf '[%s]\n' "$REG_KEY" - printf '"Pitch"=dword:%08x\n' "$P_PITCH" - printf '"Speaking Rate"=dword:%08x\n' "$P_RATE" - printf '"Volume"=dword:%08x\n' "$P_VOLUME" - printf '"Expression"=dword:%08x\n' "$P_EXPR" - printf '"Prominences"=dword:%08x\n' "$P_PROM" - printf '"Head Size"=dword:%08x\n' "$P_HEAD" - printf '"Lowering"=dword:%08x\n' "$P_LOWER" + for HIVE in 'HKEY_LOCAL_MACHINE' 'HKEY_CURRENT_USER'; do + printf '[%s\\Software\\AT&T\\Watson\\2.0\\FlexTalk\\2.0\\Speakers\\%s]\n' "$HIVE" "$SPEAKER_GUID" + printf '"Pitch"=dword:%08x\n' "$P_PITCH" + printf '"Speaking Rate"=dword:%08x\n' "$P_RATE" + printf '"Volume"=dword:%08x\n' "$P_VOLUME" + printf '"Expression"=dword:%08x\n' "$P_EXPR" + printf '"Prominences"=dword:%08x\n' "$P_PROM" + printf '"Head Size"=dword:%08x\n' "$P_HEAD" + printf '"Lowering"=dword:%08x\n' "$P_LOWER" + if [ "$IS_PRESET" = "1" ]; then + printf '"Gender"=dword:00000002\n' + printf '"Line Mode"=dword:00000001\n' + fi + printf '\n' + done + + if [ "$IS_PRESET" = "1" ]; then + for HIVE in 'HKEY_LOCAL_MACHINE' 'HKEY_CURRENT_USER'; do + printf '[%s\\Software\\AT&T\\Watson\\2.0\\FlexTalk\\2.0\\Speakers]\n' "$HIVE" + printf '"Default Speaker"="%s"\n\n' "$SPEAKER_GUID" + done + fi } > "$TMP/profile.reg" # Guard against silent reg-import failures (a malformed key would corrupt @@ -322,7 +366,7 @@ if [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ] && [ "$VOX" != "1" ]; then INDEX=0 for word in $(cat "$TXT"); do INDEX=$((INDEX + 1)) - try_run_host "$TMP/w$INDEX.wav" "$word" + try_run_host "$TMP/w$INDEX.wav" "${TAG_PREFIX}${word}" echo "file '$TMP/w$INDEX.wav'" >> "$TMP/list.txt" if [ "$GAP" -gt 0 ]; then @@ -340,7 +384,7 @@ if [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ] && [ "$VOX" != "1" ]; then exit 1 fi else - try_run_host "$RAW" "$(cat "$TXT")" + try_run_host "$RAW" "${TAG_PREFIX}$(cat "$TXT")" fi [ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; }