mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
(1) add running transcript to voice input (2) capture till end of speech when mic is used (3) notify only on important emails
This commit is contained in:
parent
753e3448f0
commit
f2b5c6b1ab
7 changed files with 147 additions and 41 deletions
|
|
@ -1040,8 +1040,9 @@ function App() {
|
||||||
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
|
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
|
||||||
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
|
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
|
||||||
|
|
||||||
const handleSubmitRecording = useCallback(() => {
|
const handleSubmitRecording = useCallback(async () => {
|
||||||
const text = voice.submit()
|
if (!isRecordingRef.current) return
|
||||||
|
const text = await voice.submit()
|
||||||
setIsRecording(false)
|
setIsRecording(false)
|
||||||
isRecordingRef.current = false
|
isRecordingRef.current = false
|
||||||
if (text) {
|
if (text) {
|
||||||
|
|
@ -6377,7 +6378,7 @@ function App() {
|
||||||
onWorkDirChange={(v) => setTabWorkDir(tab.id, v)}
|
onWorkDirChange={(v) => setTabWorkDir(tab.id, v)}
|
||||||
isRecording={isActive && isRecording}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? voice.interimText : undefined}
|
recordingText={isActive ? voice.interimText : undefined}
|
||||||
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
recordingState={isActive ? (voice.state === 'submitting' ? 'stopping' : voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
||||||
audioLevelsRef={voice.audioLevelsRef}
|
audioLevelsRef={voice.audioLevelsRef}
|
||||||
onStartRecording={isActive ? handleStartRecording : undefined}
|
onStartRecording={isActive ? handleStartRecording : undefined}
|
||||||
onSubmitRecording={isActive ? handleSubmitRecording : undefined}
|
onSubmitRecording={isActive ? handleSubmitRecording : undefined}
|
||||||
|
|
@ -6486,7 +6487,7 @@ function App() {
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
isRecording={isRecording}
|
isRecording={isRecording}
|
||||||
recordingText={voice.interimText}
|
recordingText={voice.interimText}
|
||||||
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
|
recordingState={voice.state === 'submitting' ? 'stopping' : voice.state === 'connecting' ? 'connecting' : 'listening'}
|
||||||
audioLevelsRef={voice.audioLevelsRef}
|
audioLevelsRef={voice.audioLevelsRef}
|
||||||
onStartRecording={handleStartRecording}
|
onStartRecording={handleStartRecording}
|
||||||
onSubmitRecording={handleSubmitRecording}
|
onSubmitRecording={handleSubmitRecording}
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,11 @@ interface ChatInputInnerProps {
|
||||||
onDraftChange?: (text: string) => void
|
onDraftChange?: (text: string) => void
|
||||||
isRecording?: boolean
|
isRecording?: boolean
|
||||||
recordingText?: string
|
recordingText?: string
|
||||||
recordingState?: 'connecting' | 'listening'
|
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||||
/** Live mic amplitude history (RMS per frame) driving the recording waveform. */
|
/** Live mic amplitude history (RMS per frame) driving the recording waveform. */
|
||||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||||
onStartRecording?: () => void
|
onStartRecording?: () => void
|
||||||
onSubmitRecording?: () => void
|
onSubmitRecording?: () => void | Promise<void>
|
||||||
onCancelRecording?: () => void
|
onCancelRecording?: () => void
|
||||||
voiceAvailable?: boolean
|
voiceAvailable?: boolean
|
||||||
ttsAvailable?: boolean
|
ttsAvailable?: boolean
|
||||||
|
|
@ -262,6 +262,7 @@ function ChatInputInner({
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
isRecording,
|
isRecording,
|
||||||
recordingText,
|
recordingText,
|
||||||
|
recordingState,
|
||||||
audioLevelsRef,
|
audioLevelsRef,
|
||||||
onStartRecording,
|
onStartRecording,
|
||||||
onSubmitRecording,
|
onSubmitRecording,
|
||||||
|
|
@ -829,23 +830,33 @@ function ChatInputInner({
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{/* Audio-reactive waveform only — the transcribed words are intentionally
|
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||||
not shown while recording; they're still captured and submitted. */}
|
|
||||||
<div className="flex flex-1 items-center overflow-hidden">
|
|
||||||
<VoiceWaveform audioLevelsRef={audioLevelsRef} />
|
<VoiceWaveform audioLevelsRef={audioLevelsRef} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-5 truncate text-sm leading-5',
|
||||||
|
recordingText?.trim() ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{recordingText?.trim() || (recordingState === 'stopping' ? 'Finalizing...' : 'Listening...')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onSubmitRecording}
|
onClick={onSubmitRecording}
|
||||||
disabled={!recordingText?.trim()}
|
disabled={recordingState === 'stopping'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
recordingText?.trim()
|
recordingState !== 'stopping'
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
{recordingState === 'stopping' ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1477,10 +1488,10 @@ export interface ChatInputWithMentionsProps {
|
||||||
onDraftChange?: (text: string) => void
|
onDraftChange?: (text: string) => void
|
||||||
isRecording?: boolean
|
isRecording?: boolean
|
||||||
recordingText?: string
|
recordingText?: string
|
||||||
recordingState?: 'connecting' | 'listening'
|
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||||
onStartRecording?: () => void
|
onStartRecording?: () => void
|
||||||
onSubmitRecording?: () => void
|
onSubmitRecording?: () => void | Promise<void>
|
||||||
onCancelRecording?: () => void
|
onCancelRecording?: () => void
|
||||||
voiceAvailable?: boolean
|
voiceAvailable?: boolean
|
||||||
ttsAvailable?: boolean
|
ttsAvailable?: boolean
|
||||||
|
|
|
||||||
|
|
@ -177,10 +177,10 @@ interface ChatSidebarProps {
|
||||||
// Voice / TTS props
|
// Voice / TTS props
|
||||||
isRecording?: boolean
|
isRecording?: boolean
|
||||||
recordingText?: string
|
recordingText?: string
|
||||||
recordingState?: 'connecting' | 'listening'
|
recordingState?: 'connecting' | 'listening' | 'stopping'
|
||||||
audioLevelsRef?: React.MutableRefObject<number[]>
|
audioLevelsRef?: React.MutableRefObject<number[]>
|
||||||
onStartRecording?: () => void
|
onStartRecording?: () => void
|
||||||
onSubmitRecording?: () => void
|
onSubmitRecording?: () => void | Promise<void>
|
||||||
onCancelRecording?: () => void
|
onCancelRecording?: () => void
|
||||||
voiceAvailable?: boolean
|
voiceAvailable?: boolean
|
||||||
ttsAvailable?: boolean
|
ttsAvailable?: boolean
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||||
|
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||||
|
|
||||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||||
|
|
@ -196,6 +197,25 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, [writeTranscriptToFile]);
|
}, [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(() => {
|
const cleanup = useCallback(() => {
|
||||||
if (writeTimerRef.current) {
|
if (writeTimerRef.current) {
|
||||||
clearTimeout(writeTimerRef.current);
|
clearTimeout(writeTimerRef.current);
|
||||||
|
|
@ -213,28 +233,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
clearInterval(trackPollingRef.current);
|
clearInterval(trackPollingRef.current);
|
||||||
trackPollingRef.current = null;
|
trackPollingRef.current = null;
|
||||||
}
|
}
|
||||||
if (processorRef.current) {
|
stopInputCapture();
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.onclose = null;
|
wsRef.current.onclose = null;
|
||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stopInputCapture]);
|
||||||
|
|
||||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||||
if (state !== 'idle') return null;
|
if (state !== 'idle') return null;
|
||||||
|
|
@ -551,12 +556,14 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||||
if (state !== 'recording') return;
|
if (state !== 'recording') return;
|
||||||
setState('stopping');
|
setState('stopping');
|
||||||
|
|
||||||
|
stopInputCapture();
|
||||||
|
await finalizeDeepgramStream(wsRef.current, 2200);
|
||||||
cleanup();
|
cleanup();
|
||||||
interimRef.current = new Map();
|
|
||||||
await writeTranscriptToFile();
|
await writeTranscriptToFile();
|
||||||
|
interimRef.current = new Map();
|
||||||
|
|
||||||
setState('idle');
|
setState('idle');
|
||||||
}, [state, cleanup, writeTranscriptToFile]);
|
}, [state, cleanup, stopInputCapture, writeTranscriptToFile]);
|
||||||
|
|
||||||
return { state, start, stop };
|
return { state, start, stop };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||||
|
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
|
||||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||||
import posthog from 'posthog-js';
|
import posthog from 'posthog-js';
|
||||||
import * as analytics from '@/lib/analytics';
|
import * as analytics from '@/lib/analytics';
|
||||||
|
|
||||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
export type VoiceState = 'idle' | 'connecting' | 'listening' | 'submitting';
|
||||||
|
|
||||||
const DEEPGRAM_PARAMS = new URLSearchParams({
|
const DEEPGRAM_PARAMS = new URLSearchParams({
|
||||||
model: 'nova-3',
|
model: 'nova-3',
|
||||||
|
|
@ -129,8 +130,45 @@ export function useVoiceMode() {
|
||||||
};
|
};
|
||||||
}, [refreshAuth]);
|
}, [refreshAuth]);
|
||||||
|
|
||||||
// Stop audio capture and close WS
|
const waitForWsOpen = useCallback(async (timeoutMs = 1500): Promise<boolean> => {
|
||||||
const stopAudioCapture = useCallback(() => {
|
const ws = wsRef.current;
|
||||||
|
if (!ws) return false;
|
||||||
|
if (ws.readyState === WebSocket.OPEN) return true;
|
||||||
|
if (ws.readyState !== WebSocket.CONNECTING) return false;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const finish = (ok: boolean) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.removeEventListener('open', onOpen);
|
||||||
|
ws.removeEventListener('error', onError);
|
||||||
|
ws.removeEventListener('close', onClose);
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
const onOpen = () => finish(true);
|
||||||
|
const onError = () => finish(false);
|
||||||
|
const onClose = () => finish(false);
|
||||||
|
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||||
|
ws.addEventListener('open', onOpen);
|
||||||
|
ws.addEventListener('error', onError);
|
||||||
|
ws.addEventListener('close', onClose);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushBufferedAudio = useCallback(() => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const buffered = audioBufferRef.current;
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
for (const chunk of buffered) {
|
||||||
|
ws.send(chunk);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopInputCapture = useCallback(() => {
|
||||||
if (processorRef.current) {
|
if (processorRef.current) {
|
||||||
processorRef.current.disconnect();
|
processorRef.current.disconnect();
|
||||||
processorRef.current = null;
|
processorRef.current = null;
|
||||||
|
|
@ -143,6 +181,11 @@ export function useVoiceMode() {
|
||||||
mediaStreamRef.current.getTracks().forEach(t => t.stop());
|
mediaStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
mediaStreamRef.current = null;
|
mediaStreamRef.current = null;
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop audio capture and close WS
|
||||||
|
const stopAudioCapture = useCallback(() => {
|
||||||
|
stopInputCapture();
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.onclose = null;
|
wsRef.current.onclose = null;
|
||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
|
|
@ -155,7 +198,7 @@ export function useVoiceMode() {
|
||||||
transcriptBufferRef.current = '';
|
transcriptBufferRef.current = '';
|
||||||
interimRef.current = '';
|
interimRef.current = '';
|
||||||
setState('idle');
|
setState('idle');
|
||||||
}, []);
|
}, [stopInputCapture]);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
if (state !== 'idle') return;
|
if (state !== 'idle') return;
|
||||||
|
|
@ -250,15 +293,25 @@ export function useVoiceMode() {
|
||||||
}, [state, connectWs, stopAudioCapture]);
|
}, [state, connectWs, stopAudioCapture]);
|
||||||
|
|
||||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||||
const submit = useCallback((): string => {
|
const submit = useCallback(async (): Promise<string> => {
|
||||||
|
setState('submitting');
|
||||||
|
stopInputCapture();
|
||||||
|
|
||||||
|
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||||
|
await waitForWsOpen();
|
||||||
|
}
|
||||||
|
flushBufferedAudio();
|
||||||
|
await finalizeDeepgramStream(wsRef.current);
|
||||||
|
|
||||||
let text = transcriptBufferRef.current;
|
let text = transcriptBufferRef.current;
|
||||||
if (interimRef.current) {
|
if (interimRef.current) {
|
||||||
text += (text ? ' ' : '') + interimRef.current;
|
text += (text ? ' ' : '') + interimRef.current;
|
||||||
}
|
}
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
|
|
||||||
stopAudioCapture();
|
stopAudioCapture();
|
||||||
return text;
|
return text;
|
||||||
}, [stopAudioCapture]);
|
}, [flushBufferedAudio, stopAudioCapture, stopInputCapture, waitForWsOpen]);
|
||||||
|
|
||||||
/** Cancel recording without returning transcript */
|
/** Cancel recording without returning transcript */
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
|
|
|
||||||
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
33
apps/x/apps/renderer/src/lib/deepgram-finalize.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export async function finalizeDeepgramStream(ws: WebSocket | null, timeoutMs = 1800): Promise<void> {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.removeEventListener('message', onMessage);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
timeout = setTimeout(finish, timeoutMs);
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data?.is_final || data?.speech_final || data?.type === 'UtteranceEnd') {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON control frames.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener('message', onMessage);
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'Finalize' }));
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -248,6 +248,7 @@ function notifyNewEmails(threads: SyncedThread[]): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const { threadId } of threads) {
|
for (const { threadId } of threads) {
|
||||||
const snapshot = readCachedSnapshot(threadId)?.snapshot;
|
const snapshot = readCachedSnapshot(threadId)?.snapshot;
|
||||||
|
if (snapshot?.importance !== 'important') continue;
|
||||||
if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue;
|
if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue;
|
||||||
const subject = snapshot?.subject?.trim() || '(no subject)';
|
const subject = snapshot?.subject?.trim() || '(no subject)';
|
||||||
const from = snapshot?.from?.trim();
|
const from = snapshot?.from?.trim();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue