From b33b555fa7a986f3ef4e3106da46320a325dde0c Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 17 Jul 2026 02:18:39 +0200 Subject: [PATCH] GUI: Record-Button in Kopfzeile (Aufnahme-Tab entfernt) + Live-Karte; lame-Stille-Fix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV --- app/recorder.js | 15 ++++-- app/web/src/main.js | 119 +++++++++++++++++++----------------------- app/web/src/style.css | 20 ++++++- docs/08-changelog.md | 13 +++++ 4 files changed, 99 insertions(+), 68 deletions(-) diff --git a/app/recorder.js b/app/recorder.js index 68bd634..9ec10a1 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -308,9 +308,18 @@ export async function stopAndSave() { 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. - await pexec('ffmpeg', ['-y', '-i', c.wavPath, - '-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11', - '-codec:a', 'libmp3lame', '-qscale:a', '3', mp3Path]); + // 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. + try { + await pexec('ffmpeg', ['-y', '-i', c.wavPath, + '-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11', + '-codec:a', 'libmp3lame', '-qscale:a', '3', 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, + '-codec:a', 'libmp3lame', '-qscale:a', '3', 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. diff --git a/app/web/src/main.js b/app/web/src/main.js index c4e339f..cdc0b23 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -8,7 +8,7 @@ const api = { }; const app = document.querySelector('#app'); -const state = { tab: 'record', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null }; +const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null }; 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); } @@ -31,9 +31,13 @@ function render() {

Aime Anruf-Assistent

+
+ + ${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} +
@@ -41,7 +45,43 @@ function render() { app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); app.querySelector('#themetoggle').onclick = () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); - ({ record: viewRecord, bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab](); + app.querySelector('#recbtn').onclick = toggleRecord; + if (state.recording) startTimerUI(); + ({ bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab](); +} + +// Header record button: start = defaults only (default source = CallRecMix, +// 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', {}); + state.recording = true; state.recStart = Date.now(); + render(); + if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } + } catch (err) { toast(err.message, true); } + } else { + if (btn) { btn.disabled = true; } + clearInterval(state.timer); state.timer = null; + try { + const r = await api.post('/api/record/stop', {}); + state.recording = false; + toast('Aufnahme gespeichert – wird transkribiert…'); + state.tab = 'recordings'; render(); + state.lastListSig = null; refreshRecList(); + openRecording(r.id); + } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } + } +} +// Tick every .rectime element (header + list placeholder) from one interval. +function startTimerUI() { + clearInterval(state.timer); + state.timer = setInterval(() => { + const t = fmt(Date.now() - state.recStart); + document.querySelectorAll('[data-rectime]').forEach(el => el.textContent = t); + }, 500); } // ---------- Bluetooth ---------- @@ -94,63 +134,6 @@ async function viewBluetooth() { load().catch(e => listEl.innerHTML = '
' + e.message + '
'); } -// ---------- Record ---------- -async function viewRecord() { - const v = document.querySelector('#view'); - v.innerHTML = ` -
- -
- - -
- - - - -
Wenn du die Anzahl der Sprecher kennst, hier eintragen — verbessert die Trennung. Sonst leer lassen (Auto). Titel, Zusammenfassung, ToDos & Sprecher-Erkennung laufen automatisch per KI.
-
-
00:00
- -
-
-
`; - const sel = v.querySelector('#src'); - async function loadSrc() { - try { - const s = await api.get('/api/audio/sources'); - sel.innerHTML = '' + s.map(x => ``).join(''); - } catch { sel.innerHTML = ''; } - } - await loadSrc(); - v.querySelector('#reloadsrc').onclick = loadSrc; - const timerEl = v.querySelector('#timer'); - if (state.recording) startTimerUI(timerEl); - - v.querySelector('#recbtn').onclick = async (e) => { - if (!state.recording) { - try { - await api.post('/api/record/start', { source: sel.value, codec: v.querySelector('#codec').value, numSpeakers: +v.querySelector('#nspk').value || undefined }); - state.recording = true; state.recStart = Date.now(); render(); - } catch (err) { toast(err.message, true); } - } else { - e.target.disabled = true; e.target.textContent = 'Transkribiere… (kann dauern)'; - clearInterval(state.timer); - try { - const r = await api.post('/api/record/stop', {}); - state.recording = false; - toast('Aufnahme gespeichert – wird transkribiert…'); - state.tab = 'recordings'; render(); openRecording(r.id); - } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } - } - }; -} -function startTimerUI(el) { clearInterval(state.timer); state.timer = setInterval(() => { el.textContent = fmt(Date.now() - state.recStart); }, 500); } - // ---------- Voices (enrollment) ---------- async function viewVoices() { const v = document.querySelector('#view'); @@ -161,7 +144,7 @@ async function viewVoices() {
-
00:00
+
00:00
@@ -328,7 +311,7 @@ async function refreshRecList() { const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; try { - if (!recs.length) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } + if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } // Signature of everything the list RENDERS except the live ⏱ seconds (those // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. @@ -352,7 +335,15 @@ async function refreshRecList() { // Fresh tab entry: only the newest day (first group, recs are newest-first) // stays open; manual toggles afterwards are respected until the next entry. if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; } - listEl.innerHTML = groups.map(day => { + // Live placeholder while a recording runs: its own card at the very top. + const liveHtml = state.recording ? ` +
+ +
Aufnahme läuft… +
wird nach Stop transkribiert & analysiert
+ ${fmt(Date.now() - state.recStart)} +
` : ''; + listEl.innerHTML = liveHtml + groups.map(day => { const collapsed = state.collapsedDates.has(day); const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
diff --git a/app/web/src/style.css b/app/web/src/style.css index a5d6930..15ee043 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -76,7 +76,25 @@ body { .topbar h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.01em; } .topbar .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 6px; } nav { display: flex; gap: 6px; flex-wrap: wrap; } -.topbar nav { margin-left: auto; } +.topbar nav { margin-left: 0; } + +/* Aufnahme-Steuerung in der Kopfzeile (zwischen Logo und Tabs) */ +.rec-ctl { display: flex; align-items: center; gap: 10px; margin: 0 auto; } +.recbtn { + width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--danger); + background: var(--danger-soft); color: var(--danger); font-size: 15px; line-height: 1; + cursor: pointer; transition: background .15s, transform .1s; +} +.recbtn:hover { background: var(--danger); color: #fff; } +.recbtn.rec { background: var(--danger); color: #fff; animation: recpulse 1.4s ease-in-out infinite; } +@keyframes recpulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 89, 94, .45); } 50% { box-shadow: 0 0 0 9px rgba(229, 89, 94, 0); } } +.recbtn:disabled { opacity: .55; cursor: default; animation: none; } +.rec-ctl .rectime { font-size: 14px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 44px; } + +/* Live-Platzhalter in der Aufnahmen-Liste */ +.item.rec-live { border-color: var(--danger); background: var(--danger-soft); } +.rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); flex: none; animation: recpulse 1.4s ease-in-out infinite; } +.item.rec-live .rectime { color: var(--danger); font-weight: 650; } .tab { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; diff --git a/docs/08-changelog.md b/docs/08-changelog.md index e5a3172..0cdc2b9 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,19 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-07-17 — GUI: Aufnahme-Tab gestrichen, Record-Button in der Kopfzeile + Stille-MP3-Fix + +- Der Reiter "Aufnahme" ist weg: Quelle (CallRecMix), HFP-Codec (auto) und Sprecherzahl (auto) waren + eh die Defaults. Aufnahme-Knopf (roter Kreis) + Laufzeit sitzen jetzt MITTIG in der Kopfzeile + zwischen Logo und Tabs; beim Aufnehmen pulsiert er rot. Start legt SOFORT ein rotes + "Aufnahme laeuft..."-Kaestchen mit Live-Timer ganz oben in der Liste an; Stop erzeugt daraus die + echte Zeile (transcribing) und oeffnet sie rechts. Standard-Tab ist jetzt "Aufnahmen". +- **recorder.js (`.bak-silentmp3`):** lame crashte auf komplett stiller Aufnahme + (`psymodel.c calc_energy: el >= 0` — seit dem Mic-Gate real: niemand sprach = Null-Samples, + loudnorm-Kette triggert die Assertion) -> Fallback-Encode ohne Filter statt Stop-Fehler. + E2E getestet (3-s-Stille-Aufnahme: Stop ok, Zeile erstellt, transkribiert, wieder geloescht). + Bundle `index-UPMXRPJp.js`. + ## 2026-07-17 — GUI: Scrollbalken im Logo-Kupfer Alle Scrollbalken (Liste, Transkript-Panel, Sprechblase, Seite) jetzt im Akzent-Kupfer: