Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions bundles/meeting-recorder/panel/meeting-recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -125,7 +126,9 @@ export default {
system sound.</span></span></label>
<label class="mr-check"><input type="checkbox" id="mr-src-mic" checked>
<span><strong>My microphone</strong>
<span class="mr-hint">Catches what you say, including questions you ask.</span></span></label>
<span class="mr-hint">Catches what you say. <strong>Turn this on if you will be
speaking</strong>, or if anyone in the room with you will. Leave it off when you are only
listening.</span></span></label>
<div class="mr-row" style="margin-top:var(--crow-space-4)">
<button class="btn btn-primary" id="mr-start">Start recording</button>
</div>
Expand All @@ -137,10 +140,12 @@ export default {
<div><span class="mr-dot"></span><span class="mr-big" id="mr-elapsed">0:00</span></div>
<button class="btn" id="mr-stop">Stop and transcribe</button>
</div>
<p class="mr-hint" id="mr-live-sources"></p>
<div class="mr-meterwrap"><div class="mr-meter" id="mr-meter"></div></div>
<p class="mr-hint" id="mr-stat">starting…</p>
<div class="mr-note" id="mr-silence" hidden>No sound has reached the recorder yet. If the meeting
audio is the part you need, stop, start again, and tick <strong>Share tab audio</strong>.</div>
<div class="mr-note" id="mr-silence" hidden>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 <strong>Share tab audio</strong> in the picker.</div>
<label class="mr-label" for="mr-notes" style="margin-top:var(--crow-space-4)">Notes while you listen</label>
<textarea id="mr-notes" placeholder="Decisions, questions, anything worth flagging."></textarea>
</div>
Expand Down Expand Up @@ -179,13 +184,18 @@ ${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;

async function start() {
$("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();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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; }
Expand All @@ -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 = "";
});
})();
</script>`;
Expand Down
63 changes: 44 additions & 19 deletions bundles/meeting-recorder/server/transcribe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -46,32 +51,53 @@ 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) {
const segments = [];
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,
Expand All @@ -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;
Expand Down
Loading