Sync with Veskslop integration: Flextalk profile support, words/echo/highpass, wrapper rename
This commit is contained in:
@@ -51,15 +51,19 @@ Options: `-v` voice, `-t` text, `-o` output WAV, `-p` pitch, `-s` speed, `-V` vo
|
|||||||
|
|
||||||
## Veskslop integration
|
## Veskslop integration
|
||||||
|
|
||||||
export VESKSLOP_TTS_CMD="$PWD/setup/vekslop-tts.sh {out} {text_file}"
|
export VESKSLOP_TTS_CMD="$PWD/setup/veskslop-tts.sh {out} {text_file}"
|
||||||
export VESKSLOP_TTS_VOICES="$(setup/vekslop-tts.sh --voices)"
|
export VESKSLOP_TTS_VOICES="$(setup/veskslop-tts.sh --voices)"
|
||||||
|
|
||||||
The wrapper maps voice IDs (`sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`, plus the
|
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
|
MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier),
|
||||||
`VESKSLOP_TTS_PITCH` (semitones), starts Xvfb when `DISPLAY` is unset, and writes a
|
`VESKSLOP_TTS_PITCH` (semitones) and `VESKSLOP_TTS_EXPRESSION` (0-100, Flextalk voices
|
||||||
48 kHz mono s16le WAV trimmed, faded and normalized to about -16 LUFS (-3 dBFS true peak).
|
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`,
|
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
|
## 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/setup-flextalk.sh` — Flextalk files + registry
|
||||||
- `setup/inf2reg.py` — INF AddReg -> `.reg` converter
|
- `setup/inf2reg.py` — INF AddReg -> `.reg` converter
|
||||||
- `setup/run.sh` — run helper (prefix + Xvfb)
|
- `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/NOTES.md` — registry/CLSID facts, runtime RE notes, gotchas
|
||||||
- `docs/TTS-DESIGN.md` — integration contract with Veskslop plus engine clarifications
|
- `docs/TTS-DESIGN.md` — integration contract with Veskslop plus engine clarifications
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -42,8 +42,6 @@ From `msttsl/mstts.inf` (`[TTSReg]`):
|
|||||||
- `{000031CB-3699-551F-AED0-6B4893382D57}`: "Kerry Watson", Pitch 65,
|
- `{000031CB-3699-551F-AED0-6B4893382D57}`: "Kerry Watson", Pitch 65,
|
||||||
Speaking Rate 32, Head Size 45, Expression 100, Lowering 40,
|
Speaking Rate 32, Head Size 45, Expression 100, Lowering 40,
|
||||||
Prominences 17, Age 30, Volume 100.
|
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
|
- Voice data: `EngineData\AE1M08LC.*`, `AE1M11LC.*` (male, 8/11 kHz) and
|
||||||
`AE2F08LC.*`, `AE2F11LC.*` (female); `.INV` inventory, `.NDX` index,
|
`AE2F08LC.*`, `AE2F11LC.*` (female); `.INV` inventory, `.NDX` index,
|
||||||
`.COD` codebook, plus shared `DICT_CI.*`, `phondef`, `AMPRULES`, `ENDINGS`.
|
`.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
|
- 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
|
the `InprocServer32` path does not exist. Check the file copy first
|
||||||
(this bit us: a bad `unshield` run created empty directories).
|
(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`).
|
- 32-bit engines require a 32-bit prefix (`WINEARCH=win32`).
|
||||||
- `sudo` on this machine requires a password; installs are manual.
|
- `sudo` on this machine requires a password; installs are manual.
|
||||||
|
|
||||||
|
|||||||
@@ -419,3 +419,59 @@ _Added 2026-09-15._
|
|||||||
untouched, so the voice keeps its engine default.
|
untouched, so the voice keeps its engine default.
|
||||||
- **Licensing**: keep the engine DLLs and voice data local and out of any repository; Veskslop
|
- **Licensing**: keep the engine DLLs and voice data local and out of any repository; Veskslop
|
||||||
never bundles or redistributes them.
|
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.
|
||||||
|
|||||||
+3
-3
@@ -73,12 +73,12 @@ cat <<EOF
|
|||||||
Done.
|
Done.
|
||||||
|
|
||||||
Veskslop configuration:
|
Veskslop configuration:
|
||||||
VESKSLOP_TTS_CMD="$HERE/vekslop-tts.sh {out} {text_file}"
|
VESKSLOP_TTS_CMD="$HERE/veskslop-tts.sh {out} {text_file}"
|
||||||
VESKSLOP_TTS_VOICES="\$($HERE/vekslop-tts.sh --voices)"
|
VESKSLOP_TTS_VOICES="\$($HERE/veskslop-tts.sh --voices)"
|
||||||
|
|
||||||
Manual test:
|
Manual test:
|
||||||
$HERE/run.sh -v Sam -t "Hello." -o /tmp/hello.wav
|
$HERE/run.sh -v Sam -t "Hello." -o /tmp/hello.wav
|
||||||
|
|
||||||
Normalized 48 kHz output (what Veskslop hears):
|
Normalized 48 kHz output (what Veskslop hears):
|
||||||
VESKSLOP_TTS_VOICE=keith-bell $HERE/vekslop-tts.sh /tmp/keith.wav /tmp/text.txt
|
VESKSLOP_TTS_VOICE=keith-bell $HERE/veskslop-tts.sh /tmp/keith.wav /tmp/text.txt
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Veskslop TTS command-provider wrapper for MicroslopSam.
|
|
||||||
#
|
|
||||||
# Recommended Veskslop configuration:
|
|
||||||
# VESKSLOP_TTS_CMD="<repo>/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"
|
|
||||||
Executable
+308
@@ -0,0 +1,308 @@
|
|||||||
|
#!/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_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"
|
||||||
Reference in New Issue
Block a user