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:
parent
9130a205f0
commit
4b4a61b2bd
1 changed files with 95 additions and 6 deletions
|
|
@ -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<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 ---
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue