From 643cdc38bfbe6f21f67af743b689aa5b1093fd05 Mon Sep 17 00:00:00 2001 From: Fisto Date: Tue, 15 Sep 2026 15:04:46 +0200 Subject: [PATCH] Sync with Veskslop integration: Flextalk profile support, words/echo/highpass, wrapper rename --- README.md | 18 ++- docs/NOTES.md | 6 +- docs/TTS-DESIGN.md | 56 ++++++++ setup/install.sh | 6 +- setup/vekslop-tts.sh | 158 ---------------------- setup/veskslop-tts.sh | 308 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 170 deletions(-) delete mode 100755 setup/vekslop-tts.sh create mode 100755 setup/veskslop-tts.sh diff --git a/README.md b/README.md index 183d362..b1ef54d 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,19 @@ Options: `-v` voice, `-t` text, `-o` output WAV, `-p` pitch, `-s` speed, `-V` vo ## Veskslop integration - export VESKSLOP_TTS_CMD="$PWD/setup/vekslop-tts.sh {out} {text_file}" - export VESKSLOP_TTS_VOICES="$(setup/vekslop-tts.sh --voices)" + 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) and -`VESKSLOP_TTS_PITCH` (semitones), 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). +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). Speed and pitch apply to every voice (Flextalk's numeric settings must be `REG_DWORD`, -which the setup script writes). Mapping details are in `docs/TTS-DESIGN.md`. +which the setup script writes). Expression is a Flextalk profile value applied at voice +load: the wrapper writes it to the Wine registry before synthesis. `--voices` marks those +voices with `expression=NN`, where NN is the registry default. Mapping details are in +`docs/TTS-DESIGN.md`. ## Layout @@ -70,7 +74,7 @@ which the setup script writes). Mapping details are in `docs/TTS-DESIGN.md`. - `setup/setup-flextalk.sh` — Flextalk files + registry - `setup/inf2reg.py` — INF AddReg -> `.reg` converter - `setup/run.sh` — run helper (prefix + Xvfb) -- `setup/vekslop-tts.sh` — Veskslop command-provider wrapper (post-processing included) +- `setup/veskslop-tts.sh` — Veskslop command-provider wrapper (post-processing included) - `docs/NOTES.md` — registry/CLSID facts, runtime RE notes, gotchas - `docs/TTS-DESIGN.md` — integration contract with Veskslop plus engine clarifications diff --git a/docs/NOTES.md b/docs/NOTES.md index da659c2..de90d3b 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -42,8 +42,6 @@ From `msttsl/mstts.inf` (`[TTSReg]`): - `{000031CB-3699-551F-AED0-6B4893382D57}`: "Kerry Watson", Pitch 65, Speaking Rate 32, Head Size 45, Expression 100, Lowering 40, Prominences 17, Age 30, Volume 100. - Measured effects per value and the change procedure are in - `docs/TTS-DESIGN.md` ("Flextalk profile parameters"). - Voice data: `EngineData\AE1M08LC.*`, `AE1M11LC.*` (male, 8/11 kHz) and `AE2F08LC.*`, `AE2F11LC.*` (female); `.INV` inventory, `.NDX` index, `.COD` codebook, plus shared `DICT_CI.*`, `phondef`, `AMPRULES`, `ENDINGS`. @@ -72,6 +70,10 @@ final file `WatsonUNInstall.dll` is incomplete). - Wine reports `err:ole:apartment_add_dll couldn't load in-process dll` when the `InprocServer32` path does not exist. Check the file copy first (this bit us: a bad `unshield` run created empty directories). +- Generate registry `.reg` files with `printf`, never `echo`: dash's `echo` + interprets `\2` in `...\Watson\2.0\...` as 0x02, corrupting the key so that + `wine reg import` is a silent no-op (it still returns 0). The wrapper rejects + a generated profile containing control characters. - 32-bit engines require a 32-bit prefix (`WINEARCH=win32`). - `sudo` on this machine requires a password; installs are manual. diff --git a/docs/TTS-DESIGN.md b/docs/TTS-DESIGN.md index e4f403f..28a99f5 100644 --- a/docs/TTS-DESIGN.md +++ b/docs/TTS-DESIGN.md @@ -419,3 +419,59 @@ _Added 2026-09-15._ untouched, so the voice keeps its engine default. - **Licensing**: keep the engine DLLs and voice data local and out of any repository; Veskslop never bundles or redistributes them. + +## Current integration path (Veskslop side, 2026-09-15) + +Everything below is implemented. The engine stays unchanged; the wrapper and the daemon do the work. + +### Voice metadata + +`VESKSLOP_TTS_VOICES` entries may carry flags after the language field: + + id|Name|gender|language|flags + +Flags are `+`-separated: + +- `no-pitch` - hide the pitch control for this voice. +- `expression=NN` - Flextalk profile voice; NN is the default Expression value. +- `defaults=pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59` - + per-voice engine defaults. The UI shows "default" at these values and snaps to them + within +/-2 units. + +### Wrapper contract (`microslopsam/setup/veskslop-tts.sh`) + +Inputs: `{out}`, `{text_file}`, optional positional voice/speed/pitch/expression, and: + +| Env | Meaning | +| --- | --- | +| `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_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. +`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. + +### Daemon and UI path + +1. Preferences (`tts`): `profile` (changed keys only), `profilePresets` (5 named slots), + `highpass`, `echo`, `words`, `wordGapMs`, plus `speed`, `pitch`, `expression`, `voice`. +2. `/api/audio/speak` and MCP `speak` accept all of them per line; the request overrides the + preferences and empty fields are omitted so the engine default applies. +3. The TTS panel opens the **Flextalk voice profile** window (movable, recentered on open) with + profile sliders, named presets, radio-kit controls, and test lines (Attention, Laboratory, + Critical, Health, Radiation, plus two persisted custom lines). Test buttons post the same + options as `speak`, so they audition the current profile. + +### Known pitfalls + +- The daemon runs from `tsx`: restart it after protocol, preference, or metadata changes. +- Static web files can be cached; hard-reload the browser after UI changes. +- `Number(null)` is 0: preference normalizers must treat null/undefined/"" as unset. +- The renderer log spam on a dead client is throttled in the bridge; background polls fail with + `CORE_UNAVAILABLE` and retry. +- Generate the Flextalk profile `.reg` with `printf`, never `echo`: dash's `echo` interprets + `\2` in `...\Watson\2.0\...` as an escape (0x02), so the key is corrupted and + `wine reg import` becomes a silent no-op (it still returns 0). The wrapper now rejects a + generated profile that contains control characters. This was the 2026-09-15 profile bug. diff --git a/setup/install.sh b/setup/install.sh index b4f4852..2c06cdd 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -73,12 +73,12 @@ cat </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" diff --git a/setup/veskslop-tts.sh b/setup/veskslop-tts.sh new file mode 100755 index 0000000..6b96170 --- /dev/null +++ b/setup/veskslop-tts.sh @@ -0,0 +1,308 @@ +#!/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) +# +# 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] +# `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} + +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 +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" + +# 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 + + if ! command -v wine >/dev/null 2>&1; then + echo "wine is required to set the Flextalk profile" >&2 + exit 1 + fi + if ! wine reg import "$TMP/profile.reg" >/dev/null 2>&1; then + echo "could not write the Flextalk profile" >&2 + exit 1 + fi +fi + +# 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_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:-}" = ":77" ]; then + pkill -f "Xvfb :77" 2>/dev/null || true + rm -f /tmp/.X11-unix/X77 + 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. +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" "$word" + echo "file '$TMP/w$INDEX.wav'" >> "$TMP/list.txt" + + if [ "$GAP" -gt 0 ]; then + 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 ! 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=$(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"