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:
+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 —
|
||||
// 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() {
|
||||
<h1>Aime <span class="sub">Anruf-Assistent</span></h1>
|
||||
</div>
|
||||
<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>
|
||||
${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)}"
|
||||
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>` : ''}
|
||||
@@ -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 ? `
|
||||
<div class="item rec-live">
|
||||
<span class="rec-dot"></span>
|
||||
<div class="grow"><b>Aufnahme läuft…</b>
|
||||
<div class="muted">wird nach Stop transkribiert & analysiert</div>
|
||||
<div class="grow"><b>${state.stopping ? 'Aufnahme wird gespeichert…' : 'Aufnahme läuft…'}</b>
|
||||
<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>
|
||||
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
|
||||
</div>` : '';
|
||||
@@ -1342,6 +1371,7 @@ async function openRecording(id) {
|
||||
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
||||
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
|
||||
<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}
|
||||
<div class="status-line">
|
||||
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user