diff --git a/app/src/components/DictateWindow/DictateWindow.tsx b/app/src/components/DictateWindow/DictateWindow.tsx index dfc4e546..99795d10 100644 --- a/app/src/components/DictateWindow/DictateWindow.tsx +++ b/app/src/components/DictateWindow/DictateWindow.tsx @@ -35,6 +35,10 @@ export function DictateWindow() { }; }, []); + // Mirrored from the main window: true only when dictation is armed and the + // user opted into keeping the microphone ready. + const [micWarm, setMicWarm] = useState(false); + // Snapshot of the focused UI element at chord-start, shipped over from // Rust on the ``dictate:start`` payload. Held in a ref so it survives // the 1–2 s transcribe + refine window — the paste only fires once the @@ -42,6 +46,7 @@ export function DictateWindow() { const focusRef = useRef(null); const session = useCaptureRecordingSession({ + keepMicWarm: micWarm, onFinalText: async (text, _capture, allowAutoPaste) => { const focus = focusRef.current; // Consume-once: a second chord before this fires would overwrite @@ -72,23 +77,42 @@ export function DictateWindow() { sessionRef.current = session; useEffect(() => { - const unlistens: Promise[] = []; - unlistens.push( + let disposed = false; + const unlistens: UnlistenFn[] = []; + const registrations = [ listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => { focusRef.current = event.payload?.focus ?? null; sessionRef.current.startRecording(); }), - ); - unlistens.push( listen('dictate:stop', () => { - if (sessionRef.current.isRecording) sessionRef.current.stopRecording(); + // Forward stops that arrive while getUserMedia is still resolving. + sessionRef.current.stopRecording(); }), - ); + listen('dictate:warm', (event) => { + setMicWarm(Boolean(event.payload)); + }), + ]; + Promise.all(registrations) + .then((registered) => { + if (disposed) { + for (const unlisten of registered) unlisten(); + return; + } + unlistens.push(...registered); + emit('dictate:warm-request').catch(() => {}); + }) + .catch((err) => console.warn('[dictate] event listener registration failed:', err)); return () => { - for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); + disposed = true; + for (const unlisten of unlistens) unlisten(); }; }, []); + useEffect(() => { + if (micWarm) void session.prewarm(); + else session.releaseWarm(); + }, [micWarm, session.prewarm, session.releaseWarm]); + // --- Agent-speak cycle --------------------------------------------------- const [speaking, setSpeaking] = useState<{ diff --git a/app/src/components/ServerTab/CapturesPage.tsx b/app/src/components/ServerTab/CapturesPage.tsx index 48cd16a4..f0dc3381 100644 --- a/app/src/components/ServerTab/CapturesPage.tsx +++ b/app/src/components/ServerTab/CapturesPage.tsx @@ -138,6 +138,7 @@ export function CapturesPage() { const allowAutoPaste = settings?.allow_auto_paste ?? true; const defaultVoiceId = settings?.default_playback_voice_id ?? null; const hotkeyEnabled = settings?.hotkey_enabled ?? false; + const keepMicWarm = settings?.keep_mic_warm ?? false; const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push'); const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle'); @@ -221,6 +222,22 @@ export function CapturesPage() { + { + update({ keep_mic_warm: v }); + }} + /> + } + /> + void; + /** + * Keep the microphone ``MediaStream`` open between recordings instead of + * tearing it down on every stop. This is what removes the "first words get + * clipped" problem on push-to-talk dictation: ``getUserMedia`` on macOS can + * take several hundred ms — up to a second cold — to hand back a stream, and + * ``MediaRecorder`` only starts capturing *after* it resolves, so everything + * spoken in that window is lost. With a warm stream already open, the next + * ``startRecording`` skips ``getUserMedia`` entirely. + * + * Off by default: the voice-clone sample recorders release the device + * immediately, and the dictation session only opts in when the user enables + * the "keep microphone ready" setting. While on, the warm stream stays open — + * and the OS mic-in-use indicator stays lit — until it's explicitly released + * (dictation disabled or the setting turned off), so the trade-off is visible + * and user-controlled rather than a background mic that's always warm. + */ + keepWarm?: boolean; } +// Audio constraints for capture. Kept identical to the previous inline value so +// this change is purely about *when* the stream is opened, not *how*. +const AUDIO_CONSTRAINTS: MediaTrackConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, +}; + +const streamHasLiveAudio = (stream: MediaStream | null): stream is MediaStream => + !!stream && stream.getAudioTracks().some((t) => t.readyState === 'live'); + export function useAudioRecording({ maxDurationSeconds, onRecordingComplete, + keepWarm = false, }: UseAudioRecordingOptions = {}) { const platform = usePlatform(); const [isRecording, setIsRecording] = useState(false); @@ -17,195 +46,392 @@ export function useAudioRecording({ const [error, setError] = useState(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); + // The stream currently backing the MediaRecorder. When ``keepWarm`` is set + // this is the same object as ``warmStreamRef`` and is *not* torn down on + // stop; otherwise it's stopped as soon as the recording completes. const streamRef = useRef(null); + // Persistent pre-opened stream reused across recordings when ``keepWarm``. + const warmStreamRef = useRef(null); const timerRef = useRef(null); const startTimeRef = useRef(null); const cancelledRef = useRef(false); + // Mirror of ``isRecording`` for reads inside callbacks that would otherwise + // close over a stale render. + const isRecordingRef = useRef(false); + // A ``getUserMedia`` call in flight, shared so concurrent acquirers (prewarm + // plus an immediate chord) coalesce onto one stream instead of each opening — + // and orphaning — their own. + const acquiringRef = useRef | null>(null); + // True from ``startRecording`` entry until the recorder is actually running + // (or has failed), so a stop that arrives mid-acquisition can be deferred. + const startingRef = useRef(false); + // True from MediaRecorder.stop() until onstop has snapshotted the take's + // shared refs. React state and MediaRecorder.state both flip before onstop, + // so without this gate a rapid next chord can clear chunks/duration/cancel + // state out from under the recorder that is still finalising. + const finishingRef = useRef(false); + const pendingStopRef = useRef(false); + // Bumped per recording so a stale recorder's ``onstop`` can tell it's no + // longer the active one before it touches the shared stream refs. + const recordingCounterRef = useRef(0); + // Bumped whenever the warm stream is released/aborted so a ``getUserMedia`` + // still in flight can tell its result is stale and stop it instead of + // adopting a live mic after disable/unmount. + const acquireGenRef = useRef(0); + // Set when a release is requested mid-recording; the onstop path performs the + // deferred release once capture finishes rather than yanking the device now. + const releaseAfterStopRef = useRef(false); - const startRecording = useCallback(async () => { - try { - setError(null); - chunksRef.current = []; - cancelledRef.current = false; - setDuration(0); + // Keeps the ref in lockstep with the state so the synchronous stop path reads + // a fresh value without waiting for a rerender. + const setRecording = useCallback((next: boolean) => { + isRecordingRef.current = next; + setIsRecording(next); + }, []); - // Check if getUserMedia is available - // In Tauri, navigator.mediaDevices might not be available immediately - if (typeof navigator === 'undefined') { - const errorMsg = - 'Navigator API is not available. This might be a Tauri configuration issue.'; - setError(errorMsg); - throw new Error(errorMsg); - } + const releaseWarmStream = useCallback(() => { + // Invalidate any getUserMedia still in flight so its stream is stopped on + // resolve rather than adopted as the warm stream. + acquireGenRef.current += 1; + // Don't tear the device out from under an active/starting recording — the + // warm stream is the one backing it; defer to the onstop path instead. + if (isRecordingRef.current || startingRef.current) { + releaseAfterStopRef.current = true; + return; + } + warmStreamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + warmStreamRef.current = null; + }, []); + // Assert that getUserMedia is reachable, mirroring the previous inline guard + // (Tauri webviews occasionally expose ``navigator.mediaDevices`` a beat late). + const assertMediaDevices = useCallback(async () => { + if (typeof navigator === 'undefined') { + throw new Error('Navigator API is not available. This might be a Tauri configuration issue.'); + } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + await new Promise((resolve) => setTimeout(resolve, 100)); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - // Try waiting a bit for Tauri webview to initialize - await new Promise((resolve) => setTimeout(resolve, 100)); - - if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - console.error('MediaDevices check:', { - hasNavigator: typeof navigator !== 'undefined', - hasMediaDevices: !!navigator?.mediaDevices, - hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia, - isTauri: platform.metadata.isTauri, - }); - - const errorMsg = platform.metadata.isTauri + throw new Error( + platform.metadata.isTauri ? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia' - : 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.'; - setError(errorMsg); - throw new Error(errorMsg); - } + : 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.', + ); } + } + }, [platform.metadata.isTauri]); - // Request microphone access - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, + // Return a live capture stream, reusing the warm one when available so the + // hot path (chord-down → record) never waits on getUserMedia. + const acquireStream = useCallback(async (): Promise => { + // Captured separately so it stays typed as the full stream after the live + // check narrows ``warmStreamRef.current`` itself. + const existing = warmStreamRef.current; + if (streamHasLiveAudio(warmStreamRef.current)) { + return warmStreamRef.current; + } + // Coalesce concurrent acquirers onto one getUserMedia call so prewarm and + // an immediate chord can't open two streams. + if (acquiringRef.current) return acquiringRef.current; + // A dead warm stream (device unplugged / tracks ended) — drop it and reopen. + if (existing) { + existing.getTracks().forEach((track) => { + track.stop(); }); - - streamRef.current = stream; - - // Create MediaRecorder with preferred MIME type - const options: MediaRecorderOptions = { - mimeType: 'audio/webm;codecs=opus', - }; - - // Fallback to default if webm not supported - if (!MediaRecorder.isTypeSupported(options.mimeType!)) { - delete options.mimeType; - } - - const mediaRecorder = new MediaRecorder(stream, options); - mediaRecorderRef.current = mediaRecorder; - - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - chunksRef.current.push(event.data); - } - }; - - mediaRecorder.onstop = async () => { - // Snapshot the cancellation flag and recorded duration immediately — - // cancelRecording() clears chunks and sets cancelledRef synchronously - // before this async handler runs, so we must check it first. - const wasCancelled = cancelledRef.current; - const recordedDuration = startTimeRef.current - ? (Date.now() - startTimeRef.current) / 1000 - : undefined; - - const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); - - // Stop all tracks now that we have the data - streamRef.current?.getTracks().forEach((track) => { + warmStreamRef.current = null; + } + const gen = acquireGenRef.current; + const acquisition = (async () => { + await assertMediaDevices(); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: AUDIO_CONSTRAINTS, + }); + // Released / disabled / unmounted while acquiring — this stream is stale, + // so stop it instead of leaving a live mic open, and abort the caller. + if (gen !== acquireGenRef.current) { + stream.getTracks().forEach((track) => { track.stop(); }); - streamRef.current = null; + throw new Error('microphone acquisition aborted'); + } + if (keepWarm) warmStreamRef.current = stream; + return stream; + })(); + acquiringRef.current = acquisition; + try { + return await acquisition; + } finally { + if (acquiringRef.current === acquisition) acquiringRef.current = null; + } + }, [assertMediaDevices, keepWarm]); - // Don't fire completion callback if the recording was cancelled - if (wasCancelled) return; + /** + * Open the microphone ahead of the first recording so the initial dictation + * doesn't clip. No-op unless ``keepWarm`` is set. Safe to call repeatedly and + * safe to fail (e.g. permission not yet granted) — ``startRecording`` still + * surfaces a real error if capture is genuinely unavailable. + */ + const prewarm = useCallback(async () => { + if (!keepWarm) return; + try { + await acquireStream(); + } catch { + // Permission missing / device busy / aborted — recording will report a + // real error if capture is genuinely unavailable. + } + }, [keepWarm, acquireStream]); - // Convert to WAV format to avoid needing ffmpeg on backend - try { - const wavBlob = await convertToWav(webmBlob); - onRecordingComplete?.(wavBlob, recordedDuration); - } catch (err) { - console.error('Error converting audio to WAV:', err); - // Fallback to original blob if conversion fails - onRecordingComplete?.(webmBlob, recordedDuration); + const startRecording = useCallback( + async () => { + // A second chord can arrive while the first one is still waiting on + // getUserMedia. Never create overlapping MediaRecorders on the same + // coalesced stream; the original take will honor any deferred stop. + if ( + startingRef.current || + finishingRef.current || + mediaRecorderRef.current?.state === 'recording' + ) + return; + startingRef.current = true; + pendingStopRef.current = false; + // A new recording supersedes any release deferred from a prior take. + releaseAfterStopRef.current = false; + const recordingId = ++recordingCounterRef.current; + try { + setError(null); + chunksRef.current = []; + cancelledRef.current = false; + setDuration(0); + + // Reuse the warm stream when present (instant); otherwise open one now. + const stream = await acquireStream(); + streamRef.current = stream; + + // Create MediaRecorder with preferred MIME type + const options: MediaRecorderOptions = { + mimeType: 'audio/webm;codecs=opus', + }; + + // Fallback to default if webm not supported + if (!MediaRecorder.isTypeSupported(options.mimeType!)) { + delete options.mimeType; } - }; - mediaRecorder.onerror = (event) => { - setError('Recording error occurred'); - console.error('MediaRecorder error:', event); - }; + const mediaRecorder = new MediaRecorder(stream, options); + mediaRecorderRef.current = mediaRecorder; - // WebKit's MediaRecorder drops the WebM EBML header from chunks when - // started with a timeslice, so concatenated blobs fail to parse in - // both AudioContext and ffmpeg. Starting with no timeslice produces - // exactly one dataavailable on stop() with a valid container. - mediaRecorder.start(); - setIsRecording(true); - startTimeRef.current = Date.now(); + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; - // Start timer - timerRef.current = window.setInterval(() => { - if (startTimeRef.current) { - const elapsed = (Date.now() - startTimeRef.current) / 1000; - setDuration(elapsed); + mediaRecorder.onstop = async () => { + // Whether this recorder is still the active one. A stale onstop (an + // older recorder stopping after a newer startRecording) must not touch + // the shared stream refs. + const isCurrent = recordingCounterRef.current === recordingId; + // Snapshot the cancellation flag and recorded duration immediately — + // cancelRecording() clears chunks and sets cancelledRef synchronously + // before this async handler runs, so we must check it first. + const wasCancelled = cancelledRef.current; + const recordedDuration = startTimeRef.current + ? (Date.now() - startTimeRef.current) / 1000 + : undefined; - // Auto-stop at max duration when the caller opts in — dictation - // sessions pass undefined and run until the user releases the - // chord or hits stop; voice-clone sample recorders pass 29s to - // keep reference clips short. - if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) { - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { - mediaRecorderRef.current.stop(); - setIsRecording(false); - if (timerRef.current !== null) { - clearInterval(timerRef.current); - timerRef.current = null; + const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); + + // Release the device unless we're keeping it warm for the next capture. + // Act on this recorder's own stream; only touch the shared refs when + // this is still the current recording. + if (keepWarm) { + if (isCurrent) { + streamRef.current = null; + // A release requested mid-recording (dictation disabled) is + // honored now that capture has finished; otherwise the warm + // stream stays open for the next take. + if (releaseAfterStopRef.current) { + releaseAfterStopRef.current = false; + releaseWarmStream(); + } + } + } else { + stream.getTracks().forEach((track) => { + track.stop(); + }); + if (isCurrent) streamRef.current = null; + } + + // All shared per-take refs have now been snapshotted and stream + // cleanup is complete. A new take may begin while WAV conversion and + // upload continue using the local values above. + finishingRef.current = false; + + // Don't fire completion callback if the recording was cancelled + if (wasCancelled) return; + + // Convert to WAV format to avoid needing ffmpeg on backend + try { + const wavBlob = await convertToWav(webmBlob); + onRecordingComplete?.(wavBlob, recordedDuration); + } catch (err) { + console.error('Error converting audio to WAV:', err); + // Fallback to original blob if conversion fails + onRecordingComplete?.(webmBlob, recordedDuration); + } + }; + + mediaRecorder.onerror = (event) => { + setError('Recording error occurred'); + console.error('MediaRecorder error:', event); + }; + + // WebKit's MediaRecorder drops the WebM EBML header from chunks when + // started with a timeslice, so concatenated blobs fail to parse in + // both AudioContext and ffmpeg. Starting with no timeslice produces + // exactly one dataavailable on stop() with a valid container. + mediaRecorder.start(); + setRecording(true); + startTimeRef.current = Date.now(); + startingRef.current = false; + + // A stop (chord release) that landed while the mic was still opening — + // honor it now that capture has actually begun. + if (pendingStopRef.current) { + pendingStopRef.current = false; + finishingRef.current = true; + mediaRecorder.stop(); + setRecording(false); + return; + } + + // Start timer + timerRef.current = window.setInterval(() => { + if (startTimeRef.current) { + const elapsed = (Date.now() - startTimeRef.current) / 1000; + setDuration(elapsed); + + // Auto-stop at max duration when the caller opts in — dictation + // sessions pass undefined and run until the user releases the + // chord or hits stop; voice-clone sample recorders pass 29s to + // keep reference clips short. + if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) { + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { + finishingRef.current = true; + mediaRecorderRef.current.stop(); + setRecording(false); + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } } } } + }, 100); + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : 'Failed to access microphone. Please check permissions.'; + // A fresh (non-warm) stream opened before the failure must be released + // so the mic doesn't stay lit; a warm stream is reusable, so it's kept. + if (!keepWarm) { + streamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; } - }, 100); - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : 'Failed to access microphone. Please check permissions.'; - setError(errorMessage); - setIsRecording(false); - } - }, [maxDurationSeconds, onRecordingComplete]); + startingRef.current = false; + finishingRef.current = false; + pendingStopRef.current = false; + setError(errorMessage); + setRecording(false); + } + }, + [ + maxDurationSeconds, + onRecordingComplete, + acquireStream, + keepWarm, + releaseWarmStream, + setRecording, + ], + ); const stopRecording = useCallback(() => { - if (mediaRecorderRef.current && isRecording) { - mediaRecorderRef.current.stop(); - setIsRecording(false); + // The recorder's own state is the lifecycle authority — React ``isRecording`` + // lags a render behind ``mediaRecorder.start()``, so a chord release in that + // window would otherwise be dropped. + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state === 'recording') { + finishingRef.current = true; + recorder.stop(); + setRecording(false); if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } + } else if (startingRef.current) { + // Stop arrived before capture began (mic still opening) — defer it so + // startRecording stops as soon as the recorder goes live. + pendingStopRef.current = true; } - }, [isRecording]); + }, [setRecording]); const cancelRecording = useCallback(() => { - if (mediaRecorderRef.current) { - cancelledRef.current = true; // Must be set before stop() triggers onstop + cancelledRef.current = true; // Must be set before stop() triggers onstop + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state !== 'inactive') { chunksRef.current = []; - mediaRecorderRef.current.stop(); - setIsRecording(false); + finishingRef.current = true; + recorder.stop(); + setRecording(false); setDuration(0); + } else if (startingRef.current) { + // Cancel during mic acquisition — stop as soon as capture begins; the + // cancelled flag suppresses the completion callback. + pendingStopRef.current = true; } - // Stop all tracks - streamRef.current?.getTracks().forEach((track) => { - track.stop(); - }); - streamRef.current = null; + // Keep the device warm for the next capture when opted in; otherwise stop + // the tracks so the mic is released immediately. + if (keepWarm) { + streamRef.current = null; + if (releaseAfterStopRef.current) { + releaseAfterStopRef.current = false; + releaseWarmStream(); + } + } else { + streamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + } if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } - }, []); + }, [keepWarm, releaseWarmStream, setRecording]); - // Cleanup on unmount + // Cleanup on unmount — always fully release the device, warm or not. useEffect(() => { return () => { + // Invalidate any in-flight acquisition so a stream resolving after unmount + // stops itself instead of leaking a live mic. + acquireGenRef.current += 1; if (timerRef.current !== null) { clearInterval(timerRef.current); } streamRef.current?.getTracks().forEach((track) => { track.stop(); }); + warmStreamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); }; }, []); @@ -216,5 +442,7 @@ export function useAudioRecording({ startRecording, stopRecording, cancelRecording, + prewarm, + releaseWarm: releaseWarmStream, }; } diff --git a/app/src/lib/hooks/useCaptureRecordingSession.ts b/app/src/lib/hooks/useCaptureRecordingSession.ts index 06c89740..42457030 100644 --- a/app/src/lib/hooks/useCaptureRecordingSession.ts +++ b/app/src/lib/hooks/useCaptureRecordingSession.ts @@ -54,6 +54,9 @@ const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled'; export type CapturePillState = PillState | 'hidden'; export interface UseCaptureRecordingSessionOptions { + /** Keep the microphone stream open between dictations when explicitly + * enabled. Off by default so normal recorders release the device. */ + keepMicWarm?: boolean; /** * Fired after a capture row is created on the server. Callers can use this * to select the new capture or emit a Tauri event to a sibling window. @@ -88,6 +91,8 @@ export interface UseCaptureRecordingSessionResult { dismissError: () => void; uploadFile: (file: File, source: CaptureSource) => void; refine: (captureId: string) => void; + prewarm: () => Promise; + releaseWarm: () => void; } /** @@ -249,7 +254,10 @@ export function useCaptureRecordingSession( startRecording: beginAudioRecording, stopRecording, error: recordError, + prewarm, + releaseWarm, } = useAudioRecording({ + keepWarm: options.keepMicWarm ?? false, onRecordingComplete: (blob, recordedDuration) => { // Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet // so the blob is empty or unparseable. Surface it as a transient pill @@ -324,5 +332,7 @@ export function useCaptureRecordingSession( dismissError, uploadFile, refine, + prewarm, + releaseWarm, }; } diff --git a/app/src/lib/hooks/useChordSync.ts b/app/src/lib/hooks/useChordSync.ts index 76054e7b..fbdf705a 100644 --- a/app/src/lib/hooks/useChordSync.ts +++ b/app/src/lib/hooks/useChordSync.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; -import { useEffect } from 'react'; +import { emit, listen } from '@tauri-apps/api/event'; +import { useEffect, useRef } from 'react'; import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness'; import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { usePlatform } from '@/platform/PlatformContext'; @@ -30,21 +31,45 @@ export function useChordSync() { const { settings } = useCaptureSettings(); const { canRecord } = useDictationReadiness(); const enabled = settings?.hotkey_enabled; + const keepMicWarm = settings?.keep_mic_warm; const pushKeys = settings?.chord_push_to_talk_keys; const toggleKeys = settings?.chord_toggle_to_talk_keys; + // Latest warm state, so the dictate window's mount-time request can be + // answered even between the dep-driven emits below. + const shouldWarmRef = useRef(false); + + // The floating dictate window holds the mic warm ahead of the first chord to + // avoid clipping, but it's a separate webview with no view of settings. Mirror + // the decision to it: warm only when dictation is armed AND the user enabled + // "keep microphone ready". Gating here is what stops the always-mounted pill + // from opening the mic — or prompting for access — when the user hasn't asked. + useEffect(() => { + if (!platform.metadata.isTauri) return; + const unlisten = listen('dictate:warm-request', () => { + emit('dictate:warm', shouldWarmRef.current).catch(() => {}); + }); + return () => { + unlisten.then((fn) => fn()).catch(() => {}); + }; + }, [platform.metadata.isTauri]); + useEffect(() => { if (!platform.metadata.isTauri) return; if (enabled === undefined || !pushKeys || !toggleKeys) return; const shouldArm = enabled && canRecord; + const shouldWarm = shouldArm && (keepMicWarm ?? false); + shouldWarmRef.current = shouldWarm; const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey'; const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {}; invoke(command, args).catch((err) => { console.warn(`[chord-sync] ${command} failed:`, err); }); + emit('dictate:warm', shouldWarm).catch(() => {}); }, [ platform.metadata.isTauri, enabled, + keepMicWarm, canRecord, // Stringify so a referentially-new array with the same content // doesn't fire a redundant invoke on every settings refetch. diff --git a/backend/database/migrations.py b/backend/database/migrations.py index d353b58c..49bec1da 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -243,6 +243,13 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None: "hotkey_enabled BOOLEAN NOT NULL DEFAULT 0", "hotkey_enabled", ) + if "keep_mic_warm" not in columns: + _add_column( + engine, + "capture_settings", + "keep_mic_warm BOOLEAN NOT NULL DEFAULT 0", + "keep_mic_warm", + ) def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None: diff --git a/backend/database/models.py b/backend/database/models.py index b85a55b1..c497e006 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -210,6 +210,10 @@ class CaptureSettings(Base): # "Voicebox would like to receive keystrokes from any application" dialog # before they've even opened the Captures tab. hotkey_enabled = Column(Boolean, nullable=False, default=False) + # Hold the microphone open while dictation is enabled so push-to-talk + # doesn't clip the first words. Off by default — when on, the OS mic-in-use + # indicator stays lit the whole time dictation is enabled. + keep_mic_warm = Column(Boolean, nullable=False, default=False) # Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand # modifiers by default so they don't collide with left-hand shortcuts. chord_push_to_talk_keys = Column( diff --git a/backend/models.py b/backend/models.py index 7970ce41..e1a0a3e3 100644 --- a/backend/models.py +++ b/backend/models.py @@ -258,6 +258,7 @@ class CaptureSettingsResponse(BaseModel): allow_auto_paste: bool = True default_playback_voice_id: Optional[str] = None hotkey_enabled: bool = False + keep_mic_warm: bool = False chord_push_to_talk_keys: List[str] = Field( default_factory=default_push_to_talk_chord ) @@ -282,6 +283,7 @@ class CaptureSettingsUpdate(BaseModel): allow_auto_paste: Optional[bool] = None default_playback_voice_id: Optional[str] = None hotkey_enabled: Optional[bool] = None + keep_mic_warm: Optional[bool] = None chord_push_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6) chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)