战

实战中心

音频质检 / VAD

← 返回实战列表
📡 音频质检 / VAD

FFmpeg + Silero VAD:无声判定与 Spring Boot 生产集成

2026-09-01 · 5 次真实 CLI 新进程 / 模型冷加载 · 分轨、EOU、动态应答窗、媒体 Trace、电话金标与 Java 服务接入设计

FFmpeg 8.1.2 Python 3.12.13 silero-vad 6.2.1 Torch 2.13.0 TorchAudio 2.11.0 ONNX Runtime 1.29.0
本页导航: 结论 边界与架构 安装 FFmpeg 规范化 跑通与结果 参数 输出合同 批处理 兼容性坑 生产状态机 金标与门禁 Spring Boot 落地路线 来源

一、先给结论:声学候选已跑通,业务定责还需三层证据

01 · 流程可复现
FFmpeg 先把常见输入收敛为 16kHz / 单声道 / PCM s16le WAV,再用 Silero VAD 输出人声与非人声时间段。
02 · 参数会改变事件
同一条 9.23575s 音频,min_silence=100ms 得到 2 段人声,调到 1000ms 后合并为 1 段。
03 · 不能单独定责
VAD 不知道谁在说、说了什么、AI 是否应答。业务判定必须加上角色轨、EOU/语义、应答窗与解码/播放状态。
04 · 先看计时边界
5 次命令都用新进程并冷加载模型;753.768–800.894ms 是 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/播放状态 → 无声候选 + 原因码
             ↓
人工复核 / 统计聚合 / 回放金标
双轨/双声道红线:先分轨,再分别规范化和跑 VAD。不能先用 -ac 1 下混:下混会丢失用户/AI 角色真值,串音变成更难识别的同轨人声,极端情况还可能发生相位抵消。

三、环境与固定版本

本页数据来自一次固定环境验证。下列版本是可复现快照,不等于上游的永久兼容承诺。

组件实测版本在流水线中的职责
FFmpeg / ffprobe8.1.2媒体探测、解码、分轨、重采样与 PCM 规范化
Python3.12.13CLI、原子写文件、批处理调度
silero-vad6.2.1load_silero_vad + get_speech_timestamps
Torch / TorchAudio2.13.0 / 2.11.0JIT 推理与上游依赖;本工具不调用 TorchAudio 读音频
ONNX Runtime1.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__)"
生产不要只锁 Python 包名:还要把 Python/FFmpeg/CPU 架构、模型二进制哈希、镜像 digest 和参数版本放入部署清单。当前原型已记录输入与规范化 WAV 的 SHA-256;模型文件 SHA-256 仍应在生产封装层补齐。

需要注意,onnx-cpu extra 只是增加 ONNX Runtime,PyPI 包本身仍依赖 Torch 和 TorchAudio,不等于获得纯 ONNX-only 的轻量部署。若要绕过 Python 包直接调图,必须自行实现状态、上下文与后处理,不在本原型的验收范围内。

四、FFmpeg 入口体检、分轨与规范化

4.1 先看媒体结构

ffprobe 旁证
$ 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
不能猜角色映射:左/右或流 0/流 1 由上游录音协议决定。把这个映射连同会话 ID、时钟基准、录音方向一起放进 manifest;否则 VAD 结果无法支撑“用户/AI”级判定。

五、CLI 跑通与 5 次真实结果

5.1 跑一条

默认 JIT
# 把输入路径替换为自己的合法音频;下方数值回执来自本次 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 ratioFFmpegVAD(含加载)run() 内部
19.23575s 双话轮;JIT;间隔 100ms994–3678ms
4418–8094ms
0.68862834.350ms731.301ms797.689ms
2同一音频;JIT;间隔 1000ms994–8094ms
740ms 停顿被合并
0.76875233.418ms718.295ms783.631ms
3同 run 1;ONNX;间隔 100ms与 run 1 完全一致,2 段0.68862833.282ms688.758ms753.768ms
48kHz 电话 2.623875s → 16kHz;JIT34–2623.875ms0.98704230.659ms699.841ms761.189ms
548kHz 双声道 3s 纯静音 → 16kHz 单声道;JIT0 段054.701ms693.761ms800.894ms
口径必须看清:上表 5 条命令都是新 Python 进程,VAD 耗时包含模型加载;但 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片段一致性
JIT34.003ms32.198ms79.727ms10 次一致
ONNX45.294ms34.448ms35.527ms10 次一致,与 JIT 一致
这只是辅助 microbenchmark:它隔离了模型重复加载,但仍只有一条音频、一台主机和 10 次重复。P95 按 nearest-rank 取值,样本仅 10 次时实际等于本组最大值;JIT P50 更低、ONNX P95 更稳只能算本次观察,不能据此宣称通用后端胜负。生产选型要用目标 CPU/容器、真实时长分布和并发测 P50/P95/P99、CPU 与 RSS。

六、参数不是开关,而是业务定义的一部分

参数实测默认改大的影响主要风险
threshold0.5要求更高的人声概率噪声误报可能降低,弱声/远场漏检可能上升
min_speech_duration_ms250丢弃更多极短人声可抑制瞬态噪声,也可能丢失“嗯/好”等短应答
min_silence_duration_ms100更容易把短停顿两侧合并可以降低过切,但会模糊话轮和句内停顿边界
speech_pad_ms30向前/后扩大人声边界避免切掉字头字尾,但可以吞掉短响应窗
backendjit切换为 ONNX 运行时依赖、直接 ONNX 输入状态和监控噪声不同,要分别压测
为什么默认 JIT:它直接走 Silero 的标准 Python 封装,当前原型的依赖和观测语义更简单。ONNX 保留为可选后端;一条样本上的结果一致和耗时差异,不是更换默认的证据。
不要暴露伪参数:在 6.2.1 中 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 / indexspeech 或 silence 以及各类内序号不要把 silence 名称直接翻译为“故障”
start_sample / end_sample16kHz 时钟上的原始样本边界作为对齐和二次计算的主键
start_ms / end_ms / duration_ms由样本索引换算的毫秒给状态机、数仓和人工复核 UI
*_seconds保留 6 位小数的秒值展示便利;精确窗口仍用 sample 索引

八、批处理、幂等、错误码和可观测性

8.1 稳定退出码

退出码类型调度策略
0成功校验三个产物、写入完成 manifest
2输入/参数/已有输出冲突不盲目重试;修正 manifest 或显式决定覆盖
3找不到 FFmpeg工作节点镜像或环境故障,暂停该节点派发
4FFmpeg 转换或 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。

这不是回避音频校验:工具在读取前严格检查单声道、16kHz、2 字节样本宽度和无压缩 PCM。解码职责只留在 FFmpeg,推理层只接受一种明确字节合同。

9.2 return_seconds=True 不适合精确质检边界

在实测版本中,该模式的秒数输出会进行 0.1s 级舍入。生产窗口可能正好卡在边界,所以 CLI 固定 return_seconds=False,保留 sample 索引后再换算毫秒/秒。

9.3 JIT / ONNX 的边界

十、从 VAD 候选到“用户有声、AI 无声”的完整判定层设计

设计口径:本节是可实施的本方参考架构,不是已经在真实电话生产验证的结果。最终标签的基本单位是一次 response_opportunity(应答机会),不是整通电话,也不是任意静音片段。

10.1 五道门:证据必须逐层通过

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、远端播放回执还是混合录音;不同采集点证据强度不同。
A 级 · 可确认原生双轨 + 角色真值 + 完整 playout / 录音回执,可进入根因确认。
B 级 · 可归因候选原生双轨 + 部分组件日志,可定位到 LLM / TTS / 媒体中的某一层。
C 级 · 症状候选只有双轨录音或可靠 diarization,只能报告窗口内 AI 无可听人声。
D 级 · 弃权角色不明、整轨异常、严重串音或时钟不可对齐,输出 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 / 播放事件瀑布:把“没声音”定位到最深已完成节点

user_acoustic_end
用户轨最后一个可靠人声样本;声学事实。
eou_committed
状态机正式提交话轮;应答窗口从这里开始,而不是从任意静音开始。
asr_final
最终文本和稳定度;用于语义与 expected-response 判断。
llm_first_token / completed
区分模型未启动、首 token 超时、空回复和正常文本。
tool_end
工具型话轮记录 attempt、status 和 error;失败重试不能覆盖第一次超时。
tts_first_audio
合成层首次返回有效音频样本/字节,而不是仅 HTTP 200。
decoder_first_pcm
编码音频已经成功解码为可播放 PCM。
rtp_first_packet_sent
下行链路已经发出首个有效媒体包;同时记录 SSRC、codec、packet / byte count。
playout_started
优先使用远端 SDK / 网关的播放回执;它比“服务器已发包”更接近真实可听。
recorder_ai_onset
录音中 AI 轨首次人声;用于质检旁证,但采集点不同不能代替远端 playout 真值。
{
  "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_FAILEDTTS 有编码音频,无 PCM解码器 / codec需解码错误码
RTP_NOT_SENT有 PCM,无有效下行媒体包媒体网关需 packet / byte 计数
PLAYOUT_MUTED_OR_DROPPED已发 RTP,远端未播放RTC / 终端 / jitter需远端回执或探针
RECORDING_OR_ROUTE_GAP播放已确认,但 AI 录音轨无起音录音器 / 路由可定责到录制链路
SUSPECTED_NO_RESPONSE_AUDIOAI 轨窗内无声且 Trace 不完整人工复核队列否,仅症状
DELAYED_AI_RESPONSEAI 最终起音但超过调查阈值按瀑布最长阶段路由与完全无声分开统计

更完整的问题定义、多方案比较与开源工具盘点,见《AI 会话录音无声质检专题》↗。

十一、真实电话金标与阈值校准设计

11.1 标注单位:一次应答机会,而不是整通电话

一通电话可以有几十次正常应答、一次延迟、一次无需应答和一次真正无声。金标必须以 call_id + turn_id + response_attempt_id 为主键,并保留其前后音频与事件上下文。

层人工标什么核心字段评估对象
声学层用户/AI 人声片段、串音、提示音、音乐、噪声、断轨start/end、role、speech type、confidenceVAD、角色映射、边界误差
话轮层用户是否说完、何时 EOU、是否应该应答、场景类型eou_ms、expected response、policy classEOU 和动态响应窗口
故障层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 能否工作,而不是强迫系统二选一。
POC 数量建议:先做 600–1000 个应答机会,其中至少 200 个经日志确认或可控注入的故障正样本,每个关键根因桶不少于约 30 个;这是覆盖面起点,不是统计充分性的保证。最终验收应报告 95% Wilson 置信区间,不能只看点估计。

11.3 划分与防泄漏

11.4 三组阈值分开校准

  1. 声学阈值

    只用声学金标调 threshold、最短人声、最短静音、片段合并和串音门禁;目标优先保证短语 Recall。

  2. EOU 阈值

    在话轮金标上调尾静音、语义完整度和回退 guard;同时看提前截断率与 EOU commit 延迟。

  3. 响应窗口

    在已确认正常应答的校准集上按场景估计 Q95/Q99,再结合漏检、误报和人工复核成本选择调查线与故障线。

  4. 根因门禁

    只有相关事件齐全、时钟可比较且原因码规则唯一时才允许自动归因;否则降级为 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 指标必须分层报告

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_opportunities

11.6 生产门禁模板

下列数字是 POC 建议起点,不是 Silero 官方标准或行业通用值。必须按漏检成本、人工复核能力和业务 SLA 重新批准;任一关键分层不达标都不能用总体均值掩盖。
门禁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 做可替换执行引擎

推荐结论:当前最稳妥的第一版是 Spring Boot 异步任务编排 + 独立 Python VAD Worker,直接复用本页已跑通的 FFmpeg + Silero CLI;实时语音链路则从媒体网关取得 8k/16k PCM,接入有状态的流式 VAD,不能在每个音频回调里启动 FFmpeg 或 Python 进程。等 Java ONNX 与 Python 金标逐样本对齐后,再决定是否把推理收进 JVM。

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 只受理,后台完成质检

离线文件链

任务单位可以是一通电话录音,但最终输出单位仍是 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 会错误复用旧结果。
结果不可变同一 job_key 成功后不覆盖;策略变化产生新 generation / result version,并保留旧结果用于回放。
Trace 去重键trace_id + response_attempt_id + event_name + attempt;重试不能覆盖第一次超时或空音频。
敏感数据边界API 返回证据 ID 与时间段,不回显录音本机路径、签名 URL、完整转写或原始 PCM。

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);
    }
}

12.6 接入实时 Spring 语音网关:回调只入队,不做阻塞推理

与当前 Java Speech Gateway 的精确落点:现有 Java 17 / Spring Boot 3.3.13 服务已经有 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_jobjob_id、call_id、job_key、status、attempt、policy_version、created/started/finishedjob_key 唯一;状态 CAS 更新;重复消息返回既有结果
qa_artifactinput/result/model hash、轨道 manifest、VAD 片段引用、保留期限先验 hash 后入判定;大文件不直接存数据库
trace_eventtrace_id、turn_id、attempt、event、mono/UTC/clock_id、status事件追加写;迟到事件触发新判定版本,不覆盖历史
qa_decisionopportunity_id、label、reason、evidence、confidence、bundle_version判定结果不可变;人工复核另存 reviewer label
Micrometerjob total、queue depth/age、FFmpeg timeout、VAD latency、frame drop、Trace missing、reason 分布业务/线路等高基数字段不直接做 tag;用日志或数仓分层
Actuator HealthWorker 心跳、模型加载、工作盘可写、队列/磁盘水位健康只代表可服务,不代表 VAD 准确或“当前无故障”

12.9 从现有 CLI 迁到 Java ONNX 的四道等价门

  1. 离线对照

    同一规范化 PCM、同一 Silero 模型和参数,Python 与 Java 输出逐样本对齐;同时比较 start/end sample,不只比较“有没有人声”。

  2. 状态对照

    验证 8k/16k 窗口、64-sample context、hidden state、滞回、speech pad 和 reset;覆盖空音频、短尾帧、打断和采样率切换。

  3. 影子双跑

    Spring Boot 同时调用 Python 主引擎与 Java 影子引擎,只记录差异;不得用 Java 结果影响在线告警。

  4. 灰度切换

    差异率、候选 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 级仍保留人工先告警,后评估是否允许自动处置
建议的第一张生产看板:不要只画“无声率”。至少同时展示应答机会数、可分类覆盖率、候选率、人工确认率、原因码分布、Trace 缺失率、角色不确定率、每千通复核量以及线路/业务/版本分层。
# 每次策略发布都生成不可变版本包
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 设备上可成为部署阻断,需在目标机型验证
社区 PRPR #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 必须由本方测试验证
证据界限:本页 5 次 CLI + 1 组模型复用 microbenchmark 证明“本机环境下这条工具链可运行,产物合同可检查”;它不证明对真实客服录音的准确率,不证明任何语种/声学域的泛化,也不证明生产容量。这三项都只能由目标域金标和常驻压测补齐。
← 返回实战列表 相关调研:AI 会话录音无声质检专题 ↗