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.
663 lines
29 KiB
Bash
Executable File
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"
|