Skip to content

Commit 5159bee

Browse files
committed
feat(speaker): 弱化版讲者分离落地与使用说明
1 parent 7f5859f commit 5159bee

10 files changed

Lines changed: 491 additions & 2 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# 下载说话人 embedding 模型(wespeaker,v0.7.2 REQ-153 弱化版讲者分离依赖)
2+
#
3+
# @ai-context: sherpa-onnx SpeakerEmbeddingExtractor 使用 wespeaker 转换模型
4+
# (Apache-2.0 许可)——单文件 ONNX,约 20-70MB。下载到
5+
# src-tauri/models/speaker-embedding/model.onnx(模型目录不入库,
6+
# .gitignore 已排除 src-tauri/models/)。
7+
# @ai-context: 模型缺失时讲者分离自动降级(无标注、不报错)——本脚本为可选增强;
8+
# TLS 拦截环境走 Windows 证书库(与 ffmpeg 下载同模式)。
9+
# @ai-context: 用法:powershell -ExecutionPolicy Bypass -File scripts/download-speaker-model.ps1
10+
#
11+
# 源:sherpa-onnx 模型仓库(wespeaker-zh:中文音色 embedding,sherpa-onnx 转换版)
12+
$ErrorActionPreference = "Stop"
13+
14+
$targetDir = Join-Path $PSScriptRoot "..\src-tauri\models\speaker-embedding"
15+
$targetDir = [System.IO.Path]::GetFullPath($targetDir)
16+
$targetFile = Join-Path $targetDir "model.onnx"
17+
18+
# 镜像源(多源回退防单点失效;模型经 sherpa-onnx 官方仓库分发)
19+
# 1) GitHub release(k2-fsa/sherpa-onnx 模型清单中的 wespeaker-zh 包)
20+
# 2) HuggingFace 镜像(k2-fsa 组织仓库)
21+
$mirrors = @(
22+
"https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker-zh.onnx",
23+
"https://hf-mirror.com/csukuangfj/sherpa-onnx-speaker-embedding/resolve/main/wespeaker-zh.onnx"
24+
)
25+
26+
if (Test-Path $targetFile) {
27+
Write-Host "模型已存在:$targetFile(跳过下载)"
28+
exit 0
29+
}
30+
31+
Write-Host "下载说话人 embedding 模型(wespeaker-zh)→ $targetFile"
32+
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
33+
34+
$ok = $false
35+
foreach ($url in $mirrors) {
36+
try {
37+
Write-Host "尝试镜像: $url"
38+
Invoke-WebRequest -Uri $url -OutFile $targetFile -UseBasicParsing
39+
$ok = $true
40+
break
41+
} catch {
42+
Write-Host "镜像失败: $($_.Exception.Message)"
43+
}
44+
}
45+
if (-not $ok) {
46+
Remove-Item -Force $targetFile -ErrorAction SilentlyContinue
47+
throw "所有镜像下载失败——请从 sherpa-onnx 模型仓库手动获取 wespeaker 中文模型,放置到 $targetFile"
48+
}
49+
50+
$sizeMb = [math]::Round((Get-Item $targetFile).Length / 1MB, 1)
51+
Write-Host "完成:$targetFile(${sizeMb} MB)"
52+
Write-Host "重启应用后,会话详情页将显示讲者切换标记(访谈/会议场景)"
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! 说话人分离命令(REQ-153 / v0.7.2:弱化版讲者切换离线分析)。
2+
//!
3+
//! @ai-context: analyze_session_speakers 懒加载——会话详情打开时调用:
4+
//! ① 幂等(已有 SpeakerChange 事件直接返回,不重复分析);
5+
//! ② 模型缺失 → 空列表(前端提示"未启用",诚实降级不报错);
6+
//! ③ 段边界 = session_segments(端点断句产出 = 现成语音段,
7+
//! 零新增 VAD);音频 = session-audio/<id>.wav(16k 单声道)。
8+
//! @ai-context: 分析成本 = 段数 × embedding 推理(~10ms/段)——百段会话
9+
//! <2s,可接受;单段失败跳过(不阻断整体,判定器对无效段
10+
//! 重置前驱不误判)。结果落 session_events(SpeakerChange),
11+
//! 容量守卫防写放大(低频事件,天然安全)。
12+
13+
use tauri::State;
14+
15+
use crate::commands::AppState;
16+
use crate::session_events::{EventKind, NewSessionEvent};
17+
use crate::speaker_change::{detect_speaker_changes, SpeechSegment};
18+
use crate::speaker_engine::{self, SpeakerEmbeddingEngine};
19+
20+
/// 讲者切换输出(camelCase 契约,前端展示用)。
21+
#[derive(Debug, Clone, serde::Serialize)]
22+
#[serde(rename_all = "camelCase")]
23+
pub struct SpeakerChangeOut {
24+
/// 切换时刻(会话纪元 ms)
25+
pub time_ms: u64,
26+
/// 切换置信度 0.0-1.0(距余弦阈值越远越高)
27+
pub confidence: f32,
28+
}
29+
30+
/// 讲者分析结果(前端区分"未启用"与"无切换"——诚实展示)。
31+
#[derive(Debug, Clone, serde::Serialize)]
32+
#[serde(rename_all = "camelCase")]
33+
pub struct SpeakerAnalysisResult {
34+
/// 说话人分离是否启用(模型文件存在且分析已执行)
35+
pub enabled: bool,
36+
pub changes: Vec<SpeakerChangeOut>,
37+
}
38+
39+
/// 会话讲者切换分析(弱化版说话人分离;幂等懒加载)。
40+
#[tauri::command]
41+
pub fn analyze_session_speakers(
42+
state: State<'_, AppState>,
43+
session_id: i64,
44+
) -> Result<SpeakerAnalysisResult, String> {
45+
if session_id <= 0 {
46+
return Err("无效的会话 id".to_string());
47+
}
48+
// 1) 幂等:已有分析结果(SpeakerChange 事件)→ 直接返回
49+
let existing = state
50+
.db
51+
.list_events_by_kind(session_id, EventKind::SpeakerChange)
52+
.map_err(|e| e.to_string())?;
53+
if !existing.is_empty() {
54+
return Ok(SpeakerAnalysisResult {
55+
enabled: true,
56+
changes: existing
57+
.iter()
58+
.map(|e| SpeakerChangeOut {
59+
time_ms: e.timestamp_ms,
60+
confidence: e
61+
.payload
62+
.get("confidence")
63+
.and_then(|v| v.as_f64())
64+
.map(|v| v as f32)
65+
.unwrap_or(0.0),
66+
})
67+
.collect(),
68+
});
69+
}
70+
// 2) 模型缺失 → 未启用(前端提示——诚实降级,不阻断)
71+
let Some(model_path) = speaker_engine::speaker_model_path(&state.model_dir) else {
72+
return Ok(SpeakerAnalysisResult { enabled: false, changes: Vec::new() });
73+
};
74+
// 3) 音频 + 段边界(端点断句产出 = 语音段)
75+
let wav_path = state
76+
.data_dir
77+
.join("session-audio")
78+
.join(format!("{}.wav", session_id));
79+
let wave = sherpa_onnx::Wave::read(&wav_path.to_string_lossy())
80+
.ok_or_else(|| "会话音频缺失(实时捕获未落盘或已清理)".to_string())?;
81+
let segments = state.db.list_segments(session_id).map_err(|e| e.to_string())?;
82+
if segments.is_empty() {
83+
return Ok(SpeakerAnalysisResult { enabled: true, changes: Vec::new() });
84+
}
85+
// 4) 逐段提取音色向量(<MIN_SEGMENT_MS 或越界跳过——短段向量不稳)
86+
let sr = wave.sample_rate() as u32;
87+
let samples = wave.samples();
88+
let mut engine = SpeakerEmbeddingEngine::load(&model_path)
89+
.ok_or_else(|| "说话人模型加载失败(模型文件损坏?请重新下载)".to_string())?;
90+
let mut speech: Vec<SpeechSegment> = Vec::new();
91+
for seg in &segments {
92+
if seg.end_ms.saturating_sub(seg.start_ms) < speaker_engine::MIN_SEGMENT_MS {
93+
continue;
94+
}
95+
let Some((s, e)) =
96+
speaker_engine::segment_sample_range(seg.start_ms, seg.end_ms, sr, samples.len())
97+
else {
98+
continue;
99+
};
100+
let Some(emb) = engine.embedding_of(&samples[s..e], wave.sample_rate()) else {
101+
continue;
102+
};
103+
speech.push(SpeechSegment { start_ms: seg.start_ms, embedding: emb });
104+
}
105+
// 5) 相邻余弦判定(纯函数)→ 落库 → 返回
106+
let changes = detect_speaker_changes(&speech);
107+
for c in &changes {
108+
let _ = state.db.add_event(&NewSessionEvent {
109+
session_id,
110+
kind: EventKind::SpeakerChange,
111+
timestamp_ms: c.time_ms,
112+
payload: serde_json::json!({ "confidence": c.confidence }),
113+
});
114+
}
115+
Ok(SpeakerAnalysisResult {
116+
enabled: true,
117+
changes: changes
118+
.iter()
119+
.map(|c| SpeakerChangeOut { time_ms: c.time_ms, confidence: c.confidence })
120+
.collect(),
121+
})
122+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ mod speaker_change;
153153
mod series_detect;
154154
// v0.7.2(REQ-151):会话信息聚合——采集信息面板数据源(平台/时长/合集/字幕)
155155
mod session_info;
156+
// v0.7.2(REQ-153):说话人 embedding 引擎(弱化版讲者分离离线分析)
157+
mod speaker_engine;
158+
mod commands_speaker;
156159
// v0.7.0 M1.5(REQ-108):统一信号事件域(类型/分级/容量守卫;数据层在 db_session_events)
157160
mod session_events;
158161
mod symbol_normalize;
@@ -541,6 +544,8 @@ pub fn run() {
541544
commands_video::video_profile_by_kind,
542545
// 会话结构化分析(REQ-044/045/046,v0.5.0 M2:章节/重点/术语/讲者)
543546
commands_analysis::analyze_session_command,
547+
// 说话人分离(REQ-153,v0.7.2:弱化版讲者切换离线分析——幂等懒加载)
548+
commands_speaker::analyze_session_speakers,
544549
// 健康巡检与诊断(REQ-042,M7:F2/F3/G2)
545550
commands_diag::health_status,
546551
commands_diag::diag_snapshot,

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@ pub enum EventKind {
3535
/// v0.7.2(REQ-154 S-2):语速骤变(段间语速骤降 ≥40% = 强调/变速;
3636
/// 与 VolumeSurge 音量骤变姊妹信号,重点标注备数据)
3737
SpeechRateDrop,
38+
/// v0.7.2(REQ-153):讲者切换(弱化版说话人分离——相邻语音段音色
39+
/// embedding 余弦 < 阈值;只标切换点不聚类身份;payload 含 confidence)
40+
SpeakerChange,
3841
}
3942

4043
impl EventKind {
4144
/// 全部类型(新增类型在此登记——schema 为 TEXT 无枚举约束,登记表做消费端白名单)。
42-
pub const ALL: [EventKind; 10] = [
45+
pub const ALL: [EventKind; 11] = [
4346
EventKind::FrameSwitch,
4447
EventKind::LongSilence,
4548
EventKind::VolumeSurge,
@@ -50,6 +53,7 @@ impl EventKind {
5053
EventKind::Pause,
5154
EventKind::Resume,
5255
EventKind::SpeechRateDrop,
56+
EventKind::SpeakerChange,
5357
];
5458

5559
/// kebab-case 落库名(serde 默认 PascalCase,DB 层用显式映射防契约漂移)。
@@ -65,6 +69,7 @@ impl EventKind {
6569
EventKind::Pause => "pause",
6670
EventKind::Resume => "resume",
6771
EventKind::SpeechRateDrop => "speech_rate_drop",
72+
EventKind::SpeakerChange => "speaker_change",
6873
}
6974
}
7075

@@ -129,7 +134,8 @@ pub fn event_tier(kind: EventKind) -> EventTier {
129134
| EventKind::PlayerBehavior
130135
| EventKind::Pause
131136
| EventKind::Resume
132-
| EventKind::SpeechRateDrop => EventTier::Low,
137+
| EventKind::SpeechRateDrop
138+
| EventKind::SpeakerChange => EventTier::Low,
133139
}
134140
}
135141

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! 说话人 embedding 引擎(REQ-153 / v0.7.2:弱化版讲者分离——只标切换点)。
2+
//!
3+
//! @ai-context: sherpa-onnx SpeakerEmbeddingExtractor(wespeaker 模型,Apache-2.0)
4+
//! 对语音段提取音色向量 → 相邻段余弦相似度 < 阈值 = 讲者切换
5+
//! (判定器在 speaker_change.rs,纯函数已单测:阈值 0.75 + 置信度
6+
//! 归一)。弱化版**不聚类身份**(不识别"谁"),只标"这里换人了"——
7+
//! 访谈/会议纪要分段的真实价值点。
8+
//! @ai-context: 会话后**离线分析**(懒加载命令 analyze_session_speakers):
9+
//! 段边界 = session_segments(端点断句产出 = 现成语音段,零新增
10+
//! VAD);音频 = session-audio/<id>.wav;模型缺失 → None 降级
11+
//! (无讲者标注、不阻断、不报错——诚实降级,使用说明提示)。
12+
//! @ai-context: 引擎在命令线程内创建(低频一次分析,不常驻;模型 ~20-70MB
13+
//! 加载 ~1s,可接受)。
14+
15+
use std::path::Path;
16+
17+
use sherpa_onnx::{SpeakerEmbeddingExtractor, SpeakerEmbeddingExtractorConfig};
18+
19+
/// 模型路径约定(wespeaker 单文件 ONNX 模型)。
20+
pub const SPEAKER_MODEL_REL: &str = "speaker-embedding/model.onnx";
21+
22+
/// 低于该时长的语音段不提取 embedding(音色向量对短段不稳——误判比漏判更伤)。
23+
pub const MIN_SEGMENT_MS: u64 = 500;
24+
25+
/// 模型文件是否存在(缺失 → 调用方降级无讲者标注;路径约定模型目录)。
26+
pub fn speaker_model_path(model_dir: &Path) -> Option<String> {
27+
let p = model_dir.join(SPEAKER_MODEL_REL);
28+
p.is_file().then(|| p.to_string_lossy().into_owned())
29+
}
30+
31+
/// 语音段 → 样本区间(纯函数:会话时间轴 → WAV 样本切片;越界钳制)。
32+
///
33+
/// @ai-context: WAV 为 16kHz 单声道(audio_store 写入格式);返回 (start, end)
34+
/// 样本索引;段完全越界 → None;返回区间可能被钳制到音频长度。
35+
pub fn segment_sample_range(
36+
start_ms: u64,
37+
end_ms: u64,
38+
sample_rate: u32,
39+
total_samples: usize,
40+
) -> Option<(usize, usize)> {
41+
if end_ms <= start_ms || total_samples == 0 || sample_rate == 0 {
42+
return None;
43+
}
44+
let start = (start_ms * sample_rate as u64 / 1000) as usize;
45+
let end = (end_ms * sample_rate as u64 / 1000) as usize;
46+
if start >= total_samples {
47+
return None;
48+
}
49+
Some((start, end.min(total_samples)))
50+
}
51+
52+
/// 说话人 embedding 提取器(流式接口:建流喂样本 → is_ready → compute)。
53+
pub struct SpeakerEmbeddingEngine {
54+
extractor: SpeakerEmbeddingExtractor,
55+
}
56+
57+
impl SpeakerEmbeddingEngine {
58+
/// 加载模型(模型缺失/加载失败 → None——调用方降级,不阻断)。
59+
pub fn load(model_path: &str) -> Option<Self> {
60+
let config = SpeakerEmbeddingExtractorConfig {
61+
model: Some(model_path.to_string()),
62+
..Default::default()
63+
};
64+
SpeakerEmbeddingExtractor::create(&config).map(|extractor| Self { extractor })
65+
}
66+
67+
/// 语音段 → 音色向量(段过短/未就绪/提取失败 → None,诚实跳过——
68+
/// 判定器对无效段重置前驱,不产生误判切换)。
69+
pub fn embedding_of(&mut self, samples: &[f32], sample_rate: i32) -> Option<Vec<f32>> {
70+
if samples.is_empty() || sample_rate <= 0 {
71+
return None;
72+
}
73+
let stream = self.extractor.create_stream()?;
74+
stream.accept_waveform(sample_rate, samples);
75+
if !self.extractor.is_ready(&stream) {
76+
return None;
77+
}
78+
self.extractor.compute(&stream)
79+
}
80+
}
81+
82+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
83+
#[cfg(test)]
84+
#[path = "speaker_engine_tests.rs"]
85+
mod tests;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! 说话人引擎测试(REQ-153 / v0.7.2)。
2+
//!
3+
//! @ai-context: AAA 模式;覆盖样本区间切分(边界/越界/钳制)与模型路径判定。
4+
//! 引擎本体(extractor 推理)依赖真实模型文件——集成测试标注
5+
//! (测试规范:模型相关用集成测试,单测不依赖真实模型文件)。
6+
7+
use super::*;
8+
9+
// ── 样本区间切分(纯函数) ──
10+
11+
#[test]
12+
fn segment_range_basic() {
13+
// 1s 段 @16kHz → [16000, 32000)
14+
assert_eq!(segment_sample_range(1000, 2000, 16000, 100_000), Some((16000, 32000)));
15+
}
16+
17+
#[test]
18+
fn segment_range_clamped_to_audio_len() {
19+
// 段尾越界 → 钳制到音频长度
20+
assert_eq!(segment_sample_range(1000, 10_000, 16000, 50_000), Some((16000, 50_000)));
21+
}
22+
23+
#[test]
24+
fn segment_range_start_beyond_audio_none() {
25+
// 段起点已越界 → None(跳过该段)
26+
assert_eq!(segment_sample_range(100_000, 110_000, 16000, 50_000), None);
27+
}
28+
29+
#[test]
30+
fn segment_range_invalid_inputs() {
31+
assert_eq!(segment_sample_range(2000, 1000, 16000, 100_000), None); // 倒置
32+
assert_eq!(segment_sample_range(0, 0, 16000, 100_000), None); // 零长
33+
assert_eq!(segment_sample_range(0, 1000, 0, 100_000), None); // 采样率 0
34+
assert_eq!(segment_sample_range(0, 1000, 16000, 0), None); // 空音频
35+
}
36+
37+
// ── 模型路径判定 ──
38+
39+
#[test]
40+
fn model_path_missing_is_none() {
41+
// 临时空目录 → 模型缺失 → None(降级入口)
42+
let dir = tempfile::tempdir().unwrap();
43+
assert_eq!(speaker_model_path(dir.path()), None);
44+
}
45+
46+
#[test]
47+
fn model_path_present_is_some() {
48+
let dir = tempfile::tempdir().unwrap();
49+
let p = dir.path().join(SPEAKER_MODEL_REL);
50+
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
51+
std::fs::write(&p, b"fake model").unwrap();
52+
// 文件存在即返回路径(模型内容校验由 extractor 加载时进行)
53+
assert!(speaker_model_path(dir.path()).is_some());
54+
}

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core";
1313
import ArtifactView from "../components/ArtifactView";
1414
import ImageGallery from "../components/ImageGallery";
1515
import NotePreviewView from "../components/NotePreviewView";
16+
import SpeakerSwitchCard from "../components/SpeakerSwitchCard";
1617
import type { OutlineEntry, QualityReport, SessionDetail } from "../types";
1718
import { fmtMs } from "../utils/fmt";
1819

@@ -153,6 +154,9 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
153154
</div>
154155
)}
155156

157+
{/* v0.7.2(REQ-153):讲者切换(弱化版说话人分离——懒加载幂等) */}
158+
<SpeakerSwitchCard sessionId={sessionId} />
159+
156160
{/* 三视图切换(原料 / 产物 / 笔记预览——REQ-081) */}
157161
<div style={{ display: "flex", gap: 6, marginBottom: 10 }}>
158162
{(

0 commit comments

Comments
 (0)