Skip to content

Commit 751b9c6

Browse files
committed
fix(review): 新增代码审查五处修复
1 parent 5159bee commit 751b9c6

7 files changed

Lines changed: 127 additions & 110 deletions

File tree

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,10 @@ pub fn course_of(title: &str) -> String {
348348
while i + 1 < chars.len() {
349349
if chars[i] == '第' {
350350
let mut j = i + 1;
351-
while j < chars.len() && (chars[j].is_ascii_digit() || is_cjk_num(chars[j])) {
351+
// 中文数字判定复用 series_detect(单一来源,防两处漂移)
352+
while j < chars.len()
353+
&& (chars[j].is_ascii_digit() || crate::series_detect::is_cjk_num_char(chars[j]))
354+
{
352355
j += 1;
353356
}
354357
// "第X章/节/讲/课/部分" → 前缀即课程键
@@ -361,11 +364,6 @@ pub fn course_of(title: &str) -> String {
361364
t.to_string()
362365
}
363366

364-
/// 中文数字判定(课程前缀匹配用)。
365-
fn is_cjk_num(c: char) -> bool {
366-
"零〇一二三四五六七八九十百".contains(c)
367-
}
368-
369367
/// 课程分组(会话列表按课程键分组,组内新→旧;v0.7.1:组内携带转化状态标记)。
370368
#[derive(Debug, Clone, serde::Serialize)]
371369
#[serde(rename_all = "camelCase")]

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,35 @@ pub struct SpeakerAnalysisResult {
3737
}
3838

3939
/// 会话讲者切换分析(弱化版说话人分离;幂等懒加载)。
40+
///
41+
/// @ai-context: 异步命令 + spawn_blocking——分析含模型加载(~1s)+ 逐段
42+
/// embedding(百段 <2s,千段长会话可能 10s+),不得占用异步
43+
/// 运行时线程(审查 B11:同步命令在长会话上阻塞 IPC 吞吐)。
4044
#[tauri::command]
41-
pub fn analyze_session_speakers(
45+
pub async fn analyze_session_speakers(
4246
state: State<'_, AppState>,
4347
session_id: i64,
4448
) -> Result<SpeakerAnalysisResult, String> {
4549
if session_id <= 0 {
4650
return Err("无效的会话 id".to_string());
4751
}
52+
let db = state.db.clone();
53+
let model_dir = state.model_dir.clone();
54+
let data_dir = state.data_dir.clone();
55+
tauri::async_runtime::spawn_blocking(move || analyze_speakers_impl(db, model_dir, data_dir, session_id))
56+
.await
57+
.map_err(|e| format!("讲者分析任务失败: {}", e))?
58+
}
59+
60+
/// 分析实现(阻塞线程内执行;与命令层解耦便于独立演进)。
61+
fn analyze_speakers_impl(
62+
db: crate::db::Db,
63+
model_dir: std::path::PathBuf,
64+
data_dir: std::path::PathBuf,
65+
session_id: i64,
66+
) -> Result<SpeakerAnalysisResult, String> {
4867
// 1) 幂等:已有分析结果(SpeakerChange 事件)→ 直接返回
49-
let existing = state
50-
.db
68+
let existing = db
5169
.list_events_by_kind(session_id, EventKind::SpeakerChange)
5270
.map_err(|e| e.to_string())?;
5371
if !existing.is_empty() {
@@ -68,17 +86,14 @@ pub fn analyze_session_speakers(
6886
});
6987
}
7088
// 2) 模型缺失 → 未启用(前端提示——诚实降级,不阻断)
71-
let Some(model_path) = speaker_engine::speaker_model_path(&state.model_dir) else {
89+
let Some(model_path) = speaker_engine::speaker_model_path(&model_dir) else {
7290
return Ok(SpeakerAnalysisResult { enabled: false, changes: Vec::new() });
7391
};
7492
// 3) 音频 + 段边界(端点断句产出 = 语音段)
75-
let wav_path = state
76-
.data_dir
77-
.join("session-audio")
78-
.join(format!("{}.wav", session_id));
93+
let wav_path = data_dir.join("session-audio").join(format!("{}.wav", session_id));
7994
let wave = sherpa_onnx::Wave::read(&wav_path.to_string_lossy())
8095
.ok_or_else(|| "会话音频缺失(实时捕获未落盘或已清理)".to_string())?;
81-
let segments = state.db.list_segments(session_id).map_err(|e| e.to_string())?;
96+
let segments = db.list_segments(session_id).map_err(|e| e.to_string())?;
8297
if segments.is_empty() {
8398
return Ok(SpeakerAnalysisResult { enabled: true, changes: Vec::new() });
8499
}
@@ -105,7 +120,7 @@ pub fn analyze_session_speakers(
105120
// 5) 相邻余弦判定(纯函数)→ 落库 → 返回
106121
let changes = detect_speaker_changes(&speech);
107122
for c in &changes {
108-
let _ = state.db.add_event(&NewSessionEvent {
123+
let _ = db.add_event(&NewSessionEvent {
109124
session_id,
110125
kind: EventKind::SpeakerChange,
111126
timestamp_ms: c.time_ms,

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

Lines changed: 63 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ pub fn run_screen_worker(
203203
}
204204
}
205205
}
206+
// v0.7.2(REQ-151):暂停态也探测播放器信息(时间文本仍在画面)——
207+
// 会话开始时视频已暂停的场景,时长/集号识别不因此缺席
208+
if last_info_probe_at.elapsed() >= Duration::from_secs(10) {
209+
last_info_probe_at = Instant::now();
210+
probe_player_info(&app, &engines, &session_info, &roi_tracker, &latest_frame);
211+
}
206212
}
207213
}
208214
std::thread::sleep(Duration::from_millis(WORKER_POLL_MS));
@@ -354,61 +360,7 @@ pub fn run_screen_worker(
354360
// 失败 → 静默跳过(诚实:不猜不填;下轮再试)
355361
if last_info_probe_at.elapsed() >= Duration::from_secs(10) {
356362
last_info_probe_at = Instant::now();
357-
let mut probe =
358-
latest_frame.lock().ok().and_then(|g| g.clone()).unwrap_or(LatestCapturedFrame {
359-
timestamp_ms: 0,
360-
bgraw: Vec::new(),
361-
width: 0,
362-
height: 0,
363-
});
364-
if !probe.bgraw.is_empty() {
365-
if let Some(rect) = roi_tracker.playback_rect() {
366-
let w = probe.width as i32;
367-
let h = probe.height as i32;
368-
let q = crate::capture::frame_diff::Rect {
369-
left: rect.left.clamp(0, w),
370-
top: rect.top.clamp(0, h),
371-
right: rect.right.clamp(0, w),
372-
bottom: rect.bottom.clamp(0, h),
373-
};
374-
if q.width() > 0 && q.height() > 0 {
375-
crate::capture::frame_diff::crop_frame(
376-
&mut probe.bgraw,
377-
&mut probe.width,
378-
&mut probe.height,
379-
Some(&q),
380-
);
381-
}
382-
}
383-
if !probe.bgraw.is_empty() {
384-
// P4:OCR 输入缩小(播放器 UI 文字大,质量无损)
385-
crate::capture::frame_diff::downscale_bgra(
386-
&mut probe.bgraw,
387-
&mut probe.width,
388-
&mut probe.height,
389-
960,
390-
);
391-
if let Some(img) = crate::region_ocr::bgra_to_rgb_image(
392-
&probe.bgraw,
393-
probe.width,
394-
probe.height,
395-
) {
396-
if let Ok(blocks) = engines.recognize_image(img) {
397-
let text = blocks
398-
.iter()
399-
.map(|b| b.text.as_str())
400-
.collect::<Vec<_>>()
401-
.join(" ");
402-
if session_info.observe_player_text(&text) {
403-
let _ = app.emit(
404-
"live:session-info",
405-
session_info.snapshot(),
406-
);
407-
}
408-
}
409-
}
410-
}
411-
}
363+
probe_player_info(&app, &engines, &session_info, &roi_tracker, &latest_frame);
412364
}
413365
}
414366
// 诊断:每 15s 打印采样统计(会话无 OCR 时定位失败阶段;静默失败可见化)
@@ -439,6 +391,62 @@ pub fn run_screen_worker(
439391
eprintln!("[ScreenWorker] 屏幕采样线程退出(会话 {})", session_id);
440392
}
441393

394+
/// 播放器信息探测(REQ-151,v0.7.2):播放器区域 OCR 文本(时间对/分P)→
395+
/// 会话信息更新 → 值变化才 emit live:session-info(防 IPC 风暴)。
396+
///
397+
/// @ai-context: 主采样循环与自动暂停轻量轮询共用——暂停时播放器时间文本仍
398+
/// 在画面,时长/集号识别不因暂停缺席(10s 节流由调用方控制);
399+
/// 无播放区域/OCR 失败 → 静默跳过(诚实:不猜不填,下轮再试)。
400+
fn probe_player_info(
401+
app: &tauri::AppHandle,
402+
engines: &crate::engine::EnginePool,
403+
session_info: &crate::session_info::SessionInfoCollector,
404+
roi_tracker: &crate::region_tracker::RoiTracker,
405+
latest_frame: &Arc<Mutex<Option<LatestCapturedFrame>>>,
406+
) {
407+
let mut probe = latest_frame
408+
.lock()
409+
.ok()
410+
.and_then(|g| g.clone())
411+
.unwrap_or(LatestCapturedFrame { timestamp_ms: 0, bgraw: Vec::new(), width: 0, height: 0 });
412+
if probe.bgraw.is_empty() {
413+
return;
414+
}
415+
if let Some(rect) = roi_tracker.playback_rect() {
416+
let w = probe.width as i32;
417+
let h = probe.height as i32;
418+
let q = crate::capture::frame_diff::Rect {
419+
left: rect.left.clamp(0, w),
420+
top: rect.top.clamp(0, h),
421+
right: rect.right.clamp(0, w),
422+
bottom: rect.bottom.clamp(0, h),
423+
};
424+
if q.width() > 0 && q.height() > 0 {
425+
crate::capture::frame_diff::crop_frame(
426+
&mut probe.bgraw,
427+
&mut probe.width,
428+
&mut probe.height,
429+
Some(&q),
430+
);
431+
}
432+
}
433+
if probe.bgraw.is_empty() {
434+
return;
435+
}
436+
// P4:OCR 输入缩小(播放器 UI 文字大,质量无损)
437+
crate::capture::frame_diff::downscale_bgra(&mut probe.bgraw, &mut probe.width, &mut probe.height, 960);
438+
let Some(img) =
439+
crate::region_ocr::bgra_to_rgb_image(&probe.bgraw, probe.width, probe.height)
440+
else {
441+
return;
442+
};
443+
let Ok(blocks) = engines.recognize_image(img) else { return };
444+
let text = blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>().join(" ");
445+
if session_info.observe_player_text(&text) {
446+
let _ = app.emit("live:session-info", session_info.snapshot());
447+
}
448+
}
449+
442450
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
443451
#[cfg(test)]
444452
#[path = "live_session_frame_tests.rs"]

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ pub(crate) fn run_audio_loop(
161161
);
162162
// 重建流(reset 预留给复用场景:清句音频/状态,热词重读)
163163
ctx.asr_engine.reset();
164+
// REQ-154(v0.7.2 S-2):暂停边沿重置语速基准——恢复后首段与
165+
// 暂停前比较会跨暂停区间误判语速骤变(暂停时长不计入段间)
166+
last_speech_rate = None;
164167
let _ = ctx.db.add_event(&crate::session_events::NewSessionEvent::simple(
165168
ctx.session_id,
166169
crate::session_events::EventKind::Pause,

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,36 @@ pub struct SeriesInfo {
1919
pub episode: Option<u32>,
2020
}
2121

22-
/// 平台后缀剥离表(窗口标题常见形态;命中剥离,未命中原样返回)。
23-
const PLATFORM_SUFFIXES: &[&str] = &[
24-
"_哔哩哔哩_bilibili",
25-
" - YouTube",
26-
" - 腾讯视频",
27-
"_爱奇艺",
28-
" - 优酷",
29-
" - 芒果TV",
30-
" - 西瓜视频",
31-
" - 抖音",
22+
/// 平台后缀表(单一来源:normalize_title 剥离与 session_info 平台识别共用——
23+
/// 剥离的后缀即平台证据,两处维护会漂移)。
24+
const PLATFORM_SUFFIXES: &[(&str, &str)] = &[
25+
("_哔哩哔哩_bilibili", "哔哩哔哩"),
26+
(" - YouTube", "YouTube"),
27+
(" - 腾讯视频", "腾讯视频"),
28+
("_爱奇艺", "爱奇艺"),
29+
(" - 优酷", "优酷"),
30+
(" - 芒果TV", "芒果TV"),
31+
(" - 西瓜视频", "西瓜视频"),
32+
(" - 抖音", "抖音"),
3233
];
3334

35+
/// 平台识别(REQ-151:窗口标题后缀 → 平台显示名;未知 → None)。
36+
pub fn detect_platform(title: &str) -> Option<&'static str> {
37+
let t = title.trim();
38+
PLATFORM_SUFFIXES
39+
.iter()
40+
.find(|(suffix, _)| t.ends_with(suffix))
41+
.map(|(_, name)| *name)
42+
}
43+
3444
/// 模式 2 集号后缀(中文"第X"式;**不含"章节讲课部分"**——course_of 的
3545
/// "第X章"分组语义保留现状零回归,series 检测只管集/话/期/回)。
3646
const EPISODE_UNITS: &str = "集话期回";
3747

3848
/// 标题预处理:剥离平台后缀(窗口标题污染源,必须先剥离再识别系列)。
3949
pub fn normalize_title(title: &str) -> String {
4050
let t = title.trim();
41-
for suffix in PLATFORM_SUFFIXES {
51+
for (suffix, _) in PLATFORM_SUFFIXES {
4252
if let Some(rest) = t.strip_suffix(suffix) {
4353
let rest = rest.trim();
4454
if !rest.is_empty() {

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

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,9 @@ pub struct SessionInfo {
4242
pub total_episodes: Option<u32>,
4343
}
4444

45-
/// 平台识别(纯函数):窗口标题后缀 → 平台显示名;未知 → None。
46-
///
47-
/// @ai-context: 与 series_detect::normalize_title 同后缀表(保持一致——剥离的
48-
/// 后缀即平台证据);浏览器窗口无平台后缀时诚实 None(不猜)。
49-
pub fn detect_platform(title: &str) -> Option<&'static str> {
50-
let t = title.trim();
51-
if t.ends_with("_哔哩哔哩_bilibili") {
52-
Some("哔哩哔哩")
53-
} else if t.ends_with(" - YouTube") {
54-
Some("YouTube")
55-
} else if t.ends_with(" - 腾讯视频") {
56-
Some("腾讯视频")
57-
} else if t.ends_with("_爱奇艺") {
58-
Some("爱奇艺")
59-
} else if t.ends_with(" - 优酷") {
60-
Some("优酷")
61-
} else if t.ends_with(" - 芒果TV") {
62-
Some("芒果TV")
63-
} else if t.ends_with(" - 西瓜视频") {
64-
Some("西瓜视频")
65-
} else if t.ends_with(" - 抖音") {
66-
Some("抖音")
67-
} else {
68-
None
69-
}
70-
}
45+
/// 平台识别(纯函数):复用 series_detect 单一来源(标题后缀表统一维护,
46+
/// 剥离的后缀即平台证据——两处维护会漂移)。
47+
pub use crate::series_detect::detect_platform;
7148

7249
/// 时间文本解析(`mm:ss` 或 `hh:mm:ss` → 秒;非时间形态 None)。
7350
///

‎app/src/components/LiveActivityPanel.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,27 @@ function fmtDur(secs: number): string {
8383
* 现状整句挤在一行灰斜里越滚越长;按句读切分后每句一行,全部可见。
8484
* 句读保留在句尾;无句读尾段为"残余"(调用方加 … 表示仍在识别)。
8585
* 不切英文句点(Mr./U.S. 缩写防误切)与逗号(句内成分不拆行)。
86+
* 连续句读("结束。。")切出的纯标点段过滤(防垃圾行)。
8687
*/
8788
function splitBySentence(text: string): string[] {
8889
const parts: string[] = [];
8990
let buf = "";
9091
for (const ch of text) {
9192
buf += ch;
9293
if ("。!?!?…".includes(ch)) {
93-
parts.push(buf);
94+
if (hasText(buf)) parts.push(buf);
9495
buf = "";
9596
}
9697
}
97-
if (buf.trim().length > 0) parts.push(buf);
98+
if (hasText(buf)) parts.push(buf);
9899
return parts;
99100
}
100101

102+
/** 段是否含实质内容(过滤纯标点/空白段——连续句读切出的垃圾段不展示) */
103+
function hasText(seg: string): boolean {
104+
return seg.trim().length > 0 && !/^[。!?!?…\s]+$/.test(seg);
105+
}
106+
101107
export default function LiveActivityPanel({ sessionId }: { sessionId?: number | null }) {
102108
const [tab, setTab] = useState<"transcript" | "ocr">("transcript");
103109
// 状态机(简要徽标文本)

0 commit comments

Comments
 (0)