From 06326de465d4a8298cce62563e2115ab23eac49d Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 23:28:58 +0100 Subject: [PATCH] feat(radio): add 2-second pre-buffer to reduce audio lag Buffer 384 KB of PCM data (~2 seconds at 48kHz stereo) via PassThrough stream before starting Discord playback. Falls back to immediate start after 3s timeout for slow streams. Cleanup integrated into stopAudio. Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/radio/index.ts | 44 ++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts index 5e2fcf8..0ee4003 100644 --- a/server/src/plugins/radio/index.ts +++ b/server/src/plugins/radio/index.ts @@ -1,5 +1,6 @@ import type express from 'express'; import { spawn, type ChildProcess } from 'node:child_process'; +import { PassThrough } from 'node:stream'; import { joinVoiceChannel, createAudioPlayer, createAudioResource, VoiceConnectionStatus, StreamType, getVoiceConnection, entersState, @@ -37,6 +38,7 @@ interface GuildRadioState { channelId: string; channelName: string; volume: number; + prebufferTimeout?: ReturnType; } interface Favorite { @@ -75,6 +77,7 @@ function setFavorites(favs: Favorite[]): void { function stopAudio(guildId: string): void { const state = guildRadioState.get(guildId); if (!state) return; + if (state.prebufferTimeout) clearTimeout(state.prebufferTimeout); try { state.ffmpeg.kill('SIGKILL'); } catch {} try { state.player.stop(true); } catch {} guildRadioState.delete(guildId); @@ -168,18 +171,53 @@ async function startStream( } }); + // ── Pre-buffer: 2 Sekunden PCM puffern bevor Wiedergabe startet ── + // 48kHz × 2ch × 2byte = 192.000 B/s → 384 KB ≈ 2s + const PRE_BUFFER_BYTES = 384 * 1024; + const bufferedStream = new PassThrough({ highWaterMark: PRE_BUFFER_BYTES }); + let buffered = 0; + let prebufferDone = false; + + ffmpeg.stdout!.on('data', (chunk: Buffer) => { + bufferedStream.write(chunk); + if (!prebufferDone) { + buffered += chunk.length; + if (buffered >= PRE_BUFFER_BYTES) { + prebufferDone = true; + startPlayback(); + } + } + }); + ffmpeg.stdout!.on('end', () => bufferedStream.end()); + ffmpeg.stdout!.on('error', (err) => bufferedStream.destroy(err)); + // AudioResource + Player (inlineVolume für Lautstärkeregelung) const vol = getVolume(guildId); - const resource = createAudioResource(ffmpeg.stdout!, { + const resource = createAudioResource(bufferedStream, { inputType: StreamType.Raw, inlineVolume: true, }); resource.volume?.setVolume(vol); const player = createAudioPlayer(); - player.play(resource); connection.subscribe(player); + // Starte Wiedergabe erst wenn Pre-Buffer voll ist (oder nach 3s Timeout) + function startPlayback() { + if (player.state.status !== 'idle') return; // Already started + player.play(resource); + console.log(`[Radio] Pre-buffer filled (${Math.round(buffered / 1024)} KB), starting playback`); + } + + // Fallback: starte nach 3s auch bei langsamen Streams + const prebufferTimeout = setTimeout(() => { + if (!prebufferDone) { + prebufferDone = true; + startPlayback(); + console.log(`[Radio] Pre-buffer timeout, starting with ${Math.round(buffered / 1024)} KB`); + } + }, 3000); + player.on('error', (err) => { console.error(`[Radio] Player error:`, err.message); stopStream(guildId); @@ -191,7 +229,7 @@ async function startStream( stationId, stationName, placeName, country, streamUrl, startedAt: new Date().toISOString(), ffmpeg, player, resource, channelId: voiceChannelId, channelName, - volume: vol, + volume: vol, prebufferTimeout, }); broadcastState(guildId);