diff --git a/desktop/src-tauri/src/dictation.rs b/desktop/src-tauri/src/dictation.rs index 7295506..ef7a6af 100644 --- a/desktop/src-tauri/src/dictation.rs +++ b/desktop/src-tauri/src/dictation.rs @@ -4,8 +4,9 @@ use parking_lot::Mutex; use serde_json::{json, Value}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; +use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, EventTarget, Manager}; use tokio::sync::Notify; @@ -24,12 +25,14 @@ pub struct DictationState { } struct Session { - id: String, /// 会话代际(18A/18B):每次 start 自增,on_server_msg / 收尾任务校验后才生效。 epoch: u64, capture: Option, started: Instant, got_first_partial: bool, + /// 收尾语义(true=取消):stop() 在停采集前写入,转发线程排空音频后据此 + /// 发 Stop/Cancel 帧,保证控制帧在全部音频帧之后到网关(尾部不丢,见 stop)。 + canceled: Arc, } pub fn start(app: &AppHandle) { @@ -71,23 +74,42 @@ pub fn start(app: &AppHandle) { None } }; + // 收尾语义标志:stop() 停采集前写入;转发线程排空音频后读取并发控制帧。 + let canceled = Arc::new(AtomicBool::new(false)); { let ws = ws.clone(); + let sid = session_id.clone(); + let canceled = canceled.clone(); std::thread::spawn(move || { - while let Ok(frame) = rx.recv() { - if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() { - break; + // 排空音频通道:capture 被 drop(stop 里)→ tx 关闭 → 这里 recv 收完 + // 剩余缓冲帧后返回 Err。ws 写入失败则直接退出,不再补发控制帧。 + loop { + match rx.recv() { + Ok(frame) => { + if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() { + return; + } + } + Err(_) => break, } } + // 音频已全部入队,最后才发 Stop/Cancel——保证控制帧排在所有音频帧之后, + // 网关先收完音频再收 stop,尾部半秒不再被截断(竞态修复)。 + let cmd = if canceled.load(Ordering::SeqCst) { + crate::ws::WsCmd::Cancel { session_id: sid } + } else { + crate::ws::WsCmd::Stop { session_id: sid } + }; + let _ = ws.send(cmd); }); } *state.inner.lock() = Some(Session { - id: session_id, epoch, capture, started: Instant::now(), got_first_partial: false, + canceled, }); } @@ -104,7 +126,11 @@ pub fn stop(app: &AppHandle, canceled: bool) { let epoch = sess.epoch; emit_overlay(app, "hotkey", json!({"state": "up"})); set_tray_tooltip(app, "dudu — 就绪"); - sess.capture.take(); // 停止采集 + + // 先写收尾语义,再停采集:转发线程排空音频后据此发 Stop/Cancel, + // 保证控制帧排在所有音频帧之后(尾部不被截断)。不在此处直接发控制帧。 + sess.canceled.store(canceled, Ordering::SeqCst); + sess.capture.take(); // 停止采集 → 关音频通道 → 转发线程排空后补发控制帧 let buf = app.state::(); if canceled { @@ -112,14 +138,6 @@ pub fn stop(app: &AppHandle, canceled: bool) { buf.discard(epoch); } - let ws = (*app.state::()).clone(); - let cmd = if canceled { - crate::ws::WsCmd::Cancel { session_id: sess.id.clone() } - } else { - crate::ws::WsCmd::Stop { session_id: sess.id.clone() } - }; - let _ = ws.send(cmd); - // 取尾部 final 完成信号(事件驱动注入,18C)。 let finalized = buf.finalize_signal();