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…
-
No sound has reached the recorder yet. If the meeting
- audio is the part you need, stop, start again, and tick Share tab audio.
+
No sound has reached the recorder in the last little
+ while. Recording continues either way. If the meeting audio is the part you need, stop, start
+ again, and tick Share tab audio in the picker.
@@ -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;