refactor(dictation): 抽出 dictation_session 子模块

把会话生命周期函数从 coordinator/dictation.rs 机械式搬到同级
coordinator/dictation_session.rs(行为保持):request_stop_during_starting、
begin_session、start_recorder_for_starting、spawn_recorder_error_monitor、
abort_recording_with_error、start_recorder_and_enter_listening、
finish_starting_session、dictation_error_code、cancel_session、
eligible_polish_context_turns。新模块 use super::resources::* 取资源辅助函数。
coordinator.rs glob 重导出并把 begin_session/cancel_session/
request_stop_during_starting/dictation_error_code 的命名导入改到
dictation_session::;tests.rs 的 abort_recording_with_error 路径同步;
dictation.rs 去掉随之失用的 request_stop_during_starting_state 导入,
测试 eligible_polish_context_turns 改走 crate::coordinator:: 路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吕柏青andClaude Opus 4.8 committed 2026-06-07 21:13:01 +08:00
1 parent 281c503771
commit f6775b89c8
4 files changed
+711 -704

No files matched your search

@@ -64,6 +64,7 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon
mod asr_setup;
mod capsule;
mod dictation;
mod dictation_session;
mod dictation_streaming;
mod dictation_voice_agent;
mod hotkey_supervisors;
@@ -78,6 +79,7 @@ mod voice_agent_hotkeys;
// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。
pub(crate) use asr_setup::*;
pub(crate) use capsule::*;
pub(crate) use dictation_session::*;
pub(crate) use dictation_streaming::*;
pub(crate) use dictation_voice_agent::*;
pub(crate) use hotkey_supervisors::*;
@@ -87,11 +89,9 @@ pub(crate) use qa_session::*;
pub(crate) use voice_agent_hotkeys::*;
#[cfg(test)]
use dictation::dictation_error_code;
use dictation::{
begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge,
request_stop_during_starting,
};
use dictation_session::dictation_error_code;
use dictation::{end_session, handle_pressed_edge, handle_released_edge};
use dictation_session::{begin_session, cancel_session, request_stop_during_starting};
#[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released};
use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState};
@@ -1,7 +1,6 @@
use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::coordinator_state::request_stop_during_starting_state;
use crate::correction::apply_correction_rules;
use crate::types::HotkeyMode;
@@ -127,614 +126,6 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
}
}
pub(super) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
{
let mut state = inner.state.lock();
if !request_stop_during_starting_state(&mut state) {
return;
}
}
log::info!("[coord] {reason} during Starting — queued");
stop_recorder_if_pending_start_stop(inner);
}
pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app())
else {
return Ok(());
};
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
session_id
};
#[cfg(target_os = "windows")]
{
let prepared = inner.windows_ime.prepare_session();
let mut slots = inner.prepared_windows_ime_session.lock();
store_prepared_windows_ime_session(&mut slots, current_session_id, prepared);
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner
.translation_modifier_seen
.store(false, Ordering::SeqCst);
#[cfg(any(debug_assertions, test))]
if hotkey_injection_dry_run_enabled() {
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
inner.state.lock().phase = SessionPhase::Listening;
log::info!("[coord] session started (hotkey-injection dry-run)");
return Ok(());
}
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] ASR credential gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] microphone permission gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(message);
}
// 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在
// Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。
// 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init
// 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
// Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder /
// ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch;
// online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token
// 推 partial 给前端胶囊。
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = match SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
{
Ok(local) => Arc::new(local),
Err(e) => {
log::error!("[coord] sherpa-onnx init failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("本地模型初始化失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(format!("sherpa-onnx init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(&active_asr) {
let local = match build_local_qwen3(inner).await {
Ok(l) => l,
Err(e) => {
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("本地模型初始化失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(format!("local ASR init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Local(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
if is_bailian_provider(&active_asr) {
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Bailian(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open Bailian ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {
asr.cancel();
}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
} else if is_mimo_provider(&active_asr) {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Mimo(Arc::clone(&mimo)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
// 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる
// と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々
// hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理
// モデルのコンテキスト両方に渡される」と明示しているので、Whisper
// 互換プロバイダにも揃えるのが筋。
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(
WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(&active_asr),
whisper_supports_verbose_json(&active_asr),
)
.with_request_format(whisper_request_format(&active_asr)),
);
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Whisper(Arc::clone(&whisper)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else {
let hotwords = enabled_hotwords(inner);
let creds = read_volc_credentials();
let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Volcengine(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
// open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session
// 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。
// audit HIGH #1。
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
}
Ok(())
}
pub(super) async fn start_recorder_for_starting(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
let inner_for_level = Arc::clone(inner);
// 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS
// transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔),
// 配合 CSS 短 transition 让每次 emit 完整可见。
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.state.lock().phase;
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
});
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "dictation recorder");
acquire_recording_mute(inner, "dictation").await;
let audio_archive_path = if inner.prefs.get().record_audio_for_debug {
// 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见
// 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。
// 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。
let prefs = inner.prefs.get();
let _ = crate::persistence::prune_recordings(
prefs.history_retention_days,
prefs.audio_recording_max_entries,
);
crate::persistence::recording_path_for_session(&session_id.to_string()).ok()
} else {
None
};
match Recorder::start(
microphone_device_name,
consumer,
level_handler,
audio_archive_path,
) {
Ok((rec, runtime_errors, archive_active)) => {
// 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript
// 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
store_recorder_for_session(inner, session_id, rec);
spawn_recorder_error_monitor(inner, runtime_errors);
// 不在这里 emit Recording capsule。
// Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio
// 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到
// 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。
// 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个
// 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。
//
// 现改为:level_handler 第一次被触发时才 emit Recording capsule。
// recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler,
// 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻
// 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 ==
// mic 真的在录,匹配「麦先录、UI 再弹」的预期。
//
// 原本的竞态保护交还给两条已有路径:
// - stop_recorder_if_pending_start_stop:短按时把 capsule 切到
// Transcribing;recorder 已 stop,level_handler 不会再发火。
// - level_handler 内部 phase 检查:cancel / 错误使 phase 不在
// {Starting, Listening} 时直接 return,不会在错误状态上盖
// Recording。
stop_recorder_if_pending_start_stop(inner);
log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)");
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
cancel_asr_for_session(inner, session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("录音启动失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, session_id);
release_recording_mute(inner, "dictation");
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
Ok(())
}
pub(super) fn spawn_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
// 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件,
// 不能去 abort 当前 active 的新 session(它录得好好的)。
let captured_session_id = inner.state.lock().session_id;
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.state.lock().session_id;
if captured_session_id != current_session_id {
log::warn!(
"[coord] recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] recorder runtime error: {err}");
abort_recording_with_error(&inner, format!("录音中断: {err}"));
}
})
.ok();
}
pub(super) fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
let Some(abort) = ({
let mut state = inner.state.lock();
begin_recording_abort_before_restore(&mut state)
}) else {
return;
};
discard_startup_resources_for_session(inner, abort.session_id);
restore_prepared_windows_ime_session(inner, abort.session_id);
{
let mut state = inner.state.lock();
publish_abort_idle_after_restore(&mut state, abort.session_id);
}
emit_capsule(
inner,
CapsuleState::Error,
0.0,
abort.elapsed,
Some(message),
None,
);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
pub(super) async fn start_recorder_and_enter_listening(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
start_recorder_for_starting(inner, session_id, active_asr, consumer).await?;
finish_starting_session(inner, session_id).await;
Ok(())
}
pub(super) async fn finish_starting_session(inner: &Arc<Inner>, session_id: SessionId) {
// audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。
// 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle
// 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。
let outcome = {
let mut state = inner.state.lock();
finish_starting_session_state(&mut state, session_id)
};
match outcome {
BeginOutcome::StaleContinuation => {
log::info!(
"[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring"
);
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
}
BeginOutcome::CancelRaced => {
log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin");
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
set_phase_idle_if_session_matches(inner, session_id);
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
log::info!("[coord] session started");
if matches!(outcome, BeginOutcome::PendingStop) {
log::info!("[coord] applying pending_stop edge → end_session immediately");
let _ = end_session(inner).await;
}
}
}
}
pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
@@ -1513,99 +904,13 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Ok(())
}
pub(super) fn dictation_error_code(
status: InsertStatus,
polish_failed: bool,
focus_ready_for_paste: bool,
allow_non_tsf_insertion_fallback: bool,
) -> Option<&'static str> {
if !focus_ready_for_paste && status == InsertStatus::Failed {
Some("focusRestoreFailed")
} else if cfg!(target_os = "windows")
&& focus_ready_for_paste
&& !allow_non_tsf_insertion_fallback
&& status == InsertStatus::Failed
{
Some("windowsImeTsfRequired")
} else if polish_failed {
Some("polishFailed")
} else {
None
}
}
pub(super) fn cancel_session(inner: &Arc<Inner>) {
let Some(decision) = ({
let mut state = inner.state.lock();
let phase = state.phase;
let decision = begin_cancel_session_state(&mut state);
if phase == SessionPhase::Inserting {
log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste");
}
decision
}) else {
return;
};
stop_recorder_for_session(inner, decision.session_id);
cancel_asr_for_session(inner, decision.session_id);
restore_prepared_windows_ime_session(inner, decision.session_id);
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if decision.phase != SessionPhase::Processing {
let mut state = inner.state.lock();
finish_cancel_session_state(&mut state, decision);
// 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled (was {:?})", decision.phase);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
// 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
fn eligible_polish_context_turns(
sessions: Vec<DictationSession>,
active_style_pack_id: &str,
current_translation_active: bool,
) -> Vec<(String, String)> {
sessions
.into_iter()
// 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底,
// 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。
// 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough
// 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。
.filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty())
// 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。
.filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id))
// 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味:
// - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致;
// - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析
// 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。
// - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。
.filter_map(|s| {
if s.translation_active && !current_translation_active {
s.polish_source
.filter(|src| !src.trim().is_empty())
.map(|src| (s.raw_transcript, src))
} else {
Some((s.raw_transcript, s.final_text))
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{batch_asr_chunk_limit_ms, eligible_polish_context_turns};
use super::batch_asr_chunk_limit_ms;
use crate::coordinator::{
append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with,
finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible,
eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with,
streaming_insert_eligible,
};
use crate::types::{
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
@@ -0,0 +1,702 @@
use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::coordinator_state::request_stop_during_starting_state;
use super::resources::*;
use super::*;
pub(crate) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
{
let mut state = inner.state.lock();
if !request_stop_during_starting_state(&mut state) {
return;
}
}
log::info!("[coord] {reason} during Starting — queued");
stop_recorder_if_pending_start_stop(inner);
}
pub(crate) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app())
else {
return Ok(());
};
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
session_id
};
#[cfg(target_os = "windows")]
{
let prepared = inner.windows_ime.prepare_session();
let mut slots = inner.prepared_windows_ime_session.lock();
store_prepared_windows_ime_session(&mut slots, current_session_id, prepared);
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner
.translation_modifier_seen
.store(false, Ordering::SeqCst);
#[cfg(any(debug_assertions, test))]
if hotkey_injection_dry_run_enabled() {
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
inner.state.lock().phase = SessionPhase::Listening;
log::info!("[coord] session started (hotkey-injection dry-run)");
return Ok(());
}
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] ASR credential gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] microphone permission gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(message);
}
// 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在
// Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。
// 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init
// 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
// Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder /
// ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch;
// online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token
// 推 partial 给前端胶囊。
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = match SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
{
Ok(local) => Arc::new(local),
Err(e) => {
log::error!("[coord] sherpa-onnx init failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("本地模型初始化失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(format!("sherpa-onnx init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(&active_asr) {
let local = match build_local_qwen3(inner).await {
Ok(l) => l,
Err(e) => {
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("本地模型初始化失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(format!("local ASR init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Local(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
if is_bailian_provider(&active_asr) {
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Bailian(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open Bailian ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {
asr.cancel();
}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
} else if is_mimo_provider(&active_asr) {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Mimo(Arc::clone(&mimo)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
// 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる
// と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々
// hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理
// モデルのコンテキスト両方に渡される」と明示しているので、Whisper
// 互換プロバイダにも揃えるのが筋。
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(
WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(&active_asr),
whisper_supports_verbose_json(&active_asr),
)
.with_request_format(whisper_request_format(&active_asr)),
);
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Whisper(Arc::clone(&whisper)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else {
let hotwords = enabled_hotwords(inner);
let creds = read_volc_credentials();
let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Volcengine(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
// open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session
// 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。
// audit HIGH #1。
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
}
Ok(())
}
pub(crate) async fn start_recorder_for_starting(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
let inner_for_level = Arc::clone(inner);
// 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS
// transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔),
// 配合 CSS 短 transition 让每次 emit 完整可见。
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.state.lock().phase;
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
});
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "dictation recorder");
acquire_recording_mute(inner, "dictation").await;
let audio_archive_path = if inner.prefs.get().record_audio_for_debug {
// 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见
// 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。
// 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。
let prefs = inner.prefs.get();
let _ = crate::persistence::prune_recordings(
prefs.history_retention_days,
prefs.audio_recording_max_entries,
);
crate::persistence::recording_path_for_session(&session_id.to_string()).ok()
} else {
None
};
match Recorder::start(
microphone_device_name,
consumer,
level_handler,
audio_archive_path,
) {
Ok((rec, runtime_errors, archive_active)) => {
// 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript
// 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
store_recorder_for_session(inner, session_id, rec);
spawn_recorder_error_monitor(inner, runtime_errors);
// 不在这里 emit Recording capsule。
// Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio
// 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到
// 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。
// 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个
// 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。
//
// 现改为:level_handler 第一次被触发时才 emit Recording capsule。
// recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler,
// 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻
// 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 ==
// mic 真的在录,匹配「麦先录、UI 再弹」的预期。
//
// 原本的竞态保护交还给两条已有路径:
// - stop_recorder_if_pending_start_stop:短按时把 capsule 切到
// Transcribing;recorder 已 stop,level_handler 不会再发火。
// - level_handler 内部 phase 检查:cancel / 错误使 phase 不在
// {Starting, Listening} 时直接 return,不会在错误状态上盖
// Recording。
stop_recorder_if_pending_start_stop(inner);
log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)");
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
cancel_asr_for_session(inner, session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("录音启动失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, session_id);
release_recording_mute(inner, "dictation");
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
Ok(())
}
pub(crate) fn spawn_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
// 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件,
// 不能去 abort 当前 active 的新 session(它录得好好的)。
let captured_session_id = inner.state.lock().session_id;
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.state.lock().session_id;
if captured_session_id != current_session_id {
log::warn!(
"[coord] recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] recorder runtime error: {err}");
abort_recording_with_error(&inner, format!("录音中断: {err}"));
}
})
.ok();
}
pub(crate) fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
let Some(abort) = ({
let mut state = inner.state.lock();
begin_recording_abort_before_restore(&mut state)
}) else {
return;
};
discard_startup_resources_for_session(inner, abort.session_id);
restore_prepared_windows_ime_session(inner, abort.session_id);
{
let mut state = inner.state.lock();
publish_abort_idle_after_restore(&mut state, abort.session_id);
}
emit_capsule(
inner,
CapsuleState::Error,
0.0,
abort.elapsed,
Some(message),
None,
);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
pub(crate) async fn start_recorder_and_enter_listening(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
start_recorder_for_starting(inner, session_id, active_asr, consumer).await?;
finish_starting_session(inner, session_id).await;
Ok(())
}
pub(crate) async fn finish_starting_session(inner: &Arc<Inner>, session_id: SessionId) {
// audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。
// 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle
// 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。
let outcome = {
let mut state = inner.state.lock();
finish_starting_session_state(&mut state, session_id)
};
match outcome {
BeginOutcome::StaleContinuation => {
log::info!(
"[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring"
);
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
}
BeginOutcome::CancelRaced => {
log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin");
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
set_phase_idle_if_session_matches(inner, session_id);
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
log::info!("[coord] session started");
if matches!(outcome, BeginOutcome::PendingStop) {
log::info!("[coord] applying pending_stop edge → end_session immediately");
let _ = end_session(inner).await;
}
}
}
}
pub(crate) fn dictation_error_code(
status: InsertStatus,
polish_failed: bool,
focus_ready_for_paste: bool,
allow_non_tsf_insertion_fallback: bool,
) -> Option<&'static str> {
if !focus_ready_for_paste && status == InsertStatus::Failed {
Some("focusRestoreFailed")
} else if cfg!(target_os = "windows")
&& focus_ready_for_paste
&& !allow_non_tsf_insertion_fallback
&& status == InsertStatus::Failed
{
Some("windowsImeTsfRequired")
} else if polish_failed {
Some("polishFailed")
} else {
None
}
}
pub(crate) fn cancel_session(inner: &Arc<Inner>) {
let Some(decision) = ({
let mut state = inner.state.lock();
let phase = state.phase;
let decision = begin_cancel_session_state(&mut state);
if phase == SessionPhase::Inserting {
log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste");
}
decision
}) else {
return;
};
stop_recorder_for_session(inner, decision.session_id);
cancel_asr_for_session(inner, decision.session_id);
restore_prepared_windows_ime_session(inner, decision.session_id);
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if decision.phase != SessionPhase::Processing {
let mut state = inner.state.lock();
finish_cancel_session_state(&mut state, decision);
// 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled (was {:?})", decision.phase);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
// 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
pub(crate) fn eligible_polish_context_turns(
sessions: Vec<DictationSession>,
active_style_pack_id: &str,
current_translation_active: bool,
) -> Vec<(String, String)> {
sessions
.into_iter()
// 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底,
// 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。
// 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough
// 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。
.filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty())
// 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。
.filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id))
// 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味:
// - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致;
// - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析
// 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。
// - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。
.filter_map(|s| {
if s.translation_active && !current_translation_active {
s.polish_source
.filter(|src| !src.trim().is_empty())
.map(|src| (s.raw_transcript, src))
} else {
Some((s.raw_transcript, s.final_text))
}
})
.collect()
}
@@ -1,4 +1,4 @@
use super::dictation::abort_recording_with_error;
use super::dictation_session::abort_recording_with_error;
use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy;