fix(dictation): make microphone lifecycle race-safe

Coalesce and invalidate microphone acquisition, preserve immediate stop/cancel events, block overlapping/finalising takes, and add the opt-in keep_mic_warm setting with privacy-preserving default off.\n\nVerified: frontend CI; keep_mic_warm migration default and idempotency; diff checks.
This commit is contained in:
Jamie Pine
2026-07-19 17:45:52 -07:00
parent 0070c04bcf
commit 9b1beba3e1
10 changed files with 469 additions and 144 deletions
@@ -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 // 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 // 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 // the 1–2 s transcribe + refine window — the paste only fires once the
@@ -42,6 +46,7 @@ export function DictateWindow() {
const focusRef = useRef<FocusSnapshot | null>(null); const focusRef = useRef<FocusSnapshot | null>(null);
const session = useCaptureRecordingSession({ const session = useCaptureRecordingSession({
keepMicWarm: micWarm,
onFinalText: async (text, _capture, allowAutoPaste) => { onFinalText: async (text, _capture, allowAutoPaste) => {
const focus = focusRef.current; const focus = focusRef.current;
// Consume-once: a second chord before this fires would overwrite // Consume-once: a second chord before this fires would overwrite
@@ -72,23 +77,42 @@ export function DictateWindow() {
sessionRef.current = session; sessionRef.current = session;
useEffect(() => { useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = []; let disposed = false;
unlistens.push( const unlistens: UnlistenFn[] = [];
const registrations = [
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => { listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
focusRef.current = event.payload?.focus ?? null; focusRef.current = event.payload?.focus ?? null;
sessionRef.current.startRecording(); sessionRef.current.startRecording();
}), }),
);
unlistens.push(
listen('dictate:stop', () => { listen('dictate:stop', () => {
if (sessionRef.current.isRecording) sessionRef.current.stopRecording(); // Forward stops that arrive while getUserMedia is still resolving.
sessionRef.current.stopRecording();
}), }),
); listen<boolean>('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 () => { 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 --------------------------------------------------- // --- Agent-speak cycle ---------------------------------------------------
const [speaking, setSpeaking] = useState<{ const [speaking, setSpeaking] = useState<{
@@ -138,6 +138,7 @@ export function CapturesPage() {
const allowAutoPaste = settings?.allow_auto_paste ?? true; const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null; const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false; const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const keepMicWarm = settings?.keep_mic_warm ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push'); const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle'); const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
@@ -221,6 +222,22 @@ export function CapturesPage() {
<InputMonitoringNotice enabled={hotkeyEnabled} /> <InputMonitoringNotice enabled={hotkeyEnabled} />
</div> </div>
<SettingRow
title={t('settings.captures.dictation.keepMicWarm.title')}
description={t('settings.captures.dictation.keepMicWarm.description')}
htmlFor="keepMicWarm"
action={
<Toggle
id="keepMicWarm"
checked={keepMicWarm}
disabled={!hotkeyEnabled}
onCheckedChange={(v) => {
update({ keep_mic_warm: v });
}}
/>
}
/>
<SettingRow <SettingRow
title={t('settings.captures.dictation.pushToTalk.title')} title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')} description={t('settings.captures.dictation.pushToTalk.description')}
+4
View File
@@ -887,6 +887,10 @@
"title": "Global shortcut", "title": "Global shortcut",
"description": "Hold the shortcut to record from anywhere on your machine. Release to transcribe." "description": "Hold the shortcut to record from anywhere on your machine. Release to transcribe."
}, },
"keepMicWarm": {
"title": "Keep microphone ready",
"description": "Hold the microphone open while dictation is enabled so the first words are never clipped. The macOS microphone indicator stays lit while it's on."
},
"pushToTalk": { "pushToTalk": {
"title": "Push-to-talk shortcut", "title": "Push-to-talk shortcut",
"description": "Hold these keys anywhere on your system to record. Release to stop and transcribe.", "description": "Hold these keys anywhere on your system to record. Release to stop and transcribe.",
+4
View File
@@ -213,6 +213,10 @@ export interface CaptureSettings {
/** Whether the global keyboard hotkey is armed. Off by default — turning /** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */ * this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean; hotkey_enabled: boolean;
/** Hold the mic open while dictation is enabled so push-to-talk doesn't clip
* the first words. Off by default — when on, the OS mic indicator stays lit
* the whole time dictation is enabled. */
keep_mic_warm: boolean;
/** keytap key names. Defaults are platform-specific right-hand modifiers. */ /** keytap key names. Defaults are platform-specific right-hand modifiers. */
chord_push_to_talk_keys: string[]; chord_push_to_talk_keys: string[];
/** keytap key names. Toggle adds Space to the platform-specific PTT chord. */ /** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
+364 -136
View File
@@ -5,11 +5,40 @@ import { convertToWav } from '@/lib/utils/audio';
interface UseAudioRecordingOptions { interface UseAudioRecordingOptions {
maxDurationSeconds?: number; maxDurationSeconds?: number;
onRecordingComplete?: (blob: Blob, duration?: number) => void; onRecordingComplete?: (blob: Blob, duration?: number) => 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({ export function useAudioRecording({
maxDurationSeconds, maxDurationSeconds,
onRecordingComplete, onRecordingComplete,
keepWarm = false,
}: UseAudioRecordingOptions = {}) { }: UseAudioRecordingOptions = {}) {
const platform = usePlatform(); const platform = usePlatform();
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
@@ -17,195 +46,392 @@ export function useAudioRecording({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
// 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<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
// Persistent pre-opened stream reused across recordings when ``keepWarm``.
const warmStreamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null); const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false); const cancelledRef = useRef<boolean>(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<Promise<MediaStream> | 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 () => { // Keeps the ref in lockstep with the state so the synchronous stop path reads
try { // a fresh value without waiting for a rerender.
setError(null); const setRecording = useCallback((next: boolean) => {
chunksRef.current = []; isRecordingRef.current = next;
cancelledRef.current = false; setIsRecording(next);
setDuration(0); }, []);
// Check if getUserMedia is available const releaseWarmStream = useCallback(() => {
// In Tauri, navigator.mediaDevices might not be available immediately // Invalidate any getUserMedia still in flight so its stream is stopped on
if (typeof navigator === 'undefined') { // resolve rather than adopted as the warm stream.
const errorMsg = acquireGenRef.current += 1;
'Navigator API is not available. This might be a Tauri configuration issue.'; // Don't tear the device out from under an active/starting recording — the
setError(errorMsg); // warm stream is the one backing it; defer to the onstop path instead.
throw new Error(errorMsg); 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) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Try waiting a bit for Tauri webview to initialize throw new Error(
await new Promise((resolve) => setTimeout(resolve, 100)); platform.metadata.isTauri
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
? '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:\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.'; : '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);
}
} }
}
}, [platform.metadata.isTauri]);
// Request microphone access // Return a live capture stream, reusing the warm one when available so the
const stream = await navigator.mediaDevices.getUserMedia({ // hot path (chord-down → record) never waits on getUserMedia.
audio: { const acquireStream = useCallback(async (): Promise<MediaStream> => {
echoCancellation: true, // Captured separately so it stays typed as the full stream after the live
noiseSuppression: true, // check narrows ``warmStreamRef.current`` itself.
autoGainControl: true, 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();
}); });
warmStreamRef.current = null;
streamRef.current = stream; }
const gen = acquireGenRef.current;
// Create MediaRecorder with preferred MIME type const acquisition = (async () => {
const options: MediaRecorderOptions = { await assertMediaDevices();
mimeType: 'audio/webm;codecs=opus', const stream = await navigator.mediaDevices.getUserMedia({
}; audio: AUDIO_CONSTRAINTS,
});
// Fallback to default if webm not supported // Released / disabled / unmounted while acquiring — this stream is stale,
if (!MediaRecorder.isTypeSupported(options.mimeType!)) { // so stop it instead of leaving a live mic open, and abort the caller.
delete options.mimeType; if (gen !== acquireGenRef.current) {
} stream.getTracks().forEach((track) => {
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) => {
track.stop(); 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 const startRecording = useCallback(
try { async () => {
const wavBlob = await convertToWav(webmBlob); // A second chord can arrive while the first one is still waiting on
onRecordingComplete?.(wavBlob, recordedDuration); // getUserMedia. Never create overlapping MediaRecorders on the same
} catch (err) { // coalesced stream; the original take will honor any deferred stop.
console.error('Error converting audio to WAV:', err); if (
// Fallback to original blob if conversion fails startingRef.current ||
onRecordingComplete?.(webmBlob, recordedDuration); 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) => { const mediaRecorder = new MediaRecorder(stream, options);
setError('Recording error occurred'); mediaRecorderRef.current = mediaRecorder;
console.error('MediaRecorder error:', event);
};
// WebKit's MediaRecorder drops the WebM EBML header from chunks when mediaRecorder.ondataavailable = (event) => {
// started with a timeslice, so concatenated blobs fail to parse in if (event.data.size > 0) {
// both AudioContext and ffmpeg. Starting with no timeslice produces chunksRef.current.push(event.data);
// exactly one dataavailable on stop() with a valid container. }
mediaRecorder.start(); };
setIsRecording(true);
startTimeRef.current = Date.now();
// Start timer mediaRecorder.onstop = async () => {
timerRef.current = window.setInterval(() => { // Whether this recorder is still the active one. A stale onstop (an
if (startTimeRef.current) { // older recorder stopping after a newer startRecording) must not touch
const elapsed = (Date.now() - startTimeRef.current) / 1000; // the shared stream refs.
setDuration(elapsed); 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 const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to // Release the device unless we're keeping it warm for the next capture.
// keep reference clips short. // Act on this recorder's own stream; only touch the shared refs when
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) { // this is still the current recording.
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { if (keepWarm) {
mediaRecorderRef.current.stop(); if (isCurrent) {
setIsRecording(false); streamRef.current = null;
if (timerRef.current !== null) { // A release requested mid-recording (dictation disabled) is
clearInterval(timerRef.current); // honored now that capture has finished; otherwise the warm
timerRef.current = null; // 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); startingRef.current = false;
} catch (err) { finishingRef.current = false;
const errorMessage = pendingStopRef.current = false;
err instanceof Error setError(errorMessage);
? err.message setRecording(false);
: 'Failed to access microphone. Please check permissions.'; }
setError(errorMessage); },
setIsRecording(false); [
} maxDurationSeconds,
}, [maxDurationSeconds, onRecordingComplete]); onRecordingComplete,
acquireStream,
keepWarm,
releaseWarmStream,
setRecording,
],
);
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) { // The recorder's own state is the lifecycle authority — React ``isRecording``
mediaRecorderRef.current.stop(); // lags a render behind ``mediaRecorder.start()``, so a chord release in that
setIsRecording(false); // 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) { if (timerRef.current !== null) {
clearInterval(timerRef.current); clearInterval(timerRef.current);
timerRef.current = null; 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(() => { 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 = []; chunksRef.current = [];
mediaRecorderRef.current.stop(); finishingRef.current = true;
setIsRecording(false); recorder.stop();
setRecording(false);
setDuration(0); 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 // Keep the device warm for the next capture when opted in; otherwise stop
streamRef.current?.getTracks().forEach((track) => { // the tracks so the mic is released immediately.
track.stop(); if (keepWarm) {
}); streamRef.current = null;
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) { if (timerRef.current !== null) {
clearInterval(timerRef.current); clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
} }
}, []); }, [keepWarm, releaseWarmStream, setRecording]);
// Cleanup on unmount // Cleanup on unmount — always fully release the device, warm or not.
useEffect(() => { useEffect(() => {
return () => { 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) { if (timerRef.current !== null) {
clearInterval(timerRef.current); clearInterval(timerRef.current);
} }
streamRef.current?.getTracks().forEach((track) => { streamRef.current?.getTracks().forEach((track) => {
track.stop(); track.stop();
}); });
warmStreamRef.current?.getTracks().forEach((track) => {
track.stop();
});
}; };
}, []); }, []);
@@ -216,5 +442,7 @@ export function useAudioRecording({
startRecording, startRecording,
stopRecording, stopRecording,
cancelRecording, cancelRecording,
prewarm,
releaseWarm: releaseWarmStream,
}; };
} }
@@ -54,6 +54,9 @@ const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
export type CapturePillState = PillState | 'hidden'; export type CapturePillState = PillState | 'hidden';
export interface UseCaptureRecordingSessionOptions { 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 * 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. * to select the new capture or emit a Tauri event to a sibling window.
@@ -88,6 +91,8 @@ export interface UseCaptureRecordingSessionResult {
dismissError: () => void; dismissError: () => void;
uploadFile: (file: File, source: CaptureSource) => void; uploadFile: (file: File, source: CaptureSource) => void;
refine: (captureId: string) => void; refine: (captureId: string) => void;
prewarm: () => Promise<void>;
releaseWarm: () => void;
} }
/** /**
@@ -249,7 +254,10 @@ export function useCaptureRecordingSession(
startRecording: beginAudioRecording, startRecording: beginAudioRecording,
stopRecording, stopRecording,
error: recordError, error: recordError,
prewarm,
releaseWarm,
} = useAudioRecording({ } = useAudioRecording({
keepWarm: options.keepMicWarm ?? false,
onRecordingComplete: (blob, recordedDuration) => { onRecordingComplete: (blob, recordedDuration) => {
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet // Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
// so the blob is empty or unparseable. Surface it as a transient pill // so the blob is empty or unparseable. Surface it as a transient pill
@@ -324,5 +332,7 @@ export function useCaptureRecordingSession(
dismissError, dismissError,
uploadFile, uploadFile,
refine, refine,
prewarm,
releaseWarm,
}; };
} }
+26 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core'; 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 { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
@@ -30,21 +31,45 @@ export function useChordSync() {
const { settings } = useCaptureSettings(); const { settings } = useCaptureSettings();
const { canRecord } = useDictationReadiness(); const { canRecord } = useDictationReadiness();
const enabled = settings?.hotkey_enabled; const enabled = settings?.hotkey_enabled;
const keepMicWarm = settings?.keep_mic_warm;
const pushKeys = settings?.chord_push_to_talk_keys; const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_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(() => { useEffect(() => {
if (!platform.metadata.isTauri) return; if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return; if (enabled === undefined || !pushKeys || !toggleKeys) return;
const shouldArm = enabled && canRecord; const shouldArm = enabled && canRecord;
const shouldWarm = shouldArm && (keepMicWarm ?? false);
shouldWarmRef.current = shouldWarm;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey'; const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {}; const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => { invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err); console.warn(`[chord-sync] ${command} failed:`, err);
}); });
emit('dictate:warm', shouldWarm).catch(() => {});
}, [ }, [
platform.metadata.isTauri, platform.metadata.isTauri,
enabled, enabled,
keepMicWarm,
canRecord, canRecord,
// Stringify so a referentially-new array with the same content // Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch. // doesn't fire a redundant invoke on every settings refetch.
+7
View File
@@ -243,6 +243,13 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
"hotkey_enabled BOOLEAN NOT NULL DEFAULT 0", "hotkey_enabled BOOLEAN NOT NULL DEFAULT 0",
"hotkey_enabled", "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: def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
+4
View File
@@ -210,6 +210,10 @@ class CaptureSettings(Base):
# "Voicebox would like to receive keystrokes from any application" dialog # "Voicebox would like to receive keystrokes from any application" dialog
# before they've even opened the Captures tab. # before they've even opened the Captures tab.
hotkey_enabled = Column(Boolean, nullable=False, default=False) 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 # Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand
# modifiers by default so they don't collide with left-hand shortcuts. # modifiers by default so they don't collide with left-hand shortcuts.
chord_push_to_talk_keys = Column( chord_push_to_talk_keys = Column(
+2
View File
@@ -258,6 +258,7 @@ class CaptureSettingsResponse(BaseModel):
allow_auto_paste: bool = True allow_auto_paste: bool = True
default_playback_voice_id: Optional[str] = None default_playback_voice_id: Optional[str] = None
hotkey_enabled: bool = False hotkey_enabled: bool = False
keep_mic_warm: bool = False
chord_push_to_talk_keys: List[str] = Field( chord_push_to_talk_keys: List[str] = Field(
default_factory=default_push_to_talk_chord default_factory=default_push_to_talk_chord
) )
@@ -282,6 +283,7 @@ class CaptureSettingsUpdate(BaseModel):
allow_auto_paste: Optional[bool] = None allow_auto_paste: Optional[bool] = None
default_playback_voice_id: Optional[str] = None default_playback_voice_id: Optional[str] = None
hotkey_enabled: Optional[bool] = 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_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) chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)