Add crunch presets, echo options, and streaming word mode
This commit is contained in:
+10
@@ -40,6 +40,16 @@ mobile draft at 390px.
|
||||
- [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`:
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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).
|
||||
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
|
||||
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 <
|
||||
|
||||
@@ -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 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.
|
||||
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')"
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
# 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_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);
|
||||
# combines with a preset and wins over VOX prosody
|
||||
# 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:-}
|
||||
HIGHPASS=${VESKSLOP_TTS_HIGHPASS:-40}
|
||||
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}
|
||||
WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120}
|
||||
VOX=${VESKSLOP_TTS_VOX:-0}
|
||||
@@ -408,6 +423,14 @@ try_run_host() {
|
||||
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
|
||||
# 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
|
||||
@@ -442,6 +465,7 @@ else
|
||||
try_run_host "$RAW" "$TAG_PREFIX$(cat "$TXT")"
|
||||
fi
|
||||
[ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Filter chain: adjustable high-pass, silence trim, fades, optional mono echo.
|
||||
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
|
||||
[ "$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"
|
||||
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}"
|
||||
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
|
||||
|
||||
# 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=$("$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)
|
||||
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)
|
||||
@@ -478,6 +541,68 @@ else
|
||||
GAIN=0
|
||||
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"
|
||||
# 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
|
||||
@@ -498,12 +623,12 @@ if [ "$HUM_MS" -gt 0 ]; then
|
||||
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" \
|
||||
-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
|
||||
head -c 400 "$TMP/ff" >&2
|
||||
exit 1
|
||||
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
|
||||
head -c 400 "$TMP/ff" >&2
|
||||
exit 1
|
||||
|
||||
+23
-1
@@ -2025,6 +2025,12 @@ function fillProfileControls() {
|
||||
const echo = Number(state.speech.echo);
|
||||
$("#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-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-preset").value = state.speech.preset ?? "";
|
||||
@@ -2065,7 +2071,13 @@ function effectOptions() {
|
||||
wordGapMs: Number($("#tts-word-gap").value),
|
||||
vox: $("#tts-vox").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) {
|
||||
@@ -2755,6 +2767,16 @@ function init() {
|
||||
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", () => {
|
||||
const panel = document.querySelector(".vk-profile-panel");
|
||||
if (panel) {
|
||||
|
||||
@@ -550,6 +550,21 @@
|
||||
<span class="vk-micro-label">echo</span>
|
||||
<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>
|
||||
<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 class="vk-profile-row">
|
||||
<label class="vk-tts-check"><input id="tts-words" type="checkbox" /> word mode</label>
|
||||
@@ -577,6 +592,9 @@
|
||||
<label class="vk-tts-check">
|
||||
<input id="tts-vox-clean" type="checkbox" /> clean (no stutter)
|
||||
</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>
|
||||
</section>
|
||||
<section class="vk-profile-section">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 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 type { CaptureSegment } from "./capture.js";
|
||||
import { CapturePipeline } from "./capture.js";
|
||||
@@ -146,6 +147,19 @@ export class AudioService {
|
||||
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 {
|
||||
this.queue.length = 0;
|
||||
this.manager.stopPlayback();
|
||||
@@ -350,9 +364,27 @@ export class AudioService {
|
||||
|
||||
this.playing = true;
|
||||
try {
|
||||
const file = await this.tts.synthesize(item.text, item.options);
|
||||
await this.applyDuck(this.shouldDuck());
|
||||
await this.manager.play(file);
|
||||
// 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.manager.play(file);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[audio] speak failed:", (error as Error).message);
|
||||
} finally {
|
||||
|
||||
@@ -567,7 +567,17 @@ function buildServer(): McpServer {
|
||||
preset: z
|
||||
.enum(["vox", "hev"])
|
||||
.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 ({
|
||||
@@ -584,7 +594,11 @@ function buildServer(): McpServer {
|
||||
wordGapMs,
|
||||
vox,
|
||||
voxClean,
|
||||
preset
|
||||
preset,
|
||||
crunch,
|
||||
crunchBits,
|
||||
crunchRate,
|
||||
streamWords
|
||||
}) =>
|
||||
jsonText(
|
||||
await audio.speak(text, {
|
||||
@@ -600,7 +614,11 @@ function buildServer(): McpServer {
|
||||
wordGapMs,
|
||||
vox,
|
||||
voxClean,
|
||||
preset
|
||||
preset,
|
||||
crunch,
|
||||
crunchBits,
|
||||
crunchRate,
|
||||
stream: streamWords === true && words === true
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
+43
-3
@@ -36,6 +36,15 @@ export interface TtsPreferences {
|
||||
voxClean: boolean;
|
||||
/** Half-Life profile preset for Keith Bell: vox, hev, or null for factory values. */
|
||||
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]> = {
|
||||
@@ -155,7 +164,13 @@ const DEFAULTS: WebPreferences = {
|
||||
wordGapMs: null,
|
||||
vox: false,
|
||||
voxClean: false,
|
||||
preset: null
|
||||
preset: null,
|
||||
echoDelayMs: null,
|
||||
echoLevel: null,
|
||||
crunch: null,
|
||||
crunchBits: null,
|
||||
crunchRate: null,
|
||||
streamWords: true
|
||||
},
|
||||
audio: { duck: true },
|
||||
favourites: { users: [], channels: [] },
|
||||
@@ -298,6 +313,19 @@ function normalizeExpression(value: unknown): number | null {
|
||||
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 {
|
||||
if (isUnset(value)) return null;
|
||||
const number = Number(value);
|
||||
@@ -355,7 +383,13 @@ export class PreferencesStore {
|
||||
preset:
|
||||
patch.tts?.preset === "vox" || patch.tts?.preset === "hev"
|
||||
? 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),
|
||||
vox: raw?.tts?.vox === 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: {
|
||||
duck: raw?.audio?.duck !== false
|
||||
|
||||
+35
-1
@@ -40,6 +40,22 @@ export interface TtsOptions {
|
||||
voxClean?: boolean;
|
||||
/** Half-Life profile preset for Keith Bell: vox or 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 {
|
||||
@@ -336,6 +352,7 @@ export class Tts {
|
||||
const out = this.nextPath("wav");
|
||||
const textFile = this.nextPath("txt");
|
||||
writeFileSync(textFile, text);
|
||||
options.onOutputPath?.(out);
|
||||
|
||||
const parts = command
|
||||
? command
|
||||
@@ -357,6 +374,12 @@ export class Tts {
|
||||
.replace("{vox}", options.vox ? "1" : "")
|
||||
.replace("{vox_clean}", options.voxClean ? "1" : "")
|
||||
.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];
|
||||
|
||||
@@ -385,7 +408,18 @@ export class Tts {
|
||||
// preset implication (which only fires when the variable is unset).
|
||||
VESKSLOP_TTS_VOX: options.vox ? "1" : "0",
|
||||
...(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"]
|
||||
});
|
||||
|
||||
+30
-2
@@ -511,7 +511,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
wordGapMs: tts?.wordGapMs ?? null,
|
||||
vox: tts?.vox === 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 === ""
|
||||
? null
|
||||
: 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, {
|
||||
provider: provider ?? undefined,
|
||||
@@ -781,7 +803,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||
...(vox ? { vox: 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({
|
||||
type: "audio",
|
||||
|
||||
Reference in New Issue
Block a user