Handy/src-tauri/src/transcription_coordinator.rs
johnpyp 7fdde63460
fix: transcription lock-up race condition & add small debounce (#824)
* fix: add TranscriptionState to prevent race conditions & add debounce

* refactor(transcription): replace state machine with actor coordinator

* some cleanup

* format

* minor cleanup

* format

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
2026-02-16 22:18:50 +08:00

184 lines
6.5 KiB
Rust

use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use log::{debug, error, warn};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
const DEBOUNCE: Duration = Duration::from_millis(30);
/// Commands processed sequentially by the coordinator thread.
enum Command {
Input {
binding_id: String,
hotkey_string: String,
is_pressed: bool,
push_to_talk: bool,
},
Cancel {
recording_was_active: bool,
},
ProcessingFinished,
}
/// Pipeline lifecycle, owned exclusively by the coordinator thread.
enum Stage {
Idle,
Recording(String), // binding_id
Processing,
}
/// Serialises all transcription lifecycle events through a single thread
/// to eliminate race conditions between keyboard shortcuts, signals, and
/// the async transcribe-paste pipeline.
pub struct TranscriptionCoordinator {
tx: Sender<Command>,
}
pub fn is_transcribe_binding(id: &str) -> bool {
id == "transcribe" || id == "transcribe_with_post_process"
}
impl TranscriptionCoordinator {
pub fn new(app: AppHandle) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut stage = Stage::Idle;
let mut last_press: Option<Instant> = None;
while let Ok(cmd) = rx.recv() {
match cmd {
Command::Input {
binding_id,
hotkey_string,
is_pressed,
push_to_talk,
} => {
// Debounce rapid-fire press events (key repeat / double-tap).
// Releases always pass through for push-to-talk.
if is_pressed {
let now = Instant::now();
if last_press.map_or(false, |t| now.duration_since(t) < DEBOUNCE) {
debug!("Debounced press for '{binding_id}'");
continue;
}
last_press = Some(now);
}
if push_to_talk {
if is_pressed && matches!(stage, Stage::Idle) {
start(&app, &mut stage, &binding_id, &hotkey_string);
} else if !is_pressed
&& matches!(&stage, Stage::Recording(id) if id == &binding_id)
{
stop(&app, &mut stage, &binding_id, &hotkey_string);
}
} else if is_pressed {
match &stage {
Stage::Idle => {
start(&app, &mut stage, &binding_id, &hotkey_string);
}
Stage::Recording(id) if id == &binding_id => {
stop(&app, &mut stage, &binding_id, &hotkey_string);
}
_ => {
debug!("Ignoring press for '{binding_id}': pipeline busy")
}
}
}
}
Command::Cancel {
recording_was_active,
} => {
// Don't reset during processing — wait for the pipeline to finish.
if !matches!(stage, Stage::Processing)
&& (recording_was_active || matches!(stage, Stage::Recording(_)))
{
stage = Stage::Idle;
}
}
Command::ProcessingFinished => {
stage = Stage::Idle;
}
}
}
debug!("Transcription coordinator exited");
}));
if let Err(e) = result {
error!("Transcription coordinator panicked: {e:?}");
}
});
Self { tx }
}
/// Send a keyboard/signal input event for a transcribe binding.
/// For signal-based toggles, use `is_pressed: true` and `push_to_talk: false`.
pub fn send_input(
&self,
binding_id: &str,
hotkey_string: &str,
is_pressed: bool,
push_to_talk: bool,
) {
if self
.tx
.send(Command::Input {
binding_id: binding_id.to_string(),
hotkey_string: hotkey_string.to_string(),
is_pressed,
push_to_talk,
})
.is_err()
{
warn!("Transcription coordinator channel closed");
}
}
pub fn notify_cancel(&self, recording_was_active: bool) {
if self
.tx
.send(Command::Cancel {
recording_was_active,
})
.is_err()
{
warn!("Transcription coordinator channel closed");
}
}
pub fn notify_processing_finished(&self) {
if self.tx.send(Command::ProcessingFinished).is_err() {
warn!("Transcription coordinator channel closed");
}
}
}
fn start(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!("No action in ACTION_MAP for '{binding_id}'");
return;
};
action.start(app, binding_id, hotkey_string);
if app
.try_state::<Arc<AudioRecordingManager>>()
.map_or(false, |a| a.is_recording())
{
*stage = Stage::Recording(binding_id.to_string());
} else {
debug!("Start for '{binding_id}' did not begin recording; staying idle");
}
}
fn stop(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!("No action in ACTION_MAP for '{binding_id}'");
return;
};
action.stop(app, binding_id, hotkey_string);
*stage = Stage::Processing;
}