From c66d00ee59dfe92774f7bbd29961774a713ee956 Mon Sep 17 00:00:00 2001 From: Fisto Date: Mon, 21 Sep 2026 12:31:43 +0200 Subject: [PATCH] Add crunch presets, echo options, and streaming word mode --- CURRENT.md | 10 +++ docs/voice.md | 10 +++ microslopsam/setup/test-wrapper.sh | 17 ++++ microslopsam/setup/veskslop-tts.sh | 133 ++++++++++++++++++++++++++++- poc/public/app.js | 24 +++++- poc/public/index.html | 18 ++++ poc/src/audio-service.ts | 38 ++++++++- poc/src/control-bridge.ts | 24 +++++- poc/src/preferences.ts | 46 +++++++++- poc/src/tts.ts | 36 +++++++- poc/src/webui.ts | 32 ++++++- 11 files changed, 371 insertions(+), 17 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index f933a2d..2a93fed 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -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///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 diff --git a/docs/voice.md b/docs/voice.md index 314f102..664acdc 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -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 < diff --git a/microslopsam/setup/test-wrapper.sh b/microslopsam/setup/test-wrapper.sh index 067ea63..d3e2b85 100755 --- a/microslopsam/setup/test-wrapper.sh +++ b/microslopsam/setup/test-wrapper.sh @@ -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')" diff --git a/microslopsam/setup/veskslop-tts.sh b/microslopsam/setup/veskslop-tts.sh index f788840..b200a1a 100755 --- a/microslopsam/setup/veskslop-tts.sh +++ b/microslopsam/setup/veskslop-tts.sh @@ -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 diff --git a/poc/public/app.js b/poc/public/app.js index 958d703..b89288d 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -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) { diff --git a/poc/public/index.html b/poc/public/index.html index 12d3f92..572f4ed 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -550,6 +550,21 @@ echo off + + + +
+ crunch + + +
@@ -577,6 +592,9 @@ +
diff --git a/poc/src/audio-service.ts b/poc/src/audio-service.ts index cfda289..e125282 100644 --- a/poc/src/audio-service.ts +++ b/poc/src/audio-service.ts @@ -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 { + 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 | 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 | 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 { diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index d087bae..14f28a0 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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 }) ) ); diff --git a/poc/src/preferences.ts b/poc/src/preferences.ts index d6e5409..194bca8 100644 --- a/poc/src/preferences.ts +++ b/poc/src/preferences.ts @@ -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 = { @@ -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 diff --git a/poc/src/tts.ts b/poc/src/tts.ts index 440bdbe..0bc317a 100644 --- a/poc/src/tts.ts +++ b/poc/src/tts.ts @@ -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"] }); diff --git a/poc/src/webui.ts b/poc/src/webui.ts index 44e5c69..f714758 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -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",