fix(stop): Antwort in 0,04 s statt bis 51 s — MP3-Encode in den Hintergrund
POST /api/record/stop wartete auf den kompletten loudnorm-Encode: gemessen ~1,4 s pro aufgenommener Minute (#114: 35,5 min -> 50,9 s). Sichtbar war davon nichts als ein ausgegrauter Knopf; der Reiterwechsel baute den Knopf per render() wieder klickbar auf und der zweite Klick lief in "not recording", weil stopAndSave() current sofort auf null setzt. - recorder.js: encodeMp3() ohne await, schreibt <name>.mp3.part und benennt erst danach um. MP3_ARGS + "-f mp3" (ffmpeg leitet den Muxer aus der Endung ab und scheitert an .part). - web/src/main.js: state.stopping ueberlebt render() -> Knopf bleibt gesperrt, Uhr friert ein, Toast mit geschaetzter Wartezeit, zweiter Klick verworfen, "not recording" kein roter Fehler. Player laedt nach, solange /media 404 liefert (#mp3wait, 5-s-Retry, Abbruch nach 5 min). - server.js: sweepOrphans erkennt rec_*.mp3.part. Gegenprobe: 0,033 s / 0,044 s Stop-Antwort, "mp3 fertig" ~2 s spaeter, 64 kbit CBR / 48 kHz / mono ohne Xing-Tabelle, /media 200. Doku: Kapitel 03 (neuer Abschnitt + Vorher/Nachher-Sequenzdiagramm), Changelog 2026-08-20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+61
-35
@@ -323,6 +323,54 @@ export async function stopRaw() {
|
||||
// transcription. This is the crash-safety fix: whisper on a long clip can crash
|
||||
// the board (thermal), so the audio must already be on rootfs (not /tmp) and the
|
||||
// DB row created before whisper runs. Call transcribeWav() afterwards (async).
|
||||
// Wiedergabe-MP3 aus dem fertigen WAV — laeuft im HINTERGRUND (kein await im
|
||||
// Stop-Pfad, siehe stopAndSave). Geschrieben wird nach `<name>.mp3.part` und erst
|
||||
// dann umbenannt: so hoert der Browser nie eine halbe Datei, sondern bekommt bis
|
||||
// zum Umbenennen ein 404 (die GUI laedt den Player selbst nach).
|
||||
// Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to
|
||||
// the MP3 only; whisper still transcribes the untouched WAV.
|
||||
// lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence —
|
||||
// real since the gated mic path: nobody spoke = all-zero samples. Fall back
|
||||
// to a filterless encode instead of failing the whole stop.
|
||||
// KONSTANTE BITRATE, kein VBR (früher `-qscale:a 3`). Grund ist die
|
||||
// Wort-Markierung im Transkript: der Browser springt in einer VBR-MP3 über
|
||||
// die Xing-Tabelle, und die hat nur 100 Stützstellen — bei einer 63-Minuten-
|
||||
// Aufnahme also ein Raster von 38 s. Er landet auf der Stützstelle davor,
|
||||
// meldet aber die angefragte Zeit zurück. Gemessen an #85: Sprung auf 20:40
|
||||
// spielte in Wahrheit ab 20:11,5 (−28,5 s), die Markierung lief entsprechend
|
||||
// dem Ton voraus. Bei CBR ist die Byte-Position proportional zur Zeit, jeder
|
||||
// Sprung trifft damit auf Frame-Genauigkeit (~26 ms). 64 kbit ≈ die bisherige
|
||||
// Durchschnittsrate (68 kbit), die Dateigröße bleibt also praktisch gleich.
|
||||
// `-ar 48000` fest, damit der Fallback-Zweig ohne loudnorm nicht bei 16 kHz
|
||||
// landet (= MPEG-2.5, andere Framelänge).
|
||||
// `-write_xing 0` muss dazu: die Xing-Tabelle speichert Byte-Positionen nur
|
||||
// in 1/256 der Dateigröße, bei einer Stunde Audio also ~15 s grob — ein
|
||||
// Browser, der sie benutzt, wäre trotz CBR wieder daneben. Ohne sie rechnet
|
||||
// er linear, was bei CBR exakt ist (nachgemessen: 0,02 s = ein halber Frame).
|
||||
// '-f mp3' ist Pflicht, weil der Hintergrund-Encode nach '<name>.mp3.part'
|
||||
// schreibt: ffmpeg leitet den Muxer aus der Endung ab und bricht bei '.part'
|
||||
// mit 'Error initializing the muxer ... Invalid argument' ab.
|
||||
const MP3_ARGS = ['-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '48000', '-ac', '1', '-write_xing', '0', '-f', 'mp3'];
|
||||
|
||||
async function encodeMp3(wavPath, mp3Path) {
|
||||
const part = mp3Path + '.part';
|
||||
try {
|
||||
try {
|
||||
await pexec('ffmpeg', ['-y', '-i', wavPath,
|
||||
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
||||
...MP3_ARGS, part]);
|
||||
} catch (e) {
|
||||
console.warn('mp3 encode with filters failed (silence?), retrying plain:', String(e.message).slice(0, 200));
|
||||
await pexec('ffmpeg', ['-y', '-i', wavPath, ...MP3_ARGS, part]);
|
||||
}
|
||||
fs.renameSync(part, mp3Path);
|
||||
console.log(`mp3 fertig: ${path.basename(mp3Path)}`);
|
||||
} catch (e) {
|
||||
console.error('mp3 encode endgültig fehlgeschlagen:', String(e.message).slice(0, 300));
|
||||
try { fs.unlinkSync(part); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAndSave() {
|
||||
if (!current) throw new Error('not recording');
|
||||
const c = current; current = null;
|
||||
@@ -339,42 +387,20 @@ export async function stopAndSave() {
|
||||
throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err());
|
||||
|
||||
const base = `rec_${c.startTs}`;
|
||||
const mp3Path = path.join(REC_DIR, `${base}.mp3`);
|
||||
// Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to
|
||||
// the MP3 only; whisper still transcribes the untouched WAV.
|
||||
// lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence —
|
||||
// real since the gated mic path: nobody spoke = all-zero samples. Fall back
|
||||
// to a filterless encode instead of failing the whole stop.
|
||||
// KONSTANTE BITRATE, kein VBR (früher `-qscale:a 3`). Grund ist die
|
||||
// Wort-Markierung im Transkript: der Browser springt in einer VBR-MP3 über
|
||||
// die Xing-Tabelle, und die hat nur 100 Stützstellen — bei einer 63-Minuten-
|
||||
// Aufnahme also ein Raster von 38 s. Er landet auf der Stützstelle davor,
|
||||
// meldet aber die angefragte Zeit zurück. Gemessen an #85: Sprung auf 20:40
|
||||
// spielte in Wahrheit ab 20:11,5 (−28,5 s), die Markierung lief entsprechend
|
||||
// dem Ton voraus. Bei CBR ist die Byte-Position proportional zur Zeit, jeder
|
||||
// Sprung trifft damit auf Frame-Genauigkeit (~26 ms). 64 kbit ≈ die bisherige
|
||||
// Durchschnittsrate (68 kbit), die Dateigröße bleibt also praktisch gleich.
|
||||
// `-ar 48000` fest, damit der Fallback-Zweig ohne loudnorm nicht bei 16 kHz
|
||||
// landet (= MPEG-2.5, andere Framelänge).
|
||||
// `-write_xing 0` muss dazu: die Xing-Tabelle speichert Byte-Positionen nur
|
||||
// in 1/256 der Dateigröße, bei einer Stunde Audio also ~15 s grob — ein
|
||||
// Browser, der sie benutzt, wäre trotz CBR wieder daneben. Ohne sie rechnet
|
||||
// er linear, was bei CBR exakt ist (nachgemessen: 0,02 s = ein halber Frame).
|
||||
const MP3_ARGS = ['-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '48000', '-ac', '1', '-write_xing', '0'];
|
||||
try {
|
||||
await pexec('ffmpeg', ['-y', '-i', c.wavPath,
|
||||
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
||||
...MP3_ARGS, mp3Path]);
|
||||
} catch (e) {
|
||||
console.warn('mp3 encode with filters failed (silence?), retrying plain:', String(e.message).slice(0, 200));
|
||||
await pexec('ffmpeg', ['-y', '-i', c.wavPath, ...MP3_ARGS, mp3Path]);
|
||||
}
|
||||
|
||||
// Move the wav into REC_DIR (rootfs — survives a reboot); it is NOT left in
|
||||
// /tmp, so a crash during transcription can no longer lose it.
|
||||
// Zuerst das WAV in Sicherheit bringen (REC_DIR liegt im rootfs, nicht /tmp):
|
||||
// daran haengt alles Weitere — whisper transkribiert es, das Wiedergabe-MP3
|
||||
// entsteht daraus. Ein Absturz danach verliert nichts mehr.
|
||||
const wavFile = `${base}.wav`;
|
||||
try { fs.renameSync(c.wavPath, path.join(REC_DIR, wavFile)); }
|
||||
catch { fs.copyFileSync(c.wavPath, path.join(REC_DIR, wavFile)); try { fs.unlinkSync(c.wavPath); } catch {} }
|
||||
const wavDest = path.join(REC_DIR, wavFile);
|
||||
try { fs.renameSync(c.wavPath, wavDest); }
|
||||
catch { fs.copyFileSync(c.wavPath, wavDest); try { fs.unlinkSync(c.wavPath); } catch {} }
|
||||
|
||||
// MP3 NICHT mehr abwarten: der loudnorm-Encode laeuft nur ~40-fach, bei 35 min
|
||||
// Audio also ~51 s (gemessen an #114, ~1,4 s pro Aufnahmeminute). Genau diese
|
||||
// Zeit hing /api/record/stop, die GUI zeigte einen stumm ausgegrauten Knopf und
|
||||
// der Nutzer druckte ein zweites Mal Stop. Jetzt antwortet der Server sofort;
|
||||
// bis das MP3 da ist, liefert /media ein 404 und die GUI laedt den Player nach.
|
||||
encodeMp3(wavDest, path.join(REC_DIR, `${base}.mp3`));
|
||||
|
||||
return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur,
|
||||
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers,
|
||||
|
||||
Reference in New Issue
Block a user