Add crunch presets, echo options, and streaming word mode

This commit is contained in:
Fisto
2026-09-21 12:31:43 +02:00
parent bbb580deea
commit c66d00ee59
11 changed files with 371 additions and 17 deletions
+10
View File
@@ -40,6 +40,16 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C67 Crunch, echo options, and streaming word mode. The wrapper gained a separate
compression stage applied last (presets hl90/hl90hq/telephone/adpcm/crush plus bit-depth and
rate overrides, output still 48 kHz s16le), echo delay/level knobs, and `VESKSLOP_TTS_STREAM=1`
word mode that writes the WAV as words finish. The daemon carries `tts.crunch`,
`tts.crunchBits`, `tts.crunchRate`, `tts.echoDelayMs`, `tts.echoLevel`, and `tts.streamWords`
(default on), with per-line overrides, env exports, placeholders, MCP `speak` params, and
Radio-kit controls. `audio-service` starts playback as soon as the streaming file has data.
Verified with the real engine: presets differ, stay 48 kHz; streaming wrote 5 gaps for 6
words; daemon first sound 0.5 s (streaming) vs 2.4 s (classic) on a ten-word line. Wrapper
tests cover crunch and streaming.
- [x] C66 Merged the SAM engine into Veskslop. `microslopsam/` is now a full copy of the - [x] C66 Merged the SAM engine into Veskslop. `microslopsam/` is now a full copy of the
standalone project as it ran (wrapper, docs, README, build artifacts, and the vendored standalone project as it ran (wrapper, docs, README, build artifacts, and the vendored
binaries SAM tracks), the runtime env points at `microslopsam/setup/veskslop-tts.sh` with binaries SAM tracks), the runtime env points at `microslopsam/setup/veskslop-tts.sh` with
+10
View File
@@ -163,6 +163,16 @@ from the TTS panel:
so the parser is skipped and the vendor tags go on every word (`\!R` rate, `\!%` pitch). VOX so the parser is skipped and the vendor tags go on every word (`\!R` rate, `\!%` pitch). VOX
needs `python3` on the host. Both travel to the command provider as needs `python3` on the host. Both travel to the command provider as
`VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders). `VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders).
A **crunch** stage adds the lossy 1990s codec character as its own step **after** the rest of the
chain (echo tails included): `tts.crunch` presets `hl90` (8-bit, 11 kHz), `hl90hq` (8-bit,
22 kHz), `telephone` (8 kHz band-limited), `adpcm` (4-bit) and `crush` (custom), with optional
`tts.crunchBits` (1-16) and `tts.crunchRate` (4-48 kHz) overrides. The output stays 48 kHz
s16le, so only the character changes. Echo gained `tts.echoDelayMs` (10-400) and
`tts.echoLevel` (0-0.95) alongside the amount. **Stream word mode** (`tts.streamWords`, on by
default) makes the wrapper write the WAV while it synthesizes, so playback starts on the first
word: measured first sound 0.5 s instead of 2.4 s on a ten-word line. Streaming skips the
loudness measurement (`VESKSLOP_TTS_STREAM_GAIN_DB` compensates if needed) and cannot use VOX
prosody, which needs the whole sentence.
A **preset** select (`tts.preset`: factory, VOX, HEV) shifts the Keith Bell speaker profile to A **preset** select (`tts.preset`: factory, VOX, HEV) shifts the Keith Bell speaker profile to
the Half-Life approximation; `vox` also implies VOX prosody unless VOX is explicitly off, and the Half-Life approximation; `vox` also implies VOX prosody unless VOX is explicitly off, and
overrides travel as `VESKSLOP_TTS_PRESET` / `{preset}`. Precedence is preset < profile < overrides travel as `VESKSLOP_TTS_PRESET` / `{preset}`. Precedence is preset < profile <
+17
View File
@@ -119,6 +119,23 @@ VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_PRESET=vox VESKSLOP_TTS_WORDS=1 \
check "implied VOX yields to word mode" "3" "$(grep -c '^ARGV:' "$STUB_LOG")" check "implied VOX yields to word mode" "3" "$(grep -c '^ARGV:' "$STUB_LOG")"
check "implied VOX skipped for words" "0" "$(grep -c 'wH1' "$STUB_LOG")" check "implied VOX skipped for words" "0" "$(grep -c 'wH1' "$STUB_LOG")"
# Crunch keeps the output contract and changes the sound.
VESKSLOP_TTS_VOICE=keith-bell run_wrapper
cp "$WORK/out.wav" "$WORK/off.wav"
VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_CRUNCH=telephone run_wrapper
check "crunch keeps 48k s16le" "pcm_s16le,48000,1" \
"$("${MICROSLOP_FFMPEG:-ffmpeg}" -hide_banner -loglevel error -i "$WORK/out.wav" -f null - 2>&1 >/dev/null; ffprobe -v error -show_entries stream=codec_name,sample_rate,channels -of csv=p=0 "$WORK/out.wav")"
check "crunch changes the audio" "1" "$([ "$(md5sum < "$WORK/off.wav")" != "$(md5sum < "$WORK/out.wav")" ] && echo 1 || echo 0)"
# Streaming word mode writes a live WAV with a gap between each pair of words.
VESKSLOP_TTS_VOICE=keith-bell VESKSLOP_TTS_WORDS=1 VESKSLOP_TTS_WORD_GAP_MS=200 \
VESKSLOP_TTS_STREAM=1 run_wrapper
check "stream header" "RIFF" "$(head -c 4 "$WORK/out.wav")"
check "stream is decodable" "0" \
"$("${MICROSLOP_FFMPEG:-ffmpeg}" -hide_banner -loglevel error -i "$WORK/out.wav" -f null - >/dev/null 2>&1; echo $?)"
check "stream has a gap per word break" "2" \
"$("${MICROSLOP_FFMPEG:-ffmpeg}" -hide_banner -i "$WORK/out.wav" -af silencedetect=noise=-45dB:d=0.15 -f null - 2>&1 | grep -c silence_start)"
# Voice metadata follows the active preset. # Voice metadata follows the active preset.
check "voices reflect hev" "1" \ check "voices reflect hev" "1" \
"$(VESKSLOP_TTS_PRESET=hev sh "$WRAP" --voices | tr ',' '\n' | grep -c 'keith-bell|Keith Bell|male|en|expression=59+defaults=pitch:15:rate:24')" "$(VESKSLOP_TTS_PRESET=hev sh "$WRAP" --voices | tr ',' '\n' | grep -c 'keith-bell|Keith Bell|male|en|expression=59+defaults=pitch:15:rate:24')"
+129 -4
View File
@@ -16,6 +16,14 @@
# prominences, head_size, lowering # prominences, head_size, lowering
# VESKSLOP_TTS_HIGHPASS high-pass Hz (40-400, default 40) # VESKSLOP_TTS_HIGHPASS high-pass Hz (40-400, default 40)
# VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0) # VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0)
# VESKSLOP_TTS_ECHO_DELAY_MS echo delay 10-400 (default 60)
# VESKSLOP_TTS_ECHO_LEVEL echo output gain 0-0.95 (default 0.85)
# VESKSLOP_TTS_CRUNCH off | hl90 | hl90hq | telephone | adpcm | crush
# (1990s lossy character, applied last)
# VESKSLOP_TTS_CRUNCH_BITS 1-16, overrides the preset bit depth
# VESKSLOP_TTS_CRUNCH_RATE 4000-48000 Hz, overrides the preset rate
# VESKSLOP_TTS_STREAM 1 = word mode writes the WAV as it goes
# VESKSLOP_TTS_STREAM_GAIN_DB fixed gain for streaming (no measurement)
# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk); # VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk);
# combines with a preset and wins over VOX prosody # combines with a preset and wins over VOX prosody
# VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500) # VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500)
@@ -62,6 +70,13 @@ EXPRESSION=${VESKSLOP_TTS_EXPRESSION:-${6:-}}
PROFILE=${VESKSLOP_TTS_PROFILE:-} PROFILE=${VESKSLOP_TTS_PROFILE:-}
HIGHPASS=${VESKSLOP_TTS_HIGHPASS:-40} HIGHPASS=${VESKSLOP_TTS_HIGHPASS:-40}
ECHO=${VESKSLOP_TTS_ECHO:-0} ECHO=${VESKSLOP_TTS_ECHO:-0}
ECHO_DELAY=${VESKSLOP_TTS_ECHO_DELAY_MS:-60}
ECHO_LEVEL=${VESKSLOP_TTS_ECHO_LEVEL:-0.85}
CRUNCH=${VESKSLOP_TTS_CRUNCH:-off}
CRUNCH_BITS=${VESKSLOP_TTS_CRUNCH_BITS:-}
CRUNCH_RATE=${VESKSLOP_TTS_CRUNCH_RATE:-}
STREAM=${VESKSLOP_TTS_STREAM:-0}
STREAM_GAIN=${VESKSLOP_TTS_STREAM_GAIN_DB:-0}
WORDS=${VESKSLOP_TTS_WORDS:-0} WORDS=${VESKSLOP_TTS_WORDS:-0}
WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120} WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120}
VOX=${VESKSLOP_TTS_VOX:-0} VOX=${VESKSLOP_TTS_VOX:-0}
@@ -408,6 +423,14 @@ try_run_host() {
exit 1 exit 1
} }
# Streaming word mode writes each word as it is synthesized (see below), after the
# filter chain exists, so the classic all-at-once path is skipped for it.
STREAM_WORDS=0
if [ "$STREAM" = "1" ] && [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ]; then
STREAM_WORDS=1
fi
if [ "$STREAM_WORDS" != "1" ]; then
# Word mode (Flextalk): synthesize each word and merge with silence gaps, which # 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. # gives the staccato radio cadence the Half-Life VOX/HEV voices are known for.
# It combines with a preset (the profile is already written) and takes precedence # It combines with a preset (the profile is already written) and takes precedence
@@ -442,6 +465,7 @@ else
try_run_host "$RAW" "$TAG_PREFIX$(cat "$TXT")" try_run_host "$RAW" "$TAG_PREFIX$(cat "$TXT")"
fi fi
[ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; } [ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; }
fi
# Filter chain: adjustable high-pass, silence trim, fades, optional mono echo. # Filter chain: adjustable high-pass, silence trim, fades, optional mono echo.
case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac
@@ -451,17 +475,56 @@ case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac
case "$ECHO" in *[!0-9]*|"") ECHO=0 ;; esac case "$ECHO" in *[!0-9]*|"") ECHO=0 ;; esac
[ "$ECHO" -gt 100 ] && ECHO=100 [ "$ECHO" -gt 100 ] && ECHO=100
case "$ECHO_DELAY" in *[!0-9]*|"") ECHO_DELAY=60 ;; esac
[ "$ECHO_DELAY" -lt 10 ] && ECHO_DELAY=10
[ "$ECHO_DELAY" -gt 400 ] && ECHO_DELAY=400
case "$ECHO_LEVEL" in *[!0-9.]*|""|".") ECHO_LEVEL=0.85 ;; esac
ECHO_LEVEL=$(awk -v v="$ECHO_LEVEL" 'BEGIN{ if (v<0) v=0; if (v>0.95) v=0.95; printf "%.2f", v }')
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=$HIGHPASS,$SIL,afade=t=in:st=0:d=0.008,areverse,$SIL,afade=t=in:st=0:d=0.008,areverse" 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 if [ "$ECHO" -gt 0 ]; then
DECAY=$(awk -v e="$ECHO" 'BEGIN{printf "%.2f", 0.2 + e * 0.006}') DECAY=$(awk -v e="$ECHO" 'BEGIN{printf "%.2f", 0.2 + e * 0.006}')
PRE="$PRE,aecho=0.8:0.85:60:${DECAY}" PRE="$PRE,aecho=0.8:${ECHO_LEVEL}:${ECHO_DELAY}:${DECAY}"
fi
# Compression stage ("crunch"): the lossy 1990s codec character, built as its own
# stage that runs after the rest of the chain so it colours the echo tails too. The
# signal is quantized at a low rate / bit depth and comes back to 48 kHz so the
# daemon's output contract holds.
CRUNCH_FILTERS=""
case "$CRUNCH" in
hl90|vox90) CRUNCH_BAND="highpass=f=200,lowpass=f=5000"; CRUNCH_RATE=${CRUNCH_RATE:-11025}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;;
hl90hq) CRUNCH_BAND="highpass=f=200,lowpass=f=7000"; CRUNCH_RATE=${CRUNCH_RATE:-22050}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;;
telephone|phone) CRUNCH_BAND="highpass=f=300,lowpass=f=3400"; CRUNCH_RATE=${CRUNCH_RATE:-8000}; CRUNCH_BITS=${CRUNCH_BITS:-8} ;;
adpcm) CRUNCH_BAND="highpass=f=200,lowpass=f=5000"; CRUNCH_RATE=${CRUNCH_RATE:-11025}; CRUNCH_BITS=${CRUNCH_BITS:-4} ;;
crush|custom) CRUNCH_BAND="${VESKSLOP_TTS_CRUNCH_BAND:-}"; CRUNCH_RATE=${CRUNCH_RATE:-22050}; CRUNCH_BITS=${CRUNCH_BITS:-6} ;;
off|"") ;;
*) echo "unknown crunch preset: $CRUNCH" >&2; exit 2 ;;
esac
if [ "$CRUNCH" != "off" ] && [ -n "$CRUNCH" ]; then
case "$CRUNCH_RATE" in *[!0-9]*|"") CRUNCH_RATE=22050 ;; esac
[ "$CRUNCH_RATE" -lt 4000 ] && CRUNCH_RATE=4000
[ "$CRUNCH_RATE" -gt 48000 ] && CRUNCH_RATE=48000
case "$CRUNCH_BITS" in *[!0-9]*|"") CRUNCH_BITS=8 ;; esac
[ "$CRUNCH_BITS" -lt 1 ] && CRUNCH_BITS=1
[ "$CRUNCH_BITS" -gt 16 ] && CRUNCH_BITS=16
[ -n "$CRUNCH_BAND" ] && CRUNCH_FILTERS="$CRUNCH_BAND,"
CRUNCH_FILTERS="${CRUNCH_FILTERS}aresample=$CRUNCH_RATE"
if [ "$CRUNCH_BITS" = "8" ]; then
CRUNCH_FILTERS="$CRUNCH_FILTERS,aformat=sample_fmts=u8"
else
CRUNCH_FILTERS="$CRUNCH_FILTERS,acrusher=bits=$CRUNCH_BITS:mode=log:mix=1"
fi
CRUNCH_FILTERS="$CRUNCH_FILTERS,aresample=48000,aformat=sample_fmts=s16"
fi fi
# Fixed-gain normalization: single-pass loudnorm misbehaves on short lines # Fixed-gain normalization: single-pass loudnorm misbehaves on short lines
# (LRA=0 causes heavy attenuation), so measure EBU R128 integrated loudness and # (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. # 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" \ MEASURE_CHAIN="$PRE"
[ -n "$CRUNCH_FILTERS" ] && MEASURE_CHAIN="$PRE,$CRUNCH_FILTERS"
M=$("$MICROSLOP_FFMPEG" -hide_banner -nostats -i "$RAW" -af "$MEASURE_CHAIN,ebur128=peak=true" \
-f null - 2>&1 || true) -f null - 2>&1 || true)
I=$(printf '%s' "$M" | sed -n 's/ *I: *\(-\{0,1\}[0-9.]*\) *LUFS.*/\1/p' | tail -1) 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) TP=$(printf '%s' "$M" | sed -n 's/ *Peak: *\(-\{0,1\}[0-9.]*\) *dBFS.*/\1/p' | tail -1)
@@ -478,6 +541,68 @@ else
GAIN=0 GAIN=0
fi fi
# Streaming word mode: write a WAV header with unknown sizes, then append each
# word's PCM as it is ready, so a player can start on the first word instead of
# waiting for the whole line. The loudness measurement is skipped; set
# VESKSLOP_TTS_STREAM_GAIN_DB if the engine levels need a nudge.
if [ "$STREAM_WORDS" = "1" ]; then
SGAIN=""
case "$STREAM_GAIN" in
''|*[!0-9.-]*|'.'|'-') ;;
*) SGAIN=",volume=${STREAM_GAIN}dB" ;;
esac
# RIFF/????/WAVE, fmt 16 bytes, PCM mono 48000 Hz s16le, data/????
printf 'RIFF\377\377\377\377WAVEfmt \020\000\000\000\001\000\001\000\200\273\000\000\000w\001\000\002\000\020\000data\377\377\377\377' > "$OUT" \
|| { echo "could not write $OUT" >&2; exit 1; }
set -f
# shellcheck disable=SC2046
set -- $(cat "$TXT")
set +f
WORD_COUNT=$#
INDEX=0
GAP=$WORD_GAP
case "$GAP" in *[!0-9]*|"") GAP=120 ;; esac
[ "$GAP" -gt 500 ] && GAP=500
# Leading hum, written once before the first word (same settings as the classic path).
if [ "$HUM_MS" -gt 0 ]; then
HUM_S=$(awk -v ms="$HUM_MS" 'BEGIN{printf "%.3f", ms/1000}')
HUM_HZ_C=$(awk -v v="$HUM_HZ" 'BEGIN{ if (v<30) v=30; if (v>200) v=200; printf "%.1f", v }')
HUM_DB_C=$(awk -v v="$HUM_DB" 'BEGIN{ if (v>-6) v=-6; if (v<-80) v=-80; printf "%.1f", v }')
HUM_AMP_C=$(awk -v db="$HUM_DB_C" 'BEGIN{printf "%.6f", 10^(db/20)}')
if "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -f lavfi \
-i "aevalsrc=exprs='$HUM_AMP_C*sin(2*PI*$HUM_HZ_C*t)':s=48000:d=$HUM_S" \
-f s16le -ac 1 -ar 48000 "$TMP/hum.raw"; then
cat "$TMP/hum.raw" >> "$OUT"
fi
fi
for word in "$@"; do
INDEX=$((INDEX + 1))
try_run_host "$TMP/sw$INDEX.wav" "${TAG_PREFIX}${word}"
if ! "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -y -i "$TMP/sw$INDEX.wav" \
-af "$PRE$SGAIN${CRUNCH_FILTERS:+,$CRUNCH_FILTERS}" \
-f s16le -ac 1 -ar 48000 "$TMP/sw$INDEX.raw"; then
echo "streaming post-processing failed" >&2
exit 1
fi
cat "$TMP/sw$INDEX.raw" >> "$OUT" 2>/dev/null || { echo "stream write failed" >&2; exit 1; }
if [ "$GAP" -gt 0 ] && [ "$INDEX" -lt "$WORD_COUNT" ]; then
if "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -y -f lavfi -i "anullsrc=r=48000:cl=mono" \
-t "$(awk -v g="$GAP" 'BEGIN{printf "%.3f", g/1000}')" \
-f s16le -ac 1 -ar 48000 "$TMP/gap.raw"; then
cat "$TMP/gap.raw" >> "$OUT"
fi
fi
done
[ "$INDEX" -gt 0 ] || { echo "no words to stream" >&2; exit 1; }
exit 0
fi
TMPOUT="$TMP/out.wav" TMPOUT="$TMP/out.wav"
# Leading hum: a quiet low tone in the pre-speech padding, to prime Discord's # 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 # audio pipeline so the first syllable is not gated. Level/frequency/duration
@@ -498,12 +623,12 @@ if [ "$HUM_MS" -gt 0 ]; then
if ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y \ 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" \ -f lavfi -i "aevalsrc=exprs='$HUM_AMP*sin(2*PI*$HUM_HZ*t)':s=48000:d=$HUM_S" \
-i "$RAW" \ -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]" \ -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,${CRUNCH_FILTERS:+$CRUNCH_FILTERS,}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 -map "[out]" -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
fi fi
elif ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y -i "$RAW" -af "$PRE,volume=${GAIN}dB" \ elif ! "$MICROSLOP_FFMPEG" -hide_banner -nostats -y -i "$RAW" -af "$PRE,volume=${GAIN}dB,${CRUNCH_FILTERS:+$CRUNCH_FILTERS,}aresample=48000,aformat=sample_fmts=s16" \
-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
+23 -1
View File
@@ -2025,6 +2025,12 @@ function fillProfileControls() {
const echo = Number(state.speech.echo); const echo = Number(state.speech.echo);
$("#tts-echo").value = Number.isFinite(echo) && state.speech.echo !== null ? echo : 0; $("#tts-echo").value = Number.isFinite(echo) && state.speech.echo !== null ? echo : 0;
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off"; $("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-echo-delay").value = state.speech.echoDelayMs ?? "";
$("#tts-echo-level").value = state.speech.echoLevel ?? "";
$("#tts-crunch").value = state.speech.crunch ?? "";
$("#tts-crunch-bits").value = state.speech.crunchBits ?? "";
$("#tts-crunch-rate").value = state.speech.crunchRate ?? "";
$("#tts-stream-words").checked = state.speech.streamWords !== false;
$("#tts-words").checked = state.speech.words === true; $("#tts-words").checked = state.speech.words === true;
$("#tts-preset").value = state.speech.preset ?? ""; $("#tts-preset").value = state.speech.preset ?? "";
@@ -2065,7 +2071,13 @@ function effectOptions() {
wordGapMs: Number($("#tts-word-gap").value), wordGapMs: Number($("#tts-word-gap").value),
vox: $("#tts-vox").checked, vox: $("#tts-vox").checked,
voxClean: $("#tts-vox-clean").checked, voxClean: $("#tts-vox-clean").checked,
preset: $("#tts-preset").value || null preset: $("#tts-preset").value || null,
echoDelayMs: $("#tts-echo-delay").value ? Number($("#tts-echo-delay").value) : null,
echoLevel: $("#tts-echo-level").value ? Number($("#tts-echo-level").value) : null,
crunch: $("#tts-crunch").value || null,
crunchBits: $("#tts-crunch-bits").value ? Number($("#tts-crunch-bits").value) : null,
crunchRate: $("#tts-crunch-rate").value ? Number($("#tts-crunch-rate").value) : null,
streamWords: $("#tts-stream-words").checked
}; };
} }
function syncVoiceControls(voices, selected) { function syncVoiceControls(voices, selected) {
@@ -2755,6 +2767,16 @@ function init() {
void saveSpeech(); void saveSpeech();
}); });
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech()); $("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
for (const id of [
"#tts-echo-delay",
"#tts-echo-level",
"#tts-crunch",
"#tts-crunch-bits",
"#tts-crunch-rate",
"#tts-stream-words"
]) {
$(id).addEventListener("change", () => void saveSpeech());
}
$("#profile-open").addEventListener("click", () => { $("#profile-open").addEventListener("click", () => {
const panel = document.querySelector(".vk-profile-panel"); const panel = document.querySelector(".vk-profile-panel");
if (panel) { if (panel) {
+18
View File
@@ -550,6 +550,21 @@
<span class="vk-micro-label">echo</span> <span class="vk-micro-label">echo</span>
<input id="tts-echo" type="range" min="0" max="100" step="5" value="0" /> <input id="tts-echo" type="range" min="0" max="100" step="5" value="0" />
<span id="tts-echo-value" class="vk-mono vk-dim">off</span> <span id="tts-echo-value" class="vk-mono vk-dim">off</span>
<input id="tts-echo-delay" class="input vk-num" type="number" min="10" max="400" placeholder="ms" />
<input id="tts-echo-level" class="input vk-num" type="number" min="0" max="0.95" step="0.05" placeholder="lvl" />
</div>
<div class="vk-profile-row">
<span class="vk-micro-label">crunch</span>
<select id="tts-crunch" class="input">
<option value="">off</option>
<option value="hl90">HL90 · 8-bit 11 kHz</option>
<option value="hl90hq">HL90 HQ · 8-bit 22 kHz</option>
<option value="telephone">telephone · 8 kHz</option>
<option value="adpcm">ADPCM · 4-bit</option>
<option value="crush">crush · custom</option>
</select>
<input id="tts-crunch-bits" class="input vk-num" type="number" min="1" max="16" placeholder="bits" />
<input id="tts-crunch-rate" class="input vk-num" type="number" min="4000" max="48000" step="250" placeholder="Hz" />
</div> </div>
<div class="vk-profile-row"> <div class="vk-profile-row">
<label class="vk-tts-check"><input id="tts-words" type="checkbox" /> word mode</label> <label class="vk-tts-check"><input id="tts-words" type="checkbox" /> word mode</label>
@@ -577,6 +592,9 @@
<label class="vk-tts-check"> <label class="vk-tts-check">
<input id="tts-vox-clean" type="checkbox" /> clean (no stutter) <input id="tts-vox-clean" type="checkbox" /> clean (no stutter)
</label> </label>
<label class="vk-tts-check" title="Write the WAV as words are synthesized so playback starts on the first word. Skips the loudness measurement; set a fixed gain below if needed.">
<input id="tts-stream-words" type="checkbox" /> stream word mode
</label>
</div> </div>
</section> </section>
<section class="vk-profile-section"> <section class="vk-profile-section">
+33 -1
View File
@@ -6,6 +6,7 @@
* them in the transcript, and runs speech to text when a provider is set. * them in the transcript, and runs speech to text when a provider is set.
*/ */
import { statSync } from "node:fs";
import { AudioManager } from "./audio.js"; import { AudioManager } from "./audio.js";
import type { CaptureSegment } from "./capture.js"; import type { CaptureSegment } from "./capture.js";
import { CapturePipeline } from "./capture.js"; import { CapturePipeline } from "./capture.js";
@@ -146,6 +147,19 @@ export class AudioService {
return { queued: true, id, position: this.queue.length }; return { queued: true, id, position: this.queue.length };
} }
/** Wait for the wrapper's streaming file to appear, then play it while it grows. */
private async playWhenReady(file: string): Promise<void> {
for (let attempt = 0; attempt < 400; attempt++) {
try {
if (statSync(file).size > 44) break;
} catch {
// not created yet
}
await new Promise(resolve => setTimeout(resolve, 25));
}
await this.manager.play(file);
}
stop(): void { stop(): void {
this.queue.length = 0; this.queue.length = 0;
this.manager.stopPlayback(); this.manager.stopPlayback();
@@ -350,9 +364,27 @@ export class AudioService {
this.playing = true; this.playing = true;
try { try {
const file = await this.tts.synthesize(item.text, item.options); // Streaming word mode: the wrapper writes the WAV while it synthesizes, so
// playback starts on the first word instead of after the whole line.
let streaming: Promise<void> | null = null;
const options: TtsOptions = { ...item.options };
if (item.options.stream === true) {
options.onOutputPath = path => {
streaming = (async () => {
await this.applyDuck(this.shouldDuck());
await this.playWhenReady(path);
})();
};
}
const file = await this.tts.synthesize(item.text, options);
const pending: Promise<void> | null = streaming;
if (pending) {
await pending;
} else {
await this.applyDuck(this.shouldDuck()); await this.applyDuck(this.shouldDuck());
await this.manager.play(file); await this.manager.play(file);
}
} catch (error) { } catch (error) {
console.error("[audio] speak failed:", (error as Error).message); console.error("[audio] speak failed:", (error as Error).message);
} finally { } finally {
+21 -3
View File
@@ -567,7 +567,17 @@ function buildServer(): McpServer {
preset: z preset: z
.enum(["vox", "hev"]) .enum(["vox", "hev"])
.optional() .optional()
.describe("Half-Life profile preset for the Keith Bell voice") .describe("Half-Life profile preset for the Keith Bell voice"),
crunch: z
.enum(["hl90", "hl90hq", "telephone", "adpcm", "crush"])
.optional()
.describe("Lossy 1990s compression stage, applied last"),
crunchBits: z.number().min(1).max(16).optional().describe("Crunch bit depth"),
crunchRate: z.number().min(4000).max(48000).optional().describe("Crunch sample rate"),
streamWords: z
.boolean()
.optional()
.describe("Word mode: stream audio so playback starts on the first word")
} }
}, },
async ({ async ({
@@ -584,7 +594,11 @@ function buildServer(): McpServer {
wordGapMs, wordGapMs,
vox, vox,
voxClean, voxClean,
preset preset,
crunch,
crunchBits,
crunchRate,
streamWords
}) => }) =>
jsonText( jsonText(
await audio.speak(text, { await audio.speak(text, {
@@ -600,7 +614,11 @@ function buildServer(): McpServer {
wordGapMs, wordGapMs,
vox, vox,
voxClean, voxClean,
preset preset,
crunch,
crunchBits,
crunchRate,
stream: streamWords === true && words === true
}) })
) )
); );
+43 -3
View File
@@ -36,6 +36,15 @@ export interface TtsPreferences {
voxClean: boolean; voxClean: boolean;
/** Half-Life profile preset for Keith Bell: vox, hev, or null for factory values. */ /** Half-Life profile preset for Keith Bell: vox, hev, or null for factory values. */
preset: "vox" | "hev" | null; preset: "vox" | "hev" | null;
/** Echo fine-tuning; null keeps the wrapper defaults (60 ms, 0.85). */
echoDelayMs: number | null;
echoLevel: number | null;
/** Lossy 1990s compression stage: preset, bits, rate. Null/off disables it. */
crunch: "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush" | null;
crunchBits: number | null;
crunchRate: number | null;
/** Stream word mode so playback starts on the first word. */
streamWords: boolean;
} }
const PROFILE_RANGES: Record<string, [number, number]> = { const PROFILE_RANGES: Record<string, [number, number]> = {
@@ -155,7 +164,13 @@ const DEFAULTS: WebPreferences = {
wordGapMs: null, wordGapMs: null,
vox: false, vox: false,
voxClean: false, voxClean: false,
preset: null preset: null,
echoDelayMs: null,
echoLevel: null,
crunch: null,
crunchBits: null,
crunchRate: null,
streamWords: true
}, },
audio: { duck: true }, audio: { duck: true },
favourites: { users: [], channels: [] }, favourites: { users: [], channels: [] },
@@ -298,6 +313,19 @@ function normalizeExpression(value: unknown): number | null {
return Math.round(expression); return Math.round(expression);
} }
function normalizeLevel(value: unknown): number | null {
if (isUnset(value)) return null;
const number = Number(value);
if (!Number.isFinite(number) || number < 0 || number > 0.95) return null;
return Math.round(number * 100) / 100;
}
function normalizeCrunch(value: unknown): TtsPreferences["crunch"] {
return value === "hl90" || value === "hl90hq" || value === "telephone" || value === "adpcm" || value === "crush"
? value
: null;
}
function normalizeRange(value: unknown, min: number, max: number): number | null { function normalizeRange(value: unknown, min: number, max: number): number | null {
if (isUnset(value)) return null; if (isUnset(value)) return null;
const number = Number(value); const number = Number(value);
@@ -355,7 +383,13 @@ export class PreferencesStore {
preset: preset:
patch.tts?.preset === "vox" || patch.tts?.preset === "hev" patch.tts?.preset === "vox" || patch.tts?.preset === "hev"
? patch.tts.preset ? patch.tts.preset
: null : null,
echoDelayMs: normalizeRange(patch.tts?.echoDelayMs, 10, 400),
echoLevel: normalizeLevel(patch.tts?.echoLevel),
crunch: normalizeCrunch(patch.tts?.crunch),
crunchBits: normalizeRange(patch.tts?.crunchBits, 1, 16),
crunchRate: normalizeRange(patch.tts?.crunchRate, 4000, 48000),
streamWords: patch.tts?.streamWords !== false
}; };
} }
@@ -423,7 +457,13 @@ export class PreferencesStore {
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500), wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
vox: raw?.tts?.vox === true, vox: raw?.tts?.vox === true,
voxClean: raw?.tts?.voxClean === true, voxClean: raw?.tts?.voxClean === true,
preset: raw?.tts?.preset === "vox" || raw?.tts?.preset === "hev" ? raw.tts.preset : null preset: raw?.tts?.preset === "vox" || raw?.tts?.preset === "hev" ? raw.tts.preset : null,
echoDelayMs: normalizeRange(raw?.tts?.echoDelayMs, 10, 400),
echoLevel: normalizeLevel(raw?.tts?.echoLevel),
crunch: normalizeCrunch(raw?.tts?.crunch),
crunchBits: normalizeRange(raw?.tts?.crunchBits, 1, 16),
crunchRate: normalizeRange(raw?.tts?.crunchRate, 4000, 48000),
streamWords: raw?.tts?.streamWords !== false
}, },
audio: { audio: {
duck: raw?.audio?.duck !== false duck: raw?.audio?.duck !== false
+35 -1
View File
@@ -40,6 +40,22 @@ export interface TtsOptions {
voxClean?: boolean; voxClean?: boolean;
/** Half-Life profile preset for Keith Bell: vox or hev. */ /** Half-Life profile preset for Keith Bell: vox or hev. */
preset?: "vox" | "hev"; preset?: "vox" | "hev";
/** Echo delay in ms, 10-400. */
echoDelayMs?: number;
/** Echo output gain, 0-0.95. */
echoLevel?: number;
/** Lossy 1990s compression preset, applied as its own stage last. */
crunch?: "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush";
/** Bit depth for the crunch stage, 1-16 (overrides the preset). */
crunchBits?: number;
/** Sample rate for the crunch stage, 4000-48000 (overrides the preset). */
crunchRate?: number;
/** Stream word mode: the wrapper writes the WAV while it synthesizes. */
stream?: boolean;
/** Fixed gain for streaming lines (the measurement pass is skipped). */
streamGainDb?: number;
/** Called with the output path as soon as it is chosen, before synthesis. */
onOutputPath?: (path: string) => void;
} }
export interface TtsEngine { export interface TtsEngine {
@@ -336,6 +352,7 @@ export class Tts {
const out = this.nextPath("wav"); const out = this.nextPath("wav");
const textFile = this.nextPath("txt"); const textFile = this.nextPath("txt");
writeFileSync(textFile, text); writeFileSync(textFile, text);
options.onOutputPath?.(out);
const parts = command const parts = command
? command ? command
@@ -357,6 +374,12 @@ export class Tts {
.replace("{vox}", options.vox ? "1" : "") .replace("{vox}", options.vox ? "1" : "")
.replace("{vox_clean}", options.voxClean ? "1" : "") .replace("{vox_clean}", options.voxClean ? "1" : "")
.replace("{preset}", options.preset ?? "") .replace("{preset}", options.preset ?? "")
.replace("{echo_delay_ms}", options.echoDelayMs !== undefined ? String(options.echoDelayMs) : "")
.replace("{echo_level}", options.echoLevel !== undefined ? String(options.echoLevel) : "")
.replace("{crunch}", options.crunch ?? "")
.replace("{crunch_bits}", options.crunchBits !== undefined ? String(options.crunchBits) : "")
.replace("{crunch_rate}", options.crunchRate !== undefined ? String(options.crunchRate) : "")
.replace("{stream}", options.stream ? "1" : "")
) )
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile]; : ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
@@ -385,7 +408,18 @@ export class Tts {
// preset implication (which only fires when the variable is unset). // preset implication (which only fires when the variable is unset).
VESKSLOP_TTS_VOX: options.vox ? "1" : "0", VESKSLOP_TTS_VOX: options.vox ? "1" : "0",
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {}), ...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {}),
...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {}) ...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {}),
...(options.echoDelayMs !== undefined
? { VESKSLOP_TTS_ECHO_DELAY_MS: String(options.echoDelayMs) }
: {}),
...(options.echoLevel !== undefined ? { VESKSLOP_TTS_ECHO_LEVEL: String(options.echoLevel) } : {}),
...(options.crunch ? { VESKSLOP_TTS_CRUNCH: options.crunch } : {}),
...(options.crunchBits !== undefined ? { VESKSLOP_TTS_CRUNCH_BITS: String(options.crunchBits) } : {}),
...(options.crunchRate !== undefined ? { VESKSLOP_TTS_CRUNCH_RATE: String(options.crunchRate) } : {}),
...(options.stream ? { VESKSLOP_TTS_STREAM: "1" } : {}),
...(options.streamGainDb !== undefined
? { VESKSLOP_TTS_STREAM_GAIN_DB: String(options.streamGainDb) }
: {})
}, },
stdio: ["ignore", "ignore", "pipe"] stdio: ["ignore", "ignore", "pipe"]
}); });
+30 -2
View File
@@ -511,7 +511,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
wordGapMs: tts?.wordGapMs ?? null, wordGapMs: tts?.wordGapMs ?? null,
vox: tts?.vox === true, vox: tts?.vox === true,
voxClean: tts?.voxClean === true, voxClean: tts?.voxClean === true,
preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null,
echoDelayMs: tts?.echoDelayMs ?? null,
echoLevel: tts?.echoLevel ?? null,
crunch: tts?.crunch ?? null,
crunchBits: tts?.crunchBits ?? null,
crunchRate: tts?.crunchRate ?? null,
streamWords: tts?.streamWords !== false
}; };
} }
@@ -767,6 +773,22 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
: req.body?.preset === null || req.body?.preset === "" : req.body?.preset === null || req.body?.preset === ""
? null ? null
: prefs.tts.preset; : prefs.tts.preset;
const echoDelayMs =
req.body?.echoDelayMs !== undefined ? Number(req.body.echoDelayMs) : prefs.tts.echoDelayMs ?? undefined;
const echoLevel =
req.body?.echoLevel !== undefined ? Number(req.body.echoLevel) : prefs.tts.echoLevel ?? undefined;
const crunch =
["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? ""))
? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush")
: req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off"
? undefined
: prefs.tts.crunch ?? undefined;
const crunchBits =
req.body?.crunchBits !== undefined ? Number(req.body.crunchBits) : prefs.tts.crunchBits ?? undefined;
const crunchRate =
req.body?.crunchRate !== undefined ? Number(req.body.crunchRate) : prefs.tts.crunchRate ?? undefined;
const streamWords =
req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords;
const result = await deps.audio.speak(text, { const result = await deps.audio.speak(text, {
provider: provider ?? undefined, provider: provider ?? undefined,
@@ -781,7 +803,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}), ...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
...(vox ? { vox: true } : {}), ...(vox ? { vox: true } : {}),
...(voxClean ? { voxClean: true } : {}), ...(voxClean ? { voxClean: true } : {}),
...(preset ? { preset } : {}) ...(preset ? { preset } : {}),
...(Number.isFinite(echoDelayMs) ? { echoDelayMs } : {}),
...(Number.isFinite(echoLevel) ? { echoLevel } : {}),
...(crunch ? { crunch } : {}),
...(Number.isFinite(crunchBits) ? { crunchBits } : {}),
...(Number.isFinite(crunchRate) ? { crunchRate } : {}),
stream: streamWords && words === true
}); });
deps.getPuppet()?.appendAction({ deps.getPuppet()?.appendAction({
type: "audio", type: "audio",