From 6d010292f2806280d127e578fc0828d6301f24b5 Mon Sep 17 00:00:00 2001 From: Fisto Date: Wed, 16 Sep 2026 08:08:15 +0200 Subject: [PATCH] Inline /rate and /pitch controls handled by the wrapper; docs --- README.md | 3 +- docs/NOTES.md | 3 ++ docs/TTS-DESIGN.md | 20 +++++++++++ setup/veskslop-tts.sh | 83 ++++++++++++++++++++++++++++++++----------- 4 files changed, 88 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 38d88b8..c3e8cc2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ voices with `expression=NN`, where NN is the registry default. Mapping details a **VOX prosody** (NetTTS-style, Flextalk voices): set `VESKSLOP_TTS_VOX=1` in the command environment (`VESKSLOP_TTS_VOX_CLEAN=1` omits the `\!wH1/\!wH0` stutter -wrapper). See `docs/TTS-DESIGN.md`. +wrapper). Line-leading `/rate N` and `/pitch N` controls are also handled by the +wrapper, so the WebUI does not need to implement them. See `docs/TTS-DESIGN.md`. ## Layout diff --git a/docs/NOTES.md b/docs/NOTES.md index 33c7bf7..754921a 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -105,6 +105,9 @@ final file `WatsonUNInstall.dll` is incomplete). - VOX mode is Flextalk-only. The wrapper enables it with `VESKSLOP_TTS_VOX=1` (`VESKSLOP_TTS_VOX_CLEAN=1` drops the stutter wrapper) and it takes precedence over `VESKSLOP_TTS_WORDS`. +- Line-leading `/rate N` and `/pitch N` controls are consumed by the wrapper + (percent, 100 = normal; `<= 2.0` means multiplier). They override the env + speed/pitch and are not spoken; a controls-only line yields 100 ms of silence. ## Decompiled corpora (Ghidra) diff --git a/docs/TTS-DESIGN.md b/docs/TTS-DESIGN.md index ef22846..fa30946 100644 --- a/docs/TTS-DESIGN.md +++ b/docs/TTS-DESIGN.md @@ -371,6 +371,26 @@ Other NetTTS features and their relevance: our prefix runs the engines with builtin Wine libraries. - Win32 GUI and preset sliders: Veskslop provides the UI. +### Inline rate/pitch controls (engine-side) + +The wrapper consumes NetTTS-style, line-leading controls, so Veskslop and the WebUI do +not need to parse or implement them: + + /rate 150 Attention all personnel. # 150% rate, then the line + /pitch 90 Attention all personnel. # 90% pitch + /rate 120 /pitch 110 Inbound tram. # both, in any order + +- `N` is a percent (100 = normal, clamped to 0-200); values `<= 2.0` are multipliers + (`/rate 1.5` = 150%). +- They override `VESKSLOP_TTS_SPEED` / `VESKSLOP_TTS_PITCH` for that line and are not + spoken. +- A line that contains only controls yields 100 ms of silence, so the daemon always + receives a valid WAV. +- Rate maps through the normal speed scale; pitch percent maps to semitones + (`st = 12*log2(N/100)`) and then through the per-voice natural pitch. +- Verified on the dev host: `/rate 50` doubles the line duration; `/pitch 150` raises + the median F0 from 122 Hz to 177 Hz. + ### Transport and latency - Installation is one command: `setup/install.sh` (system dependencies with sudo, wine diff --git a/setup/veskslop-tts.sh b/setup/veskslop-tts.sh index b3e4dd1..b424491 100755 --- a/setup/veskslop-tts.sh +++ b/setup/veskslop-tts.sh @@ -87,6 +87,66 @@ case "$ENGVOICE" in "Kerry Watson") SMAX=390; PMAX=250; NAT_PITCH=186 ;; *) SMAX=450; PMAX=200; NAT_PITCH=100 ;; esac +# The daemon does not export DISPLAY; keep a private Xvfb for the engine. +start_xvfb() { + if ! [ -S /tmp/.X11-unix/X77 ]; then + Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 & + sleep 1 + fi +} +if [ -z "${DISPLAY:-}" ]; then + start_xvfb + export DISPLAY=:77 +fi + +# NetTTS-style line controls, handled here so no caller/WebUI work is needed: +# /rate N and/or /pitch N at the start of the line. +# N is a percent (100 = normal); values <= 2.0 are multipliers (/rate 1.5 = 150%). +# They override the env speed/pitch for this line and are not spoken. A line that +# contains only commands yields 100 ms of silence, so the daemon still gets a WAV. +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +RAW="$TMP/raw.wav" +TXT="$TMP/text.cp1252" +TEXT_IN="$TEXTFILE" +if [ -f "$TEXTFILE" ]; then + set -f + # shellcheck disable=SC2046 + set -- $(cat "$TEXTFILE") + set +f + CMD_SPEED="" + CMD_PITCH="" + while [ "$#" -ge 2 ]; do + case "$1" in + /rate|/pitch) + case "$2" in + ''|*[!0-9.]*) break ;; + esac + if [ "$1" = "/rate" ]; then CMD_SPEED=$2; else CMD_PITCH=$2; fi + shift 2 + ;; + *) break ;; + esac + done + if [ -n "$CMD_SPEED" ] || [ -n "$CMD_PITCH" ]; then + if [ -n "$CMD_SPEED" ]; then + SPEED=$(awk -v v="$CMD_SPEED" 'BEGIN{ if (v<=2.0) v*=100; if (v<0) v=0; if (v>200) v=200; printf "%.4f", v/100 }') + fi + if [ -n "$CMD_PITCH" ]; then + PITCH=$(awk -v v="$CMD_PITCH" 'BEGIN{ if (v<=2.0) v*=100; if (v<0) v=0; if (v>200) v=200; printf "%.4f", 12*log(v/100)/log(2) }') + fi + if [ -z "$*" ]; then + ffmpeg -hide_banner -loglevel error -f lavfi -i anullsrc=r=48000:cl=mono \ + -t 0.1 -c:a pcm_s16le -y "$OUT" >/dev/null 2>&1 \ + || { echo "could not write silence" >&2; exit 1; } + exit 0 + fi + printf '%s\n' "$*" > "$TMP/input.txt" + TEXT_IN="$TMP/input.txt" + fi +fi + +# Build the engine arguments from the (possibly command-overridden) speed/pitch. SET_ARGS="" if [ -n "$SPEED" ]; then S=$(awk -v m="$SPEED" 'BEGIN{printf "%d", 150*m+0.5}') @@ -101,23 +161,6 @@ if [ -n "$PITCH" ]; then SET_ARGS="$SET_ARGS -p $P" fi -# The daemon does not export DISPLAY; keep a private Xvfb for the engine. -start_xvfb() { - if ! [ -S /tmp/.X11-unix/X77 ]; then - Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 & - sleep 1 - fi -} -if [ -z "${DISPLAY:-}" ]; then - start_xvfb - export DISPLAY=:77 -fi - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -RAW="$TMP/raw.wav" -TXT="$TMP/text.cp1252" - # Apply the Flextalk profile before synthesis. Profiles are read at voice load and # the host loads the engine per request, so the values are written every call: the # engine defaults plus any VESKSLOP_TTS_PROFILE overrides. A missing value therefore @@ -204,13 +247,13 @@ 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. -TEXT_SRC="$TEXTFILE" +TEXT_SRC="$TEXT_IN" if [ "$VOX" = "1" ] && [ -n "$SPEAKER_GUID" ]; then if command -v python3 >/dev/null 2>&1 && [ -f "$ROOT/setup/vox_parser.py" ]; then if [ "$VOX_CLEAN" = "1" ]; then - python3 "$ROOT/setup/vox_parser.py" --clean < "$TEXTFILE" > "$TMP/vox.txt" + python3 "$ROOT/setup/vox_parser.py" --clean < "$TEXT_IN" > "$TMP/vox.txt" else - python3 "$ROOT/setup/vox_parser.py" < "$TEXTFILE" > "$TMP/vox.txt" + python3 "$ROOT/setup/vox_parser.py" < "$TEXT_IN" > "$TMP/vox.txt" fi TEXT_SRC="$TMP/vox.txt" else