#!/bin/sh # Veskslop TTS command-provider wrapper for MicroslopSam. # # Recommended Veskslop configuration: # VESKSLOP_TTS_CMD="/setup/vekslop-tts.sh {out} {text_file}" # VESKSLOP_TTS_VOICES="$(setup/vekslop-tts.sh --voices)" # # Inputs: # $1 {out} output WAV path # $2 {text_file} UTF-8 text file # $3... optional: voice id, speed multiplier, pitch semitones # env VESKSLOP_TTS_VOICE, VESKSLOP_TTS_SPEED, VESKSLOP_TTS_PITCH # # Output: 48 kHz mono s16le WAV, trimmed, faded, loudness-normalized. set -eu HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam} export WINEARCH=win32 # VESKSLOP_TTS_VOICES entries: id|Name|gender|language[|flags] VOICES="sam|Sam|male|en,mary|Mary|female|en,mike|Mike|male|en,keith-bell|Keith Bell|male|en,kerry-watson|Kerry Watson|female|en,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:-}} 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 # 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 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 # The daemon does not export DISPLAY; keep a private Xvfb for the engine. start_xvfb() { if ! [ -S /tmp/.X11-unix/X77 ]; then Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 & sleep 1 fi } if [ -z "${DISPLAY:-}" ]; then start_xvfb export DISPLAY=:77 fi TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT RAW="$TMP/raw.wav" TXT="$TMP/text.cp1252" # SAPI4 is ANSI/CP1252; transliterate UTF-8 (emoji become '?'). if command -v iconv >/dev/null 2>&1; then iconv -f UTF-8 -t WINDOWS-1252//TRANSLIT "$TEXTFILE" > "$TXT" 2>/dev/null \ || cp "$TEXTFILE" "$TXT" else cp "$TEXTFILE" "$TXT" fi HOST="$ROOT/build/sapi4host-winlib.exe" if [ ! -x "$HOST" ]; then HOST="$ROOT/build/sapi4host.exe"; fi if [ ! -x "$HOST" ]; then echo "engine host not built" >&2; exit 1; fi if ! command -v ffmpeg >/dev/null 2>&1; then echo "ffmpeg not found" >&2; exit 1; fi run_engine() { # shellcheck disable=SC2086 "$HOST" -v "$ENGVOICE" -t "$(cat "$TXT")" -o "$RAW" $SET_ARGS \ >/dev/null 2>"$TMP/err" } if ! run_engine; then # A stale private Xvfb can break audio-destination creation; retry once. if [ "${DISPLAY:-}" = ":77" ]; then pkill -f "Xvfb :77" 2>/dev/null || true rm -f /tmp/.X11-unix/X77 start_xvfb if ! run_engine; then head -c 400 "$TMP/err" >&2 exit 1 fi else head -c 400 "$TMP/err" >&2 exit 1 fi fi [ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; } SIL="silenceremove=start_periods=1:start_duration=0.05:start_silence=0.03:start_threshold=-50dB" PRE="highpass=f=40,$SIL,afade=t=in:st=0:d=0.008,areverse,$SIL,afade=t=in:st=0:d=0.008,areverse" # 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=$(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" if ! 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"