Skip to content

Commit a7fa886

Browse files
committed
feat(live-session): 暂停边沿收敛与漏边沿补偿(no-rescore 断句+合成事件对+边界切断)
1 parent ea33da2 commit a7fa886

10 files changed

Lines changed: 728 additions & 85 deletions

‎app/src-tauri/src/commands_live.rs‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ pub struct LiveSessionStatus {
2828
/// 2026-08 修复:是否处于暂停(挂载拉取恢复右侧面板状态机用——
2929
/// recording 事件只发一次,刷新/重进页面后需靠此字段还原 phase)
3030
pub paused: bool,
31+
/// 批 2a:暂停来源(kebab-case:manual/media/foreground;paused=false 恒
32+
/// None——前端据此显示暂停原因与恢复语义,自动暂停可提示"视频仍暂停")
33+
pub paused_reason: Option<String>,
3134
/// v0.9.0 M2(REQ-189):当前生效画面档(kebab-case;None=未定档——
3235
/// 前端面板挂载拉取兑底,tier-changed 事件可能早于监听注册)
3336
pub tier: Option<String>,
@@ -199,6 +202,8 @@ pub fn live_session_status(state: State<'_, AppState>) -> LiveSessionStatus {
199202
crate::live_session_prepare::PrepareStatus::Ready
200203
),
201204
paused: state.live_session.is_paused(),
205+
// 批 2a:暂停来源(None=未暂停)
206+
paused_reason: state.live_session.paused_reason(),
202207
// v0.9.0 M2(REQ-189):当前生效画面档(None=未定档/未激活)
203208
tier: state.live_session.applied_tier().map(|t| t.as_str().to_string()),
204209
}
@@ -258,17 +263,19 @@ pub fn release_live_prepare(state: State<'_, AppState>) -> Result<(), String> {
258263
state.live_session.release_prepare().map_err(|e| e.to_string())
259264
}
260265

261-
/// 暂停实时会话(2026-08 A1 硬暂停:完全停采,时间轴冻结)。
266+
/// 暂停实时会话(2026-08 A1 硬暂停:完全停采,时间轴冻结;批 2a 经 request
267+
/// API 锁存 Manual 来源——reason=manual)。
262268
///
263-
/// @ai-context: 只置共享标志——实际暂停由捕获线程边沿检测执行(WASAPI 端点
264-
/// Stop + 暂停时长累计),会话线程发出 live:paused 事件与落库;
265-
/// 无活动会话/已暂停 → 明确报错(幂等拒绝)。
269+
/// @ai-context: request 只落共享状态——实际暂停由捕获线程边沿检测执行(WASAPI
270+
/// 端点 Stop + 暂停时长累计),会话线程发出 live:paused 事件与落库
271+
/// (载荷带 reason);无活动会话/已暂停 → 明确报错(幂等拒绝)。
266272
#[tauri::command]
267273
pub fn pause_live_session(state: State<'_, AppState>) -> Result<(), String> {
268274
state.live_session.pause().map_err(|e| e.to_string())
269275
}
270276

