fix(dictation): report capture acceptance

This commit is contained in:
psiberfunk
2026-09-07 18:06:15 -04:00
parent 0d7625ee1a
commit 0189b2a84f
6 changed files with 296 additions and 35 deletions
+3 -2
View File
@@ -21,8 +21,9 @@ The **Transcriptions** page offers the same recorder as one contextual
**Start dictation** action: it appears in the empty state before the first
transcript and moves to the page header once history exists. Desktop starts
wake the recorder window before dispatch, so a hidden WebView cannot silently
miss the request. The in-app action resolves only after the recorder
acknowledges delivery; otherwise the page reports the failed start.
miss the request. The in-app action confirms listener receipt, then resolves
only after microphone startup is accepted. Disabled, rejected, timed-out, or
failed starts are reported back on the page.
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
+66 -6
View File
@@ -1031,23 +1031,68 @@ pub async fn request_dictation_capture(
})
.await
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
if acknowledged {
return Ok(());
if !acknowledged {
let flags = app.state::<AppFlags>();
let cancelled = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_delivery(delivery_id);
if let Some(event) = cancelled.filter(|event| event.name == "tray-dictate") {
flags.output.finish_session(event.payload.session_id);
}
return Err("capture window did not acknowledge the request".into());
}
let outcome_app = app.clone();
let completed = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = outcome_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(!capture.completion_ready(delivery_id))
},
CAPTURE_ACCEPTANCE_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acceptance worker failed: {error}"))??;
let flags = app.state::<AppFlags>();
let cancelled = flags
if completed {
let completion = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion(delivery_id);
return completion
.unwrap_or_else(|| Err("capture request completed without an outcome".into()));
}
if let Some(completion) = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_delivery(delivery_id);
if let Some(event) = cancelled.filter(|event| event.name == "tray-dictate") {
.take_completion(delivery_id)
{
return completion;
}
if let Some(event) = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_delivery(delivery_id)
.filter(|event| event.name == "tray-dictate")
{
flags.output.finish_session(event.payload.session_id);
}
Err("capture window did not acknowledge the request".into())
Err("dictation capture did not start in time".into())
}
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
const CAPTURE_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(60);
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
@@ -1255,6 +1300,21 @@ pub fn acknowledge_dictation_capture_delivery(
capture.acknowledge(registration_id, delivery_id);
}
#[tauri::command]
pub fn complete_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.complete(registration_id, delivery_id, error);
}
#[tauri::command]
pub fn end_dictation_capture_registration(app: tauri::AppHandle, registration_id: u64) {
let flags = app.state::<AppFlags>();
+89 -7
View File
@@ -26,7 +26,7 @@ pub mod watch_folder;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -107,6 +107,12 @@ pub struct CaptureDispatchState {
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
in_flight: HashMap<u64, CaptureInFlight>,
}
struct CaptureInFlight {
event: CaptureEvent,
outcome: Option<Result<(), String>>,
}
struct CaptureEnqueue {
@@ -122,6 +128,7 @@ impl Default for CaptureDispatchState {
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
in_flight: HashMap::new(),
}
}
}
@@ -178,10 +185,48 @@ impl CaptureDispatchState {
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
if let Some(event) = self.pending.remove(index) {
if event.await_result {
self.in_flight.insert(
delivery_id,
CaptureInFlight {
event,
outcome: None,
},
);
}
}
}
}
pub(crate) fn complete(
&mut self,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(delivery) = self.in_flight.get_mut(&delivery_id) {
delivery.outcome = Some(error.map_or_else(|| Ok(()), Err));
}
}
pub(crate) fn completion_ready(&self, delivery_id: u64) -> bool {
self.in_flight
.get(&delivery_id)
.is_some_and(|delivery| delivery.outcome.is_some())
}
pub(crate) fn take_completion(&mut self, delivery_id: u64) -> Option<Result<(), String>> {
let ready = self.completion_ready(delivery_id);
ready
.then(|| self.in_flight.remove(&delivery_id))
.flatten()
.and_then(|delivery| delivery.outcome)
}
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
self.pending
.iter()
@@ -189,11 +234,16 @@ impl CaptureDispatchState {
}
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
let index = self
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)?;
self.pending.remove(index)
.position(|event| event.payload.delivery_id == delivery_id)
{
return self.pending.remove(index);
}
self.in_flight
.remove(&delivery_id)
.map(|delivery| delivery.event)
}
pub(crate) fn end_registration(&mut self, registration_id: u64) {
@@ -216,6 +266,7 @@ pub(crate) struct DictationCapturePayload {
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
await_result: bool,
}
pub struct TrayHandle {
@@ -232,20 +283,21 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, false);
}
pub(crate) fn request_dictation_capture_delivery(
app: &tauri::AppHandle,
action: &str,
) -> Option<u64> {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut)
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, true)
}
fn dispatch_dictation_capture_from(
app: &tauri::AppHandle,
action: &str,
origin: CaptureOrigin,
await_result: bool,
) -> Option<u64> {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
@@ -277,6 +329,7 @@ fn dispatch_dictation_capture_from(
delivery_id: 0,
registration_id: 0,
},
await_result,
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
@@ -316,6 +369,7 @@ mod dictation_capture_tests {
delivery_id: 0,
registration_id: 0,
},
await_result: false,
}
}
@@ -371,6 +425,31 @@ mod dictation_capture_tests {
assert!(state.delivery_pending(second_id));
}
#[test]
fn awaited_delivery_preserves_frontend_rejection_for_the_requester() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
state.acknowledge(registration_id, delivery_id);
assert!(!state.completion_ready(delivery_id));
state.complete(
registration_id,
delivery_id,
Some("Dictation is disabled".into()),
);
assert!(state.completion_ready(delivery_id));
assert_eq!(
state.take_completion(delivery_id),
Some(Err("Dictation is disabled".into()))
);
assert_eq!(state.take_completion(delivery_id), None);
}
#[test]
fn stale_listener_cannot_claim_or_clear_a_newer_registration() {
let mut state = CaptureDispatchState::default();
@@ -797,6 +876,7 @@ pub fn run() {
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::complete_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
@@ -1104,12 +1184,14 @@ pub fn run() {
app,
"stop",
CaptureOrigin::Tray,
false,
);
} else {
let _ = dispatch_dictation_capture_from(
app,
"start",
CaptureOrigin::Tray,
false,
);
}
}
+81 -16
View File
@@ -711,13 +711,30 @@ export default function CaptureWidget({ onDismiss }) {
const eventRegistrationId = event?.payload?.registrationId;
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
if (deliveryId != null) {
void tauriInvoke('acknowledge_dictation_capture_delivery', {
return tauriInvoke('acknowledge_dictation_capture_delivery', {
registrationId,
deliveryId,
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
})
.catch((err) => {
console.warn('dictation delivery acknowledgement failed:', err);
})
.then(() => true);
}
return true;
};
const completeDelivery = async (event, error = null) => {
const deliveryId = event?.payload?.deliveryId;
if (deliveryId == null) return;
try {
await tauriInvoke('complete_dictation_capture_delivery', {
registrationId,
deliveryId,
error,
});
} catch (err) {
console.warn('dictation delivery completion failed:', err);
}
};
(async () => {
try {
registrationId = await tauriInvoke('begin_dictation_capture_registration');
@@ -727,13 +744,19 @@ export default function CaptureWidget({ onDismiss }) {
}
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', async (event) => {
if (!acknowledgeDelivery(event)) return;
const acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) await acknowledgement;
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
if (now - nativeEventAtRef.current.start < 150) {
await completeDelivery(event, 'Duplicate dictation start ignored');
return;
}
nativeEventAtRef.current.start = now;
const sessionId = event?.payload?.sessionId;
if (!sessionId) {
hideWidgetWindow();
await completeDelivery(event, 'Dictation output session is missing');
return;
}
await ensureDictationPrefsHydrated();
@@ -741,7 +764,8 @@ export default function CaptureWidget({ onDismiss }) {
// The hotkey is inert, but Rust has already shown the window.
// Put it back rather than leaving an empty capsule on screen.
hideWidgetWindow();
finishOutputSession(sessionId);
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation is disabled');
return;
}
const sequence = ++nativeStartSequenceRef.current;
@@ -757,6 +781,7 @@ export default function CaptureWidget({ onDismiss }) {
} catch (err) {
console.warn('reject dictation output session failed:', err);
}
await completeDelivery(event, 'Dictation is already active');
return;
}
const trackHold = modeRef.current === 'hold';
@@ -781,11 +806,13 @@ export default function CaptureWidget({ onDismiss }) {
clearPendingHold();
await finishOutputSession(sessionId);
hideWidgetWindow();
await completeDelivery(event, `Could not activate dictation output: ${err}`);
return;
}
if (cancelled || sequence !== nativeStartSequenceRef.current || !enabledRef.current) {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation start was cancelled');
return;
}
if (startupWasInFlight) {
@@ -793,8 +820,10 @@ export default function CaptureWidget({ onDismiss }) {
if (startInFlightRef.current) {
outputSessionIdRef.current = sessionId;
pendingNativeStartRef.current = { sessionId, trackHold, sequence };
await completeDelivery(event, 'Another dictation start is already in progress');
} else if (current === 'recording' || current === 'transcribing') {
outputSessionIdRef.current = sessionId;
await completeDelivery(event);
} else if (
current === 'idle' ||
current === 'done' ||
@@ -802,10 +831,14 @@ export default function CaptureWidget({ onDismiss }) {
current === 'setup'
) {
outputSessionIdRef.current = sessionId;
startRecordingRef.current?.(trackHold, sessionId);
void Promise.resolve(startRecordingRef.current?.(trackHold, sessionId)).then(
(accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
} else {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation could not accept this start');
}
return;
}
@@ -814,34 +847,65 @@ export default function CaptureWidget({ onDismiss }) {
// in System Settings. A missing grant no longer blocks capture:
// native delivery can truthfully fall back to clipboard-only.
outputSessionIdRef.current = sessionId;
checkAccessibility().then(() => {
if (outputSessionIdRef.current !== sessionId) return;
startRecordingRef.current?.(modeRef.current === 'hold', sessionId);
void checkAccessibility().then(async () => {
if (outputSessionIdRef.current !== sessionId) {
await completeDelivery(event, 'Dictation output session changed before startup');
return;
}
const accepted = await startRecordingRef.current?.(
modeRef.current === 'hold',
sessionId,
);
await completeDelivery(event, accepted ? null : 'Dictation could not start');
});
return;
}
const idle = s === 'idle' || s === 'done' || s === 'error';
if (modeRef.current === 'toggle') {
// Press once to start, again to stop.
if (idle) startRecordingRef.current?.(false, sessionId);
else if (s === 'recording') stopRecordingRef.current?.();
if (idle) {
void Promise.resolve(startRecordingRef.current?.(false, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
} else if (s === 'recording') {
stopRecordingRef.current?.();
await completeDelivery(event);
return;
}
} else if (idle) {
// Hold mode: keydown → start.
startRecordingRef.current?.(true, sessionId);
void Promise.resolve(startRecordingRef.current?.(true, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
}
await completeDelivery(event, 'Dictation could not start');
});
unlistenStop = await listen('tray-dictate-stop', async (event) => {
if (!acknowledgeDelivery(event)) return;
const acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) await acknowledgement;
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
if (now - nativeEventAtRef.current.stop < 150) {
await completeDelivery(event, 'Duplicate dictation stop ignored');
return;
}
nativeEventAtRef.current.stop = now;
await ensureDictationPrefsHydrated();
// Only hold mode acts on release; toggle ignores it.
let accepted = false;
if (modeRef.current === 'hold' && stateRef.current === 'recording') {
stopRecordingRef.current?.();
accepted = true;
} else if (modeRef.current === 'hold' && holdStartRef.current === 'starting') {
holdStartRef.current = 'released';
accepted = true;
}
await completeDelivery(
event,
accepted ? null : 'Dictation is not recording in hold mode',
);
});
await ensureDictationPrefsHydrated();
if (cancelled) {
@@ -1856,11 +1920,11 @@ export default function CaptureWidget({ onDismiss }) {
// newer non-empty lease without launching a second microphone graph.
outputSessionIdRef.current = sessionId;
}
return;
return false;
}
if (inTauri() && !sessionId) {
hideWidgetWindow();
return;
return false;
}
startInFlightRef.current = true;
if (sessionId) outputSessionIdRef.current = sessionId;
@@ -1893,6 +1957,7 @@ export default function CaptureWidget({ onDismiss }) {
}
}
}
return stateRef.current === 'recording' || stateRef.current === 'transcribing';
},
[startRecordingImpl],
);
@@ -112,8 +112,11 @@ class FakeWS {
function pressShortcut() {
const handler = eventHandlers['tray-dictate'];
if (handler) handler({ payload: { sessionId: 'setup-race-session' } });
else eventState.pendingStart = true;
if (handler) {
handler({
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
} else eventState.pendingStart = true;
}
let realWebSocket;
@@ -128,7 +131,7 @@ beforeEach(() => {
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
eventState.pendingStart = false;
return eventHandlers['tray-dictate']?.({
payload: { sessionId: 'setup-race-session' },
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
}
return undefined;
@@ -136,6 +139,7 @@ beforeEach(() => {
eventState.pendingStart = false;
eventUnlisteners.length = 0;
FakeWS.instances = [];
storeState.dictationEnabled = true;
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
globalThis.WebSocket = FakeWS;
@@ -192,6 +196,47 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
});
});
it('completes an in-page delivery only after microphone startup is accepted', async () => {
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(invokeMock).not.toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
});
micControl.resolve(micStop);
await waitFor(() =>
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
}),
);
});
it('rejects an in-page delivery when dictation is disabled', async () => {
storeState.dictationEnabled = false;
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation is disabled',
});
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);
@@ -238,6 +283,11 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation could not start',
});
});
it('setup REJECTION after the terminal frame must not clobber it with a mic error', async () => {
+4 -1
View File
@@ -78,7 +78,7 @@ def test_capture_dispatch_wakes_widget_before_emitting_start(lib_rs: str) -> Non
)
def test_in_page_capture_waits_for_listener_acknowledgement(commands_rs: str) -> None:
def test_in_page_capture_waits_for_listener_acceptance(commands_rs: str) -> None:
request = re.search(
r"pub async fn request_dictation_capture\(.*?\n\}", commands_rs, re.S
)
@@ -86,8 +86,11 @@ def test_in_page_capture_waits_for_listener_acknowledgement(commands_rs: str) ->
body = request.group(0)
assert "request_dictation_capture_delivery" in body
assert "wait_for_capture_delivery" in body
assert "completion_ready" in body
assert "take_completion" in body
assert "cancel_delivery" in body
assert "capture window did not acknowledge the request" in body
assert "dictation capture did not start in time" in body
def test_no_computed_window_target_can_resolve_to_the_widget(lib_rs: str) -> None: