FFmpeg + Silero VAD:无声判定与 Spring Boot 生产集成
一、先给结论:声学候选已跑通,业务定责还需三层证据
min_silence=100ms 得到 2 段人声,调到 1000ms 后合并为 1 段。run() 内部时钟,不是进程级 wall clock。practice-hub/ffmpeg-silero-vad/ 内已有 CLI、固定依赖、单元测试和 5 组真实回执。本公开页面不暴露本地音频、输出路径或会话内容。
二、能力边界与生产架构
2.1 这个原型能做什么
| 问题 | 本流程能否回答 | 说明 |
|---|---|---|
| 录音能否解码、采样率/声道是什么 | 能 | ffprobe 做旁证,FFmpeg 实际转码做可解码性判定。 |
| 某时间段是否有人声 | 能,但是模型候选 | 输出 speech / non-speech 互补区间、样本索引、毫秒和占比。 |
| 说话人是用户还是 AI | 不能 | VAD 不是声纹识别或 diarization;要靠双轨真值或额外的角色映射。 |
| 用户是否已经说完 | 不能 | 静音可能只是思考停顿;要合并相邻片段并结合 EOU / ASR 语义。 |
| AI 是否应答却没有出声 | 不能单独回答 | 还要知道应答义务、响应窗、LLM/TTS 解码状态、播放与录音链路。 |
2.2 从录音到可复核候选
输入清单 + SHA-256 + 角色轨映射 ↓ ffprobe 体检 → FFmpeg 分轨/规范化 → 16kHz mono PCM ↓ Silero VAD → 用户轨 speech segments / AI 轨 speech segments ↓ 用户片段合并 + EOU/语义 + 应答义务 → response window ↓ AI 轨无 speech + 解码/TTS/播放状态 → 无声候选 + 原因码 ↓ 人工复核 / 统计聚合 / 回放金标
-ac 1 下混:下混会丢失用户/AI 角色真值,串音变成更难识别的同轨人声,极端情况还可能发生相位抵消。
三、环境与固定版本
本页数据来自一次固定环境验证。下列版本是可复现快照,不等于上游的永久兼容承诺。
| 组件 | 实测版本 | 在流水线中的职责 |
|---|---|---|
| FFmpeg / ffprobe | 8.1.2 | 媒体探测、解码、分轨、重采样与 PCM 规范化 |
| Python | 3.12.13 | CLI、原子写文件、批处理调度 |
| silero-vad | 6.2.1 | load_silero_vad + get_speech_timestamps |
| Torch / TorchAudio | 2.13.0 / 2.11.0 | JIT 推理与上游依赖;本工具不调用 TorchAudio 读音频 |
| ONNX Runtime | 1.29.0 | --backend onnx 的可选推理时 |
$ cd practice-hub/ffmpeg-silero-vad $ python3.12 -m venv .venv $ .venv/bin/python -m pip install --upgrade pip $ .venv/bin/python -m pip install -r requirements.txt # 官方 6.2.1 提供 ONNX CPU extra;需要 ONNX 时可用 $ .venv/bin/python -m pip install "silero-vad[onnx-cpu]==6.2.1" # 如需与本次验证环境完全对齐,显式锁定运行时 $ .venv/bin/python -m pip install \ "torch==2.13.0" "torchaudio==2.11.0" "onnxruntime==1.29.0" $ ffmpeg -version $ ffprobe -version $ .venv/bin/python -c "import silero_vad; print(silero_vad.__version__)"
需要注意,onnx-cpu extra 只是增加 ONNX Runtime,PyPI 包本身仍依赖 Torch 和 TorchAudio,不等于获得纯 ONNX-only 的轻量部署。若要绕过 Python 包直接调图,必须自行实现状态、上下文与后处理,不在本原型的验收范围内。
四、FFmpeg 入口体检、分轨与规范化
4.1 先看媒体结构
$ ffprobe -v error \
-show_entries \
format=format_name,duration,bit_rate:stream=index,codec_type,codec_name,sample_rate,channels,channel_layout \
-of json ./recordings/call.wav需要保存:音频流数量、流索引、编码、采样率、声道数、布局、时长和比特率。ffprobe 失败时可继续尝试 FFmpeg;真正的可解码性以转码返回码和产物合同为准。
4.2 单轨规范化
$ ffmpeg -hide_banner -loglevel error -nostdin -y \
-i ./recordings/call.wav -map 0:a:0 -vn \
-ac 1 -ar 16000 -c:a pcm_s16le ./work/normalized.wav输出必须再校验为 1 声道、16000Hz、16-bit PCM、无压缩 WAV,不能只因为 FFmpeg 返回 0 就默认合同成立。
4.3 双声道或双音频流:先分轨
# 例:左声道=用户,右声道=AI;映射必须来自录音系统真值 $ ffmpeg -hide_banner -loglevel error -nostdin -y \ -i ./recordings/call-stereo.wav \ -filter_complex "[0:a]channelsplit=channel_layout=stereo[user][ai]" \ -map "[user]" -ar 16000 -c:a pcm_s16le ./work/user.wav \ -map "[ai]" -ar 16000 -c:a pcm_s16le ./work/ai.wav
$ ffmpeg -hide_banner -loglevel error -nostdin -y \
-i ./recordings/call-multistream.mka \
-map 0:a:0 -ac 1 -ar 16000 -c:a pcm_s16le ./work/user.wav \
-map 0:a:1 -ac 1 -ar 16000 -c:a pcm_s16le ./work/ai.wav五、CLI 跑通与 5 次真实结果
5.1 跑一条
# 把输入路径替换为自己的合法音频;下方数值回执来自本次 run 1 $ cd practice-hub/ffmpeg-silero-vad $ .venv/bin/python -m ffmpeg_silero_vad \ /path/to/call.wav \ --output-dir ./outputs/run-01 \ --backend jit \ --threshold 0.5 \ --min-speech-duration-ms 250 \ --min-silence-duration-ms 100 \ --speech-pad-ms 30 { "ok": true, "speech_ratio": 0.688628, "speech_segment_count": 2, "total_elapsed_ms": 797.689 }
输出目录已有三个固定产物时默认拒绝覆盖;明确确认后才加 --force。即使加了 --force,输入文件与任一固定输出同路径也会被拒绝。
5.2 五次真实 CLI 回执
| # | 输入 / 变量 | 人声结果 | speech ratio | FFmpeg | VAD(含加载) | run() 内部 |
|---|---|---|---|---|---|---|
| 1 | 9.23575s 双话轮;JIT;间隔 100ms | 994–3678ms4418–8094ms | 0.688628 | 34.350ms | 731.301ms | 797.689ms |
| 2 | 同一音频;JIT;间隔 1000ms | 994–8094ms740ms 停顿被合并 | 0.768752 | 33.418ms | 718.295ms | 783.631ms |
| 3 | 同 run 1;ONNX;间隔 100ms | 与 run 1 完全一致,2 段 | 0.688628 | 33.282ms | 688.758ms | 753.768ms |
| 4 | 8kHz 电话 2.623875s → 16kHz;JIT | 34–2623.875ms | 0.987042 | 30.659ms | 699.841ms | 761.189ms |
| 5 | 48kHz 双声道 3s 纯静音 → 16kHz 单声道;JIT | 0 段 | 0 | 54.701ms | 693.761ms | 800.894ms |
total_elapsed_ms 从 VADPipeline.run() 内部开始,在 CSV/JSON 写入前结束。它包含输入哈希、ffprobe、FFmpeg、模型加载/推理、区间处理与结果组装,不包含 Python 进程启动、参数解析、CSV/JSON 落盘、stdout 和进程退出。它不是外部端到端 wall clock、常驻服务吞吐、RTF 或并发能力。
run 3 出现了 ONNX Runtime 遥测 device ID 无法持久化的 warning,但命令返回 0、产物完整、时间戳与 JIT 相同。实际监控应以退出码 + 结构化结果 + 产物合同为成功条件,同时单独收集 warning,不能仅靠 stderr 有字符就判失败。
5.3 同进程模型复用 microbenchmark
辅助基准对 run 1 的 9.23575s normalized.wav 测试:每个后端在同一 Python 进程内只加载模型一次,没有额外未计时 warm-up,首轮也计入,再重复推理 10 次。它不计入首页的 5 条真实回执统计。
| 后端 | 加载 | 推理 P50 | 推理 P95 | 片段一致性 |
|---|---|---|---|---|
| JIT | 34.003ms | 32.198ms | 79.727ms | 10 次一致 |
| ONNX | 45.294ms | 34.448ms | 35.527ms | 10 次一致,与 JIT 一致 |
六、参数不是开关,而是业务定义的一部分
| 参数 | 实测默认 | 改大的影响 | 主要风险 |
|---|---|---|---|
threshold | 0.5 | 要求更高的人声概率 | 噪声误报可能降低,弱声/远场漏检可能上升 |
min_speech_duration_ms | 250 | 丢弃更多极短人声 | 可抑制瞬态噪声,也可能丢失“嗯/好”等短应答 |
min_silence_duration_ms | 100 | 更容易把短停顿两侧合并 | 可以降低过切,但会模糊话轮和句内停顿边界 |
speech_pad_ms | 30 | 向前/后扩大人声边界 | 避免切掉字头字尾,但可以吞掉短响应窗 |
backend | jit | 切换为 ONNX 运行时 | 依赖、直接 ONNX 输入状态和监控噪声不同,要分别压测 |
window_size_samples 已标为 deprecated 且不起作用,内部固定为 16kHz 下 512 samples、8kHz 下 256 samples(约 32ms)。另外,neg_threshold 为空时使用 max(threshold - 0.15, 0.01) 形成进入/退出滞回。这两点都应进参数契约和回放记录。
参数调优必须在电话 8k、宽带 16k、远场、强噪、提示音/彩铃、音乐、咳嗽、极短应答、串音、双讲等分层金标上分开做,不能把上游默认值当生产阈值。
七、JSON / CSV 输出合同
vad-output/
├── normalized.wav # mono / 16kHz / PCM s16le
├── result.json # 媒体、参数、后端、区间、哈希、耗时
└── segments.csv # speech / silence 时间线7.1 result.json 必看字段
{
"schema_version": "1.0",
"pipeline": "ffmpeg-silero-vad",
"input_audio": {
"filename": "two-turn.wav",
"sha256": "…",
"ffprobe": { "streams": […], "format": {…} }
},
"normalized_audio": {
"sample_rate_hz": 16000,
"channels": 1,
"codec": "pcm_s16le",
"duration_seconds": 9.23575,
"sha256": "…"
},
"parameters": {
"threshold": 0.5,
"min_speech_duration_ms": 250,
"min_silence_duration_ms": 100,
"speech_pad_ms": 30,
"backend": "jit"
},
"model_backend": {
"engine": "silero-vad",
"package_version": "6.2.1",
"execution_backend": "jit",
"torch_num_threads": 1
},
"summary": {
"speech_segment_count": 2,
"speech_ratio": 0.688628
},
"speech_segments": [{
"start_sample": 15904,
"end_sample": 58848,
"start_ms": 994.0,
"end_ms": 3678.0
}],
"runtime": {
"scope": "in_process_run_through_result_assembly",
"excludes": ["python_process_startup", "cli_argument_parsing",
"segments_csv_and_result_json_write", "stdout_and_process_exit"],
"ffmpeg_elapsed_ms": 34.35,
"vad_elapsed_ms": 731.301,
"total_elapsed_ms": 797.689
}
}silence_segments 的精确语义是“Silero 未判为人声”,不是数字全零、也不是业务 Dead Air。人声与非人声区间经截断、排序和合并后严格互补。
7.2 segments.csv 字段
| 字段 | 语义 | 使用建议 |
|---|---|---|
type / index | speech 或 silence 以及各类内序号 | 不要把 silence 名称直接翻译为“故障” |
start_sample / end_sample | 16kHz 时钟上的原始样本边界 | 作为对齐和二次计算的主键 |
start_ms / end_ms / duration_ms | 由样本索引换算的毫秒 | 给状态机、数仓和人工复核 UI |
*_seconds | 保留 6 位小数的秒值 | 展示便利;精确窗口仍用 sample 索引 |
八、批处理、幂等、错误码和可观测性
8.1 稳定退出码
| 退出码 | 类型 | 调度策略 |
|---|---|---|
| 0 | 成功 | 校验三个产物、写入完成 manifest |
| 2 | 输入/参数/已有输出冲突 | 不盲目重试;修正 manifest 或显式决定覆盖 |
| 3 | 找不到 FFmpeg | 工作节点镜像或环境故障,暂停该节点派发 |
| 4 | FFmpeg 转换或 WAV 合同失败 | 录音级隔离;按容器/编码聚合告警 |
| 5 | 模型加载、依赖或推理失败 | 节点级快速失败;对同一依赖指纹限次重试 |
| 6 | 输出目录/产物写入失败 | 检查磁盘、权限、配额;不发布半成品 |
normalized.wav、segments.csv 与 result.json 都使用临时文件后再做单文件原子替换;模型失败时不会发布新的 WAV。但当前原型的三件套不是同一事务:若 CSV/JSON 写盘中途失败,目录可能留下已更新的前序文件。下游只能在退出码为 0、三件套齐全且可解析/哈希通过后接收结果;生产封装应写入独立 generation 临时目录,最后生成 COMPLETE manifest,再通过目录改名或版本指针一次切换。
8.2 生产批处理的幂等键
# 逻辑示意:真实调度器应用 manifest / 数据库代替文件遍历 job_key = SHA256(input_bytes) + role_mapping_version + ffmpeg_profile_version + silero_package_version + model_sha256 + parameter_set_version + output_schema_version if completed(job_key) and artifacts_match_hashes: SKIP_AS_IDEMPOTENT elif previous_exit_code in (4, 2): QUARANTINE_WITH_REASON elif node_failure and retry_count < retry_limit: RETRY_WITH_BACKOFF else: RUN_IN_ISOLATED_OUTPUT_DIR
每条录音独立输出目录,不共用三个固定文件名。日志要记录 job key、会话匿名 ID、节点、进程版本、各阶段耗时、退出码和原因码;不把原始转写、音频路径或用户内容打进普通应用日志。
九、真实跑通时遇到的兼容性坑
9.1 read_audio() 进入 TorchAudio / TorchCodec 解码链
RuntimeError: torchaudio version 2.11.0 requires torchcodec for audio I/O.
Install torchcodec or pin torchaudio < 2.9这是本次实际发生的失败。因为 FFmpeg 已经把输入收敛为固定 PCM WAV,工具不再二次调用 Silero read_audio(),而是用 Python 标准库 wave + array 读 int16,除以 32768 转为 torch.float32。
9.2 return_seconds=True 不适合精确质检边界
在实测版本中,该模式的秒数输出会进行 0.1s 级舍入。生产窗口可能正好卡在边界,所以 CLI 固定 return_seconds=False,保留 sample 索引后再换算毫秒/秒。
9.3 JIT / ONNX 的边界
- 本次仅证明一条双话轮样本上的时间戳一致,不是全域等价证明。
- 继续使用
load_silero_vad(onnx=True)封装;不要为了“少一层”直接向 ONNX 图喂 512 样本块。社区 issue #771 记录了绕过封装时缺少 64-sample context 导致近乎全阴性的隐蔽风险。 - 6.2.1 的官方 loader 在
onnx=True时强制 CPU Provider;即使安装 GPU Runtime,也不能默认它会使用 GPU。 - ONNX warning 要单独采集与聚合;运行成功与否以结构化合同为准,不应粗暴把 stderr 非空视为失败。
- 常驻服务要预加载模型、固定
torch.set_num_threads(1)后重新测吞吐和尾延迟;不使用 5 次 CLI 冷启动数据推导容量。 - 若日后改为流式
VADIterator,独立会话之间要reset_states(),并发任务不共享同一个有状态实例。
十、从 VAD 候选到“用户有声、AI 无声”的完整判定层设计
response_opportunity(应答机会),不是整通电话,也不是任意静音片段。
10.1 五道门:证据必须逐层通过
角色轨与时钟
确认 user / AI 轨映射、录音方向、起点偏移、漂移和断轨。映射不可信时直接弃权。
用户有效话轮
用户轨确有人声;EOU 已提交;排除咳嗽、背景声、未完句和被打断话轮。
应答义务
业务状态或语义确认 expected_ai_response=true;告别、挂机、转人工、显式等待不进入故障分母。
动态响应窗
从 eou_committed 开窗,到下一次用户开口、挂机、转人工或场景上限时停止。
音频 + Trace 归因
同时检查 AI 可听起音与 LLM、TTS、解码、RTP、播放、录音事件,输出标签、原因码和证据等级。
10.2 用户 / AI 分轨合同
分轨不是“把立体声拆成两个文件”就结束。每个会话必须先生成一份角色轨 manifest;下面字段缺失时,只能降级为症状候选,不能自动定责。
| 字段组 | 必填字段 | 门禁规则 |
|---|---|---|
| 身份 | track_id、role、role_mapping_source、role_confidence | 优先使用录音协议真值;diarization 推断低于批准阈值则标 role_uncertain。 |
| 媒体 | stream_index、codec、sample rate、channels、duration、SHA-256 | 时长差超容忍值、全零、截断、不可解码或轨缺失进入 INPUT_OR_TRACK_INVALID。 |
| 时钟 | clock_id、clock_origin、start_offset_ms、drift_ppm | 跨主机单调时钟不可直接相减;先按同步样本归一化,再计算响应时延。 |
| 质量 | dropout_ranges、clipping、noise floor、crosstalk_score | 串音、回声和丢轨必须作为独立证据;不能把 AI 轨上的用户回声当作 AI 已出话。 |
| 方向 | recording_point、direction、SSRC / leg / device | 明确是 TTS 输出侧、下行 RTP、远端播放回执还是混合录音;不同采集点证据强度不同。 |
abstain 而非强猜。10.3 EOU 状态机:静音结束不等于用户说完
撤销条件
用户在提交前重新开口、ASR 显示未完句、连续读号码/地址、填充词或长句内部停顿:CANDIDATE_END → USER_SPEAKING。
提交条件
最小尾静音满足,且 ASR final / 语义完整 / DTMF 完成 / 业务按钮事件至少一个可靠信号成立;记录 eou_reason 和置信度。
抑制条件
用户挂机、已经转人工、系统处于 hold、显式“请稍等”状态或本轮只有背景声:不创建普通应答义务。
打断条件
AI 正在播放时用户 barge-in:关闭旧窗口、记录 interrupted,再为新用户话轮创建新的 EOU;不把旧回答中断算无声。
eou_event = {
"call_id": "anon-call-id",
"turn_id": "turn-0042",
"user_acoustic_end_ms": 18420,
"eou_commit_ms": 19160,
"eou_reason": "vad_stop+asr_final+semantic_complete",
"eou_confidence": 0.93,
"policy_version": "eou-cn-sales-v3"
}10.4 动态应答窗口:按场景定,不用一个 3 秒阈值打天下
window_start = eou_commit_ms window_stop = min(next_user_speech_start_ms, hangup_ms, transfer_ms, policy_max_observe_ms) warning_deadline = max(policy_warning_ms, Q95(normal_response_gap | scenario) + safety_margin_ms) failure_deadline = max(policy_failure_ms, Q99(normal_response_gap | scenario) + safety_margin_ms) # Q95/Q99 只能来自校准集中的正常应答;隐藏测试集不参与阈值选择。
| 场景 | 是否要求 AI 出声 | POC 起始策略 | 窗口如何结束 |
|---|---|---|---|
| 普通问答 / 确认 | 是 | 调查线 1.5s、故障候选线 3.0s;随后用本方 Q95/Q99 校准 | AI 首个人声、用户重新开口、挂机或硬上限 |
| 工具调用前承接语 | 是 | 先要求约 1.2s 内有承接语;最终结果另建工具完成窗口 | 承接语起音或工具状态进入 working |
| 工具执行 / 显式等待 | 分阶段 | 不把最终结果 SLA 当首包 SLA;记录 periodic notice / tool deadline | 工具结束、用户取消、转人工或阶段超时 |
| 告别、挂机、转人工 | 通常否 | expected_ai_response=false;如业务要求告别语,单独建策略 | 业务状态事件立即关闭 |
| 用户在窗口内再次开口 | 右删失 / 打断 | 不能简单计为 AI 无声;记录 censored_by_user 或 frustrated_repeat | 下一次用户人声开始 |
上表数字是 POC 调查起点,不是行业 SLA。最终阈值需按业务阶段、线路、语种和漏检/误报代价批准,并与“事故 SLA”分开管理。
10.5 TTS / RTP / 播放事件瀑布:把“没声音”定位到最深已完成节点
{
"schema_version": "voice-trace-1.0",
"call_id": "anon-call-id",
"turn_id": "turn-0042",
"response_attempt_id": "resp-01",
"trace_id": "trace-...",
"event_name": "rtp_first_packet_sent",
"component": "media-gateway",
"status": "ok",
"event_time_utc": "2026-09-01T01:23:45.678Z",
"t_mono_ms": 3819221.447,
"clock_id": "host-media-03/boot-8f2",
"payload_bytes": 172,
"codec": "opus",
"ssrc": "hashed-ssrc",
"error_code": null
}clock_id、同步偏差和校正版本。不能直接拿两台机器的 t_mono_ms 相减,也不能只靠墙上 UTC 时间做毫秒级根因判断。
10.6 判定引擎:先判可用性,再判业务,再找最深故障点
if not track_contract_passed:
return INPUT_OR_TRACK_INVALID, abstain
if role_confidence < role_threshold:
return ROLE_UNCERTAIN, abstain
if not user_valid_speech or eou_state != "committed":
return EOU_NOT_COMMITTED, no_response_opportunity
if not expected_ai_response:
return NO_RESPONSE_REQUIRED, valid_negative
ai_onset = first_ai_audible_onset(window)
if ai_onset and ai_onset <= warning_deadline:
return OK
if ai_onset and ai_onset <= failure_deadline:
return DELAYED_AI_RESPONSE
if not llm_output: return LLM_EMPTY_OR_TIMEOUT
if not tts_audio: return TTS_EMPTY_OR_FAILED
if not decoder_pcm: return DECODE_FAILED
if not downstream_rtp: return RTP_NOT_SENT
if not playout_started: return PLAYOUT_MUTED_OR_DROPPED
if not recorder_ai_onset: return RECORDING_OR_ROUTE_GAP
return SUSPECTED_NO_RESPONSE_AUDIO每个结果同时输出 label、reason_code、evidence_level、confidence、窗口起止、所用策略/模型版本和缺失事件列表。Trace 不完整时只能降低证据等级,不能用“缺日志”证明某组件失败。
10.7 原因码与路由责任
| 原因码 | 最小证据 | 路由 | 是否可自动定责 |
|---|---|---|---|
INPUT_OR_TRACK_INVALID | 缺轨、全零、截断、时钟不可对齐 | 录音/数据平台 | 否,先修输入 |
ROLE_UNCERTAIN | 角色映射低置信或严重串音 | 录音协议 / diarization | 否,人工复核 |
EOU_NOT_COMMITTED | 用户有声但状态机未提交话轮 | VAD / EOU / ASR | 完整日志下可定位 |
NO_RESPONSE_REQUIRED | 业务/语义明确无需回答 | 正常负样本 | 非故障 |
LLM_EMPTY_OR_TIMEOUT | 已提交且应答义务成立,模型无有效文本 | LLM 编排 | 需完整 request/attempt 日志 |
TTS_EMPTY_OR_FAILED | 有回复文本,无有效音频样本 | TTS | 需 TTS 状态与字节合同 |
DECODE_FAILED | TTS 有编码音频,无 PCM | 解码器 / codec | 需解码错误码 |
RTP_NOT_SENT | 有 PCM,无有效下行媒体包 | 媒体网关 | 需 packet / byte 计数 |
PLAYOUT_MUTED_OR_DROPPED | 已发 RTP,远端未播放 | RTC / 终端 / jitter | 需远端回执或探针 |
RECORDING_OR_ROUTE_GAP | 播放已确认,但 AI 录音轨无起音 | 录音器 / 路由 | 可定责到录制链路 |
SUSPECTED_NO_RESPONSE_AUDIO | AI 轨窗内无声且 Trace 不完整 | 人工复核队列 | 否,仅症状 |
DELAYED_AI_RESPONSE | AI 最终起音但超过调查阈值 | 按瀑布最长阶段路由 | 与完全无声分开统计 |
更完整的问题定义、多方案比较与开源工具盘点,见《AI 会话录音无声质检专题》↗。
十一、真实电话金标与阈值校准设计
11.1 标注单位:一次应答机会,而不是整通电话
一通电话可以有几十次正常应答、一次延迟、一次无需应答和一次真正无声。金标必须以 call_id + turn_id + response_attempt_id 为主键,并保留其前后音频与事件上下文。
| 层 | 人工标什么 | 核心字段 | 评估对象 |
|---|---|---|---|
| 声学层 | 用户/AI 人声片段、串音、提示音、音乐、噪声、断轨 | start/end、role、speech type、confidence | VAD、角色映射、边界误差 |
| 话轮层 | 用户是否说完、何时 EOU、是否应该应答、场景类型 | eou_ms、expected response、policy class | EOU 和动态响应窗口 |
| 故障层 | AI 是否及时可听、最深完成事件、最终根因与证据等级 | audible onset、reason code、root cause、evidence level | 候选检测和根因归因 |
gold_event = {
"call_id": "anon-call-id",
"turn_id": "turn-0042",
"response_attempt_id": "resp-01",
"user_speech": [17120, 18420],
"eou_ms": 19160,
"expected_ai_response": true,
"scenario": "simple_qa",
"warning_deadline_ms": 20660,
"failure_deadline_ms": 22160,
"ai_audible_onset_ms": null,
"label": "confirmed_silent_failure",
"root_cause": "TTS_EMPTY_OR_FAILED",
"evidence_level": "A",
"annotator_1": "blind",
"annotator_2": "blind",
"adjudication": "agreed"
}标注员先只看脱敏音频与必要业务状态,不能先看到系统原因码;否则会被模型/日志结论锚定。两人独立标注,分歧由第三人仲裁,并保留原始意见而不是只留最终答案。
11.2 样本矩阵:正常、困难负样本和真实/注入故障都要有
| 样本桶 | 必须覆盖 | 为什么需要 |
|---|---|---|
| 正常应答 | 不同业务、线路、语种、8k/16k、长短句、不同延迟分布 | 估计正常 response_gap_ms 的 Q95/Q99,防止阈值只围绕故障样本。 |
| 困难负样本 | 告别、挂机、转人工、hold、显式等待、工具执行、DTMF、用户未完句 | 决定 Precision;这些最容易被“用户有声 + AI 暂时无声”规则误报。 |
| 声学困难样本 | 弱声、远场、强噪、音乐、提示音、咳嗽、双讲、串音、回声、丢轨 | 校准 VAD 与角色映射,避免把声学错误传播到业务状态机。 |
| 自然故障 | 真实 LLM、TTS、codec、RTP、播放、录音事故 | 保留真实依赖关系和非理想日志,作为最终可信度最高的正样本。 |
| 可控故障注入 | 阻断 LLM 输出、TTS 空音频、解码错误、丢首包、静音播放、录音断轨 | 补齐稀有根因并验证原因码,但必须与自然故障分开报告。 |
| 角色不确定 | 单轨多人、speaker 翻转、AI 变声、真人接管、重叠说话 | 验证 abstain 能否工作,而不是强迫系统二选一。 |
11.3 划分与防泄漏
- 开发集:用于修规则、特征和数据合同;允许反复查看。
- 校准集:只用于选择 VAD、EOU、应答窗和置信阈值;记录每次尝试。
- 隐藏测试集:阈值冻结后只运行一次;失败后不能“微调再报同一结果”。
- 按完整
call_id、日期批次和线路切分;同一通电话、同一故障注入模板或相邻切片不能跨集合。 - 保留一个时间外测试集和至少一条未见线路,检查版本与渠道漂移。
11.4 三组阈值分开校准
- 声学阈值
只用声学金标调
threshold、最短人声、最短静音、片段合并和串音门禁;目标优先保证短语 Recall。 - EOU 阈值
在话轮金标上调尾静音、语义完整度和回退 guard;同时看提前截断率与 EOU commit 延迟。
- 响应窗口
在已确认正常应答的校准集上按场景估计 Q95/Q99,再结合漏检、误报和人工复核成本选择调查线与故障线。
- 根因门禁
只有相关事件齐全、时钟可比较且原因码规则唯一时才允许自动归因;否则降级为
suspected或abstain。
loss = C_fn * missed_silent_failure + C_fp * false_review_candidate + C_root * wrong_root_cause + C_eou * premature_eou + C_load * human_review_minutes # 成本系数由业务、质量、平台和人工复核团队共同批准; # 不把单一 F1 当作全部业务价值。
11.5 指标必须分层报告
- 采样率:8k 电话 / 16k 宽带 / 异常 44.1k、48k 输入。
- 声道与方向:单轨、双轨、串音、丢轨、时钟偏移。
- 声学:弱声、远场、强噪、音乐/提示音、呼吸/咳嗽、双讲。
- 对话:极短应答、句内停顿、迟疑、打断、真人接管、无应答义务。
- 故障:LLM 空、TTS 空/超时、解码失败、播放丢包、录音缺失、路由错误。
Candidate Precision = true_candidates / all_candidates
Event Recall = detected_true_failures / all_true_failures
Root-cause Accuracy = correct_reason_code / full-trace_failures
EOU early-cut rate = premature_commits / committed_turns
Review load = candidates / 1000_calls
Coverage = classifiable_opportunities / all_opportunities11.6 生产门禁模板
| 门禁 | POC 建议起点 | 不达标时 |
|---|---|---|
| 数据与结果合同 | 金标 100%;影子流量 ≥ 99% 有输入哈希、角色映射、时钟与策略版本 | 停止根因自动归因,先修可追溯性 |
| VAD 人声片段 Recall | 总体 ≥ 97%,关键分层 ≥ 95% | 分层调参或增加能量/ASR 并行证据 |
| EOU 提前截断率 | 关键业务分层 ≤ 1% | 增加未完句、数字串和长停顿 guard |
| 业务无声候选 Recall | ≥ 95% | 查 EOU、应答窗、角色映射和状态丢失 |
| 进人工复核队列的 Precision | ≥ 85% | 增加负样本规则,不得自动定责 |
| 原因码准确率 | 完整 Trace 样本 ≥ 90%,并逐根因报告 | 降级为症状候选,不自动路由责任人 |
| 自动告警/自动处置 | 候选 Precision ≥ 95%,且 95% 置信区间与人工抽检均通过 | 仅保留候选队列和统计,不触发处罚/自动关停 |
| 性能与资源 | 在目标机型上用常驻模型验证 P50/P95/P99、CPU、RSS、队列积压 | 不用本页冷启动时延直接做容量规划 |
上线后每周固定随机抽样、全量复核 P0 候选,并跟踪分层 Precision / Recall、原因码分布、复核队列、输入编码/采样率漂移和 Trace 缺失率。任何 FFmpeg、Silero、Torch、ONNX Runtime、模型二进制、EOU 或窗口策略变化,都先回放同一金标并跑一个完整业务周期的影子流量再切换。
十二、融合到 Spring Boot:Java 做控制面,VAD 做可替换执行引擎
12.1 四种接法:先选清楚运行边界
| 接法 | 适用链路 | 优点 | 主要风险 | 建议 |
|---|---|---|---|---|
| Spring Boot → Python Worker / Sidecar | 离线录音、批量质检 | 直接复用已验证 CLI、Python 官方封装和当前参数合同;JVM 与 Torch 隔离 | 需要进程/容器治理、任务队列和产物回收 | 第一版推荐 |
| Spring Boot → 消息队列 → Worker 集群 | 生产批处理、大批次回放 | 削峰、重试、独立扩缩容,失败录音可隔离 | 要解决幂等、顺序、重复投递和最终一致性 | 规模化推荐 |
| Java 进程内 ONNX Runtime | 实时 PCM、低延迟流式 VAD | 无跨进程拷贝,部署单元更少 | 必须自己实现窗口/context、state reset、滞回和时间戳;容易产生静默全阴性 | 金标等价后再启用 |
| 请求线程直接 ProcessBuilder / JVM 内嵌 Python | 临时演示 | 代码看起来最短 | 阻塞 Tomcat、无背压;Torch 原生库、GIL、崩溃和升级边界混入 JVM | 生产禁用 |
12.2 推荐的离线主链:HTTP 只受理,后台完成质检
REST 受理
POST /v1/silent-qa/jobs 校验录音引用、轨道 manifest 和策略版本,返回 202 + job_id,不在请求线程跑模型。
幂等任务 / Outbox
以输入 SHA-256 + 角色映射版本 + 判定策略版本生成 job_key;数据库提交与消息发布可追溯。
FFmpeg + Silero
唯一工作目录中规范化 user / AI 轨,运行常驻 Worker 或受控 CLI,产出 result.json、segments.csv 和输入/模型哈希。
应答机会判定
合并 VAD、EOU、expected-response 与 TTS/RTP/播放 Trace,输出 label、reason_code 和 evidence_level。
结果与复核
事务性保存证据和策略版本;疑似项进入人工队列,Actuator / Micrometer 只暴露聚合指标。
离线文件链
任务单位可以是一通电话录音,但最终输出单位仍是 response_opportunity。Spring Boot 只接收受控对象存储 key / 文件 ID;Worker 下载到隔离目录后处理,不让客户端指定任意本机路径。
实时 PCM 链
RTP 网关已解码出 PCM 时直接送入流式 VAD。每个 session_id + generation 持有独立状态;打断、重连、换代或会话关闭必须 reset,旧 generation 的帧直接丢弃。
12.3 Spring Boot 模块边界:业务状态机不依赖 Python DTO
com.example.silentqa
├── api/
│ ├── SilentQaController.java # 202 受理与结果查询
│ └── SilentQaRequest.java # Jakarta Validation
├── application/
│ ├── SilentQaApplicationService.java
│ └── QaJobDispatcher.java # DB outbox / MQ / 有界 POC executor
├── domain/
│ ├── VadEngine.java # 可替换端口
│ ├── ResponseOpportunityEvaluator.java
│ ├── TraceAssembler.java
│ └── QaDecision.java
└── infrastructure/
├── worker/PythonCliVadEngine.java
├── onnx/OnnxVadEngine.java # 通过金标等价门后再启用
├── persistence/QaJobRepository.java
└── messaging/ReviewQueuePublisher.java领域层只认识稳定的 VAD 证据、话轮事件和原因码,不认识 Python 包对象、Spring MVC DTO 或具体消息中间件。这样切换 Python Worker、Java ONNX 或远程推理服务时,不需要改判定状态机。
12.4 Java 端口与任务合同:版本和哈希必须进主键语义
public record QaJob(
String jobId,
String callId,
URI userTrack,
URI aiTrack,
String inputSha256,
String roleMappingVersion,
String policyVersion,
String traceId
) {}
public interface VadEngine {
VadEvidence detect(QaJob job, VadProfile profile) throws VadEngineException;
}
public record QaDecision(
String responseOpportunityId,
String label,
String reasonCode,
String evidenceLevel,
BigDecimal confidence,
Instant windowStart,
Instant windowEnd,
List<String> missingEvents,
String decisionBundleVersion
) {}SHA256(input bytes + role mapping + VAD profile + EOU/window/reason versions);只用文件名或 call_id 会错误复用旧结果。trace_id + response_attempt_id + event_name + attempt;重试不能覆盖第一次超时或空音频。12.5 复用现有 Python CLI:ProcessBuilder 只是 Worker 适配器
@Component
final class PythonCliVadEngine implements VadEngine {
VadEvidence detect(QaJob job, VadProfile profile) throws Exception {
Path input = storage.materializeAuthorized(job.userTrack(), job.inputSha256());
Path work = workRoot.resolve(job.jobId()); // 每任务唯一目录
Files.createDirectory(work); // 父目录由服务启动时创建
List<String> command = List.of(
pythonExecutable, "-m", "ffmpeg_silero_vad", input.toString(),
"--output-dir", work.toString(),
"--backend", "onnx",
"--threshold", profile.threshold().toPlainString(),
"--min-speech-duration-ms", String.valueOf(profile.minSpeechMs()),
"--min-silence-duration-ms", String.valueOf(profile.minSilenceMs()),
"--speech-pad-ms", String.valueOf(profile.speechPadMs())
);
Process process = new ProcessBuilder(command) // 不经过 shell
.directory(pythonPackageDir.toFile())
.redirectOutput(work.resolve("stdout.json").toFile())
.redirectError(work.resolve("stderr.log").toFile())
.start();
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
process.descendants().forEach(ProcessHandle::destroyForcibly);
process.destroyForcibly();
throw new VadEngineException("VAD_TIMEOUT");
}
if (process.exitValue() != 0) {
throw mapCliExitCode(process.exitValue(), work.resolve("stderr.log"));
}
return verifyHashesAndRead(work.resolve("result.json"), job, profile);
}
}- 不要在 Controller 或 Tomcat 线程中调用上述代码;它必须运行在独立 Worker、消息消费者或明确有界的 POC executor。
- stdout / stderr 重定向到限额文件,避免管道未消费导致子进程死锁;日志只保留截断后的错误摘要。
- 限制输入字节数、时长、格式、并发和磁盘配额;超时同时清理后代进程。失败工作目录进入有 TTL 的隔离区,成功产物持久化后再删除。
- 工作目录由服务生成,禁止客户端传
--output-dir、--force或任意可执行文件路径。
12.6 接入实时 Spring 语音网关:回调只入队,不做阻塞推理
SpeechPipeline、StreamingInputProvider、带 timestampNs 的 PcmPacket、generation 打断语义、Actuator 与 Micrometer。可以新增 VadProvider extends StreamingInputProvider 并注入 SpeechPipeline;现有 onAudio() 会同步遍历 provider,所以 accept() 必须复制必要字节、尝试入有界队列后立即返回。
public interface VadProvider extends StreamingInputProvider {}
@Component
final class SileroStreamingVadProvider implements VadProvider {
private final ConcurrentMap<String, VadSession> sessions = new ConcurrentHashMap<>();
public void open(String sessionId, int sampleRate) {
sessions.put(sessionId, VadSession.start(sampleRate, queueCapacity));
}
public void accept(String sessionId, long generation, PcmPacket packet) {
VadSession state = requireSession(sessionId);
byte[] pcmCopy = packet.pcm16le().clone();
if (!state.offer(new VadFrame(generation, packet.timestampNs(),
packet.sampleRate(), pcmCopy))) {
metrics.increment("silent_qa_vad_frame_dropped_total");
} // 立即返回
}
public void interrupt(String sessionId, long generation, String reason) {
requireSession(sessionId).resetTo(generation); // 清 state/context/旧帧
}
public void close(String sessionId) {
Optional.ofNullable(sessions.remove(sessionId)).ifPresent(VadSession::close);
}
}实时链路已经拿到 PCM,就不再跑 FFmpeg。若输入是 8k/16k,应为每条线路固定 profile、窗口长度和 context;严禁在同一 session 中悄悄切采样率。现有媒体层的有界 dispatcher 是包内实现,provider 若要复用需抽成公共组件,或者另建 provider 级每会话 mailbox,不能通过反射或复制无界队列绕过背压。
12.7 配置、并发与优雅停机
silent-qa:
mode: ${SILENT_QA_MODE:python-worker}
python-executable: ${SILENT_QA_PYTHON:/opt/silent-qa/venv/bin/python}
python-package-dir: ${SILENT_QA_PACKAGE:/opt/silent-qa/app}
work-root: ${SILENT_QA_WORK_ROOT:/var/lib/silent-qa/work}
timeout: ${SILENT_QA_TIMEOUT:5m}
max-concurrency: ${SILENT_QA_MAX_CONCURRENCY:4}
queue-capacity: ${SILENT_QA_QUEUE_CAPACITY:100}
vad:
backend: onnx
threshold: 0.50
min-speech-ms: 250
min-silence-ms: 100
speech-pad-ms: 30
profile-version: silero-cn-phone-poc-v1用 @ConfigurationProperties(prefix="silent-qa") + Jakarta Validation 绑定配置,启动时验证 Python/FFmpeg/模型可用性、工作目录权限和磁盘水位。POC 可以使用专用 ThreadPoolTaskExecutor,但必须显式设置 core/max/queue/rejection;生产批量任务优先使用 MQ。停机顺序是“停止受理 → 停止拉取 → 等待有界宽限期 → 取消 Worker/子进程 → 落盘当前状态”,不能直接让 Pod 消失。
12.8 数据表、指标和健康检查
| 对象 | 最小字段 / 指标 | 关键约束 |
|---|---|---|
qa_job | job_id、call_id、job_key、status、attempt、policy_version、created/started/finished | job_key 唯一;状态 CAS 更新;重复消息返回既有结果 |
qa_artifact | input/result/model hash、轨道 manifest、VAD 片段引用、保留期限 | 先验 hash 后入判定;大文件不直接存数据库 |
trace_event | trace_id、turn_id、attempt、event、mono/UTC/clock_id、status | 事件追加写;迟到事件触发新判定版本,不覆盖历史 |
qa_decision | opportunity_id、label、reason、evidence、confidence、bundle_version | 判定结果不可变;人工复核另存 reviewer label |
| Micrometer | job total、queue depth/age、FFmpeg timeout、VAD latency、frame drop、Trace missing、reason 分布 | 业务/线路等高基数字段不直接做 tag;用日志或数仓分层 |
| Actuator Health | Worker 心跳、模型加载、工作盘可写、队列/磁盘水位 | 健康只代表可服务,不代表 VAD 准确或“当前无故障” |
12.9 从现有 CLI 迁到 Java ONNX 的四道等价门
- 离线对照
同一规范化 PCM、同一 Silero 模型和参数,Python 与 Java 输出逐样本对齐;同时比较 start/end sample,不只比较“有没有人声”。
- 状态对照
验证 8k/16k 窗口、64-sample context、hidden state、滞回、speech pad 和 reset;覆盖空音频、短尾帧、打断和采样率切换。
- 影子双跑
Spring Boot 同时调用 Python 主引擎与 Java 影子引擎,只记录差异;不得用 Java 结果影响在线告警。
- 灰度切换
差异率、候选 Precision/Recall、P95/P99、RSS 和队列积压达到门禁后,按线路小流量切换,并保留一键回到 Python Worker 的开关。
十三、最小可落地路线:先影子判定,再逐级开放告警
| 阶段 | 交付物 | 验收 | 此时允许做什么 |
|---|---|---|---|
| P0 · 数据合同 | 双轨 manifest、统一 ID、单调时钟/偏差记录、T0–T10 事件 schema | 抽样回放能把录音、话轮和 Trace 一一对齐 | 只做数据质量看板 |
| P1 · 离线候选 | 分轨 VAD、EOU、expected-response、动态窗口和原因码引擎 | 同一输入与版本产生确定性结果,Trace 缺失会降级 | 生成离线人工复核队列 |
| P2 · 金标校准 | 600–1000 个应答机会、多根因正样本、困难负样本、隐藏测试集 | 分层指标和置信区间达到批准门禁 | 锁定候选策略版本 |
| P3 · 影子流量 | 全量旁路运行、人工盲审、复核负载与漂移监控 | 完整业务周期内无关键分层回退,误报量可承受 | 发内部统计,不影响在线通话 |
| P4 · 分级告警 | A/B/C/D 证据等级、责任路由、回滚开关、周度抽检 | A 级原因码通过复核;B/C 级仍保留人工 | 先告警,后评估是否允许自动处置 |
# 每次策略发布都生成不可变版本包 decision_bundle = { "role_mapping_version": "role-map-v2", "vad_profile_version": "silero-cn-phone-v4", "eou_policy_version": "eou-cn-sales-v3", "response_window_version": "window-scenario-v2", "trace_schema_version": "voice-trace-1.0", "reason_code_version": "silent-reason-v1", "gold_set_version": "phone-gold-2026q3", "code_commit": "immutable-revision" }
十四、官方来源、社区个案与证据边界
链接和 issue 状态检查日期:2026-09-01。官方文档用于界定 API/版本;社区 issue / PR 只是风险线索,不是经过上游承诺的故障范围或修复日期。
| 类型 | 来源 | 本页如何使用 |
|---|---|---|
| 官方 | Silero VAD v6.2.1 README ↗ | MIT 许可、8k/16k 支持、Python API、JIT/ONNX 与 ONNX-only 边界 |
| 官方 | v6.2.1 Release ↗ | 本次固定版本;发布于 2026-02-24,该版本主题为将 ONNX Runtime 改为可选 |
| 官方 | PyPI: silero-vad 6.2.1 ↗ | 包版本和 Python 要求的发布元数据 |
| 官方 | utils_vad.py v6.2.1 源码 ↗ | 核对 get_speech_timestamps、滞回阈值、sample/秒时间戳、固定窗和状态复位 |
| 官方 | model.py v6.2.1 源码 ↗ | 核对 JIT/ONNX loader、单线程设定与 ONNX 强制 CPU Provider |
| 官方 | ffprobe 文档 ↗ / FFmpeg 文档 ↗ / channelsplit ↗ | 输入探测、显式 stream mapping 与双声道分轨命令 |
| 官方 | Spring Boot 3.3 Task Execution ↗ / Actuator Metrics ↗ | 专用有界 executor、外部任务编排、Actuator / Micrometer 接入边界 |
| 官方 | Java 17 ProcessBuilder ↗ | 不经过 shell 的参数列表、工作目录、stdout/stderr 重定向与子进程生命周期 |
| 官方 | ONNX Runtime Java ↗ | Java 进程内 ONNX 的候选执行层;Silero 的窗口、context、state 与后处理仍由本方实现和对照验证 |
| 本机代码核对 | Java 17 / Spring Boot 3.3.13 Speech Gateway | 核对 SpeechPipeline、StreamingInputProvider、PcmPacket.timestampNs、generation、Actuator 与 Micrometer;本节是接入设计,尚未修改该服务源码 |
| 官方 | TorchAudio 2.11 load 文档 ↗ | 理解新版 TorchAudio 解码路径与 TorchCodec 依赖;本工具用固定 PCM 合同避开二次解码 |
| 官方开发分支 | 2026-06-19 TorchAudio 上限提交 ↗ | master 已增加 torchaudio<2.10,但截止本页日期还没有比 6.2.1 更新的稳定 Release;说明发布元数据与开发分支存在时间差 |
| 社区个案 | Issue #735: PyTorch ≥2.9 / TorchCodec / AArch64 ↗ | 开放 issue;说明解码依赖在 ARM 设备上可成为部署阻断,需在目标机型验证 |
| 社区 PR | PR #749: Restrict torchaudio <2.9.0 ↗ | 开放 PR;记录 read_audio() 所用 TorchAudio API 在 2.9 被移除的兼容性风险 |
| 社区个案 | Issue #771: 直接 ONNX 的 64-sample context ↗ | 已关闭 issue;警示绕过官方封装直接调 ONNX 时的静默全阴性风险 |
| 社区问题 | Issue #790: 采样率与流式状态问题 ↗ | 已关闭且无公开回答;只作为问题清单线索,提醒采样率策略和流式 state reset 必须由本方测试验证 |