From 83f481bc83d7f01cfacbb4d5b61566f43660eb10 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:56:02 -0500 Subject: [PATCH 1/2] Meeting recorder: fix the level meter in a background tab, show live sources The meter ran on requestAnimationFrame, which browsers suspend in a background tab. Sharing a meeting tab is exactly the case that puts the recorder page in the background, so the meter froze and the "no sound has reached the recorder" note appeared while audio was recording correctly. A 100 ms interval keeps sampling. Alongside that, the panel now says what it is capturing rather than leaving it to be discovered afterwards: a live "Meeting audio: on / Microphone: off" line while recording, the microphone hint spelling out that it belongs on whenever the user or anyone in the room will speak, and a plain line at start when the microphone is off. The silence note no longer implies the recording has stopped. Claude-Session: https://claude.ai/code/session_01SXuk2GeBZTwo9wP3D2VEQF --- .../panel/meeting-recorder.js | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/bundles/meeting-recorder/panel/meeting-recorder.js b/bundles/meeting-recorder/panel/meeting-recorder.js index 4407f54d..116baf1d 100644 --- a/bundles/meeting-recorder/panel/meeting-recorder.js +++ b/bundles/meeting-recorder/panel/meeting-recorder.js @@ -92,6 +92,7 @@ export default { border: 1px solid var(--crow-border); border-radius: var(--crow-radius-pill); overflow: hidden; margin: var(--crow-space-3) 0 var(--crow-space-1); } .mr-meter { height: 100%; width: 0%; background: var(--crow-accent); transition: width .1s linear; } + .mr-warn { color: var(--crow-warning); } .mr-big { font-size: var(--crow-text-3xl); font-weight: 600; font-variant-numeric: tabular-nums; color: var(--crow-text-primary); } .mr-note { border-left: 3px solid var(--crow-warning); background: var(--crow-bg-elevated); @@ -125,7 +126,9 @@ export default { system sound. + Catches what you say. Turn this on if you will be + speaking, or if anyone in the room with you will. Leave it off when you are only + listening.
@@ -137,10 +140,12 @@ export default {
0:00
+

starting…

- + @@ -179,6 +184,7 @@ ${section( let rec = null, sid = null, t0 = 0, timer = null, streams = [], ac = null; let uploaded = 0, chunks = 0, sawSound = false, stopping = false; + let levelTimer = null, active = []; if (!window.isSecureContext) $("mr-insecure").hidden = false; @@ -186,6 +192,10 @@ ${section( $("mr-setup-msg").textContent = ""; const wantTab = $("mr-src-tab").checked, wantMic = $("mr-src-mic").checked; if (!wantTab && !wantMic) { $("mr-setup-msg").textContent = "Pick at least one source."; return; } + if (!wantMic) { + $("mr-setup-msg").textContent = + "Recording meeting audio only. Your own voice will not be captured."; + } ac = new AudioContext(); const mix = ac.createMediaStreamDestination(); @@ -234,15 +244,17 @@ ${section( analyser.fftSize = 512; ac.createMediaStreamSource(mix.stream).connect(analyser); const buf = new Uint8Array(analyser.frequencyBinCount); - (function draw() { + // A timer, not requestAnimationFrame: the browser suspends animation frames in a + // background tab, and sharing a meeting tab puts this page in the background, which + // froze the meter and tripped the silence note while the audio recorded correctly. + levelTimer = setInterval(() => { if (!rec) return; analyser.getByteTimeDomainData(buf); let m = 0; for (const v of buf) m = Math.max(m, Math.abs(v - 128)); if (m > 4) sawSound = true; $("mr-meter").style.width = Math.min(100, (m / 60) * 100) + "%"; - requestAnimationFrame(draw); - })(); + }, 100); rec = new MediaRecorder(mix.stream, { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 48000 }); rec.ondataavailable = async (e) => { @@ -259,6 +271,11 @@ ${section( $("mr-stat").textContent = "Upload failed, still recording locally: " + err.message; } }; + active = sources.slice(); + $("mr-live-sources").textContent = "Meeting audio: " + + (active.includes("meeting") ? "on" : "off") + + " \u00b7 Microphone: " + (active.includes("microphone") ? "on" : "off"); + $("mr-live-sources").classList.toggle("mr-warn", !active.includes("microphone")); rec.start(15000); t0 = Date.now(); $("mr-setup").hidden = true; $("mr-upload-card").hidden = true; @@ -345,6 +362,8 @@ ${section( } function cleanup() { + if (levelTimer) { clearInterval(levelTimer); levelTimer = null; } + $("mr-meter").style.width = "0%"; streams.forEach((s) => s.getTracks().forEach((t) => t.stop())); streams = []; if (ac) { ac.close().catch(() => {}); ac = null; } @@ -359,6 +378,7 @@ ${section( $("mr-done").hidden = true; $("mr-setup").hidden = false; $("mr-upload-card").hidden = false; $("mr-stop").disabled = false; $("mr-up-wrap").hidden = true; $("mr-up-bar").style.width = "0%"; $("mr-file").value = ""; uploaded = 0; chunks = 0; sawSound = false; $("mr-notes").value = ""; + active = []; $("mr-live-sources").textContent = ""; $("mr-setup-msg").textContent = ""; }); })(); `; From f135f2a2c46a0fd288abac63c7309486f414c81a Mon Sep 17 00:00:00 2001 From: kh0pper Date: Wed, 9 Sep 2026 14:59:57 -0500 Subject: [PATCH 2/2] Meeting recorder: stop losing a transcription to undici's 300 s timeout A ninety-minute recording failed at minute 60 with "fetch failed" while the transcription endpoint had answered every request with 200 OK. Node's fetch has no public knob for undici's 300 s headers timeout, and a ten-minute slice on CPU sits right on that boundary: the slice that broke it took 306 seconds. Three changes, none of which add a dependency. - Slices default to four minutes rather than ten, which finishes well inside the limit with room for the machine to be busy. - A failed slice retries three times with backoff, so a transient hiccup costs seconds rather than the whole run. - Each finished slice is cached beside the audio, so a re-run resumes instead of paying again for minutes already transcribed. The first failure discarded sixty minutes of completed work. Claude-Session: https://claude.ai/code/session_01SXuk2GeBZTwo9wP3D2VEQF --- bundles/meeting-recorder/server/transcribe.js | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/bundles/meeting-recorder/server/transcribe.js b/bundles/meeting-recorder/server/transcribe.js index ac95303f..18728059 100644 --- a/bundles/meeting-recorder/server/transcribe.js +++ b/bundles/meeting-recorder/server/transcribe.js @@ -18,7 +18,12 @@ import { findSource, readMeta, sessionDir, writeMeta } from "./store.js"; const WHISPER_URL = process.env.WHISPER_URL || "http://localhost:8004/v1/audio/transcriptions"; const WHISPER_MODEL = process.env.WHISPER_MODEL || "Systran/faster-whisper-large-v3"; -const SLICE_SECONDS = Number(process.env.WHISPER_SLICE_SECONDS || 600); +// Node's fetch has no public knob for undici's 300 s headers timeout, and a slice that +// takes longer than that to come back fails as "fetch failed" even though the endpoint +// answered. Four minutes of audio transcribes well inside the limit on CPU, with room for +// the machine to be busy. Raising this is how you reintroduce that failure. +const SLICE_SECONDS = Number(process.env.WHISPER_SLICE_SECONDS || 240); +const SLICE_ATTEMPTS = Number(process.env.WHISPER_SLICE_ATTEMPTS || 3); const EXPORT_DIR = process.env.MEETING_RECORDER_EXPORT_DIR || ""; function run(cmd, args) { @@ -46,13 +51,23 @@ async function durationSeconds(path) { } async function postSlice(path) { - const form = new FormData(); - form.append("model", WHISPER_MODEL); - form.append("response_format", "verbose_json"); - form.append("file", new Blob([readFileSync(path)], { type: "audio/wav" }), basename(path)); - const res = await fetch(WHISPER_URL, { method: "POST", body: form }); - if (!res.ok) throw new Error(`transcription endpoint returned ${res.status}`); - return res.json(); + let last; + for (let attempt = 1; attempt <= SLICE_ATTEMPTS; attempt++) { + try { + const form = new FormData(); + form.append("model", WHISPER_MODEL); + form.append("response_format", "verbose_json"); + form.append("file", new Blob([readFileSync(path)], { type: "audio/wav" }), basename(path)); + const res = await fetch(WHISPER_URL, { method: "POST", body: form }); + if (!res.ok) throw new Error(`transcription endpoint returned ${res.status}`); + return await res.json(); + } catch (err) { + last = err; + if (attempt < SLICE_ATTEMPTS) await new Promise((r) => setTimeout(r, 5000 * attempt)); + } + } + throw new Error(`slice ${basename(path)} failed after ${SLICE_ATTEMPTS} attempts: ` + + String(last && last.message ? last.message : last)); } async function transcribeWav(wav, id, total) { @@ -60,18 +75,29 @@ async function transcribeWav(wav, id, total) { const sliceDir = join(sessionDir(id), "slices"); mkdirSync(sliceDir, { recursive: true }); for (let start = 0; start < Math.max(total, 1); start += SLICE_SECONDS) { - const part = join(sliceDir, `part-${String(start / SLICE_SECONDS).padStart(3, "0")}.wav`); - await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", - "-ss", String(start), "-t", String(SLICE_SECONDS), "-i", wav, - "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", part]); - if (!existsSync(part) || statSync(part).size < 2000) { + const stem = join(sliceDir, `part-${String(start / SLICE_SECONDS).padStart(3, "0")}`); + const part = `${stem}.wav`; + const cached = `${stem}.json`; + // A finished slice is kept on disk, so a re-run after a failure resumes rather than + // paying for the minutes already transcribed. + let result; + if (existsSync(cached)) { + result = JSON.parse(readFileSync(cached, "utf8")); + } else { + await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-ss", String(start), "-t", String(SLICE_SECONDS), "-i", wav, + "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", part]); + if (!existsSync(part) || statSync(part).size < 2000) { + rmSync(part, { force: true }); + break; + } + writeMeta(id, { + progress: `transcribing minute ${Math.round(start / 60)} of ${Math.round(total / 60)}`, + }); + result = await postSlice(part); + writeFileSync(cached, JSON.stringify(result)); rmSync(part, { force: true }); - break; } - writeMeta(id, { - progress: `transcribing minute ${Math.round(start / 60)} of ${Math.round(total / 60)}`, - }); - const result = await postSlice(part); for (const seg of result.segments || []) { segments.push({ start: Math.round((Number(seg.start || 0) + start) * 100) / 100, @@ -82,7 +108,6 @@ async function transcribeWav(wav, id, total) { if (!(result.segments || []).length && result.text) { segments.push({ start, end: start, text: result.text.trim() }); } - rmSync(part, { force: true }); } rmSync(sliceDir, { recursive: true, force: true }); return segments;