Wait for the first streamed word before playing

This commit is contained in:
Fisto
2026-09-21 12:43:44 +02:00
parent baaaba3dcd
commit 7701664bc6
4 changed files with 34 additions and 5 deletions
+6
View File
@@ -40,6 +40,12 @@ 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] 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
`<file>.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 - [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 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 the VOX/clean toggles left the Radio kit; the clean option appears only for the VOX voice, and
+3 -1
View File
@@ -170,7 +170,9 @@ from the TTS panel:
s16le, so only the character changes. Echo gained `tts.echoDelayMs` (10-400) and 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 `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 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
`<file>.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 loudness measurement (`VESKSLOP_TTS_STREAM_GAIN_DB` compensates if needed) and cannot use VOX
prosody, which needs the whole sentence. prosody, which needs the whole sentence.
The Half-Life voices are first-class entries in the voice list: **VOX (Half-Life)** and **HEV The Half-Life voices are first-class entries in the voice list: **VOX (Half-Life)** and **HEV
+3
View File
@@ -602,6 +602,9 @@ if [ "$STREAM_WORDS" = "1" ]; then
fi fi
cat "$TMP/sw$INDEX.raw" >> "$OUT" 2>/dev/null || { echo "stream write failed" >&2; exit 1; } 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 [ "$GAP" -gt 0 ] && [ "$INDEX" -lt "$WORD_COUNT" ]; then
if "$MICROSLOP_FFMPEG" -hide_banner -loglevel error -y -f lavfi -i "anullsrc=r=48000:cl=mono" \ 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}')" \ -t "$(awk -v g="$GAP" 'BEGIN{printf "%.3f", g/1000}')" \
+21 -3
View File
@@ -6,7 +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 { statSync, unlinkSync } 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";
@@ -147,17 +147,35 @@ 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. */ /**
* Wait until the wrapper's streaming file has real audio in it, then play it while it
* grows. The wrapper touches `<file>.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<void> { private async playWhenReady(file: string): Promise<void> {
for (let attempt = 0; attempt < 400; attempt++) { for (let attempt = 0; attempt < 400; attempt++) {
try { 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 { } catch {
// not created yet // not created yet
} }
await new Promise(resolve => setTimeout(resolve, 25)); await new Promise(resolve => setTimeout(resolve, 25));
} }
try {
await this.manager.play(file); await this.manager.play(file);
} finally {
try {
unlinkSync(`${file}.ready`);
} catch {
// best effort
}
}
} }
stop(): void { stop(): void {