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:
+51
-25
@@ -323,23 +323,10 @@ export async function stopRaw() {
|
|||||||
// transcription. This is the crash-safety fix: whisper on a long clip can crash
|
// 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
|
// 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).
|
// DB row created before whisper runs. Call transcribeWav() afterwards (async).
|
||||||
export async function stopAndSave() {
|
// Wiedergabe-MP3 aus dem fertigen WAV — laeuft im HINTERGRUND (kein await im
|
||||||
if (!current) throw new Error('not recording');
|
// Stop-Pfad, siehe stopAndSave). Geschrieben wird nach `<name>.mp3.part` und erst
|
||||||
const c = current; current = null;
|
// dann umbenannt: so hoert der Browser nie eine halbe Datei, sondern bekommt bis
|
||||||
c.proc.kill('SIGINT');
|
// zum Umbenennen ein 404 (die GUI laedt den Player selbst nach).
|
||||||
await new Promise(resolve => {
|
|
||||||
if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve();
|
|
||||||
let done = false;
|
|
||||||
const fin = () => { if (!done) { done = true; resolve(); } };
|
|
||||||
c.proc.on('close', fin);
|
|
||||||
setTimeout(fin, 4000);
|
|
||||||
});
|
|
||||||
const dur = Date.now() - c.startTs;
|
|
||||||
if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000)
|
|
||||||
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
|
// Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to
|
||||||
// the MP3 only; whisper still transcribes the untouched WAV.
|
// the MP3 only; whisper still transcribes the untouched WAV.
|
||||||
// lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence —
|
// lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence —
|
||||||
@@ -360,21 +347,60 @@ export async function stopAndSave() {
|
|||||||
// in 1/256 der Dateigröße, bei einer Stunde Audio also ~15 s grob — ein
|
// 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
|
// 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).
|
// 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'];
|
// '-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', c.wavPath,
|
try {
|
||||||
|
await pexec('ffmpeg', ['-y', '-i', wavPath,
|
||||||
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
||||||
...MP3_ARGS, mp3Path]);
|
...MP3_ARGS, part]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('mp3 encode with filters failed (silence?), retrying plain:', String(e.message).slice(0, 200));
|
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]);
|
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 { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the wav into REC_DIR (rootfs — survives a reboot); it is NOT left in
|
export async function stopAndSave() {
|
||||||
// /tmp, so a crash during transcription can no longer lose it.
|
if (!current) throw new Error('not recording');
|
||||||
|
const c = current; current = null;
|
||||||
|
c.proc.kill('SIGINT');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve();
|
||||||
|
let done = false;
|
||||||
|
const fin = () => { if (!done) { done = true; resolve(); } };
|
||||||
|
c.proc.on('close', fin);
|
||||||
|
setTimeout(fin, 4000);
|
||||||
|
});
|
||||||
|
const dur = Date.now() - c.startTs;
|
||||||
|
if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000)
|
||||||
|
throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err());
|
||||||
|
|
||||||
|
const base = `rec_${c.startTs}`;
|
||||||
|
// 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`;
|
const wavFile = `${base}.wav`;
|
||||||
try { fs.renameSync(c.wavPath, path.join(REC_DIR, wavFile)); }
|
const wavDest = path.join(REC_DIR, wavFile);
|
||||||
catch { fs.copyFileSync(c.wavPath, path.join(REC_DIR, wavFile)); try { fs.unlinkSync(c.wavPath); } catch {} }
|
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,
|
return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur,
|
||||||
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers,
|
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers,
|
||||||
|
|||||||
+4
-1
@@ -632,7 +632,10 @@ async function sweepOrphans() {
|
|||||||
const keep = new Set(inUse);
|
const keep = new Set(inUse);
|
||||||
let removed = 0, bytes = 0;
|
let removed = 0, bytes = 0;
|
||||||
for (const f of fs.readdirSync(REC_DIR)) {
|
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);
|
const p = path.join(REC_DIR, f);
|
||||||
try {
|
try {
|
||||||
const st = fs.statSync(p);
|
const st = fs.statSync(p);
|
||||||
|
|||||||
+54
-10
@@ -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 —
|
// `ctxDrafts` hält getippten, noch nicht gespeicherten Text pro Aufnahme —
|
||||||
// die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das
|
// die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das
|
||||||
// Getippte weg.
|
// 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 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); }
|
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() {
|
|||||||
<h1>Aime <span class="sub">Anruf-Assistent</span></h1>
|
<h1>Aime <span class="sub">Anruf-Assistent</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="rec-ctl">
|
<div class="rec-ctl">
|
||||||
<button id="recbtn" class="recbtn ${state.recording ? 'rec' : ''}" title="${state.recording ? 'Stop & Transkribieren' : 'Aufnahme starten'}">${state.recording ? '■' : '●'}</button>
|
<button id="recbtn" class="recbtn ${state.recording && !state.stopping ? 'rec' : ''}" ${state.stopping ? 'disabled' : ''}
|
||||||
|
title="${state.stopping ? 'Aufnahme wird gespeichert – bitte warten' : state.recording ? 'Stop & Transkribieren' : 'Aufnahme starten'}">${state.stopping ? '⏳' : state.recording ? '■' : '●'}</button>
|
||||||
<span class="rectime mono" data-rectime>${state.recording ? fmt(Date.now() - state.recStart) : '00:00'}</span>
|
<span class="rectime mono" data-rectime>${state.recording ? fmt(Date.now() - state.recStart) : '00:00'}</span>
|
||||||
${state.recording ? `
|
${state.stopping ? '<span class="muted small">speichert…</span>' : ''}
|
||||||
|
${state.recording && !state.stopping ? `
|
||||||
<input id="mkname" class="input mkin" placeholder="Wer spricht? Name + Enter" value="${escapeHtml(state.mkDraft)}"
|
<input id="mkname" class="input mkin" placeholder="Wer spricht? Name + Enter" value="${escapeHtml(state.mkDraft)}"
|
||||||
title="Namen der Person eintippen, die JETZT spricht — der Zeitstempel ordnet ihn später dem Sprecher zu">
|
title="Namen der Person eintippen, die JETZT spricht — der Zeitstempel ordnet ihn später dem Sprecher zu">
|
||||||
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
|
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
|
||||||
@@ -72,7 +81,7 @@ function render() {
|
|||||||
paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM
|
paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM
|
||||||
startSysmon();
|
startSysmon();
|
||||||
bindMarkerInput();
|
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]();
|
({ 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).
|
// 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.
|
// A live placeholder card appears at the top of the recordings list instantly.
|
||||||
async function toggleRecord() {
|
async function toggleRecord() {
|
||||||
const btn = document.querySelector('#recbtn');
|
|
||||||
if (!state.recording) {
|
if (!state.recording) {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/record/start', {});
|
await api.post('/api/record/start', {});
|
||||||
@@ -207,11 +215,25 @@ async function toggleRecord() {
|
|||||||
if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
|
if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
|
||||||
} catch (err) { toast(err.message, true); }
|
} catch (err) { toast(err.message, true); }
|
||||||
} else {
|
} 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;
|
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 {
|
try {
|
||||||
const r = await api.post('/api/record/stop', {});
|
const r = await api.post('/api/record/stop', {});
|
||||||
state.recording = false;
|
state.recording = false; state.stopping = false;
|
||||||
toast('Aufnahme gespeichert – wird transkribiert…');
|
toast('Aufnahme gespeichert – wird transkribiert…');
|
||||||
// Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription
|
// Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription
|
||||||
// läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da,
|
// läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da,
|
||||||
@@ -220,7 +242,14 @@ async function toggleRecord() {
|
|||||||
state.tab = 'recordings'; render();
|
state.tab = 'recordings'; render();
|
||||||
state.lastListSig = null; refreshRecList();
|
state.lastListSig = null; refreshRecList();
|
||||||
openRecording(r.id);
|
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 ----------
|
// ---------- live speaker markers ----------
|
||||||
@@ -1113,8 +1142,8 @@ async function refreshRecList() {
|
|||||||
const liveHtml = state.recording ? `
|
const liveHtml = state.recording ? `
|
||||||
<div class="item rec-live">
|
<div class="item rec-live">
|
||||||
<span class="rec-dot"></span>
|
<span class="rec-dot"></span>
|
||||||
<div class="grow"><b>Aufnahme läuft…</b>
|
<div class="grow"><b>${state.stopping ? 'Aufnahme wird gespeichert…' : 'Aufnahme läuft…'}</b>
|
||||||
<div class="muted">wird nach Stop transkribiert & analysiert</div>
|
<div class="muted">${state.stopping ? 'MP3 wird erzeugt, danach Transkription & Analyse' : 'wird nach Stop transkribiert & analysiert'}</div>
|
||||||
<div class="mkchips" id="mkchips">${markerChipsHtml()}</div></div>
|
<div class="mkchips" id="mkchips">${markerChipsHtml()}</div></div>
|
||||||
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
|
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
@@ -1342,6 +1371,7 @@ async function openRecording(id) {
|
|||||||
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
||||||
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
|
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
|
||||||
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
|
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
|
||||||
|
<div class="muted small hidden" id="mp3wait">🎧 Wiedergabe-Datei wird noch erzeugt – lädt automatisch nach…</div>
|
||||||
${vizHtml}
|
${vizHtml}
|
||||||
<div class="status-line">
|
<div class="status-line">
|
||||||
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
|
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
|
||||||
@@ -1514,6 +1544,20 @@ async function openRecording(id) {
|
|||||||
setTimeout(pollDetail, 5000);
|
setTimeout(pollDetail, 5000);
|
||||||
}
|
}
|
||||||
const audio = detail.querySelector('#player');
|
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) ----
|
// ---- marker actions (seek / add at playhead / delete / re-assign) ----
|
||||||
detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => {
|
detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => {
|
||||||
audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play();
|
audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play();
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ sequenceDiagram
|
|||||||
SRV->>REC: addMarker() → Namensmarker mit Offset im Puffer
|
SRV->>REC: addMarker() → Namensmarker mit Offset im Puffer
|
||||||
GUI->>SRV: Stop
|
GUI->>SRV: Stop
|
||||||
SRV->>REC: stopAndSave()
|
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
|
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->>GUI: Kontextbox klappt auf (Freitext, seit 2026-08-17)
|
||||||
GUI->>SRV: PATCH /api/recordings/:id/context {context_note}
|
GUI->>SRV: PATCH /api/recordings/:id/context {context_note}
|
||||||
SRV->>DB: setContextNote() → recordings.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
|
- **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“).
|
`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,<br/>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,<br/>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 `<name>.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 `<audio>`-Tags ruft alle 5 s `audio.load()` (max. 5 min). `sweepOrphans` kennt jetzt auch
|
||||||
|
`rec_*.mp3.part`, damit eine beim Absturz liegengebliebene Halbdatei nach der Altersgrenze wegfällt.
|
||||||
|
|
||||||
|
Nachgemessen nach dem Umbau: Stop antwortet in **0,033–0,044 s** (12- und 15-s-Testclips), das MP3 stand
|
||||||
|
~2 s später mit 64 kbit CBR, 48 kHz, mono und ohne Xing-Tabelle da — die Anforderungen des nächsten
|
||||||
|
Abschnitts bleiben also erfüllt.
|
||||||
|
|
||||||
## Wiedergabe-MP3: konstante Bitrate, keine Xing-Tabelle
|
## Wiedergabe-MP3: konstante Bitrate, keine Xing-Tabelle
|
||||||
|
|
||||||
Die WAV ist die Arbeitskopie (Whisper, Diarization), die MP3 ist nur fürs Abspielen im Browser —
|
Die WAV ist die Arbeitskopie (Whisper, Diarization), die MP3 ist nur fürs Abspielen im Browser —
|
||||||
@@ -87,9 +161,12 @@ Die WAV ist die Arbeitskopie (Whisper, Diarization), die MP3 ist nur fürs Abspi
|
|||||||
Einstellungen nicht verhandelbar**, sonst läuft die Wortmarkierung im Transkript aus dem Takt:
|
Einstellungen nicht verhandelbar**, sonst läuft die Wortmarkierung im Transkript aus dem Takt:
|
||||||
|
|
||||||
```
|
```
|
||||||
-codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0
|
-codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0 -f mp3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`-f mp3` kam mit dem Hintergrund-Encode dazu, weil die Zieldatei `<name>.mp3.part` heißt — siehe
|
||||||
|
vorherigen Abschnitt.)
|
||||||
|
|
||||||
**Kein VBR** (früher `-qscale:a 3`) und **keine Xing-Tabelle**. Der Grund liegt im Sprung, nicht in der
|
**Kein VBR** (früher `-qscale:a 3`) und **keine Xing-Tabelle**. Der Grund liegt im Sprung, nicht in der
|
||||||
Wiedergabe:
|
Wiedergabe:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,43 @@
|
|||||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||||
Was, Warum, betroffene Dateien/Backups).
|
Was, Warum, betroffene Dateien/Backups).
|
||||||
|
|
||||||
|
## 2026-08-20 — Stop-Knopf reagiert sofort (MP3-Encode in den Hintergrund)
|
||||||
|
|
||||||
|
**Meldung:** „Wenn ich eine laufende Aufnahme stoppen will, passiert auf der GUI nichts. Ich muss den
|
||||||
|
Reiter wechseln und zurück, dann kann ich nochmal auf Stopp klicken und dann geht's."
|
||||||
|
|
||||||
|
**Gemessene Ursache:** `POST /api/record/stop` wartete auf den kompletten `loudnorm`-MP3-Encode —
|
||||||
|
**~1,4 s pro aufgenommener Minute** (#114: 35,5 min → 50,9 s; #118: 30,8 min → 43,8 s; berechnet aus
|
||||||
|
`wav_file`-Zeitstempel + `duration_ms` gegen `created_at`). Die GUI zeigte dabei nur einen ausgegrauten
|
||||||
|
Knopf. Der Reiterwechsel löst `render()` aus, das den Knopf frisch und klickbar aufbaut, obwohl
|
||||||
|
`state.recording` noch `true` ist → der zweite Klick schickte einen zweiten Stop, der an
|
||||||
|
`not recording` scheiterte (`stopAndSave()` setzt `current` in seiner ersten Zeile auf `null`). **Nicht**
|
||||||
|
die Kontextnotiz vom 2026-08-17: die Wartezeit gab es vorher genauso (#113 am 14.08.: 44,9 s).
|
||||||
|
|
||||||
|
**Was geändert wurde**
|
||||||
|
|
||||||
|
- `recorder.js`: `stopAndSave()` verschiebt das WAV und startet `encodeMp3()` **ohne `await`**. Der Encode
|
||||||
|
schreibt nach `<name>.mp3.part` und benennt erst danach um (nie eine halbe Datei im Player).
|
||||||
|
`MP3_ARGS` bekam **`-f mp3`** — ohne das leitet ffmpeg den Muxer aus `.part` ab und bricht mit
|
||||||
|
`Error initializing the muxer … Invalid argument` ab (erster Testlauf: MP3 fehlte komplett, beide
|
||||||
|
Encode-Zweige waren still gescheitert).
|
||||||
|
- `web/src/main.js`: `state.stopping` überlebt `render()` → Knopf bleibt über Reiterwechsel hinweg
|
||||||
|
gesperrt (⏳ + „speichert…"), Uhr friert ein, Live-Karte sagt „Aufnahme wird gespeichert…", Toast nennt
|
||||||
|
die geschätzte Wartezeit, zweiter Klick wird verworfen, `not recording` ist kein roter Fehler mehr.
|
||||||
|
Dazu lädt der Player nach (`#mp3wait`, `error`-Ereignis → `audio.load()` alle 5 s, Abbruch nach 5 min).
|
||||||
|
- `server.js`: `sweepOrphans` erkennt `rec_*.mp3.part`, damit eine beim Absturz liegengebliebene
|
||||||
|
Halbdatei nach `ORPHAN_MIN_AGE_MS` mit weggeräumt wird.
|
||||||
|
|
||||||
|
**Gegenprobe:** Stop antwortet in **0,033 s / 0,044 s** (12- und 15-s-Testclips #119/#120, danach in den
|
||||||
|
Papierkorb), `mp3 fertig: …` erscheint ~2 s später im Journal, Datei ist 64 kbit CBR / 48 kHz / mono ohne
|
||||||
|
Xing-Tabelle, `/media/…` liefert 200.
|
||||||
|
|
||||||
|
**Dateien/Backups:** `recorder.js.bak-stopfast`, `server.js.bak-stopfast`, `main.js.bak-stopfix`,
|
||||||
|
`main.js.bak-mp3wait`. Backend geändert ⇒ Neustart, vorher `analysis_status='pending'` geprüft (leer).
|
||||||
|
Der Vite-Build wurde bis zum Ende der laufenden Analyse #118 aufgeschoben: bei 816 MB freiem RAM neben
|
||||||
|
dem residenten rkllm (~6,5 GB) hätte er den OOM-Killer auf genau diese Analyse gezogen. Kapitel 03
|
||||||
|
aktualisiert (neuer Abschnitt „Der Stop-Knopf" mit Vorher/Nachher-Sequenzdiagramm).
|
||||||
|
|
||||||
## 2026-08-17 (Nachtrag 6) — Diagramme zur Systemanzeige
|
## 2026-08-17 (Nachtrag 6) — Diagramme zur Systemanzeige
|
||||||
|
|
||||||
Kapitel 07 nachgerüstet: ein Sequenzdiagramm für den Abfrageweg (GUI-Poll → `/api/sysstat` →
|
Kapitel 07 nachgerüstet: ein Sequenzdiagramm für den Abfrageweg (GUI-Poll → `/api/sysstat` →
|
||||||
|
|||||||
Reference in New Issue
Block a user