#!/bin/sh # Veskslop TTS command-provider wrapper for MicroslopSam. # # Recommended Veskslop configuration: # VESKSLOP_TTS_CMD="/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_WORDS 1 = synthesize each word, merge (Flextalk) # 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) # # Output: 48 kHz mono s16le WAV, trimmed, faded, loudness-normalized. set -eu HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$HERE/config.sh" ROOT=$MICROSLOP_ROOT # 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" 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} 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} 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" ;; 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 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}') [ "$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 # 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="" 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 REG_KEY="HKLM\\Software\\AT&T\\Watson\\2.0\\FlexTalk\\2.0\\Speakers\\$SPEAKER_GUID" # 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' 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" } > "$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. TEXT_SRC="$TEXT_IN" if [ "$VOX" = "1" ] && [ -n "$SPEAKER_GUID" ]; 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 } # 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 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" "$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" "$(cat "$TXT")" fi [ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; } # 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 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" 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}" 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" \ -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 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,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" \ -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"