Skip to content

Commit 6103832

Browse files
committed
feat(live): 课堂助手三项优化——停止即时/播放暂停驱动/引擎预热
1 parent 3943273 commit 6103832

11 files changed

Lines changed: 718 additions & 65 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
- 手动标记按钮"⭐ 标记此刻":save_user_screenshot 补 live:image-saved 事件,"最近画面"条即时刷新(快捷键 Ctrl+Shift+S 同效)
3232
- 融合完成直达:停止后右侧显示"查看时间轴 →"卡片,一键跳会话页并自动打开对应会话详情
3333
- **开始前准备流(2026-08 用户需求,C 批)**:引擎就绪清单卡片(流式模型/SenseVoice/OCR 设备/结构模型/磁盘,聚合现有只读命令,零后端改动)——缺什么一目了然,全就绪显示"可开始"
34+
- **课堂助手三项优化(2026-08 用户需求)**:
35+
- 停止即时生效:停止信号置位即停捕获线程(channel 断开后 drain 残留块),停止响应从 ~8s 降至 <1s;"会话线程 5s 内未退出,已 detach" 由常态日志降级为真卡死兜底;stop 命令改 spawn_blocking 不再阻塞异步运行时
36+
- 视频播放状态自动暂停/恢复:REQ-125 暂停图标检测驱动 A1 硬暂停标志——视频暂停 → 整链路停采(WASAPI 端点 Stop / 不喂 ASR / 不写 WAV / 屏幕零分析),恢复播放自动继续;暂停边沿 flush 尾句 + 引擎 reset(替代 100ms 静音方案——真正断句,暂停前后语音不连句)
37+
- 引擎预热(开始即录):进课堂助手页即后台加载流式 ASR 引擎(预备线程 park 等待交接——引擎非 Send 不跨线程),点"开始"毫秒级启动;离开页面释放 + 15min TTL 兜底;预热失败/未就绪回退内联加载(零回归)
38+
- 修复:A1 暂停标志/补偿时长跨会话残留(新会话起始即暂停、时间戳偏移)——start 时按会话复位
3439

3540
- **课堂助手页面优化(2026-08 用户需求)**:
3641
- 窗口选择:自动过滤无法采集的窗口(最小化/零尺寸/cloaked/工具窗口);B站/YouTube 等站点首页(无视频内容落地页)降权移出推荐并标注"站点首页",仍可手动选择兜底

‎app/src-tauri/src/capture/audio_loopback.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ pub struct SessionPause {
6666
pub total_paused_ms: Arc<std::sync::atomic::AtomicU64>,
6767
}
6868

