Perf: Loudnorm-Cache für minimale Play-Latenz

ffmpeg loudnorm läuft jetzt nur noch beim allerersten Play eines Sounds.
Das Ergebnis wird als PCM in .norm-cache/ gespeichert und danach direkt
abgespielt (kein ffmpeg-Spawn mehr → praktisch null Overhead).

- .norm-cache/ Verzeichnis mit automatischer Invalidierung (Quell-mtime)
- Cache-Cleanup bei Admin delete/rename
- Hintergrund-Warmup beim Serverstart: Top-50 Sounds vorab normalisieren
- Fallback auf direktes Abspielen wenn ffmpeg fehlschlägt
- .norm-cache wird aus Soundliste gefiltert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bot 2026-03-01 21:16:56 +01:00
parent 9130a205f0
commit 4b4a61b2bd

View file

@ -153,6 +153,45 @@ const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16');
const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11'); const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11');
const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5'); const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5');
// Loudnorm-Cache: normalisierte PCM-Dateien werden gecacht, damit ffmpeg nur 1× pro Sound läuft
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
fs.mkdirSync(NORM_CACHE_DIR, { recursive: true });
/** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */
function normCacheKey(filePath: string): string {
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm';
}
/** Liefert den Pfad zur gecachten normalisierten PCM-Datei, oder null wenn kein gültiger Cache existiert */
function getNormCachePath(filePath: string): string | null {
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
if (!fs.existsSync(cacheFile)) return null;
// Invalidieren wenn Quelldatei neuer als Cache
try {
const srcMtime = fs.statSync(filePath).mtimeMs;
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; }
} catch { return null; }
return cacheFile;
}
/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */
function normalizeToCache(filePath: string): Promise<string> {
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
return new Promise((resolve, reject) => {
const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath,
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
'-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile];
const ff = child_process.spawn('ffmpeg', ffArgs);
ff.on('error', reject);
ff.on('close', (code) => {
if (code === 0) resolve(cacheFile);
else reject(new Error(`ffmpeg exited with code ${code}`));
});
});
}
// --- Voice Abhängigkeiten prüfen --- // --- Voice Abhängigkeiten prüfen ---
await sodium.ready; await sodium.ready;
// init nacl to ensure it loads // init nacl to ensure it loads
@ -272,11 +311,29 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
: (state.currentVolume ?? 1); : (state.currentVolume ?? 1);
let resource: AudioResource; let resource: AudioResource;
if (NORMALIZE_ENABLE) { if (NORMALIZE_ENABLE) {
const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath, // Cache-basierte Normalisierung: ffmpeg läuft nur beim 1. Mal
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, let cachedPath = getNormCachePath(filePath);
'-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']; if (!cachedPath) {
const ff = child_process.spawn('ffmpeg', ffArgs); try {
resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); cachedPath = await normalizeToCache(filePath);
console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`);
} catch (e) {
console.warn(`${new Date().toISOString()} | Loudnorm cache failed, fallback direct:`, e);
// Fallback: direkt ohne Normalisierung abspielen
resource = createAudioResource(filePath, { inlineVolume: true });
if (resource.volume) resource.volume.setVolume(useVolume);
state.player.stop();
state.player.play(resource);
state.currentResource = resource;
state.currentVolume = useVolume;
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
nowPlaying.set(guildId, soundLabel);
sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel });
if (relativeKey) incrementPlaysFor(relativeKey);
return;
}
}
resource = createAudioResource(cachedPath, { inlineVolume: true, inputType: StreamType.Raw });
} else { } else {
resource = createAudioResource(filePath, { inlineVolume: true }); resource = createAudioResource(filePath, { inlineVolume: true });
} }
@ -598,7 +655,7 @@ function listAllSounds(): ListedSound[] {
})); }));
const folderItems: ListedSound[] = []; const folderItems: ListedSound[] = [];
const subFolders = rootEntries.filter((d) => d.isDirectory()); const subFolders = rootEntries.filter((d) => d.isDirectory() && d.name !== '.norm-cache');
for (const dirent of subFolders) { for (const dirent of subFolders) {
const folderName = dirent.name; const folderName = dirent.name;
const folderPath = path.join(SOUNDS_DIR, folderName); const folderPath = path.join(SOUNDS_DIR, folderName);
@ -856,6 +913,8 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response)
try { try {
if (fs.existsSync(full) && fs.statSync(full).isFile()) { if (fs.existsSync(full) && fs.statSync(full).isFile()) {
fs.unlinkSync(full); fs.unlinkSync(full);
// Loudnorm-Cache aufräumen
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
results.push({ path: rel, ok: true }); results.push({ path: rel, ok: true });
} else { } else {
results.push({ path: rel, ok: false, error: 'nicht gefunden' }); results.push({ path: rel, ok: false, error: 'nicht gefunden' });
@ -883,6 +942,8 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response)
if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' }); if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' });
if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' }); if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' });
fs.renameSync(src, dst); fs.renameSync(src, dst);
// Loudnorm-Cache für alte Datei aufräumen (neue wird beim nächsten Play erzeugt)
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
res.json({ ok: true, from, to: dstRel }); res.json({ ok: true, from, to: dstRel });
} catch (e: any) { } catch (e: any) {
res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' }); res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' });
@ -1282,6 +1343,34 @@ if (fs.existsSync(webDistPath)) {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server läuft auf http://0.0.0.0:${PORT}`); console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
// Hintergrund-Warmup: häufigste Sounds vorab normalisieren, damit der erste Play sofort schnell ist
if (NORMALIZE_ENABLE) {
(async () => {
try {
const allSounds = listAllSounds();
// Sortiere nach Play-Count (häufigste zuerst), maximal 50 vorab cachen
const plays = persistedState.plays ?? {};
const sorted = [...allSounds].sort((a, b) => ((plays[b.relativePath] ?? 0) as number) - ((plays[a.relativePath] ?? 0) as number));
const toWarm = sorted.slice(0, 50);
let cached = 0;
for (const s of toWarm) {
const fp = path.join(SOUNDS_DIR, s.relativePath);
if (!fs.existsSync(fp)) continue;
if (getNormCachePath(fp)) continue; // schon gecacht
try {
await normalizeToCache(fp);
cached++;
} catch (e) {
console.warn(`Warmup failed for ${s.relativePath}:`, e);
}
}
if (cached > 0) console.log(`Loudnorm-Warmup: ${cached} Sounds vorab normalisiert`);
} catch (e) {
console.warn('Loudnorm-Warmup error:', e);
}
})();
}
}); });