diff --git a/server/src/index.ts b/server/src/index.ts index 81f220d..ee7138f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -153,6 +153,45 @@ const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16'); const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11'); 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 { + 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 --- await sodium.ready; // init nacl to ensure it loads @@ -272,11 +311,29 @@ async function playFilePath(guildId: string, channelId: string, filePath: string : (state.currentVolume ?? 1); let resource: AudioResource; if (NORMALIZE_ENABLE) { - 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', 'pipe:1']; - const ff = child_process.spawn('ffmpeg', ffArgs); - resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); + // Cache-basierte Normalisierung: ffmpeg läuft nur beim 1. Mal + let cachedPath = getNormCachePath(filePath); + if (!cachedPath) { + try { + 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 { resource = createAudioResource(filePath, { inlineVolume: true }); } @@ -598,7 +655,7 @@ function listAllSounds(): 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) { const folderName = dirent.name; const folderPath = path.join(SOUNDS_DIR, folderName); @@ -856,6 +913,8 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) try { if (fs.existsSync(full) && fs.statSync(full).isFile()) { 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 }); } else { 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(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' }); 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 }); } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' }); @@ -1282,6 +1343,34 @@ if (fs.existsSync(webDistPath)) { app.listen(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); + } + })(); + } });