diff --git a/app/recorder.js b/app/recorder.js index de87a09..018909c 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -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 `.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 '.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, diff --git a/app/server.js b/app/server.js index 66400d3..a69cad1 100644 --- a/app/server.js +++ b/app/server.js @@ -632,7 +632,10 @@ async function sweepOrphans() { const keep = new Set(inUse); let removed = 0, bytes = 0; for (const f of fs.readdirSync(REC_DIR)) { - if (!/^rec_.*\.(mp3|wav)$/.test(f) || keep.has(f)) continue; + // `.mp3.part` = ein Encode, der gerade laeuft (recorder.js encodeMp3). + // Faellt der Prozess mitten im Encode aus, bleibt die Halbdatei liegen — + // die Altersgrenze ORPHAN_MIN_AGE_MS raeumt sie dann wie jede Waise weg. + if (!/^rec_.*\.(mp3|wav)(\.part)?$/.test(f) || keep.has(f)) continue; const p = path.join(REC_DIR, f); try { const st = fs.statSync(p); diff --git a/app/web/src/main.js b/app/web/src/main.js index ba51775..6ab15ff 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -21,7 +21,14 @@ const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, s // `ctxDrafts` hält getippten, noch nicht gespeicherten Text pro Aufnahme — // die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das // Getippte weg. - ctxPromptFor: null, ctxDrafts: {} }; + ctxPromptFor: null, ctxDrafts: {}, + // `stopping` = der Stop-Klick ist raus, die Antwort steht noch aus. Der Server + // braucht dafuer ~1,4 s pro aufgenommener Minute (ffmpeg-loudnorm ueber die + // ganze Aufnahme, recorder.js stopAndSave; #114: 35 min Audio -> 51 s). Ohne + // dieses Flag baut jeder render() - also auch ein Reiterwechsel - den Knopf + // wieder klickbar auf; der zweite Klick schickt einen zweiten Stop, und der + // scheitert mit "not recording", weil `current` serverseitig schon null ist. + stopping: false }; function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } @@ -44,9 +51,11 @@ function render() {

Aime Anruf-Assistent

- + ${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} - ${state.recording ? ` + ${state.stopping ? 'speichert…' : ''} + ${state.recording && !state.stopping ? ` 🏷 ${state.markers.length}` : ''} @@ -72,7 +81,7 @@ function render() { paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM startSysmon(); bindMarkerInput(); - if (state.recording) startTimerUI(); + if (state.recording && !state.stopping) startTimerUI(); ({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); } @@ -197,7 +206,6 @@ function startSysmon() { // codec auto, speaker count auto — the old options tab is gone on purpose). // A live placeholder card appears at the top of the recordings list instantly. async function toggleRecord() { - const btn = document.querySelector('#recbtn'); if (!state.recording) { try { await api.post('/api/record/start', {}); @@ -207,11 +215,25 @@ async function toggleRecord() { if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } } catch (err) { toast(err.message, true); } } else { - if (btn) { btn.disabled = true; } + // Zweiter Klick, waehrend die erste Anfrage noch laeuft: ignorieren. Frueher + // sah der Nutzer bis zu 51 s lang nur einen ausgegrauten Knopf, wechselte + // den Reiter (= render(), Knopf wieder aktiv) und stoppte ein zweites Mal - + // das lief dann in "Stop fehlgeschlagen: not recording". + if (state.stopping) return; + state.stopping = true; clearInterval(state.timer); state.timer = null; + const recSec = Math.max(0, Math.round((Date.now() - state.recStart) / 1000)); + state.lastListSig = null; // Live-Karte auf "wird gespeichert" umschreiben + render(); + // Ehrliche Wartezeit ansagen statt stumm auszugrauen: gemessen ~1,4 s + // Encode pro aufgenommener Minute. + const waitSec = Math.round(recSec * 1.4 / 60); + toast(waitSec >= 5 + ? `Aufnahme wird gespeichert… dauert etwa ${waitSec} s (${Math.round(recSec / 60)} min Audio)` + : 'Aufnahme wird gespeichert…'); try { const r = await api.post('/api/record/stop', {}); - state.recording = false; + state.recording = false; state.stopping = false; toast('Aufnahme gespeichert – wird transkribiert…'); // Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription // läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da, @@ -220,7 +242,14 @@ async function toggleRecord() { state.tab = 'recordings'; render(); state.lastListSig = null; refreshRecList(); openRecording(r.id); - } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } + } catch (err) { + state.recording = false; state.stopping = false; + // "not recording" heisst: serverseitig ist schon gestoppt (zweites + // Browserfenster, F5 mitten im Speichern) - kein Fehler fuer den Nutzer. + if (/not recording/i.test(err.message)) toast('Aufnahme war bereits gestoppt'); + else toast('Stop fehlgeschlagen: ' + err.message, true); + state.lastListSig = null; render(); refreshRecList(); + } } } // ---------- live speaker markers ---------- @@ -1113,8 +1142,8 @@ async function refreshRecList() { const liveHtml = state.recording ? `
-
Aufnahme läuft… -
wird nach Stop transkribiert & analysiert
+
${state.stopping ? 'Aufnahme wird gespeichert…' : 'Aufnahme läuft…'} +
${state.stopping ? 'MP3 wird erzeugt, danach Transkription & Analyse' : 'wird nach Stop transkribiert & analysiert'}
${markerChipsHtml()}
${fmt(Date.now() - state.recStart)}
` : ''; @@ -1342,6 +1371,7 @@ async function openRecording(id) {

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

+ ${vizHtml}
${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} @@ -1514,6 +1544,20 @@ async function openRecording(id) { setTimeout(pollDetail, 5000); } const audio = detail.querySelector('#player'); + // Direkt nach dem Stop gibt es die MP3 noch nicht: recorder.js encodeMp3 laeuft + // im Hintergrund (~1,4 s pro Aufnahmeminute), /media liefert bis dahin 404. + // Statt eines toten Players: Hinweis zeigen und alle 5 s neu laden. Nach 5 min + // aufgeben — dann ist der Encode wirklich gescheitert (steht dann im Journal). + if (audio) { + const mp3wait = detail.querySelector('#mp3wait'); + let tries = 0; + audio.addEventListener('error', () => { + if (state.selectedId !== id || ++tries > 60) return; + if (mp3wait) mp3wait.classList.remove('hidden'); + setTimeout(() => { if (state.selectedId === id) audio.load(); }, 5000); + }); + audio.addEventListener('loadedmetadata', () => { if (mp3wait) mp3wait.classList.add('hidden'); }); + } // ---- marker actions (seek / add at playhead / delete / re-assign) ---- detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => { audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play(); diff --git a/docs/03-aufnahme-pipeline.md b/docs/03-aufnahme-pipeline.md index aa015fc..03f724f 100644 --- a/docs/03-aufnahme-pipeline.md +++ b/docs/03-aufnahme-pipeline.md @@ -19,9 +19,11 @@ sequenceDiagram SRV->>REC: addMarker() → Namensmarker mit Offset im Puffer GUI->>SRV: Stop SRV->>REC: stopAndSave() - REC->>DB: WAV + MP3 + Zeile (status=transcribing) + REC->>REC: parecord beenden, WAV nach recordings/ verschieben + REC-)REC: encodeMp3() — Hintergrund, kein await + REC->>DB: WAV-Zeile (status=transcribing) SRV->>DB: addMarkers() → Tabelle markers - Note over REC: kehrt nach ~1,5 s zurück — GUI frei + Note over REC: kehrt in ~0,04 s zurück — GUI frei GUI->>GUI: Kontextbox klappt auf (Freitext, seit 2026-08-17) GUI->>SRV: PATCH /api/recordings/:id/context {context_note} SRV->>DB: setContextNote() → recordings.context_note @@ -80,6 +82,78 @@ stateDiagram-v2 - **GUI-Anzeige „⏱ Analysezeit“:** `analysis_started_at`/`analysis_ms` in der DB; beim Polling auf `analysis_status` achten, NICHT auf `status` (sonst wirkt die Summary „leer“). +## Der Stop-Knopf: sofortige Antwort, MP3 im Hintergrund + +Bis 2026-08-20 hing `POST /api/record/stop` so lange, wie der MP3-Encode dauerte — **gemessen ~1,4 s pro +aufgenommener Minute** (aus `wav_file`-Zeitstempel + `duration_ms` gegen `created_at`): + +| Aufnahme | Dauer | Stop-Antwort | +|---|---|---| +| #114 | 35,5 min | 50,9 s | +| #118 | 30,8 min | 43,8 s | +| #111 | 20,8 min | 30,4 s | +| #117 | 1,1 min | 2,0 s | + +Sichtbar war in dieser Zeit **nichts**: die GUI grante nur den Knopf aus (`opacity .55`) und stoppte die +Uhr. Der Nutzer hielt die Anwendung für hängengeblieben, wechselte den Reiter — was `render()` auslöst und +den Knopf frisch (also klickbar) aufbaut, obwohl `state.recording` noch `true` war — und drückte ein +zweites Mal Stop. Der zweite Stop lief zwangsläufig in `not recording`, weil `stopAndSave()` das +`current`-Objekt schon in seiner ersten Zeile auf `null` setzt. + +```mermaid +sequenceDiagram + autonumber + participant U as Nutzer + participant GUI as Web-GUI + participant SRV as server.js + participant FF as ffmpeg (Hintergrund) + + rect rgba(229,89,94,.12) + Note over U,SRV: VORHER — Encode blockierte die Antwort + U->>GUI: Klick Stop + GUI->>SRV: POST /api/record/stop + Note over GUI: Knopf grau, Uhr steht,
kein Toast (bis 51 s) + U->>GUI: Reiterwechsel → render() → Knopf wieder aktiv + U->>GUI: Klick Stop (zum zweiten Mal) + GUI->>SRV: POST /api/record/stop + SRV--)GUI: 500 „not recording" + SRV--)GUI: (erste Antwort trifft jetzt ein) + end + + rect rgba(126,182,124,.12) + Note over U,FF: NACHHER — Antwort in 0,04 s + U->>GUI: Klick Stop + GUI->>GUI: state.stopping = true → ⏳, gesperrt,
Toast mit geschätzter Wartezeit + GUI->>SRV: POST /api/record/stop + SRV->>SRV: parecord beenden, WAV verschieben, DB-Zeile + SRV-)FF: encodeMp3() ohne await + SRV--)GUI: {id, …} nach ~0,04 s + FF-->>FF: rec_*.mp3.part → rename → rec_*.mp3 + GUI->>GUI: Player lädt alle 5 s nach, solange /media 404 liefert + end +``` + +Drei Teile hat der Fix: + +1. **`recorder.js stopAndSave()`** verschiebt erst das WAV nach `recordings/` (das ist die Arbeitskopie für + Whisper) und ruft `encodeMp3()` dann **ohne `await`** auf. Geschrieben wird nach `.mp3.part`, + danach `fs.renameSync` — so bekommt der Browser nie eine halbe Datei zu hören, sondern bis zum + Umbenennen ein 404. **`-f mp3` ist dabei Pflicht:** ffmpeg leitet den Muxer aus der Dateiendung ab und + scheitert an `.part` mit `Error initializing the muxer … Invalid argument` (erst so aufgefallen — + beide Encode-Zweige fielen still aus, das MP3 fehlte ganz). +2. **`state.stopping` in der GUI** überlebt jeden `render()`. Der Knopf bleibt darum auch nach einem + Reiterwechsel gesperrt (zeigt ⏳), die Uhr friert ein, die Live-Karte in der Liste sagt „Aufnahme wird + gespeichert…", und ein Toast nennt die geschätzte Wartezeit. Ein zweiter Klick wird verworfen; kommt + trotzdem ein `not recording` zurück (zweites Browserfenster, F5 mitten im Speichern), meldet die GUI + sachlich „Aufnahme war bereits gestoppt" statt eines roten Fehlers. +3. **Der Player lädt nach:** `#mp3wait` zeigt „Wiedergabe-Datei wird noch erzeugt", das `error`-Ereignis + des `