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 = ""; }); })(); `; 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;