rowboat/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts
Gagan 3419cf8ef0 fix(x): don't overwrite same-titled meeting notes within a day
Note filenames derive from the meeting title, so every ad-hoc detected
meeting (titled "Meeting") — and any recurring calendar event repeating
its summary — resolved to the same file and each recording clobbered the
previous one's notes. Suffix with the start timestamp when the path
already exists.
2026-07-14 00:51:11 +05:30

578 lines
26 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import { toast } from 'sonner';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
const DEEPGRAM_PARAMS = new URLSearchParams({
model: 'nova-3',
encoding: 'linear16',
sample_rate: '16000',
channels: '2',
multichannel: 'true',
diarize: 'true',
interim_results: 'true',
smart_format: 'true',
punctuate: 'true',
language: 'en',
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// RMS threshold: system audio above this = "active" (speakers playing)
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
// RMS threshold for "someone is talking" on either channel. Drives silence
// detection — kept a touch above the gate threshold so faint room noise on the
// mic doesn't read as speech and keep a finished recording alive.
const SPEECH_RMS_THRESHOLD = 0.01;
// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER
// the mic or the system-audio channel (i.e. nobody — local or remote — talking).
// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop.
// - After SILENCE_BACKSTOP_MS we stop unconditionally.
// - Once past the linked calendar event's end time we use the shorter
// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a
// strong signal the meeting is actually over.
const SILENCE_NUDGE_MS = 2 * 60 * 1000;
const SILENCE_BACKSTOP_MS = 5 * 60 * 1000;
const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000;
// How often the silence checker runs.
const SILENCE_CHECK_INTERVAL_MS = 5 * 1000;
// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute"
// when the meeting ends, and its readyState stays "live" — only track.muted flips
// to true. But muted is ambiguous: it also goes true whenever no system audio is
// playing (a quiet but live meeting), so muted alone can't safely trigger a stop.
// See the poll in start() for how the muted signal is gated on the scheduled
// calendar end so a quiet stretch never cuts a live meeting short.
const TRACK_POLL_INTERVAL_MS = 3 * 1000;
const MUTE_POLLS_TO_STOP = 3;
// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended"
// event fires normally (handled by the listener in start()), so the poll below is
// gated to macOS.
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac');
// ---------------------------------------------------------------------------
// Headphone detection
// ---------------------------------------------------------------------------
async function detectHeadphones(): Promise<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outputs = devices.filter(d => d.kind === 'audiooutput');
const defaultOutput = outputs.find(d => d.deviceId === 'default');
const label = (defaultOutput?.label ?? '').toLowerCase();
// Heuristic: built-in speakers won't match these patterns
const headphonePatterns = ['headphone', 'airpod', 'earpod', 'earphone', 'earbud', 'bluetooth', 'bt_', 'jabra', 'bose', 'sony wh', 'sony wf'];
return headphonePatterns.some(p => label.includes(p));
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Transcript formatting
// ---------------------------------------------------------------------------
interface TranscriptEntry {
speaker: string;
text: string;
}
export interface CalendarEventMeta {
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
conferenceLink?: string
source?: string
}
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
const noteTitle = calendarEvent?.summary || 'Meeting Notes';
const lines = [
'---',
'type: meeting',
'source: rowboat',
`title: ${noteTitle}`,
`date: "${date}"`,
];
if (calendarEvent) {
// Serialize as a JSON string on one line — the frontmatter system
// only supports flat key: value pairs, not nested YAML objects.
const eventObj: Record<string, string> = {}
if (calendarEvent.summary) eventObj.summary = calendarEvent.summary
if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime
else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date
if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime
else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date
if (calendarEvent.location) eventObj.location = calendarEvent.location
if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink
if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink
if (calendarEvent.source) eventObj.source = calendarEvent.source
lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`)
}
lines.push(
'---',
'',
`# ${noteTitle}`,
'',
);
// Build the raw transcript text
const transcriptLines: string[] = [];
for (let i = 0; i < entries.length; i++) {
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
transcriptLines.push('');
}
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
transcriptLines.push('');
}
const transcriptText = transcriptLines.join('\n').trim();
const transcriptData = JSON.stringify({ transcript: transcriptText });
lines.push('```transcript', transcriptData, '```');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useMeetingTranscription(onAutoStop?: () => void) {
const { refresh: refreshRowboatAccount } = useRowboatAccount();
const [state, setState] = useState<MeetingTranscriptionState>('idle');
const wsRef = useRef<WebSocket | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const systemStreamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const transcriptRef = useRef<TranscriptEntry[]>([]);
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
const notePathRef = useRef<string>('');
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Silence detection: timestamp of the last speech-level audio on either
// channel, plus the interval that checks it. calendarEndMsRef holds the
// linked event's end time (null if none).
const lastAudioActivityRef = useRef<number>(0);
const silenceCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
const calendarEndMsRef = useRef<number | null>(null);
const nudgeToastIdRef = useRef<string | number | null>(null);
// On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire
// "ended"/"mute" when the meeting ends, so we poll its readyState/muted
// state instead.
const trackPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onAutoStopRef = useRef(onAutoStop);
onAutoStopRef.current = onAutoStop;
const dateRef = useRef<string>('');
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
const writeTranscriptToFile = useCallback(async () => {
if (!notePathRef.current) return;
const entries = [...transcriptRef.current];
for (const interim of interimRef.current.values()) {
if (!interim.text) continue;
if (entries.length > 0 && entries[entries.length - 1].speaker === interim.speaker) {
entries[entries.length - 1] = { speaker: interim.speaker, text: entries[entries.length - 1].text + ' ' + interim.text };
} else {
entries.push({ speaker: interim.speaker, text: interim.text });
}
}
if (entries.length === 0) return;
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
try {
await window.ipc.invoke('workspace:writeFile', {
path: notePathRef.current,
data: content,
opts: { encoding: 'utf8' },
});
} catch (err) {
console.error('[meeting] Failed to write transcript:', err);
}
}, []);
const scheduleDebouncedWrite = useCallback(() => {
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = setTimeout(() => {
void writeTranscriptToFile();
}, 1000);
}, [writeTranscriptToFile]);
const stopInputCapture = useCallback(() => {
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(t => t.stop());
micStreamRef.current = null;
}
if (systemStreamRef.current) {
systemStreamRef.current.getTracks().forEach(t => t.stop());
systemStreamRef.current = null;
}
}, []);
const cleanup = useCallback(() => {
if (writeTimerRef.current) {
clearTimeout(writeTimerRef.current);
writeTimerRef.current = null;
}
if (silenceCheckRef.current) {
clearInterval(silenceCheckRef.current);
silenceCheckRef.current = null;
}
if (nudgeToastIdRef.current !== null) {
toast.dismiss(nudgeToastIdRef.current);
nudgeToastIdRef.current = null;
}
if (trackPollingRef.current) {
clearInterval(trackPollingRef.current);
trackPollingRef.current = null;
}
stopInputCapture();
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
}, [stopInputCapture]);
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
if (state !== 'idle') return null;
setState('connecting');
// Run independent setup steps in parallel for faster startup
const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([
// 1. Detect headphones vs speakers
detectHeadphones(),
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
(async () => {
const account = await refreshRowboatAccount();
let ws: WebSocket;
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (!config?.deepgram) {
throw new Error('No Deepgram config available');
}
console.log('[meeting] Using Deepgram API key');
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
}
const ok = await new Promise<boolean>((resolve) => {
ws.onopen = () => resolve(true);
ws.onerror = () => resolve(false);
setTimeout(() => resolve(false), 5000);
});
if (!ok) throw new Error('WebSocket failed to connect');
console.log('[meeting] WebSocket connected');
return ws;
})(),
// 3. Get mic stream
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
}),
// 4. Get system audio via getDisplayMedia (loopback)
(async () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
stream.getVideoTracks().forEach(t => t.stop());
if (stream.getAudioTracks().length === 0) {
stream.getTracks().forEach(t => t.stop());
throw new Error('No audio track from getDisplayMedia');
}
console.log('[meeting] System audio captured');
return stream;
})(),
]);
// Check for failures — clean up any successful resources if something failed
const failed = wsResult.status === 'rejected'
|| micResult.status === 'rejected'
|| systemResult.status === 'rejected';
if (failed) {
if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason);
if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason);
if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason);
// Clean up any resources that did succeed
if (wsResult.status === 'fulfilled') { wsResult.value.close(); }
if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); }
if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); }
cleanup();
setState('idle');
return null;
}
const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false;
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
const ws = wsResult.value;
wsRef.current = ws;
// Set up WS message handler
transcriptRef.current = [];
interimRef.current = new Map();
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (!data.channel?.alternatives?.[0]) return;
const transcript = data.channel.alternatives[0].transcript;
if (!transcript) return;
const channelIndex = data.channel_index?.[0] ?? 0;
const isMic = channelIndex === 0;
// Channel 0 = mic = "You", Channel 1 = system audio with diarization
let speaker: string;
if (isMic) {
speaker = 'You';
} else {
// Use Deepgram diarization speaker ID for system audio channel
const words = data.channel.alternatives[0].words;
const speakerId = words?.[0]?.speaker;
speaker = speakerId != null ? `Speaker ${speakerId}` : 'System audio';
}
if (data.is_final) {
interimRef.current.delete(channelIndex);
const entries = transcriptRef.current;
if (entries.length > 0 && entries[entries.length - 1].speaker === speaker) {
entries[entries.length - 1].text += ' ' + transcript;
} else {
entries.push({ speaker, text: transcript });
}
} else {
interimRef.current.set(channelIndex, { speaker, text: transcript });
}
scheduleDebouncedWrite();
};
ws.onclose = () => {
console.log('[meeting] WebSocket closed');
wsRef.current = null;
};
const micStream = micResult.value;
micStreamRef.current = micStream;
const systemStream = systemResult.value;
systemStreamRef.current = systemStream;
// If the shared source goes away (user closes the call window / clicks
// "Stop sharing"), the track fires "ended" — treat that as the meeting
// ending and stop. Our own cleanup() calls track.stop(), which does NOT
// fire "ended", so this won't double-trigger on a manual stop.
systemStream.getAudioTracks().forEach(track => {
track.addEventListener('ended', () => {
console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');
onAutoStopRef.current?.();
});
});
// On macOS the system-audio track's "ended"/"mute" events don't fire when
// the meeting ends, so poll its state instead. (On Windows the "ended"
// listener above already covers this, so the poll is macOS-only.)
//
// - readyState === 'ended' is unambiguous (the source is gone) → stop now.
// It never actually fires on macOS (readyState stays 'live'); it's just
// a safety net should polling ever observe the track ending.
// - muted is ambiguous on macOS: it flips true both when the meeting ends
// AND when nothing is playing system audio (a quiet but live meeting).
// So we only treat sustained mute as "meeting over" once we're past the
// linked event's scheduled end — a dead audio track after the meeting
// was due to finish is a strong signal. With no calendar event, or
// before the scheduled end, we DON'T hard-stop on mute; the silence
// checker's nudge + backstop handles it, so a quiet stretch can never
// silently cut a live meeting short.
const pollTrack = systemStream.getAudioTracks()[0];
if (isMac && pollTrack) {
let mutedPolls = 0;
if (trackPollingRef.current) clearInterval(trackPollingRef.current);
trackPollingRef.current = setInterval(() => {
if (pollTrack.readyState === 'ended') {
console.log('[meeting] system-audio track ended (poll) — auto-stopping');
onAutoStopRef.current?.();
return;
}
if (pollTrack.muted) {
mutedPolls++;
const endMs = calendarEndMsRef.current;
const pastCalendarEnd = endMs != null && Date.now() > endMs;
if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) {
console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping');
onAutoStopRef.current?.();
}
} else {
mutedPolls = 0;
}
}, TRACK_POLL_INTERVAL_MS);
}
// ----- Audio pipeline -----
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
const micSource = audioCtx.createMediaStreamSource(micStream);
const systemSource = audioCtx.createMediaStreamSource(systemStream);
const merger = audioCtx.createChannelMerger(2);
micSource.connect(merger, 0, 0); // mic → channel 0
systemSource.connect(merger, 0, 1); // system audio → channel 1
const processor = audioCtx.createScriptProcessor(4096, 2, 2);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const micRaw = e.inputBuffer.getChannelData(0);
const sysRaw = e.inputBuffer.getChannelData(1);
// RMS of each channel, computed once per frame and reused for
// silence detection and gating the mic in speaker mode.
let micSum = 0;
for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i];
const micRms = Math.sqrt(micSum / micRaw.length);
let sysSum = 0;
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
const sysRms = Math.sqrt(sysSum / sysRaw.length);
// Reset the silence clock whenever EITHER channel has speech-level
// audio. Uses the raw mic (pre-gating) so the user's own voice counts
// even in speaker mode where the outgoing mic gets muted.
if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) {
lastAudioActivityRef.current = Date.now();
}
// Mode 1 (headphones): pass both streams through unmodified
// Mode 2 (speakers): gate/mute mic when system audio is active
let micOut: Float32Array;
if (usingHeadphones) {
micOut = micRaw;
} else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
// System audio is playing — mute mic to prevent bleed
micOut = new Float32Array(micRaw.length); // all zeros
} else {
// System audio is silent — pass mic through
micOut = micRaw;
}
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
const int16 = new Int16Array(micOut.length * 2);
for (let i = 0; i < micOut.length; i++) {
const s0 = Math.max(-1, Math.min(1, micOut[i]));
const s1 = Math.max(-1, Math.min(1, sysRaw[i]));
int16[i * 2] = s0 < 0 ? s0 * 0x8000 : s0 * 0x7fff;
int16[i * 2 + 1] = s1 < 0 ? s1 * 0x8000 : s1 * 0x7fff;
}
wsRef.current.send(int16.buffer);
};
merger.connect(processor);
processor.connect(audioCtx.destination);
// Create the note file, organized by date like voice memos
const now = new Date();
const dateStr = now.toISOString();
dateRef.current = dateStr;
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
const filename = calendarEvent?.summary
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
: `meeting-${timestamp}`;
let notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
// Title-derived names collide within a day — every ad-hoc detection is
// titled "Meeting", and recurring calendar events repeat their summary.
// Never overwrite an earlier meeting's note: suffix with the timestamp.
if (calendarEvent?.summary) {
try {
const { exists } = await window.ipc.invoke('workspace:exists', { path: notePath });
if (exists) notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}-${timestamp}.md`;
} catch { /* fall through with the unsuffixed path */ }
}
notePathRef.current = notePath;
calendarEventRef.current = calendarEvent;
// Parse the linked event's end time (timed events only) so the silence
// window can shorten once the meeting is past its scheduled end.
const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN;
calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null;
const initialContent = formatTranscript([], dateStr, calendarEvent);
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
data: initialContent,
opts: { encoding: 'utf8', mkdirp: true },
});
// Arm silence detection. Initialise the activity clock to "now" so the
// checker is live from the very start of recording — a session that
// never captures any audio still auto-stops at the backstop instead of
// running forever.
lastAudioActivityRef.current = Date.now();
if (silenceCheckRef.current) clearInterval(silenceCheckRef.current);
silenceCheckRef.current = setInterval(() => {
const silentMs = Date.now() - lastAudioActivityRef.current;
const endMs = calendarEndMsRef.current;
const pastCalendarEnd = endMs != null && Date.now() > endMs;
const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS;
if (silentMs >= hardStopMs) {
console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`);
onAutoStopRef.current?.();
return;
}
if (silentMs >= SILENCE_NUDGE_MS) {
// Ask once; the toast persists until dismissed or acted on. Past
// the scheduled end we skip straight to the hard stop above, so
// the nudge only ever shows for an in-progress meeting.
if (nudgeToastIdRef.current === null) {
nudgeToastIdRef.current = toast('Still in a meeting?', {
description: "It's been quiet for a couple of minutes.",
duration: Infinity,
action: {
label: 'Stop recording',
onClick: () => { onAutoStopRef.current?.(); },
},
});
}
} else if (nudgeToastIdRef.current !== null) {
// Audio resumed before the backstop — retract the nudge.
toast.dismiss(nudgeToastIdRef.current);
nudgeToastIdRef.current = null;
}
}, SILENCE_CHECK_INTERVAL_MS);
setState('recording');
return notePath;
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
const stop = useCallback(async () => {
if (state !== 'recording') return;
setState('stopping');
stopInputCapture();
await finalizeDeepgramStream(wsRef.current, 2200);
cleanup();
await writeTranscriptToFile();
interimRef.current = new Map();
setState('idle');
}, [state, cleanup, stopInputCapture, writeTranscriptToFile]);
return { state, start, stop };
}