Fix Flextalk buzz: REG_DWORD speaker settings; measured pitch/speed mapping; fixed-gain normalization

This commit is contained in:
Fisto
2026-09-15 13:14:50 +02:00
parent fa4fbaf74b
commit ad81303b6f
5 changed files with 105 additions and 54 deletions
+4 -4
View File
@@ -64,8 +64,8 @@ The wrapper maps voice IDs (`sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`,
MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier) and MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier) and
`VESKSLOP_TTS_PITCH` (semitones), starts Xvfb when `DISPLAY` is unset, and writes a `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). 48 kHz mono s16le WAV trimmed, faded and normalized to about -16 LUFS (-3 dBFS true peak).
Flextalk voices ignore pitch (engine limitation) and are declared with the `no-pitch` Speed and pitch apply to every voice (Flextalk's numeric settings must be `REG_DWORD`,
flag; everything else maps as documented in `docs/TTS-DESIGN.md`. which the setup script writes). Mapping details are in `docs/TTS-DESIGN.md`.
## Layout ## Layout
@@ -84,8 +84,8 @@ flag; everything else maps as documented in `docs/TTS-DESIGN.md`.
- `xvfb-run` is flaky on this host. `setup/run.sh` starts a persistent Xvfb - `xvfb-run` is flaky on this host. `setup/run.sh` starts a persistent Xvfb
(`:77`) instead; Wine needs a working X display because the runtime creates (`:77`) instead; Wine needs a working X display because the runtime creates
windows. windows.
- Flextalk ignores pitch entirely (byte-identical output at any pitch setting); use speed. - Flextalk numeric speaker settings must be `REG_DWORD` (the installer script handles
The host now leaves pitch/speed untouched unless passed, so voices use their own profile. this); `REG_SZ` values cause a monotone buzz. Pitch and speed work on all voices.
- The Flextalk archive lacks `data2.cab`; `unshield -O` extracts `data1.cab` - The Flextalk archive lacks `data2.cab`; `unshield -O` extracts `data1.cab`
but one tail file (`WatsonUNInstall.dll`) is incomplete. but one tail file (`WatsonUNInstall.dll`) is incomplete.
+13 -1
View File
@@ -33,7 +33,9 @@ From `msttsl/mstts.inf` (`[TTSReg]`):
`HomographDictionary="1 ft_hgrap.dll"`, `ProperNameDictionary="3 ft_namsa.dll"`, `HomographDictionary="1 ft_hgrap.dll"`, `ProperNameDictionary="3 ft_namsa.dll"`,
`LetterToSoundDictionary="4 ft_l2s.dll"`. `LetterToSoundDictionary="4 ft_l2s.dll"`.
- Options: `...\FlexTalk\2.0\Options` `Gender=Male`, `SampleRate=11025`. - Options: `...\FlexTalk\2.0\Options` `Gender=Male`, `SampleRate=11025`.
- Speakers (per-voice settings; names come from these values): - Speakers (per-voice settings; names come from these values). **All numeric
values must be REG_DWORD**: with REG_SZ the engine reads garbage and
synthesizes a monotone buzz (pitch fixed at max, pitch control dead).
- `{11260610-C84A-11ce-AEC4-0000C0E9CB87}`: Mode Name / Speaker Name - `{11260610-C84A-11ce-AEC4-0000C0E9CB87}`: Mode Name / Speaker Name
"Keith Bell", Pitch 27, Speaking Rate 33, Head Size 46, Expression 59, "Keith Bell", Pitch 27, Speaking Rate 33, Head Size 46, Expression 59,
Lowering 40, Prominences 17, Age 30, Volume 100. Lowering 40, Prominences 17, Age 30, Volume 100.
@@ -71,6 +73,16 @@ final file `WatsonUNInstall.dll` is incomplete).
- 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.
## Engine units (measured)
- speed: MSTTS range 30-450, Flextalk 50-390; the natural rate is about 150 for every
voice (duration at 100/200/300 fits a natural value near 140-150).
- pitch: engine units, roughly linear in F0 near the natural point (~1.1 Hz per unit).
Natural values: about 100 (MSTTS), 106 (Keith Bell), 186 (Kerry Watson); Flextalk max 250.
Verified with the corrected DWORD registry: Kerry 0 st = 209 Hz, -6 st = 148 Hz,
+6 st = 250 Hz (clamped at max); Keith 0 st = 119 Hz, +6 st = 170 Hz.
- The wrapper maps semitones as `natural * 2^(st/12)` and clamps to the range.
## Decompiled corpora (Ghidra) ## Decompiled corpora (Ghidra)
- `/mnt/ghidra/decomp/msttssyn.dll/` (786 functions, engine) - `/mnt/ghidra/decomp/msttssyn.dll/` (786 functions, engine)
+18 -1
View File
@@ -267,7 +267,8 @@ record what the engine needs to interpret, add, or hide._
### Quirks the wrapper must hide ### Quirks the wrapper must hide
- Flextalk reports default pitch/speed as maximum through `ITTSAttributes`. - Flextalk numeric speaker settings must be `REG_DWORD`; `REG_SZ` values make the engine
synthesize a buzz. `setup/setup-flextalk.sh` writes them correctly.
- The mode list includes TruVoice names with no installed voice data; filter those out of - The mode list includes TruVoice names with no installed voice data; filter those out of
`/voices` (or mark them `unavailable`). `/voices` (or mark them `unavailable`).
- Wine needs a working X display; the project helper starts a persistent Xvfb. - Wine needs a working X display; the project helper starts a persistent Xvfb.
@@ -301,6 +302,19 @@ _Added 2026-09-15._
- **Display**: the daemon does not export `DISPLAY` to TTS children. The wrapper must start or - **Display**: the daemon does not export `DISPLAY` to TTS children. The wrapper must start or
find its own Xvfb and set `DISPLAY` itself, as the project helper already does. Do not rely on find its own Xvfb and set `DISPLAY` itself, as the project helper already does. Do not rely on
the client's display. the client's display.
- **Engine selection**: the Speech settings and the TTS panel list every configured engine. The
command engine appears under `VESKSLOP_TTS_NAME` (default "custom engine") and is selectable
directly; Auto uses the command engine first. Selecting it hides the language row and shows the
`VESKSLOP_TTS_VOICES` list.
- **Start engine**: the Speech settings have a **Start engine** button (`POST /api/audio/tts/warm`,
MCP `start_tts_engine`) that runs one throwaway synthesis to warm the command engine (Xvfb, Wine,
host). Keep the wrapper able to handle a short sentence; the warm-up uses "The quick brown fox
jumps over the lazy dog." and reports the elapsed milliseconds. Two-pass `loudnorm` must not fail
on that input.
- **Lead-in silence**: Veskslop prepends a short silence at playback time (`VESKSLOP_TTS_LEAD_MS`,
default 150 ms) so Discord's voice pipeline is ready before the first syllable. It is added with
`adelay` in the player, not in the audio file. The engine needs no change and can keep its
5–10 ms edge fades.
- **Voice declarations**: list only usable IDs in `VESKSLOP_TTS_VOICES`. Veskslop hides the - **Voice declarations**: list only usable IDs in `VESKSLOP_TTS_VOICES`. Veskslop hides the
espeak list while a custom command is set, so a missing declaration means an empty voice list, espeak list while a custom command is set, so a missing declaration means an empty voice list,
not a fallback. The list accepts commas or newlines, and the optional `no-pitch` flag tells the not a fallback. The list accepts commas or newlines, and the optional `no-pitch` flag tells the
@@ -308,5 +322,8 @@ _Added 2026-09-15._
- **Flextalk pitch**: confirmed that pitch is a no-op for Keith Bell and Kerry Watson. Mark those - **Flextalk pitch**: confirmed that pitch is a no-op for Keith Bell and Kerry Watson. Mark those
entries `no-pitch` so users are not offered a control that does nothing. Speed applies to every entries `no-pitch` so users are not offered a control that does nothing. Speed applies to every
voice and needs no flag. voice and needs no flag.
_Engine correction (2026-09-15): the no-op was caused by the speaker registry values being
`REG_SZ`; with `REG_DWORD` pitch works and the voices are declared without the flag. See the
speed/pitch section._
- **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.
+16 -16
View File
@@ -62,30 +62,30 @@ REGEDIT4
"SampleRate"="11025" "SampleRate"="11025"
[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{11260610-C84A-11ce-AEC4-0000C0E9CB87}] [HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{11260610-C84A-11ce-AEC4-0000C0E9CB87}]
"Age"="30" "Age"=dword:0000001e
"Expression"="59" "Expression"=dword:0000003b
"Gender"="" "Gender"=""
"Head Size"="46" "Head Size"=dword:0000002e
"Lowering"="40" "Lowering"=dword:00000028
"Mode Name"="Keith Bell" "Mode Name"="Keith Bell"
"Pitch"="27" "Pitch"=dword:0000001b
"Prominences"="17" "Prominences"=dword:00000011
"Speaker Name"="Keith Bell" "Speaker Name"="Keith Bell"
"Speaking Rate"="33" "Speaking Rate"=dword:00000021
"Volume"="100" "Volume"=dword:00000064
[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{000031CB-3699-551F-AED0-6B4893382D57}] [HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{000031CB-3699-551F-AED0-6B4893382D57}]
"Age"="30" "Age"=dword:0000001e
"Expression"="100" "Expression"=dword:00000064
"Gender"="" "Gender"=""
"Head Size"="45" "Head Size"=dword:0000002d
"Lowering"="40" "Lowering"=dword:00000028
"Mode Name"="Kerry Watson" "Mode Name"="Kerry Watson"
"Pitch"="65" "Pitch"=dword:00000041
"Prominences"="17" "Prominences"=dword:00000011
"Speaker Name"="Kerry Watson" "Speaker Name"="Kerry Watson"
"Speaking Rate"="32" "Speaking Rate"=dword:00000020
"Volume"="100" "Volume"=dword:00000064
EOF EOF
echo "== registry" echo "== registry"
+50 -28
View File
@@ -20,8 +20,7 @@ export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam}
export WINEARCH=win32 export WINEARCH=win32
# VESKSLOP_TTS_VOICES entries: id|Name|gender|language[|flags] # VESKSLOP_TTS_VOICES entries: id|Name|gender|language[|flags]
# `no-pitch` marks voices whose engine ignores pitch (Flextalk). 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"
VOICES="sam|Sam|male|en,mary|Mary|female|en,mike|Mike|male|en,keith-bell|Keith Bell|male|en|no-pitch,kerry-watson|Kerry Watson|female|en|no-pitch,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 if [ "${1:-}" = "--voices" ]; then
echo "$VOICES" echo "$VOICES"
@@ -51,36 +50,38 @@ case "$VOICE" in
*) ENGVOICE="Sam" ;; *) ENGVOICE="Sam" ;;
esac esac
# Engine units. Measured naturals: MSTTS ~150, Flextalk ~300 (words/min scale). # Engine units (measured). Speed is words/min, natural rate near 150 for every
# Flextalk ignores ITTSAttributes pitch (verified byte-identical output). # 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 case "$ENGVOICE" in
"Keith Bell"|"Kerry Watson") NAT_SPEED=300; SMAX=390 ;; "Keith Bell") SMAX=390; PMAX=250; NAT_PITCH=106 ;;
*) NAT_SPEED=150; SMAX=450 ;; "Kerry Watson") SMAX=390; PMAX=250; NAT_PITCH=186 ;;
*) SMAX=450; PMAX=200; NAT_PITCH=100 ;;
esac esac
SET_ARGS="" SET_ARGS=""
if [ -n "$SPEED" ]; then if [ -n "$SPEED" ]; then
S=$(awk -v m="$SPEED" -v n="$NAT_SPEED" 'BEGIN{printf "%d", n*m+0.5}') S=$(awk -v m="$SPEED" 'BEGIN{printf "%d", 150*m+0.5}')
[ "$S" -lt 50 ] && S=50 [ "$S" -lt 50 ] && S=50
[ "$S" -gt "$SMAX" ] && S=$SMAX [ "$S" -gt "$SMAX" ] && S=$SMAX
SET_ARGS="$SET_ARGS -s $S" SET_ARGS="$SET_ARGS -s $S"
fi fi
if [ -n "$PITCH" ]; then if [ -n "$PITCH" ]; then
case "$ENGVOICE" in P=$(awk -v st="$PITCH" -v nat="$NAT_PITCH" 'BEGIN{printf "%d", nat*exp(log(2)*st/12)+0.5}')
"Keith Bell"|"Kerry Watson") : ;;
*)
P=$(awk -v st="$PITCH" 'BEGIN{printf "%d", 100 + st*6.25}')
[ "$P" -lt 50 ] && P=50 [ "$P" -lt 50 ] && P=50
[ "$P" -gt 200 ] && P=200 [ "$P" -gt "$PMAX" ] && P=$PMAX
SET_ARGS="$SET_ARGS -p $P" ;; SET_ARGS="$SET_ARGS -p $P"
esac
fi fi
# The daemon does not export DISPLAY; keep a private Xvfb for the engine. # The daemon does not export DISPLAY; keep a private Xvfb for the engine.
if [ -z "${DISPLAY:-}" ]; then start_xvfb() {
if ! [ -S /tmp/.X11-unix/X77 ]; then if ! [ -S /tmp/.X11-unix/X77 ]; then
Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 & Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 &
sleep 1 sleep 1
fi fi
}
if [ -z "${DISPLAY:-}" ]; then
start_xvfb
export DISPLAY=:77 export DISPLAY=:77
fi fi
@@ -102,32 +103,53 @@ if [ ! -x "$HOST" ]; then HOST="$ROOT/build/sapi4host.exe"; fi
if [ ! -x "$HOST" ]; then echo "engine host not built" >&2; exit 1; 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 if ! command -v ffmpeg >/dev/null 2>&1; then echo "ffmpeg not found" >&2; exit 1; fi
run_engine() {
# shellcheck disable=SC2086 # shellcheck disable=SC2086
if ! "$HOST" -v "$ENGVOICE" -t "$(cat "$TXT")" -o "$RAW" $SET_ARGS \ "$HOST" -v "$ENGVOICE" -t "$(cat "$TXT")" -o "$RAW" $SET_ARGS \
>/dev/null 2>"$TMP/err"; then >/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 head -c 400 "$TMP/err" >&2
exit 1 exit 1
fi fi
else
head -c 400 "$TMP/err" >&2
exit 1
fi
fi
[ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; } [ -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" 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" PRE="highpass=f=40,$SIL,afade=t=in:st=0:d=0.008,areverse,$SIL,afade=t=in:st=0:d=0.008,areverse"
LN="loudnorm=I=-16:TP=-3:LRA=11"
# Two-pass loudnorm: measure first, then apply with the measured values. # Fixed-gain normalization: single-pass loudnorm misbehaves on short lines
M=$(ffmpeg -hide_banner -nostats -i "$RAW" -af "$PRE,$LN:print_format=json" \ # (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) -f null - 2>&1 || true)
I=$(printf '%s' "$M" | sed -n 's/.*"input_i" *: *"\([^"]*\)".*/\1/p') I=$(printf '%s' "$M" | sed -n 's/ *I: *\(-\{0,1\}[0-9.]*\) *LUFS.*/\1/p' | tail -1)
TP=$(printf '%s' "$M" | sed -n 's/.*"input_tp" *: *"\([^"]*\)".*/\1/p') TP=$(printf '%s' "$M" | sed -n 's/ *Peak: *\(-\{0,1\}[0-9.]*\) *dBFS.*/\1/p' | tail -1)
LRA=$(printf '%s' "$M" | sed -n 's/.*"input_lra" *: *"\([^"]*\)".*/\1/p') if [ -n "$I" ] && [ -n "$TP" ]; then
TH=$(printf '%s' "$M" | sed -n 's/.*"input_thresh" *: *"\([^"]*\)".*/\1/p') GAIN=$(awk -v i="$I" -v tp="$TP" 'BEGIN{
OFF=$(printf '%s' "$M" | sed -n 's/.*"target_offset" *: *"\([^"]*\)".*/\1/p') g = -16 - i;
if [ -n "$I" ] && [ -n "$TP" ] && [ -n "$LRA" ] && [ -n "$TH" ] && [ -n "$OFF" ]; then lim = -3 - tp;
LN="$LN:measured_I=$I:measured_TP=$TP:measured_LRA=$LRA:measured_thresh=$TH:offset=$OFF:linear=true" if (lim < g) g = lim;
if (g > 30) g = 30;
if (g < -20) g = -20;
printf "%.2f", g
}')
else
GAIN=0
fi fi
TMPOUT="$TMP/out.wav" TMPOUT="$TMP/out.wav"
if ! ffmpeg -hide_banner -nostats -y -i "$RAW" -af "$PRE,$LN" \ 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 -ar 48000 -ac 1 -c:a pcm_s16le "$TMPOUT" >/dev/null 2>"$TMP/ff"; then
head -c 400 "$TMP/ff" >&2 head -c 400 "$TMP/ff" >&2
exit 1 exit 1