diff --git a/CURRENT.md b/CURRENT.md index bb29ed1..c2e09de 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,12 @@ 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] C69 Streaming silence fix. Word mode with streaming on played nothing but the hum: the + daemon started ffplay as soon as the streamed file passed 44 bytes (the bare WAV header), so + the player hit EOF and exited before the first word landed. The wrapper now touches + `.ready` after the first word, and `playWhenReady` waits for it (size threshold and + timeout fallbacks for other providers). Verified by capturing `veskslop_tts.monitor`: peak + -11.2 dBFS with 7 word gaps, versus -48 dBFS (hum only) before. - [x] C68 Half-Life voices and a tidier Radio kit. `vox` and `hev` are now real voices in the list (Keith Bell with the VOX/HEV profile; VOX implies its prosody), so the preset dropdown and the VOX/clean toggles left the Radio kit; the clean option appears only for the VOX voice, and diff --git a/docs/voice.md b/docs/voice.md index 8498633..ab569cf 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -170,7 +170,9 @@ from the TTS panel: 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 + word: measured first sound 0.5 s instead of 2.4 s on a ten-word line. The wrapper touches + `.ready` after the first word and the daemon waits for that before starting playback, so a + player never opens the bare WAV header and exits before the words arrive. Streaming skips the loudness measurement (`VESKSLOP_TTS_STREAM_GAIN_DB` compensates if needed) and cannot use VOX prosody, which needs the whole sentence. The Half-Life voices are first-class entries in the voice list: **VOX (Half-Life)** and **HEV diff --git a/microslopsam/setup/veskslop-tts.sh b/microslopsam/setup/veskslop-tts.sh index 914e6f3..8dafc79 100755 --- a/microslopsam/setup/veskslop-tts.sh +++ b/microslopsam/setup/veskslop-tts.sh @@ -602,6 +602,9 @@ if [ "$STREAM_WORDS" = "1" ]; then fi cat "$TMP/sw$INDEX.raw" >> "$OUT" 2>/dev/null || { echo "stream write failed" >&2; exit 1; } + # The player waits for this marker so it does not start on the bare header. + if [ "$INDEX" = "1" ]; then : > "$OUT.ready"; fi + 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}')" \ diff --git a/poc/src/audio-service.ts b/poc/src/audio-service.ts index e125282..031c899 100644 --- a/poc/src/audio-service.ts +++ b/poc/src/audio-service.ts @@ -6,7 +6,7 @@ * them in the transcript, and runs speech to text when a provider is set. */ -import { statSync } from "node:fs"; +import { statSync, unlinkSync } from "node:fs"; import { AudioManager } from "./audio.js"; import type { CaptureSegment } from "./capture.js"; import { CapturePipeline } from "./capture.js"; @@ -147,17 +147,35 @@ 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. */ + /** + * Wait until the wrapper's streaming file has real audio in it, then play it while it + * grows. The wrapper touches `.ready` after the first word, which is the reliable + * signal; a size threshold and a timeout cover providers that do not mark readiness. + */ private async playWhenReady(file: string): Promise { for (let attempt = 0; attempt < 400; attempt++) { try { - if (statSync(file).size > 44) break; + if (statSync(`${file}.ready`).size >= 0) break; + } catch { + // no marker yet + } + try { + if (statSync(file).size > 100_000) break; } catch { // not created yet } await new Promise(resolve => setTimeout(resolve, 25)); } - await this.manager.play(file); + + try { + await this.manager.play(file); + } finally { + try { + unlinkSync(`${file}.ready`); + } catch { + // best effort + } + } } stop(): void {