Skip to content

Commit a5c912d

Browse files
committed
feat(capture): 视频暂停随播随停——声画双通道检测(REQ-291)
1 parent d7d589b commit a5c912d

6 files changed

Lines changed: 279 additions & 0 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ mod log_redact;
340340
mod model_downloader;
341341
// v0.7.0 M3(REQ-131):模型版本管理与磁盘占用(可查可回退)
342342
mod model_registry;
343+
// REQ-291(v0.19.7):视频随播随停——声画双通道检测纯状态机
344+
mod media_state;
343345
mod note_filter;
344346
mod note_filter_ai;
345347
mod note_filter_discourse;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ pub(crate) fn run_session_after_engine(
252252
let mut asr_segments: Vec<crate::types::TranscriptSegment> = Vec::new();
253253
let subtitle_segments: Arc<Mutex<Vec<SubtitleSegment>>> = Arc::new(Mutex::new(Vec::new()));
254254
let speech_active = Arc::new(AtomicBool::new(false));
255+
// REQ-291(v0.19.7):媒体级"最后有声时刻"共享戳(音频线程写/屏幕线程读)
256+
let media_sound: Arc<Mutex<Option<std::time::Instant>>> = Arc::new(Mutex::new(None));
255257
// M4/REQ-068(S4):实时链路音频落盘(WAV PCM16;创建失败降级不阻断)
256258
let mut audio_writer = crate::audio_store::SessionAudioWriter::create(
257259
&params.data_dir.join("session-audio"),
@@ -286,6 +288,8 @@ pub(crate) fn run_session_after_engine(
286288
let foreground_monitor = crate::foreground_timeline::ForegroundMonitor::new(params.hwnd);
287289
// 2026-08 A1:屏幕 worker 共享暂停状态(暂停跳过采样;恢复后时间戳补偿)
288290
let worker_pause = pause.clone();
291+
// REQ-291(v0.19.7):媒体级声音戳(worker 随播随停检测读取)
292+
let worker_media_sound = media_sound.clone();
289293
// v0.7.2(REQ-151):会话信息聚合(worker 播放器 OCR 写入)
290294
let worker_session_info = session_info.clone();
291295
// v0.9.0 M2(REQ-189):画面档降档确认共享状态(前端确认后写入,
@@ -317,6 +321,8 @@ pub(crate) fn run_session_after_engine(
317321
foreground_monitor,
318322
// 2026-08 A1:暂停共享状态
319323
worker_pause,
324+
// REQ-291(v0.19.7):媒体级声音戳(随播随停检测输入)
325+
worker_media_sound,
320326
// v0.7.2(REQ-151):会话信息聚合(播放器 OCR 写入)
321327
worker_session_info,
322328
// v0.9.0 M2(REQ-189):画面档降档确认共享状态
@@ -356,6 +362,8 @@ pub(crate) fn run_session_after_engine(
356362
vad_slot: Some(params.vad_slot.as_ref()),
357363
// 2026-08 A1:暂停共享状态(主循环做边沿断句/事件/落库)
358364
pause: &pause,
365+
// REQ-291(v0.19.7):媒体级声音戳(音频线程每块写"最后有声时刻")
366+
media_sound,
359367
};
360368
run_audio_loop(rx, audio, loop_ctx, &params.data_dir);
361369

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ fn liveness_check(
129129
}
130130
}
131131

132+
/// REQ-291:媒体级"最近有声"判定(窗口=SOUND_RECENT_MS ≥ 采样拍 1s——
133+
/// 保证一拍内出现的声音必被读到;锁中毒按无声处理,零阻断)。
134+
fn media_sound_recent(slot: &Arc<Mutex<Option<Instant>>>) -> bool {
135+
let last = slot.lock().ok().and_then(|g| *g);
136+
last.is_some_and(|t| t.elapsed().as_millis() as u64 <= crate::media_state::SOUND_RECENT_MS)
137+
}
138+
132139
/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
133140
///
134141
/// @ai-context: ScreenCaptureSampler 持 COM 对象(非 Send),在本线程内创建与使用,
@@ -159,6 +166,8 @@ pub fn run_screen_worker(
159166
mut foreground_monitor: crate::foreground_timeline::ForegroundMonitor,
160167
// 2026-08 A1:会话暂停共享状态(暂停跳过采样;恢复后时间戳补偿暂停时长)
161168
pause: crate::capture::audio_loopback::SessionPause,
169+
// REQ-291(v0.19.7):媒体级"最后有声时刻"戳(音频线程写;随播随停输入)
170+
media_sound: Arc<Mutex<Option<Instant>>>,
162171
// v0.7.2(REQ-151):会话信息聚合(播放器 OCR 文本 → 平台/时长/合集信息面板)
163172
session_info: crate::session_info::SessionInfoCollector,
164173
// v0.9.0 M2(REQ-189):画面档降档确认共享状态(前端确认后写入——
@@ -242,6 +251,11 @@ pub fn run_screen_worker(
242251
// process_frame/capture_latest_only 内部清零,无需手动重置)
243252
let mut liveness = crate::frame_liveness::FrameLiveness::new();
244253
let mut got_frame = false;
254+
// REQ-291(v0.19.7):随播随停状态——双通道检测器 + 画面动最近时刻
255+
// (got_frame 采样点更新;1s 媒体拍独立于采样——idle 静默期仍判暂停)
256+
let mut media_detector = crate::media_state::MediaDetector::new();
257+
let mut last_motion_at: Option<Instant> = None;
258+
let mut last_media_tick = Instant::now();
245259
// M2/REQ-037:动态字幕区域跟踪(播放区域检测 + ROI 锁定/重扫;尺寸首帧自适应)
246260
let mut roi_tracker = crate::region_tracker::RoiTracker::new(0, 0);
247261
// M6/REQ-051:关键帧样本缓冲(全帧分支收集,停止时投票产出关键图候选)
@@ -304,6 +318,31 @@ pub fn run_screen_worker(
304318
&mut last_capture_error,
305319
&mut got_frame,
306320
);
321+
// REQ-291 快恢复(主通道):声画任一恢复 ≤~1.5s 解除自动暂停
322+
// (OCR 5s 判定降级为辅助——保留其后兜底)
323+
if last_media_tick.elapsed() >= Duration::from_secs(1) {
324+
last_media_tick = Instant::now();
325+
let sound_recent = media_sound_recent(&media_sound);
326+
if media_detector.tick(sound_recent, got_frame)
327+
== crate::media_state::MediaDecision::Resume
328+
{
329+
let resume_ms = comp_epoch.elapsed().as_millis() as u64;
330+
crate::player_behavior::record_action(
331+
&crate::player_behavior::PlayerAction {
332+
kind: crate::player_behavior::PlayerActionKind::Play,
333+
value: None,
334+
},
335+
resume_ms,
336+
session_id,
337+
&db,
338+
);
339+
pause.paused.store(false, Ordering::SeqCst);
340+
auto_paused = false;
341+
last_player_paused = false;
342+
let _ = app.emit("live:media-resumed", ());
343+
eprintln!("[ScreenWorker] 随播随停:声画恢复 → 自动解除暂停");
344+
}
345+
}
307346
if last_player_check_at.elapsed() >= Duration::from_secs(5) {
308347
last_player_check_at = Instant::now();
309348
let check_now_ms = comp_epoch.elapsed().as_millis() as u64;
@@ -328,6 +367,7 @@ pub fn run_screen_worker(
328367
pause.paused.store(false, Ordering::SeqCst);
329368
auto_paused = false;
330369
last_player_paused = false;
370+
let _ = app.emit("live:media-resumed", ());
331371
eprintln!("[ScreenWorker] 视频恢复播放,自动解除暂停");
332372
}
333373
}
@@ -356,6 +396,32 @@ pub fn run_screen_worker(
356396
// 纪元传入(暂停期间不采样,补偿值在恢复后恒定)
357397
let comp_epoch = epoch
358398
+ Duration::from_millis(pause.total_paused_ms.load(Ordering::SeqCst));
399+
// REQ-291(v0.19.7):随播随停 1s 拍(独立于采样——idle 静默期仍判暂停;
400+
// 手动暂停不判:auto_paused=false 时语义是用户冻结,不跟随视频)
401+
if !paused_now && last_media_tick.elapsed() >= Duration::from_secs(1) {
402+
last_media_tick = Instant::now();
403+
let sound_recent = media_sound_recent(&media_sound);
404+
let motion_recent = last_motion_at.is_some_and(|t| {
405+
last_media_tick.duration_since(t) <= Duration::from_millis(1500)
406+
});
407+
let decision = media_detector.tick(sound_recent, motion_recent);
408+
if decision == crate::media_state::MediaDecision::Suspend {
409+
let ms = comp_epoch.elapsed().as_millis() as u64;
410+
pause.paused.store(true, Ordering::SeqCst);
411+
auto_paused = true;
412+
crate::player_behavior::record_action(
413+
&crate::player_behavior::PlayerAction {
414+
kind: crate::player_behavior::PlayerActionKind::Pause,
415+
value: None,
416+
},
417+
ms,
418+
session_id,
419+
&db,
420+
);
421+
let _ = app.emit("live:media-paused", ());
422+
eprintln!("[ScreenWorker] 随播随停:声画双通道确认视频暂停 → 自动暂停捕获");
423+
}
424+
}
359425
// M4:每 2s 采样 CPU 负载(降级标志变化打印——静默失败可见化)
360426
if last_load_check_at.elapsed() >= Duration::from_secs(2) {
361427
last_load_check_at = Instant::now();
@@ -424,6 +490,11 @@ pub fn run_screen_worker(
424490
);
425491
// REQ-281(v0.19.6):停更监测 + WGC watchdog + 帧心跳(真实采样拍)
426492
liveness_check(&app, screen.as_mut(), &mut liveness, got_frame, Instant::now());
493+
// REQ-291:画面动时刻(媒体拍 motion_recent 数据源——WGC 内容驱动
494+
// 出帧,got_frame 即"画面变了"的近真信号)
495+
if got_frame {
496+
last_motion_at = Some(Instant::now());
497+
}
427498
// v0.11.5(Task 6):OCR 文本累计(去重→领域检测用)
428499
// v0.11.5 审查修复(A6):FIFO 上限 50→100——领域检测信号缓存
429500
// 保守放大,避免 B站选集证据(`P3/12`/`第3集/共12集`)在
@@ -650,6 +721,7 @@ pub fn run_screen_worker(
650721
if paused && !pause.paused.load(Ordering::SeqCst) {
651722
pause.paused.store(true, Ordering::SeqCst);
652723
auto_paused = true;
724+
let _ = app.emit("live:media-paused", ());
653725
eprintln!("[ScreenWorker] 视频处于暂停态,会话自动暂停");
654726
}
655727
} else if paused != last_player_paused {
@@ -676,13 +748,15 @@ pub fn run_screen_worker(
676748
// 下一轮循环进入轻量轮询,恢复检测不中断)
677749
pause.paused.store(true, Ordering::SeqCst);
678750
auto_paused = true;
751+
let _ = app.emit("live:media-paused", ());
679752
eprintln!("[ScreenWorker] 检测到视频暂停,自动暂停捕获");
680753
}
681754
} else if paused && !pause.paused.load(Ordering::SeqCst) {
682755
// P2 兜底:手动恢复后视频仍暂停 → 重新自动暂停
683756
// (语义:捕获跟随视频状态,用户手动继续不覆盖)
684757
pause.paused.store(true, Ordering::SeqCst);
685758
auto_paused = true;
759+
let _ = app.emit("live:media-paused", ());
686760
eprintln!("[ScreenWorker] 视频处于暂停态,重新自动暂停");
687761
}
688762
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pub(crate) struct LiveLoopCtx<'a> {
5151
pub session_id: i64,
5252
pub asr_segments: &'a mut Vec<TranscriptSegment>,
5353
pub speech_active: Arc<AtomicBool>,
54+
/// REQ-291(v0.19.7):媒体级"最后有声时刻"戳(Arc<Mutex<Option<Instant>>>——
55+
/// 音频线程每块写、屏幕线程拍级读;区别于 speech_active(VAD 语音级)——
56+
/// 判定"有任何声音"(含音乐/环境声),随播随停双通道之一)
57+
pub media_sound: Arc<std::sync::Mutex<Option<Instant>>>,
5458
pub asr_engine: &'a mut StreamingAsrEngine,
5559
pub audio_writer: &'a mut Option<crate::audio_store::SessionAudioWriter>,
5660
/// REQ-115:VAD 阈值共享槽(诊断可查;None=无槽注入——测试路径)
@@ -235,6 +239,13 @@ pub(crate) fn run_audio_loop(
235239
let silent = raw_rms < vad_threshold;
236240
// B3:语音活跃度共享(屏幕 worker 自适应采样依据)
237241
ctx.speech_active.store(!silent, Ordering::Relaxed);
242+
// REQ-291(v0.19.7):媒体级"任何声音"戳(阈值低于 VAD 语音级——
243+
// 音乐/环境声也算;屏幕 worker 随播随停检测读取,见 media_state.rs)
244+
if raw_rms >= crate::media_state::MEDIA_AUDIO_ACTIVE_RMS {
245+
if let Ok(mut g) = ctx.media_sound.lock() {
246+
*g = Some(Instant::now());
247+
}
248+
}
238249
// REQ-108(v0.7.0 M1.5):长静音事件——连续静音 ≥3s 落库
239250
// (章节检测真实信号;与 analysis LONG_SILENCE_GAP_MS 同口径)
240251
if silent {

0 commit comments

Comments
 (0)