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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 23:28:58 +01:00
parent 7786d02f86
commit 06326de465

View file

@ -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<typeof setTimeout>;
}
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);