GUI: Record-Button in Kopfzeile (Aufnahme-Tab entfernt) + Live-Karte; lame-Stille-Fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
co-authored by
Claude Fable 5
parent
aef50dda64
commit
b33b555fa7
+12
-3
@@ -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.
|
||||
|
||||
+55
-64
@@ -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() {
|
||||
<svg class="brand-wave" viewBox="0 0 34 20" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="3" y1="7" x2="3" y2="13"/><line x1="9" y1="4" x2="9" y2="16"/><line x1="15" y1="1" x2="15" y2="19"/><line x1="21" y1="5" x2="21" y2="15"/><line x1="27" y1="8" x2="27" y2="12"/><line x1="33" y1="9.5" x2="33" y2="10.5"/></g></svg>
|
||||
<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>
|
||||
<span class="rectime mono" data-rectime>${state.recording ? fmt(Date.now() - state.recStart) : '00:00'}</span>
|
||||
</div>
|
||||
<nav>
|
||||
${['record', 'recordings', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ record: 'Aufnahme', bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
${['recordings', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
</nav>
|
||||
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
|
||||
</header>
|
||||
@@ -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 = '<div class="err">' + e.message + '</div>');
|
||||
}
|
||||
|
||||
// ---------- Record ----------
|
||||
async function viewRecord() {
|
||||
const v = document.querySelector('#view');
|
||||
v.innerHTML = `
|
||||
<div class="card">
|
||||
<label class="lbl">Audioquelle (Mikrofon)</label>
|
||||
<div class="row">
|
||||
<select id="src" class="grow"></select>
|
||||
<button id="reloadsrc" class="btn">↻</button>
|
||||
</div>
|
||||
<label class="lbl">HFP-Codec (nur Bluetooth) — A/B-Test</label>
|
||||
<select id="codec" class="grow">
|
||||
<option value="auto" selected>Auto (mSBC bevorzugt)</option>
|
||||
<option value="msbc">mSBC — 16 kHz Breitband</option>
|
||||
<option value="cvsd">CVSD — 8 kHz Schmalband</option>
|
||||
</select>
|
||||
<label class="lbl">Sprecheranzahl (optional)</label>
|
||||
<input id="nspk" class="input" type="number" min="1" max="8" placeholder="leer = automatisch erkennen"/>
|
||||
<div class="hint">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.</div>
|
||||
<div class="rec-panel">
|
||||
<div id="timer" class="timer">00:00</div>
|
||||
<button id="recbtn" class="btn big ${state.recording ? 'danger' : 'primary'}">${state.recording ? '■ Stop & Transkribieren' : '● Aufnahme starten'}</button>
|
||||
</div>
|
||||
<div id="recmsg" class="muted"></div>
|
||||
</div>`;
|
||||
const sel = v.querySelector('#src');
|
||||
async function loadSrc() {
|
||||
try {
|
||||
const s = await api.get('/api/audio/sources');
|
||||
sel.innerHTML = '<option value="">(Standardquelle)</option>' + s.map(x => `<option value="${x.name}" ${x.isBluetooth ? 'selected' : ''}>${x.isBluetooth ? '🎧 ' : '🎙️ '}${x.label || x.name}</option>`).join('');
|
||||
} catch { sel.innerHTML = '<option value="">(keine gefunden)</option>'; }
|
||||
}
|
||||
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() {
|
||||
<label class="lbl">Name</label>
|
||||
<input id="vname" class="input" placeholder="z.B. Sandra" value="${escapeHtml(state.enrollName || '')}" ${state.enrolling ? 'disabled' : ''}/>
|
||||
<div class="rec-panel">
|
||||
<div id="vtimer" class="timer">00:00</div>
|
||||
<div id="vtimer" class="timer" data-rectime>00:00</div>
|
||||
<button id="vbtn" class="btn big ${state.enrolling ? 'danger' : 'primary'}">${state.enrolling ? '■ Stop & Speichern' : '● Aufnahme starten'}</button>
|
||||
</div>
|
||||
<div id="vmsg" class="muted"></div>
|
||||
@@ -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 = '<div class="muted">Noch keine Aufnahmen.</div>'; state.lastListSig = 'empty'; scheduleNext(); return; }
|
||||
if (!recs.length && !state.recording) { listEl.innerHTML = '<div class="muted">Noch keine Aufnahmen.</div>'; 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 ? `
|
||||
<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>
|
||||
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
|
||||
</div>` : '';
|
||||
listEl.innerHTML = liveHtml + groups.map(day => {
|
||||
const collapsed = state.collapsedDates.has(day);
|
||||
const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
|
||||
<div class="item rec ${state.selectedId == r.id ? 'sel' : ''}" data-open="${r.id}">
|
||||
|
||||
+19
-1
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user