271-
/// 恢复暂停的实时会话(2026-08 A1;未暂停 → 明确报错)。
277+
/// 恢复暂停的实时会话(2026-08 A1;批 2a Release(Manual)——manual 解除瞬间
278+
/// 重评估 auto 条件仍真则对应源自动重暂停;未暂停 → 明确报错)。
272279
#[tauri::command]
273280
pub fn resume_live_session(state: State<'_, AppState>) -> Result<(), String> {
274281
state.live_session.resume().map_err(|e| e.to_string())

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ mod live_session_lifecycle;
372372
#[cfg(target_os = "windows")]
373373
mod pause_state;
374374
#[cfg(target_os = "windows")]
375+
mod live_session_pause;
376+
#[cfg(target_os = "windows")]
375377
mod live_keyframes;
376378
mod load_monitor;
377379
// v0.7.0 M1(REQ-106,TRUST-4):诊断日志脱敏(OCR 文本/会话标题等敏感内容过滤)

‎app/src-tauri/src/live_session_loop.rs‎

Lines changed: 21 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub(crate) struct LiveLoopCtx<'a> {
7373
pub(crate) fn run_audio_loop(
7474
rx: mpsc::Receiver<AudioChunk>,
7575
mut audio: crate::capture::AudioLoopbackCapture,
76-
ctx: LiveLoopCtx<'_>,
76+
mut ctx: LiveLoopCtx<'_>,
7777
data_dir: &std::path::Path,
7878
) {
7979
let mut asr_health = crate::asr_health::AsrHealthMonitor::new();
@@ -124,70 +124,28 @@ pub(crate) fn run_audio_loop(
124124
// deadline 早已过期,宽限从未生效;改为 draining 置位时才起算(Option<Instant>)
125125
let mut drain_deadline: Option<Instant> = None;
126126
let mut draining = false;
127-
// 2026-08 A1:暂停边沿跟踪(false→true 断句隔离;暂停期捕获线程停采,
128-
// channel 空 → recv_timeout 空转,无需显式消费处理)
129-
let mut loop_paused = ctx.pause.paused.load(Ordering::SeqCst);
127+
// 批 2a:暂停边沿收敛域(live_session_pause.rs)——可见边沿 flush_no_rescore
128+
// 断句 + seq 漏边沿代数补偿 + 合成事件对;本循环只留编排调用。
129+
// @ai-context: 暂停期捕获线程停采 → channel 空 → recv_timeout 空转,无需显式消费
130+
let mut pause_view = crate::live_session_pause::PauseEdgeView::new(ctx.pause);
130131
loop {
131-
// ── 暂停边沿(2026-08 A1)──
132+
// ── 暂停边沿(批 2a:检测+应用收敛于 live_session_pause.rs)──
132133
// @ai-context: 时间戳 = 会话时间(epoch - 已补偿暂停时长)——暂停开始
133134
// 时补偿尚未累计(正确,时间轴冻结点);恢复时补偿已更新
134135
// (时间戳回到冻结点附近,时间轴无缝衔接)。
135-
let paused_now = ctx.pause.paused.load(Ordering::SeqCst);
136-
if paused_now != loop_paused {
137-
let now_ms = ctx.epoch.elapsed().as_millis() as u64
138-
- ctx.pause.total_paused_ms.load(Ordering::SeqCst);
139-
if paused_now {
140-
// 进入暂停(P2 增强:替代"喂 100ms 静音"方案——静音块不足以触发
141-
// sherpa 端点规则(rule1 需 2.4s 尾静音),句无法断开;flush 尾句
142-
// 落库 + reset 重建流才能保证暂停前后的语音不连句,恢复后干净开始)
143-
// REQ-154(v0.7.2 S-1):动态合并阈值先算(借用释放后再构造 ctx)
144-
let merge_gap_ms = crate::asr_merge::adaptive_merge_gap(
145-
pause_history.iter().copied(),
146-
);
147-
flush_tail_and_persist(
148-
FinalEventCtx {
149-
app: ctx.app,
150-
db: ctx.db,
151-
session_id: ctx.session_id,
152-
asr_segments: ctx.asr_segments,
153-
sentence_start_ms: &mut sentence_start_ms,
154-
last_speech_ms: &mut last_speech_ms,
155-
last_final_clean: &mut last_final_clean,
156-
pending_merge: &mut pending_merge,
157-
last_segment_end: &mut last_segment_end,
158-
// REQ-154(v0.7.2 S-1/S-2):停顿历史/动态阈值/语速基准
159-
pause_history: &mut pause_history,
160-
merge_gap_ms,
161-
last_speech_rate: &mut last_speech_rate,
162-
},
163-
ctx.asr_engine,
164-
now_ms,
165-
&mut sentence_rms_sum,
166-
&mut sentence_rms_count,
167-
);
168-
// 重建流(reset 预留给复用场景:清句音频/状态,热词重读)
169-
ctx.asr_engine.reset();
170-
// REQ-154(v0.7.2 S-2):暂停边沿重置语速基准——恢复后首段与
171-
// 暂停前比较会跨暂停区间误判语速骤变(暂停时长不计入段间)
172-
last_speech_rate = None;
173-
let _ = ctx.db.add_event(&crate::session_events::NewSessionEvent::simple(
174-
ctx.session_id,
175-
crate::session_events::EventKind::Pause,
176-
now_ms,
177-
));
178-
let _ = ctx.app.emit("live:paused", ());
179-
eprintln!("[LiveSession] 会话 {} 暂停 @{}ms", ctx.session_id, now_ms);
180-
} else {
181-
let _ = ctx.db.add_event(&crate::session_events::NewSessionEvent::simple(
182-
ctx.session_id,
183-
crate::session_events::EventKind::Resume,
184-
now_ms,
185-
));
186-
let _ = ctx.app.emit("live:resumed", ());
187-
eprintln!("[LiveSession] 会话 {} 恢复 @{}ms", ctx.session_id, now_ms);
188-
}
189-
loop_paused = paused_now;
190-
}
136+
crate::live_session_pause::detect_and_apply_pause_edges(
137+
&mut pause_view,
138+
&mut ctx,
139+
&mut sentence_start_ms,
140+
&mut last_speech_ms,
141+
&mut last_final_clean,
142+
&mut pending_merge,
143+
&mut last_segment_end,
144+
&mut pause_history,
145+
&mut last_speech_rate,
146+
&mut sentence_rms_sum,
147+
&mut sentence_rms_count,
148+
);
191149
// H1 修复:deadline 在 draining 置位时才起算(见声明处注释),此处
192150
// 仅在已置位的情况下判定到期;None 表示尚未进入 drain 阶段
193151
if draining
@@ -423,6 +381,8 @@ pub(crate) fn run_audio_loop(
423381
stop_now_ms,
424382
&mut sentence_rms_sum,
425383
&mut sentence_rms_count,
384+
// 停止路径保留 SenseVoice 重打分(与端点同质量兜底;暂停边沿才跳过)
385+
true,
426386
);
427387
audio.stop();
428388

‎app/src-tauri/src/live_session_manager.rs‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ impl LiveSessionManager {
3939
self.pause.paused.load(Ordering::SeqCst)
4040
}
4141

42+
/// 暂停来源(批 2a:status 查询下发——paused=false 恒 None)。
43+
pub fn paused_reason(&self) -> Option<String> {
44+
self.pause.paused_reason().map(|s| s.as_str().to_string())
45+
}
46+
4247
/// 画面档降档确认共享状态句柄(command 层组装 LiveSessionParams 时获取;
4348
/// v0.9.0 M2 REQ-189——前端确认降档后写入,worker 消费)。
4449
pub fn tier_override(&self) -> std::sync::Arc<std::sync::Mutex<Option<crate::video_profile_spec::VisualTier>>> {
@@ -99,33 +104,57 @@ impl LiveSessionManager {
99104
self.applied_profile.clone()
100105
}
101106

102-
/// 暂停活动会话(2026-08 A1 硬暂停:完全停采)。
107+
/// 暂停活动会话(2026-08 A1 硬暂停:完全停采;批 2a 改经 request API——
108+
/// Manual 来源锁存,reason 由此推导)。
103109
///
104-
/// @ai-context: 只置共享标志——实际暂停由捕获线程边沿检测执行
110+
/// @ai-context: request 只落共享状态——实际暂停由捕获线程边沿检测执行
105111
/// (WASAPI 端点 Stop)并累计补偿时长;事件/落库由会话
106112
/// 线程边沿检测发出(保证与真实暂停时序一致)。
107-
/// @ai-context: 无活动会话/已暂停 → 明确报错(幂等拒绝)。
113+
/// @ai-context: 守卫(文案与锁纪律不变):无活动会话/已暂停 → 明确报错
114+
/// (幂等拒绝;自动暂停期按已暂停拒绝——手动接管留 2b UI
115+
/// 层按 paused_reason 决策)。
108116
pub fn pause(&self) -> Result<()> {
109117
let guard = self.active.lock().expect("live session lock poisoned");
110118
if guard.is_none() {
111119
return Err(AppError::Io("无活动实时会话".to_string()));
112120
}
113-
if self.pause.paused.swap(true, Ordering::SeqCst) {
121+
if self.is_paused() {
122+
// 先查后请求:Err 路径不得锁存 Manual(幂等拒绝,与旧 swap 语义一致)
114123
return Err(AppError::Io("会话已处于暂停".to_string()));
115124
}
116-
Ok(())
125+
use crate::pause_state::{PauseOutcome, PauseRequest, PauseSource};
126+
match self.pause.request(PauseRequest::Pause(PauseSource::Manual)) {
127+
PauseOutcome::Paused => Ok(()),
128+
// 理论不可达(先查未暂停)——防御:不静默
129+
_ => Err(AppError::Io("会话已处于暂停".to_string())),
130+
}
117131
}
118132

119-
/// 恢复暂停的会话(2026-08 A1;未暂停 → 明确报错)。
133+
/// 恢复暂停的会话(2026-08 A1;批 2a 改经 request API——Release(Manual))。
134+
///
135+
/// @ai-context: 单状态机语义:manual 解除瞬间重评估 auto 条件(媒体/前台
136+
/// 锁存仍真 → 对应源自动重暂停——pause_state 模块头真值表);
137+
/// 自动暂停期按手动恢复请求:无 manual 锁存可释放 → Ok 但
138+
/// paused 保持(物理无变化,条件由 worker 检测器自行解除),
139+
/// 前端以 paused_reason 感知。
120140
pub fn resume(&self) -> Result<()> {
121141
let guard = self.active.lock().expect("live session lock poisoned");
122142
if guard.is_none() {
123143
return Err(AppError::Io("无活动实时会话".to_string()));
124144
}
125-
if !self.pause.paused.swap(false, Ordering::SeqCst) {
126-
return Err(AppError::Io("会话未处于暂停".to_string()));
145+
use crate::pause_state::{PauseOutcome, PauseRequest, PauseSource};
146+
match self.pause.request(PauseRequest::Release(PauseSource::Manual)) {
147+
PauseOutcome::Resumed => Ok(()),
148+
PauseOutcome::StillHeld => {
149+
// auto 条件仍持暂停(manual 解除瞬间重评估)——如实记录可观测
150+
eprintln!("[LiveSession] 手动恢复但自动暂停条件仍持(reason={:?})", self.pause.reason());
151+
Ok(())
152+
}
153+
PauseOutcome::NotPaused => Err(AppError::Io("会话未处于暂停".to_string())),
154+
PauseOutcome::Paused | PauseOutcome::AlreadyPaused => {
155+
Err(AppError::Io("会话未处于暂停".to_string()))
156+
}
127157
}
128-
Ok(())
129158
}
130159

131160
/// 停止活动会话(有界等待线程退出,返回其会话 id)。

0 commit comments

Comments
 (0)