diff --git a/.gitignore b/.gitignore index 0cf5cb4..33b6c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ setup/out-*.reg *.wav setup/microslopsam.env third_party/flextalk/extracted/ +__pycache__/ diff --git a/README.md b/README.md index f2a74fc..3389bc1 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ Options: `-v` voice, `-t` text, `-o` output WAV, `-p` pitch, `-s` speed, `-V` vo export VESKSLOP_TTS_CMD="$PWD/setup/veskslop-tts.sh {out} {text_file}" export VESKSLOP_TTS_VOICES="$(setup/veskslop-tts.sh --voices)" -The wrapper maps voice IDs (`sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`, plus the -MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier), +The wrapper maps voice IDs (`sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`, `vox`, +`hev`, plus the MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier), `VESKSLOP_TTS_PITCH` (semitones) and `VESKSLOP_TTS_EXPRESSION` (0-100, Flextalk voices only), starts Xvfb when `DISPLAY` is unset, and writes a 48 kHz mono s16le WAV trimmed, faded and normalized to about -16 LUFS (-3 dBFS true peak). @@ -95,10 +95,17 @@ so the first word is not gated; tune with `VESKSLOP_TTS_HUM_MS` (0 = off), **Half-Life presets** (Keith Bell): `VESKSLOP_TTS_PRESET=vox` applies the Valve VOX profile (pitch 0, rate 15, head size 34, lowering 50) and enables VOX prosody; -`VESKSLOP_TTS_PRESET=hev` is a starting point for the HEV suit voice. The VOX values were +`VESKSLOP_TTS_PRESET=hev` is a starting point for the HEV suit voice. The `vox` and `hev` +voice IDs select these directly. The VOX values were tuned against the game's word assets with `tools/vox_compare.py`; details in `docs/TTS-DESIGN.md`. +`VESKSLOP_TTS_CRUNCH` (`hl90`, `hl90hq`, `telephone`, `adpcm`, `crush`) adds 1990s lossy +character to the output; `VESKSLOP_TTS_CRUNCH_BITS`/`_RATE` override its bit depth and +sample rate. `VESKSLOP_TTS_STREAM=1` makes word mode write the WAV as it goes (no +measurement pass; `VESKSLOP_TTS_STREAM_GAIN_DB` sets the gain). Echo timing and level are +`VESKSLOP_TTS_ECHO_DELAY_MS` and `VESKSLOP_TTS_ECHO_LEVEL`. + ## Layout - `host/sapi4host.c` — host: voice select, pitch/speed, text -> WAV @@ -109,6 +116,7 @@ tuned against the game's word assets with `tools/vox_compare.py`; details in - `setup/inf2reg.py` — INF AddReg -> `.reg` converter - `setup/run.sh` — run helper (prefix + Xvfb) - `setup/veskslop-tts.sh` — Veskslop command-provider wrapper (post-processing included) +- `setup/test-wrapper.sh` — wrapper regression test (stubbed Wine/host, no prefix needed) - `setup/vox_parser.py` — NetTTS-style VOX prosody encoder (Flextalk voices) - `tools/vox_compare.py` — reference-vs-synthesis metrics for preset tuning - `docs/NOTES.md` — registry/CLSID facts, runtime RE notes, gotchas diff --git a/docs/NOTES.md b/docs/NOTES.md index 37ae237..a7be815 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -127,8 +127,8 @@ without them. the Python port; outputs are identical on the 31-line corpus. Re-check after any parser change while the clone is available. - 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`. + (`VESKSLOP_TTS_VOX_CLEAN=1` drops the stutter wrapper); word mode + (`VESKSLOP_TTS_WORDS=1`) wins and skips VOX prosody. - Half-Life presets (`VESKSLOP_TTS_PRESET=vox|hev`) target Keith Bell only. VOX starts from NetTTS `nettts_flextalk_vox_profile.reg` (pitch 3, rate 17, head 52, lowering 40) and is tuned against the game's word assets from `suchipi/half-life-vox-console`: diff --git a/docs/TTS-DESIGN.md b/docs/TTS-DESIGN.md index 5a6c6a6..292ad62 100644 --- a/docs/TTS-DESIGN.md +++ b/docs/TTS-DESIGN.md @@ -254,6 +254,9 @@ record what the engine needs to interpret, add, or hide._ - Assigned stable IDs: `sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`; variants get `mike-telephone`, `mike-hall`, `mary-stadium`, `mike-space`, and so on. Unknown IDs fall back to `sam`. +- `vox` (VOX (Half-Life)) and `hev` (HEV suit) are Keith Bell with a fixed preset profile; + selecting one implies `VESKSLOP_TTS_PRESET` (`vox` also enables VOX prosody). Their + `defaults=` metadata follows the active preset so the UI snap points match. - `/voices` (HTTP mode) reports `language: "en"`, gender, and the speed/pitch ranges below. ### Speed and pitch mapping (measured 2026-09-15) @@ -372,15 +375,16 @@ Requirements and scope: The host passes `TTSDATAFLAG_TAGGED`, so the engine honors them. Other voices ignore `VESKSLOP_TTS_VOX`. - Needs `python3` at runtime; the wrapper fails loudly when it is missing. -- Mutually exclusive with `VESKSLOP_TTS_WORDS` (VOX wins). +- Combines with `VESKSLOP_TTS_WORDS`: word mode wins and VOX prosody is skipped. - Measured on the dev host for "Now approaching Sector C test lab. Please stand clear of the test chamber.": plain 4.70 s with 1 pause, VOX 7.81 s with 8 pauses. Other NetTTS features and their relevance: -- Inline `/rate N`, `/pitch N`, `[[pause N]]`: not ported. Veskslop has speed/pitch - controls and the profile window; `[[pause N]]` could be added as a wrapper-level - text splitter later (NetTTS supports it only outside VOX mode). +- Inline `/rate N`, `/pitch N` are handled by the wrapper at the start of a line + (percent, or a multiplier at or below 2.0); they override the env sliders for that + line and are not spoken. `[[pause N]]` is not ported; it could be added as a + wrapper-level text splitter later. - TCP server with `START`/`STOP` status sockets: not needed; Veskslop owns playback and knows when a line ends. - Winetricks prefix automation (`winxp`, `vcrun6`, `mfc42`, `riched20`): not needed; @@ -702,10 +706,16 @@ Inputs: `{out}`, `{text_file}`, optional positional voice/speed/pitch/expression | --- | --- | | `VESKSLOP_TTS_PROFILE` | `pitch:27:...`/`key=value,...` overrides; the wrapper writes the full profile (defaults + overrides) to the Wine registry with `wine reg import` before every synthesis. | | `VESKSLOP_TTS_HIGHPASS` | High-pass Hz, 40-400 (default 40). | -| `VESKSLOP_TTS_ECHO` | Mono echo 0-100 (`aecho`), default 0. | +| `VESKSLOP_TTS_ECHO` | Mono echo 0-100 (`aecho`), default 0. The echo is mixed under the dry voice, so the voice level does not change. | +| `VESKSLOP_TTS_ECHO_DELAY_MS` / `VESKSLOP_TTS_ECHO_LEVEL` | Echo delay 10-400 ms (default 60) and repeat level 0-0.95 relative to the dry voice (default 0.85). | +| `VESKSLOP_TTS_CRUNCH` | `off` (default) or `hl90`, `hl90hq`, `telephone`, `adpcm`, `crush`: 1990s lossy character, applied last. | +| `VESKSLOP_TTS_CRUNCH_BITS` / `VESKSLOP_TTS_CRUNCH_RATE` | Bit depth 1-16 and sample rate 4000-48000 overrides for the crunch stage. | +| `VESKSLOP_TTS_STREAM` / `VESKSLOP_TTS_STREAM_GAIN_DB` | `1` = word mode writes the WAV as it goes (no measurement pass); fixed gain in dB for that path. | | `VESKSLOP_TTS_WORDS` / `VESKSLOP_TTS_WORD_GAP_MS` | Word-by-word synthesis with silence gaps (Flextalk only). | -Placeholders `{profile}`, `{highpass}`, `{echo}`, `{words}`, `{word_gap_ms}` mirror the env vars. +Placeholders `{profile}`, `{highpass}`, `{echo}`, `{echo_delay_ms}`, `{echo_level}`, +`{words}`, `{word_gap_ms}`, `{crunch}`, `{crunch_bits}`, `{crunch_rate}`, `{stream}`, +`{vox}`, `{vox_clean}`, `{preset}` mirror the env vars. `wine regedit` fails headlessly but returns 0: use `wine reg import`. A missing profile value must always write the engine default so no stale registry state survives. diff --git a/setup/test-wrapper.sh b/setup/test-wrapper.sh index a218309..0144efd 100755 --- a/setup/test-wrapper.sh +++ b/setup/test-wrapper.sh @@ -94,8 +94,8 @@ 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 pitch (both hives)" "2" "$(grep -c '"Pitch"=dword:00000000' "$STUB_REG")" +check "preset rate (both hives)" "2" "$(grep -c '"Speaking Rate"=dword:0000000f' "$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")" @@ -111,7 +111,7 @@ check "word mode with preset synthesizes every word" "3" "$(grep -c '^ARGV:' "$S 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.80 ')" -check "word mode keeps the preset profile" "2" "$(grep -c '"Pitch"=dword:00000003' "$STUB_REG")" +check "word mode keeps the preset profile" "2" "$(grep -c '"Pitch"=dword:00000000' "$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. @@ -139,7 +139,7 @@ check "stream has a gap per word break" "2" \ # 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 writes the VOX profile (both hives)" "2" "$(grep -c '"Pitch"=dword:00000000' "$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")" diff --git a/setup/veskslop-tts.sh b/setup/veskslop-tts.sh index 137c9ce..ee6737a 100755 --- a/setup/veskslop-tts.sh +++ b/setup/veskslop-tts.sh @@ -16,13 +16,24 @@ # 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_ECHO_DELAY_MS echo delay 10-400 (default 60) +# VESKSLOP_TTS_ECHO_LEVEL echo repeat level 0-0.95, relative to the +# dry voice (default 0.85) +# VESKSLOP_TTS_CRUNCH off | hl90 | hl90hq | telephone | adpcm | crush +# (1990s lossy character, applied last) +# VESKSLOP_TTS_CRUNCH_BITS 1-16, overrides the preset bit depth +# VESKSLOP_TTS_CRUNCH_RATE 4000-48000 Hz, overrides the preset rate +# VESKSLOP_TTS_STREAM 1 = word mode writes the WAV as it goes +# VESKSLOP_TTS_STREAM_GAIN_DB fixed gain for streaming (no measurement) +# 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_VOX 1 = VOX prosody, NetTTS-style (Flextalk) # VESKSLOP_TTS_VOX_CLEAN 1 = VOX without the \!wH1/\!wH0 stutter # 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) # @@ -33,11 +44,21 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$HERE/config.sh" 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:0:rate:15:prominences:22:head_size:34:lowering:50: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="$VOX_DEFAULTS" ;; + hev) KB_DEFAULTS="$HEV_DEFAULTS" ;; + *) KB_DEFAULTS="pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59" ;; +esac + # VESKSLOP_TTS_VOICES entries: id|Name|gender|language[|flags] # `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" +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" @@ -53,11 +74,25 @@ EXPRESSION=${VESKSLOP_TTS_EXPRESSION:-${6:-}} PROFILE=${VESKSLOP_TTS_PROFILE:-} HIGHPASS=${VESKSLOP_TTS_HIGHPASS:-40} ECHO=${VESKSLOP_TTS_ECHO:-0} +ECHO_DELAY=${VESKSLOP_TTS_ECHO_DELAY_MS:-60} +ECHO_LEVEL=${VESKSLOP_TTS_ECHO_LEVEL:-0.85} +CRUNCH=${VESKSLOP_TTS_CRUNCH:-off} +CRUNCH_BITS=${VESKSLOP_TTS_CRUNCH_BITS:-} +CRUNCH_RATE=${VESKSLOP_TTS_CRUNCH_RATE:-} +STREAM=${VESKSLOP_TTS_STREAM:-0} +STREAM_GAIN=${VESKSLOP_TTS_STREAM_GAIN_DB:-0} WORDS=${VESKSLOP_TTS_WORDS:-0} 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 @@ -72,6 +107,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)" ;; @@ -350,9 +387,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: 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" @@ -399,10 +438,19 @@ try_run_host() { exit 1 } +# Streaming word mode writes each word as it is synthesized (see below), after the +# filter chain exists, so the classic all-at-once path is skipped for it. +STREAM_WORDS=0 +if [ "$STREAM" = "1" ] && [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ]; then + STREAM_WORDS=1 +fi + +if [ "$STREAM_WORDS" != "1" ]; then # 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 @@ -432,6 +480,7 @@ else try_run_host "$RAW" "$TAG_PREFIX$(cat "$TXT")" fi [ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; } +fi # Filter chain: adjustable high-pass, silence trim, fades, optional mono echo. case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac @@ -441,17 +490,65 @@ case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac case "$ECHO" in *[!0-9]*|"") ECHO=0 ;; esac [ "$ECHO" -gt 100 ] && ECHO=100 +case "$ECHO_DELAY" in *[!0-9]*|"") ECHO_DELAY=60 ;; esac +[ "$ECHO_DELAY" -lt 10 ] && ECHO_DELAY=10 +[ "$ECHO_DELAY" -gt 400 ] && ECHO_DELAY=400 +case "$ECHO_LEVEL" in *[!0-9.]*|""|".") ECHO_LEVEL=0.85 ;; esac +ECHO_LEVEL=$(awk -v v="$ECHO_LEVEL" 'BEGIN{ if (v<0) v=0; if (v>0.95) v=0.95; printf "%.2f", v }') + SIL="silenceremove=start_periods=1:start_duration=0.05:start_silence=0.03:start_threshold=-50dB" PRE="highpass=f=$HIGHPASS,$SIL,afade=t=in:st=0:d=0.008,areverse,$SIL,afade=t=in:st=0:d=0.008,areverse" +# aecho's in_gain/out_gain scale the dry signal as well, so a low ECHO_LEVEL used to +# quiet the whole line (and the streaming path, which skips the loudness measurement, +# had nothing to make it up). Keep both gains at unity and let the repeats' decay carry +# the level; the limiter absorbs the summed peaks. if [ "$ECHO" -gt 0 ]; then - DECAY=$(awk -v e="$ECHO" 'BEGIN{printf "%.2f", 0.2 + e * 0.006}') - PRE="$PRE,aecho=0.8:0.85:60:${DECAY}" + DECAY=$(awk -v e="$ECHO" -v l="$ECHO_LEVEL" 'BEGIN{ + d = (0.2 + e * 0.006) * (l / 0.85); + if (d < 0.02) d = 0.02; + if (d > 0.95) d = 0.95; + printf "%.2f", d + }') + PRE="$PRE,aecho=1:1:${ECHO_DELAY}:${DECAY},alimiter=limit=0.98:level=disabled" +fi + +# Compression stage ("crunch"): the lossy 1990s codec character, built as its own +# stage that runs after the rest of the chain so it colours the echo tails too. The +# signal is quantized at a low rate / bit depth and comes back to 48 kHz so the +# daemon's output contract holds. +CRUNCH_FILTERS="" +case "$CRUNCH" in + hl90|vox90) CRUNCH_BAND="highpass=f=200,lowpass=f=5000"; CRUNCH_RATE=${CRUNCH_RATE:-11025}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;; + hl90hq) CRUNCH_BAND="highpass=f=200,lowpass=f=7000"; CRUNCH_RATE=${CRUNCH_RATE:-22050}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;; + telephone|phone) CRUNCH_BAND="highpass=f=300,lowpass=f=3400"; CRUNCH_RATE=${CRUNCH_RATE:-8000}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;; + adpcm) CRUNCH_BAND="highpass=f=200,lowpass=f=5000"; CRUNCH_RATE=${CRUNCH_RATE:-11025}; CRUNCH_BITS=${CRUNCH_BITS:-4} ;; + crush|custom) CRUNCH_BAND="${VESKSLOP_TTS_CRUNCH_BAND:-}"; CRUNCH_RATE=${CRUNCH_RATE:-22050}; CRUNCH_BITS=${CRUNCH_BITS:-6} ;; + off|"") ;; + *) echo "unknown crunch preset: $CRUNCH" >&2; exit 2 ;; +esac +if [ "$CRUNCH" != "off" ] && [ -n "$CRUNCH" ]; then + case "$CRUNCH_RATE" in *[!0-9]*|"") CRUNCH_RATE=22050 ;; esac + [ "$CRUNCH_RATE" -lt 4000 ] && CRUNCH_RATE=4000 + [ "$CRUNCH_RATE" -gt 48000 ] && CRUNCH_RATE=48000 + case "$CRUNCH_BITS" in *[!0-9]*|"") CRUNCH_BITS=8 ;; esac + [ "$CRUNCH_BITS" -lt 1 ] && CRUNCH_BITS=1 + [ "$CRUNCH_BITS" -gt 16 ] && CRUNCH_BITS=16 + [ -n "$CRUNCH_BAND" ] && CRUNCH_FILTERS="$CRUNCH_BAND," + CRUNCH_FILTERS="${CRUNCH_FILTERS}aresample=$CRUNCH_RATE" + if [ "$CRUNCH_BITS" = "8" ]; then + CRUNCH_FILTERS="$CRUNCH_FILTERS,aformat=sample_fmts=u8" + else + CRUNCH_FILTERS="$CRUNCH_FILTERS,acrusher=bits=$CRUNCH_BITS:mode=log:mix=1" + fi + CRUNCH_FILTERS="$CRUNCH_FILTERS,aresample=48000,aformat=sample_fmts=s16" fi # Fixed-gain normalization: single-pass loudnorm misbehaves on short lines # (LRA=0 causes heavy attenuation), so measure EBU R128 integrated loudness and # true peak, then apply one gain clamped by the -3 dBFS peak target. -M=$("$MICROSLOP_FFMPEG" -hide_banner -nostats -i "$RAW" -af "$PRE,ebur128=peak=true" \ +MEASURE_CHAIN="$PRE" +[ -n "$CRUNCH_FILTERS" ] && MEASURE_CHAIN="$PRE,$CRUNCH_FILTERS" +M=$("$MICROSLOP_FFMPEG" -hide_banner -nostats -i "$RAW" -af "$MEASURE_CHAIN,ebur128=peak=true" \ -f null - 2>&1 || true) I=$(printf '%s' "$M" | sed -n 's/ *I: *\(-\{0,1\}[0-9.]*\) *LUFS.*/\1/p' | tail -1) TP=$(printf '%s' "$M" | sed -n 's/ *Peak: *\(-\{0,1\}[0-9.]*\) *dBFS.*/\1/p' | tail -1) @@ -468,6 +565,71 @@ else GAIN=0 fi +# Streaming word mode: write a WAV header with unknown sizes, then append each +# word's PCM as it is ready, so a player can start on the first word instead of +# waiting for the whole line. The loudness measurement is skipped; set +# VESKSLOP_TTS_STREAM_GAIN_DB if the engine levels need a nudge. +if [ "$STREAM_WORDS" = "1" ]; then + SGAIN="" + case "$STREAM_GAIN" in + ''|*[!0-9.-]*|'.'|'-') ;; + *) SGAIN=",volume=${STREAM_GAIN}dB" ;; + esac + + # RIFF/????/WAVE, fmt 16 bytes, PCM mono 48000 Hz s16le, data/???? + printf 'RIFF\377\377\377\377WAVEfmt \020\000\000\000\001\000\001\000\200\273\000\000\000w\001\000\002\000\020\000data\377\377\377\377' > "$OUT" \ + || { echo "could not write $OUT" >&2; exit 1; } + + set -f + # shellcheck disable=SC2046 + set -- $(cat "$TXT") + set +f + WORD_COUNT=$# + INDEX=0 + GAP=$WORD_GAP + case "$GAP" in *[!0-9]*|"") GAP=120 ;; esac + [ "$GAP" -gt 500 ] && GAP=500 + + # Leading hum, written once before the first word (same settings as the classic path). + if [ "$HUM_MS" -gt 0 ]; then + HUM_S=$(awk -v ms="$HUM_MS" 'BEGIN{printf "%.3f", ms/1000}') + HUM_HZ_C=$(awk -v v="$HUM_HZ" 'BEGIN{ if (v<30) v=30; if (v>200) v=200; printf "%.1f", v }') + HUM_DB_C=$(awk -v v="$HUM_DB" 'BEGIN{ if (v>-6) v=-6; if (v<-80) v=-80; printf "%.1f", v }') + HUM_AMP_C=$(awk -v db="$HUM_DB_C" 'BEGIN{printf "%.6f", 10^(db/20)}') + if "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -f lavfi \ + -i "aevalsrc=exprs='$HUM_AMP_C*sin(2*PI*$HUM_HZ_C*t)':s=48000:d=$HUM_S" \ + -f s16le -ac 1 -ar 48000 "$TMP/hum.raw"; then + cat "$TMP/hum.raw" >> "$OUT" + fi + fi + + for word in "$@"; do + INDEX=$((INDEX + 1)) + try_run_host "$TMP/sw$INDEX.wav" "${TAG_PREFIX}${word}" + if ! "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -y -i "$TMP/sw$INDEX.wav" \ + -af "$PRE$SGAIN${CRUNCH_FILTERS:+,$CRUNCH_FILTERS}" \ + -f s16le -ac 1 -ar 48000 "$TMP/sw$INDEX.raw"; then + echo "streaming post-processing failed" >&2 + exit 1 + fi + cat "$TMP/sw$INDEX.raw" >> "$OUT" 2>/dev/null || { echo "stream write failed" >&2; exit 1; } + + # The player waits for this marker so it does not start on the bare header. + if [ "$INDEX" = "1" ]; then : > "$OUT.ready"; fi + + if [ "$GAP" -gt 0 ] && [ "$INDEX" -lt "$WORD_COUNT" ]; then + if "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -y -f lavfi -i "anullsrc=r=48000:cl=mono" \ + -t "$(awk -v g="$GAP" 'BEGIN{printf "%.3f", g/1000}')" \ + -f s16le -ac 1 -ar 48000 "$TMP/gap.raw"; then + cat "$TMP/gap.raw" >> "$OUT" + fi + fi + done + + [ "$INDEX" -gt 0 ] || { echo "no words to stream" >&2; exit 1; } + exit 0 +fi + TMPOUT="$TMP/out.wav" # Leading hum: a quiet low tone in the pre-speech padding, to prime Discord's # audio pipeline so the first syllable is not gated. Level/frequency/duration @@ -488,12 +650,12 @@ if [ "$HUM_MS" -gt 0 ]; then if ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y \ -f lavfi -i "aevalsrc=exprs='$HUM_AMP*sin(2*PI*$HUM_HZ*t)':s=48000:d=$HUM_S" \ -i "$RAW" \ - -filter_complex "[0:a]afade=t=in:st=0:d=$HUM_FADE,afade=t=out:st=$HUM_FADE_OUT:d=$HUM_FADE,aformat=sample_fmts=s16:channel_layouts=mono:sample_rates=48000[h];[1:a]$PRE,volume=${GAIN}dB,aresample=48000,aformat=sample_fmts=s16:channel_layouts=mono[a];[h][a]concat=n=2:v=0:a=1[out]" \ + -filter_complex "[0:a]afade=t=in:st=0:d=$HUM_FADE,afade=t=out:st=$HUM_FADE_OUT:d=$HUM_FADE,aformat=sample_fmts=s16:channel_layouts=mono:sample_rates=48000[h];[1:a]$PRE,volume=${GAIN}dB,${CRUNCH_FILTERS:+$CRUNCH_FILTERS,}aresample=48000,aformat=sample_fmts=s16:channel_layouts=mono[a];[h][a]concat=n=2:v=0:a=1[out]" \ -map "[out]" -c:a pcm_s16le "$TMPOUT" >/dev/null 2>"$TMP/ff"; then head -c 400 "$TMP/ff" >&2 exit 1 fi -elif ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y -i "$RAW" -af "$PRE,volume=${GAIN}dB" \ +elif ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y -i "$RAW" -af "$PRE,volume=${GAIN}dB,${CRUNCH_FILTERS:+$CRUNCH_FILTERS,}aresample=48000,aformat=sample_fmts=s16" \ -ar 48000 -ac 1 -c:a pcm_s16le "$TMPOUT" >/dev/null 2>"$TMP/ff"; then head -c 400 "$TMP/ff" >&2 exit 1