69+
impl SessionPause {
70+
/// 按会话复位(P2 补漏:标志/补偿时长不得跨会话残留——上次会话若在
71+
/// 暂停中停止,paused 残留会让新会话起始即暂停;补偿时长残留会让
72+
/// 新会话时间戳整体偏移)。
73+
pub fn reset(&self) {
74+
self.paused.store(false, Ordering::SeqCst);
75+
self.total_paused_ms.store(0, Ordering::SeqCst);
76+
}
77+
}
78+
6979
impl AudioLoopbackCapture {
7080
/// 启动捕获。on_chunk 在捕获线程内被调用(消费者需自行做轻量处理或转发)。
7181
///
@@ -312,6 +322,25 @@ where
312322
t.elapsed().as_millis() as u64,
313323
Ordering::SeqCst,
314324
);
325+
// 清空残留缓冲(2026-08 审查修复):Stop 前缓冲内
326+
// 的数据在恢复后已无意义——不清空会以补偿后时间戳
327+
// 混入暂停前的音频(内容错位);循环读到空为止
328+
loop {
329+
let pending = capture_client.GetNextPacketSize()?;
330+
if pending == 0 {
331+
break;
332+
}
333+
let mut _d: *mut u8 = std::ptr::null_mut();
334+
let mut _f: u32 = 0;
335+
let mut _fl: u32 = 0;
336+
capture_client
337+
.GetBuffer(&mut _d, &mut _f, &mut _fl, None, None)?;
338+
capture_client.ReleaseBuffer(_f)?;
339+
}
340+
// TD-H 补漏(十五轮审查):应用层切块累加器同样存在
341+
// 暂停前未凑满 200ms 的残留样本——不清空会以补偿后
342+
// 时间戳混入恢复后首个 chunk(内容错位);重建清零
343+
accumulator = ChunkAccumulator::new(0);
315344
eprintln!("[AudioLoopback] 会话恢复(端点已重启,暂停 {}ms)", t.elapsed().as_millis());
316345
}
317346
Err(e) => {

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use crate::live_session::LiveSessionParams;
2323
pub struct LiveSessionStatus {
2424
pub active: bool,
2525
pub session_id: Option<i64>,
26+
/// P3:引擎预热是否已就绪(前端"开始即录"提示)
27+
pub prepared: bool,
2628
}
2729

2830
/// 启动实时捕获会话(REQ-007~012 汇总入口)。
@@ -83,20 +85,73 @@ pub async fn start_live_session(
8385
/// 停止实时捕获会话;返回被停止的会话 id(None=无活动会话)。
8486
#[tauri::command]
8587
pub async fn stop_live_session(state: State<'_, AppState>) -> Result<Option<i64>, String> {
86-
let stopped = state.live_session.stop_active().map_err(|e| e.to_string())?;
88+
// 停止含 5s 有界等待——异步 command 不得直接阻塞运行时线程(审查要求;
89+
// LiveSessionManager 为 Clone+Send,可安全移入 spawn_blocking)
90+
let manager = state.live_session.clone();
91+
let stopped = tauri::async_runtime::spawn_blocking(move || manager.stop_active())
92+
.await
93+
.map_err(|e| format!("停止任务失败: {}", e))?
94+
.map_err(|e| e.to_string())?;
8795
stop_clipboard_monitor(&state);
8896
Ok(stopped)
8997
}
9098

91-
/// 查询实时会话状态(活动会话 id)。
99+
/// 查询实时会话状态(活动会话 id + 预热就绪标记)。
92100
#[tauri::command]
93101
pub fn live_session_status(state: State<'_, AppState>) -> LiveSessionStatus {
94102
LiveSessionStatus {
95103
active: state.live_session.active_session_id().is_some(),
96104
session_id: state.live_session.active_session_id(),
105+
prepared: matches!(
106+
state.live_session.prepare_status(),
107+
crate::live_session_prepare::PrepareStatus::Ready
108+
),
97109
}
98110
}
99111

112+
/// 预热流式 ASR 引擎(P3):选窗口阶段后台加载,点"开始"即录。
113+
///
114+
/// @ai-context: 返回 PrepareStatus(loading/ready/failed/idle)供前端提示;
115+
/// 幂等;模型文件缺失预检快速返回 Failed(不白起线程);
116+
/// 页面卸载时由 release_live_prepare 释放(15min TTL 兜底)。
117+
#[tauri::command]
118+
pub fn prepare_live_session(
119+
state: State<'_, AppState>,
120+
) -> Result<crate::live_session_prepare::PrepareStatus, String> {
121+
// 防御性预检:四件套任一缺失 → 预热必失败,快速返回不白起线程
122+
let m = &state.streaming_models;
123+
let missing: Vec<&str> = [
124+
(&m.encoder, "encoder"),
125+
(&m.decoder, "decoder"),
126+
(&m.joiner, "joiner"),
127+
(&m.tokens, "tokens"),
128+
]
129+
.iter()
130+
.filter(|(p, _)| !std::path::Path::new(p).exists())
131+
.map(|(_, name)| *name)
132+
.collect();
133+
if !missing.is_empty() {
134+
return Ok(crate::live_session_prepare::PrepareStatus::Failed(format!(
135+
"模型缺失: {}",
136+
missing.join(", ")
137+
)));
138+
}
139+
let env = crate::live_session_prepare::PrepareEnv {
140+
streaming_models: state.streaming_models.clone(),
141+
engines: state.engines.clone(),
142+
vocab: state.vocab.clone(),
143+
// ADR-012 F4-2:标点恢复模型路径(与 start 同口径)
144+
punctuation_model: crate::punctuation_model(&state.model_dir),
145+
};
146+
Ok(state.live_session.prepare(env))
147+
}
148+
149+
/// 释放预热引擎(P3:离开课堂助手页时调用;有界 join ≤1s,超时 detach)。
150+
#[tauri::command]
151+
pub fn release_live_prepare(state: State<'_, AppState>) -> Result<(), String> {
152+
state.live_session.release_prepare().map_err(|e| e.to_string())
153+
}
154+
100155
/// 暂停实时会话(2026-08 A1 硬暂停:完全停采,时间轴冻结)。
101156
///
102157
/// @ai-context: 只置共享标志——实际暂停由捕获线程边沿检测执行(WASAPI 端点

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ mod live_session_fusion;
115115
mod live_frame_process;
116116
#[cfg(target_os = "windows")]
117117
mod live_session_frame;
118+
// P3:引擎预热(预备线程——选窗口阶段后台加载,start 交接)
119+
#[cfg(target_os = "windows")]
120+
mod live_session_prepare;
118121
#[cfg(target_os = "windows")]
119122
mod live_keyframes;
120123
mod load_monitor;
@@ -505,6 +508,11 @@ pub fn run() {
505508
commands_live::pause_live_session,
506509
#[cfg(target_os = "windows")]
507510
commands_live::resume_live_session,
511+
// P3:引擎预热(选窗口阶段后台加载,开始即录)/ 释放
512+
#[cfg(target_os = "windows")]
513+
commands_live::prepare_live_session,
514+
#[cfg(target_os = "windows")]
515+
commands_live::release_live_prepare,
508516
// 视频文件导入(REQ-015,ADR-008:字幕优先 + ASR fallback + 关键帧 OCR)
509517
commands_import::import_video,
510518
// OCR 设备状态(REQ-036,ADR-009:GPU 卸载决策/回退可观测)

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,54 @@ impl TriggerState {
107107
}
108108
}
109109

110+
/// 暂停模式仅取帧(P2 自动暂停:视频暂停 → 捕获自动暂停时,worker 保持
111+
/// 低频取帧刷新 latest_frame——播放器恢复检测的信号源,不刷新则无法发现
112+
/// 恢复播放;零分析:不做 diff/OCR/落库,成本与静默轮询同级)。
113+
///
114+
/// @ai-context: 事件转发保持(窗口关闭等信号不因暂停丢失);失败节流日志
115+
/// 5s(暂停期 1s 一拍,全量打印会刷屏);取帧成功即覆盖共享
116+
/// 缓存(时间戳用补偿后纪元——调用方注入)。
117+
pub fn capture_latest_only(
118+
screen: Option<&mut ScreenCaptureSampler>,
119+
app: &tauri::AppHandle,
120+
epoch: Instant,
121+
latest_frame: &std::sync::Arc<std::sync::Mutex<Option<LatestCapturedFrame>>>,
122+
last_capture_error: &mut Option<Instant>,
123+
) {
124+
let Some(sampler) = screen else { return };
125+
// ADR-007:目标窗口关闭等捕获事件无论捕获结果如何都要转发(同 process_frame)
126+
match sampler.take_event() {
127+
Some(crate::capture::dxgi_capture::CaptureEvent::WindowLost) => {
128+
let _ = app.emit("live:window-lost", ());
129+
}
130+
None => {}
131+
}
132+
match sampler.capture(None) {
133+
Ok(Some(frame)) => {
134+
if let Ok(mut guard) = latest_frame.lock() {
135+
*guard = Some(LatestCapturedFrame {
136+
timestamp_ms: epoch.elapsed().as_millis() as u64,
137+
bgraw: frame.bgraw,
138+
width: frame.width,
139+
height: frame.height,
140+
});
141+
}
142+
}
143+
// DXGI 超时(桌面无变化)——暂停期常态,非错误
144+
Ok(None) => {}
145+
Err(e) => {
146+
let now = Instant::now();
147+
let should_log = last_capture_error
148+
.map(|t| now.duration_since(t) >= Duration::from_secs(5))
149+
.unwrap_or(true);
150+
if should_log {
151+
*last_capture_error = Some(now);
152+
eprintln!("[ScreenWorker] 暂停期取帧失败(日志节流 5s): {}", e);
153+
}
154+
}
155+
}
156+
}
157+
110158
/// 处理一帧屏幕采样(字幕区/全帧 OCR)。
111159
///
112160
/// @ai-context: ADR-011 两级判变:①全帧网格差异(每 tick,与路径无关)——

0 commit comments

Comments
 (0)