Files
Fisto c6b5fb71e8 Revert VOX preset to the NetTTS approximation
The tuned values (pitch 0, rate 15, head size 34, lowering 50) matched
the reference metrics better but sound far off the game voice, so restore
the published NetTTS approximation: pitch 3, rate 17, prominences 22,
head size 52, lowering 40, volume 70. Also revert the test expectations,
the published default metadata, and the tuning notes in the docs; keep
tools/vox_compare.py for future experiments.
2026-09-22 19:56:22 +02:00

663 lines
29 KiB
Bash
Executable File

#!/bin/sh
# Veskslop TTS command-provider wrapper for MicroslopSam.
#
# Recommended Veskslop configuration:
# VESKSLOP_TTS_CMD="<repo>/setup/veskslop-tts.sh {out} {text_file}"
# VESKSLOP_TTS_VOICES="$(setup/veskslop-tts.sh --voices)"
#
# Inputs:
# $1 {out} output WAV path
# $2 {text_file} UTF-8 text file
# $3... optional: voice id, speed multiplier, pitch semitones, expression
# env VESKSLOP_TTS_VOICE, VESKSLOP_TTS_SPEED, VESKSLOP_TTS_PITCH,
# VESKSLOP_TTS_EXPRESSION
# VESKSLOP_TTS_PROFILE Flextalk profile: key=value,key=value
# keys: pitch, rate, volume, expression,
# 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_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)
#
# Output: 48 kHz mono s16le WAV, trimmed, faded, loudness-normalized.
set -eu
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: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="$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=$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"
exit 0
fi
OUT=${1:?missing out path}
TEXTFILE=${2:?missing text file}
VOICE=${VESKSLOP_TTS_VOICE:-${3:-sam}}
SPEED=${VESKSLOP_TTS_SPEED:-${4:-}}
PITCH=${VESKSLOP_TTS_PITCH:-${5:-}}
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
VOX=1
fi
HUM_MS=${VESKSLOP_TTS_HUM_MS:-80}
HUM_HZ=${VESKSLOP_TTS_HUM_HZ:-75}
HUM_DB=${VESKSLOP_TTS_HUM_DB:--48}
case "$VOICE" in
sam) ENGVOICE="Sam" ;;
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)" ;;
mike-hall) ENGVOICE="Mike in Hall" ;;
mike-stadium) ENGVOICE="Mike in Stadium" ;;
mike-space) ENGVOICE="Mike in Space" ;;
mary-hall) ENGVOICE="Mary in Hall" ;;
mary-stadium) ENGVOICE="Mary in Stadium" ;;
mary-space) ENGVOICE="Mary in Space" ;;
*) ENGVOICE="Sam" ;;
esac
# Flextalk speakers have a registry profile; MSTTS voices do not.
case "$ENGVOICE" in
"Keith Bell") SPEAKER_GUID="{11260610-C84A-11ce-AEC4-0000C0E9CB87}"; DEF_PITCH=27; DEF_RATE=33; DEF_HEAD=46 ;;
"Kerry Watson") SPEAKER_GUID="{000031CB-3699-551F-AED0-6B4893382D57}"; DEF_PITCH=65; DEF_RATE=32; DEF_HEAD=45 ;;
*) SPEAKER_GUID=""; DEF_PITCH=100; DEF_RATE=150; DEF_HEAD=45 ;;
esac
# Engine units (measured). Speed is words/min, natural rate near 150 for every
# voice. Pitch units differ per voice: the engine's natural pitch corresponds to
# about 100 (MSTTS), 106 (Keith Bell) and 186 (Kerry Watson). Semitones scale
# that natural value by 2^(st/12) and clamp to the engine range.
case "$ENGVOICE" in
"Keith Bell") SMAX=390; PMAX=250; NAT_PITCH=106 ;;
"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 "$MICROSLOP_XSOCK" ]; then
"$MICROSLOP_XVFB" "$MICROSLOP_DISPLAY" -screen 0 1024x768x24 >/dev/null 2>&1 &
sleep 1
fi
}
if [ -z "${DISPLAY:-}" ]; then
start_xvfb
export DISPLAY=$MICROSLOP_DISPLAY
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
"$MICROSLOP_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 controls from the (possibly command-overridden) speed/pitch.
# Flextalk voices take sticky vendor tags relative to the registry profile, the
# way NetTTS does it: \!R is a duration factor (smaller = faster, 1.0 neutral)
# and \!% is a pitch multiplier (1.0 neutral). ITTSAttributes -s/-p would
# override the profile and make presets sound fast and shallow, so tags are used
# for Flextalk. MSTTS has no vendor tags and keeps -s/-p.
SET_ARGS=""
TAG_PREFIX=""
if [ -n "$SPEAKER_GUID" ]; then
if [ -n "$SPEED" ]; then
# A neutral multiplier must not emit a tag: \!R1.00 means a 1.0x rate,
# which would override the profile's own speaking rate.
R=$(awk -v m="$SPEED" 'BEGIN{ if (m<=0) m=1; if (m==1) { exit } f=1/m; if (f<0.25) f=0.25; if (f>4) f=4; printf "%.2f", f }')
[ -n "$R" ] && TAG_PREFIX="$TAG_PREFIX\\!R$R "
fi
if [ -n "$PITCH" ]; then
PCT=$(awk -v st="$PITCH" 'BEGIN{ if (st==0) { exit } f=exp(log(2)*st/12); if (f<0.5) f=0.5; if (f>2) f=2; printf "%.2f", f }')
[ -n "$PCT" ] && TAG_PREFIX="$TAG_PREFIX\\!%$PCT "
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
# 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
# restores the default.
if [ -n "$SPEAKER_GUID" ]; then
P_PITCH=$DEF_PITCH
P_RATE=$DEF_RATE
P_VOLUME=100
P_PROM=17
P_HEAD=$DEF_HEAD
P_LOWER=40
P_EXPR=""
# Half-Life presets for Keith Bell (the game's announcement voice).
# "vox" is the approximated Valve VOX profile published by NetTTS
# (scripts/winetricks/nettts_flextalk_vox_profile.reg); "hev" is a starting
# point for the HEV suit voice, which has no published profile.
case "$PRESET" in
vox)
if [ "$ENGVOICE" = "Keith Bell" ]; then
P_PITCH=3; P_RATE=17; P_VOLUME=70
P_PROM=22; P_HEAD=52; P_LOWER=40
fi
;;
hev)
if [ "$ENGVOICE" = "Keith Bell" ]; then
P_PITCH=15; P_RATE=24; P_VOLUME=70
P_PROM=22; P_HEAD=52; P_LOWER=40
fi
;;
esac
if [ -n "$PROFILE" ]; then
OLDIFS=$IFS
IFS=','
for pair in $PROFILE; do
IFS='='
# shellcheck disable=SC2086
set -- $pair
IFS=','
case "$1" in
pitch) P_PITCH=$2 ;;
rate) P_RATE=$2 ;;
volume) P_VOLUME=$2 ;;
expression) P_EXPR=$2 ;;
prominences) P_PROM=$2 ;;
head_size|headSize) P_HEAD=$2 ;;
lowering) P_LOWER=$2 ;;
esac
done
IFS=$OLDIFS
fi
if [ -n "$EXPRESSION" ]; then P_EXPR=$EXPRESSION; fi
if [ -z "$P_EXPR" ]; then
case "$ENGVOICE" in
"Keith Bell") P_EXPR=59 ;;
*) P_EXPR=100 ;;
esac
fi
for value in "$P_PITCH" "$P_RATE" "$P_VOLUME" "$P_EXPR" "$P_PROM" "$P_HEAD" "$P_LOWER"; do
case "$value" in
*[!0-9]*|"")
echo "profile values must be numbers" >&2
exit 1
;;
esac
done
# NetTTS's VOX profile also carries Gender, Line Mode, the text-class
# switches and the default-speaker pointer. Write the full set for presets,
# exactly like its nettts_flextalk_vox_profile.reg, in both hives.
FULL_PROFILE=0
if [ "$PRESET" = "vox" ] || [ "$PRESET" = "hev" ]; then
if [ "$ENGVOICE" = "Keith Bell" ]; then FULL_PROFILE=1; fi
fi
REG_ROOT='Software\AT&T\Watson\2.0\FlexTalk\2.0'
# 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.
{
printf 'Windows Registry Editor Version 5.00\n\n'
if [ "$FULL_PROFILE" = "1" ]; then
printf '[HKEY_LOCAL_MACHINE\\%s]\n' "$REG_ROOT"
printf '"Default Speaker"="%s"\n\n' "$SPEAKER_GUID"
printf '[HKEY_CURRENT_USER\\%s]\n' "$REG_ROOT"
printf '"Default Speaker"="%s"\n\n' "$SPEAKER_GUID"
fi
for hive in HKEY_LOCAL_MACHINE HKEY_CURRENT_USER; do
printf '[%s\\%s\\Speakers\\%s]\n' "$hive" "$REG_ROOT" "$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 [ "$FULL_PROFILE" = "1" ]; then
printf '"Abbreviation Expansion"=dword:00000001\n'
printf '"Address Class"=dword:00000001\n'
printf '"Age"=dword:0000001e\n'
printf '"Alphabetic Hyphen"=dword:00000000\n'
printf '"Case Significance"=dword:00000001\n'
printf '"Date Class"=dword:00000001\n'
printf '"Fraction Class"=dword:00000001\n'
printf '"Gender"=dword:00000002\n'
printf '"Line Mode"=dword:00000001\n'
printf '"Math Mode"=dword:00000000\n'
printf '"Measurements Class"=dword:00000001\n'
printf '"Money Class"=dword:00000001\n'
printf '"Numerical Hyphen"=dword:00000000\n'
printf '"Ordinal Numbers Class"=dword:00000001\n'
printf '"ProofRead Mode"=dword:00000000\n'
printf '"Proper Names Class"=dword:00000001\n'
printf '"Spell Mode"=dword:00000000\n'
printf '"Telephone Numbers Class"=dword:00000001\n'
printf '"Time Class"=dword:00000001\n'
fi
printf '\n'
done
} > "$TMP/profile.reg"
# Guard against silent reg-import failures (a malformed key would corrupt
# only the import, and reg.exe can still return 0).
if LC_ALL=C grep -q '[[:cntrl:]]' "$TMP/profile.reg"; then
echo "generated Flextalk profile is malformed" >&2
exit 1
fi
# Skip the wine round-trip when this voice's profile was already applied.
# The cache lives inside the prefix, so recreating the prefix invalidates
# it automatically.
PROFILE_CACHE="$WINEPREFIX/.microslopsam-profile$SPEAKER_GUID.reg"
if [ -f "$PROFILE_CACHE" ] && cmp -s "$TMP/profile.reg" "$PROFILE_CACHE"; then
PROFILE_APPLIED=1
else
PROFILE_APPLIED=0
fi
if [ "$PROFILE_APPLIED" != "1" ]; then
if ! command -v "$MICROSLOP_WINE" >/dev/null 2>&1; then
echo "wine is required to set the Flextalk profile" >&2
exit 1
fi
if ! "$MICROSLOP_WINE" reg import "$TMP/profile.reg" >/dev/null 2>&1; then
echo "could not write the Flextalk profile" >&2
exit 1
fi
cp -f "$TMP/profile.reg" "$PROFILE_CACHE" 2>/dev/null || true
fi
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. 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" ] && [ "$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"
else
"$MICROSLOP_PYTHON" "$MICROSLOP_ROOT/setup/vox_parser.py" < "$TEXT_IN" > "$TMP/vox.txt"
fi
TEXT_SRC="$TMP/vox.txt"
else
echo "vox mode needs python3 and setup/vox_parser.py" >&2
exit 1
fi
fi
# transliterate UTF-8 (emoji become '?')
if command -v iconv >/dev/null 2>&1; then
iconv -f UTF-8 -t WINDOWS-1252//TRANSLIT "$TEXT_SRC" > "$TXT" 2>/dev/null \
|| cp "$TEXT_SRC" "$TXT"
else
cp "$TEXT_SRC" "$TXT"
fi
HOST="$MICROSLOP_BUILD/sapi4host-winlib.exe"
if [ ! -x "$HOST" ]; then HOST="$MICROSLOP_BUILD/sapi4host.exe"; fi
if [ ! -x "$HOST" ]; then echo "engine host not built" >&2; exit 1; fi
if ! command -v "$MICROSLOP_FFMPEG" >/dev/null 2>&1; then echo "ffmpeg not found" >&2; exit 1; fi
run_host() {
# shellcheck disable=SC2086
"$HOST" -v "$ENGVOICE" -t "$2" -o "$1" $SET_ARGS >/dev/null 2>"$TMP/err"
}
# A stale private Xvfb can break audio-destination creation; retry once.
try_run_host() {
if run_host "$1" "$2"; then return 0; fi
if [ "${DISPLAY:-}" = "$MICROSLOP_DISPLAY" ]; then
pkill -f "Xvfb $MICROSLOP_DISPLAY" 2>/dev/null || true
rm -f "$MICROSLOP_XSOCK"
start_xvfb
if run_host "$1" "$2"; then return 0; fi
fi
head -c 400 "$TMP/err" >&2
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.
# 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
: > "$TMP/list.txt"
INDEX=0
for word in $(cat "$TXT"); do
INDEX=$((INDEX + 1))
try_run_host "$TMP/w$INDEX.wav" "$TAG_PREFIX$word"
echo "file '$TMP/w$INDEX.wav'" >> "$TMP/list.txt"
if [ "$GAP" -gt 0 ]; then
"$MICROSLOP_FFMPEG" -hide_banner -loglevel error -f lavfi -i "anullsrc=r=11025:cl=mono" \
-t "$(awk -v g="$GAP" 'BEGIN{printf "%.3f", g/1000}')" -c:a pcm_s16le -y "$TMP/sil.wav" \
>/dev/null 2>&1
echo "file '$TMP/sil.wav'" >> "$TMP/list.txt"
fi
done
[ "$INDEX" -gt 0 ] || { echo "no words to speak" >&2; exit 1; }
if ! "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -f concat -safe 0 -i "$TMP/list.txt" -c copy -y "$RAW" \
>/dev/null 2>&1; then
echo "could not merge the words" >&2
exit 1
fi
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
[ "$HIGHPASS" -lt 40 ] && HIGHPASS=40
[ "$HIGHPASS" -gt 400 ] && HIGHPASS=400
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" -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.
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)
if [ -n "$I" ] && [ -n "$TP" ]; then
GAIN=$(awk -v i="$I" -v tp="$TP" 'BEGIN{
g = -16 - i;
lim = -3 - tp;
if (lim < g) g = lim;
if (g > 30) g = 30;
if (g < -20) g = -20;
printf "%.2f", g
}')
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
# are configurable; 0 ms disables it.
case "$HUM_MS" in *[!0-9]*|"") HUM_MS=80 ;; esac
[ "$HUM_MS" -gt 2000 ] && HUM_MS=2000
case "$HUM_HZ" in *[!0-9.]*|"") HUM_HZ=75 ;; esac
case "$HUM_DB" in *[!0-9.-]*|""|"."|"-") HUM_DB=-48 ;; esac
if [ "$HUM_MS" -gt 0 ]; then
HUM_S=$(awk -v ms="$HUM_MS" 'BEGIN{printf "%.3f", ms/1000}')
HUM_HZ=$(awk -v v="$HUM_HZ" 'BEGIN{ if (v<30) v=30; if (v>200) v=200; printf "%.1f", v }')
HUM_DB=$(awk -v v="$HUM_DB" 'BEGIN{ if (v>-6) v=-6; if (v<-80) v=-80; printf "%.1f", v }')
HUM_AMP=$(awk -v db="$HUM_DB" 'BEGIN{printf "%.6f", 10^(db/20)}')
HUM_FADE=$(awk -v s="$HUM_S" 'BEGIN{ f=s/4; if (f>0.02) f=0.02; if (f<0.005) f=0.005; printf "%.3f", f }')
HUM_FADE_OUT=$(awk -v s="$HUM_S" -v f="$HUM_FADE" 'BEGIN{printf "%.3f", s-f}')
# The hum is mixed on its own chain and deliberately bypasses $PRE (highpass,
# trim, fades): VESKSLOP_TTS_HIGHPASS must never remove it.
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,${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,${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
fi
[ -s "$TMPOUT" ] || { echo "post-processing produced no audio" >&2; exit 1; }
mv -f "$TMPOUT" "$OUT"