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:
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;
|
||||
|
||||
Reference in new issue
Block a user