[本地语音降噪]:完成本地语音链路和噪音过滤,包含降噪模型、本地ASR和实时字幕稳定策略

This commit is contained in:
mkbk
2026-06-17 22:55:33 +08:00
parent a77a172412
commit 8c75fc5baf
22 changed files with 803 additions and 64 deletions
+4 -1
View File
@@ -15,6 +15,9 @@ OWNER_WAKE_ACK_TEXT=我在
OWNER_POST_PLAYBACK_DRAIN_MS=0
OWNER_PIPELINE_MODE=live_turn_based
OWNER_ENDPOINT_MODE=primary_speaker
OWNER_NOISE_FILTER_ENABLED=1
OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn
OWNER_WAKE_DENOISE_ENABLED=0
OWNER_SPEAKER_PROFILE_MS=600
OWNER_SPEAKER_PROFILE_MIN_MS=120
OWNER_SPEAKER_ABSENT_MS=300
@@ -26,7 +29,7 @@ OWNER_VAD_MIN_DURATION_MS=250
OWNER_VAD_END_SILENCE_MS=350
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
OWNER_VAD_MAX_RECORDING_MS=12000
OWNER_SPEECH_PROVIDER=cloud
OWNER_SPEECH_PROVIDER=local
OWNER_ASR_MODEL=mimo-v2.5-asr
OWNER_TTS_MODEL=mimo-v2.5-tts
OWNER_TTS_VOICE=mimo_default
+17 -10
View File
@@ -2,7 +2,7 @@
这是一个 Python 语音桌宠运行程序。当前第一版先提供无 GUI 的真实实时语音循环:
`小杰小杰` 唤醒 -> 本地应答“我在” -> 主说话人端点采集问题 -> ASR 转文字 -> 携带本次进程内临时历史调用 LLM -> TTS 生成语音 -> 本机扬声器播放 -> 回到待机继续监听。
`小杰小杰` 本地唤醒 -> 本地应答“我在” -> 本地降噪 -> 主说话人端点采集问题 -> 本地 STT/实时字幕 -> 携带本次进程内临时历史调用云端 LLM -> 本地 TTS -> 本机扬声器播放 -> 回到待机继续监听。
## 当前能力
@@ -10,10 +10,10 @@
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
- `.env` 直接读取配置,不要求导出 shell 环境变量。
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
- `OWNER_SPEECH_PROVIDER=cloud|local`选择云端语音模型或本地语音模型
- 默认云端语音模型:`mimo-v2.5-asr``mimo-v2.5-tts`
- `OWNER_SPEECH_PROVIDER=local`默认除 LLM 外全用本地语音链路;`cloud` 仅作为显式兼容选项
- 本地语音默认:wake/VAD/STT/实时字幕/TTS/降噪全部在本机执行,LLM 继续走 `.env` 中的云端配置
- 本地设备:`sounddevice` 读取麦克风,扬声器或 `afplay` 播放。
- 本地模型:`models/` 存放 `sherpa-onnx` wake/VAD/STT 模型,目录不提交 Git。
- 本地模型:`models/` 存放 `sherpa-onnx` wake/VAD/STT/denoiser 模型,目录不提交 Git。
- Pipeline`run-live` 使用 stage 化 `VoiceAssistantPipeline`,通过事件总线输出终端状态。
- 主说话人端点:默认 `OWNER_ENDPOINT_MODE=primary_speaker`,本轮音色消失后结束录音,避免背景噪声拖慢 STT。
- 临时上下文:同一次 `run-live` 进程内携带最近 user/assistant 历史,退出即清空。
@@ -43,6 +43,9 @@ OWNER_WAKE_ACK_TEXT=我在
OWNER_POST_PLAYBACK_DRAIN_MS=0
OWNER_PIPELINE_MODE=live_turn_based
OWNER_ENDPOINT_MODE=primary_speaker
OWNER_NOISE_FILTER_ENABLED=1
OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn
OWNER_WAKE_DENOISE_ENABLED=0
OWNER_SPEAKER_PROFILE_MS=600
OWNER_SPEAKER_PROFILE_MIN_MS=120
OWNER_SPEAKER_ABSENT_MS=300
@@ -54,20 +57,22 @@ OWNER_VAD_MIN_DURATION_MS=250
OWNER_VAD_END_SILENCE_MS=350
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
OWNER_VAD_MAX_RECORDING_MS=12000
OWNER_SPEECH_PROVIDER=cloud
OWNER_SPEECH_PROVIDER=local
OWNER_ASR_MODEL=mimo-v2.5-asr
OWNER_TTS_MODEL=mimo-v2.5-tts
OWNER_TTS_VOICE=mimo_default
OWNER_CONTEXT_MODE=session_memory
```
`OWNER_WAKE_PROVIDER=local_kws` 表示唤醒词“小杰小杰”由本地模型检测。唤醒命中后会先本地播报 `OWNER_WAKE_ACK_TEXT=我在`,再开始听取问题。`OWNER_SPEECH_PROVIDER=cloud` 只影响唤醒后的正式问题 ASR/TTS:它会把 VAD 切出来的用户问题片段发送到云端 ASR,不会上传连续麦克风流,也不会用云端判断唤醒。改成 `local` 时使用项目 `models/` 下的本地语音模型路径
`OWNER_WAKE_PROVIDER=local_kws` 表示唤醒词“小杰小杰”由本地模型检测。唤醒命中后会先本地播报 `OWNER_WAKE_ACK_TEXT=我在`,再开始听取问题。`OWNER_SPEECH_PROVIDER=local` 表示正式问题 STT、实时字幕和 TTS 都走本地模型或 macOS 本地能力;云端只接收 final 文本和本次会话历史用于 LLM 回复
`OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 表示录音期间会使用本地 streaming STT 实时显示中间转写,终端会输出 `实时转写:...`;最终发送给 LLM 的内容仍以 `转写结果:...` 为准。设置为 `0` 可以临时关闭实时显示
`OWNER_NOISE_FILTER_ENABLED=1` 表示唤醒后的正式问题阶段默认启用本地 GTCRN 降噪。降噪后的同一份音频会进入 VAD、实时字幕和 final STT;wake 阶段默认保持原始音频,`OWNER_WAKE_DENOISE_ENABLED=0` 可以避免降噪影响“小杰小杰”的 KWS 特征
`OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 表示录音期间会使用本地 streaming STT 实时显示中间转写,终端会输出 `实时转写:...`。为了过滤噪音,实时字幕默认不会显示 `家``家确` 这类很短的瞬时误识别;最终发送给 LLM 的内容仍只以 `转写结果:...` 为准。设置为 `0` 可以临时关闭实时显示。
## 本地模型
即使默认走云端 ASR/TTS,也必须准备本地唤醒模型;同一脚本会下载 wake、VAD、STT 模型,便于切换到 `OWNER_SPEECH_PROVIDER=local`
首次运行前必须准备本地语音模型;同一脚本会下载 wake、VAD、2025 中文 CTC STT 和 GTCRN denoiser 模型
```bash
python3.11 scripts/download_speech_models.py --dir models
@@ -76,6 +81,8 @@ python3.11 scripts/download_speech_models.py --dir models
`models/` 已在 `.gitignore` 中,不会提交大模型文件。
默认本地 STT 使用 sherpa-onnx 官方 2025 中文 CTC int8 模型,manifest 里的关键文件是 `tokens.txt``model.int8.onnx`。旧 14M transducer 模型仍可通过旧 manifest 兼容,但不再作为默认实时字幕质量基线。
本地唤醒关键词文件位于 `models/wake/keywords.txt`。默认 `OWNER_WAKE_KWS_THRESHOLD=0.15` 已偏向灵敏;如果真人唤醒仍不灵敏,可以继续降到 `0.10`,若误唤醒变多再回调到 `0.20`
默认 `OWNER_ENDPOINT_MODE=primary_speaker` 会在本轮问题开头建立临时音色画像;`OWNER_SPEAKER_PROFILE_MIN_MS=120` 表示最少 120 毫秒有效语音即可让画像参与端点判断。当这个主说话人音色连续消失 `OWNER_SPEAKER_ABSENT_MS=300` 毫秒后,直接结束录音进入转写。它不保存长期声纹、不做主人注册、不跨进程记忆。
@@ -104,7 +111,7 @@ python3.11 scripts/download_speech_models.py --dir models
.venv/bin/python -m owner_voice_pet run-live
```
运行后终端状态来自 pipeline event bus,会显示待机、唤醒命中、应答中、请说出问题、录音中、检测到用户语音、实时转写、用户语音结束、转写中、转写结果、思考中、播放中、恢复待机等状态。说“小杰小杰”,听到“我在”且看到“请说出问题”后再提问;听到回复后可以再次说“小杰小杰”继续下一轮。本次进程内会携带临时历史,程序退出后不保存。
运行后终端状态来自 pipeline event bus,会显示待机、唤醒命中、应答中、请说出问题、录音中、检测到用户语音、实时转写、用户语音结束、转写中、转写结果、思考中、播放中、恢复待机等状态。说“小杰小杰”,听到“我在”且看到“请说出问题”后再提问;听到回复后可以再次说“小杰小杰”继续下一轮。本次进程内会携带临时历史,程序退出后不保存。背景噪声下如果实时字幕仍偶发短错字,先看最终 `转写结果`;最终文本才会进入 LLM。
## 验证
@@ -130,5 +137,5 @@ LLM smoke
- API key 只写入本地 `.env``.env` 不提交 Git。
- `models/``.venv/` 和临时音频文件不提交 Git。
- 默认不持久化麦克风原始音频。
- 云端 ASR 只接收 VAD 切分后的语音片段
- 默认不使用云端 ASR/TTS;云端 LLM 只接收最终用户文本和本次运行内临时历史
- `security-check` 会扫描已跟踪文本文件中的 `sk-...``tp-...` 形式密钥。
@@ -31,12 +31,14 @@ run-live
-> SoundDeviceAudioTransport
-> SherpaOnnxKeywordWakeWordProvider(local models/wake)
-> AcknowledgeStage(local "我在")
-> CaptureStage(primary speaker endpoint by default)
-> CloudAsrSttProvider or SherpaOnnxSttProvider
-> CaptureStage(raw mic frames)
-> AudioPreprocessStage(local GTCRN denoise, capture only)
-> PrimarySpeakerEndpoint/VAD(denoised frames)
-> SherpaOnnxSttProvider(local CTC partial/final by default)
-> DialogStage(session memory)
-> ConversationContext
-> OpenAICompatibleLlmProvider
-> CloudTtsProvider or MacSayTtsProvider
-> MacSayTtsProvider
-> speaker playback
```
@@ -51,11 +53,12 @@ run-live
7. On wake hit, reset wake stream and VAD recorder.
8. Record user utterance with VAD. The default provider is `hybrid`: project-local `sherpa-onnx` VAD remains the primary detector, and an energy threshold fallback prevents low microphone gain from being treated as no speech.
9. When `OWNER_ENDPOINT_MODE=primary_speaker`, build a temporary per-turn speaker profile from the first valid user speech frames and end the capture when the primary speaker is absent for `OWNER_SPEAKER_ABSENT_MS`.
10. Transcribe user utterance.
11. Emit transcript event to terminal.
12. Append user text and call LLM.
13. Synthesize/play reply.
14. Append assistant reply and return to standby.
10. When `OWNER_NOISE_FILTER_ENABLED=1`, pass formal user utterance frames through the local GTCRN denoiser before VAD, realtime STT, and final STT segment assembly.
11. Transcribe user utterance with the configured STT provider. The default is local CTC STT; cloud STT remains an explicit compatibility option.
12. Emit transcript event to terminal.
13. Append final user text and call LLM.
14. Synthesize/play reply with local TTS by default.
15. Append assistant reply and return to standby.
## Interfaces
@@ -88,6 +91,29 @@ class PipelineEventBus:
Required event types are `pipeline_started`, `wake_listening`, `wake_detected`, `ack_started`, `capture_started`, `speech_started`, `speech_ended`, `stt_started`, `transcript_final`, `llm_started`, `tts_started`, `playback_finished`, `standby_resumed`, and `stage_error`.
### `AudioPreprocessor`
```python
class AudioPreprocessor:
def load(self) -> None: ...
def reset(self) -> None: ...
def process_frame(self, frame: AudioFrame) -> AudioFrame: ...
def flush(self) -> list[AudioFrame]: ...
```
`NoopAudioPreprocessor` preserves tests and disabled configurations. `SherpaOnnxDenoiserPreprocessor` uses `sherpa_onnx.OnlineSpeechDenoiser` with an `OfflineSpeechDenoiserGtcrnModelConfig` pointing to `models/denoise/gtcrn_simple.onnx`. It converts int16 PCM to float32, calls `run(samples, sample_rate)`, converts returned `DenoisedAudio.samples` back to int16 PCM, and adds diagnostic metadata without storing audio.
The first version applies preprocessing only in capture. Wake listening remains raw by default because wake KWS models can be sensitive to spectral changes introduced by denoising. `OWNER_WAKE_DENOISE_ENABLED=1` reserves an explicit future switch for wake preprocessing.
### Local CTC STT
`SherpaOnnxSttProvider` reads `providers.stt.type` from `models/manifest.json`:
1. `sherpa-onnx-streaming-transducer`: existing `tokens`/`encoder`/`decoder`/`joiner` files and `OnlineRecognizer.from_transducer`.
2. `sherpa-onnx-streaming-zipformer2-ctc`: new default `tokens`/`model` files and `OnlineRecognizer.from_zipformer2_ctc`.
Realtime partial and final transcript use the same local recognizer when `OWNER_SPEECH_PROVIDER=local`. Partial output is filtered before emitting `transcript_partial`: texts with fewer than two meaningful characters, duplicate texts, and short regressions from the last displayed text are ignored. The final transcript remains the only user text appended to `ConversationContext`.
### `TurnController`
```python
@@ -136,7 +162,11 @@ models/
vad/
silero_vad.onnx
stt/
sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23/
sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30/
tokens.txt
model.int8.onnx
denoise/
gtcrn_simple.onnx
```
## Error Handling
@@ -152,6 +182,10 @@ models/
9. Playback drain misconfiguration: negative `OWNER_POST_PLAYBACK_DRAIN_MS` remains invalid; non-zero values are treated as explicit user tuning rather than default behavior.
10. Speaker profile threshold misconfiguration: non-positive `OWNER_SPEAKER_PROFILE_MIN_MS` fails config validation.
11. Realtime STT failure: startup model缺失按 `model-check` 暴露;capture 中 partial session 失败不得把 partial 文本写入上下文。
12. Denoiser missing model: `model-check` reports `denoise/gtcrn_simple.onnx` and live startup fails before microphone listening.
13. Denoiser runtime failure: the current turn emits `stage_error` and recovers to standby without invoking final STT or LLM.
14. CTC manifest mismatch: missing `model` or `tokens` files report `STT_MODEL_MISSING`; transducer manifests remain supported for existing local setups.
15. Partial text noise: single-character or transient partial output is ignored rather than displayed as terminal feedback.
## Testing Strategy
@@ -168,6 +202,11 @@ models/
11. Transport batching test validates that queued SoundDevice frames are returned together.
12. Partial transcript event test validates that realtime text appears after speech start and before final transcript.
13. Context isolation test validates partial transcript does not enter LLM messages.
14. Denoiser manifest/model-check test validates the required GTCRN file is present.
15. Fake denoiser test validates VAD, partial STT, and final STT receive denoised frames from the same processed stream.
16. Local speech provider test validates `OWNER_SPEECH_PROVIDER=local` does not construct cloud ASR/TTS providers.
17. Partial filter test validates single-character and short transient results are not emitted.
18. CTC STT loading test validates manifest type selects `from_zipformer2_ctc` and does not require transducer encoder/decoder/joiner files.
## Migration
@@ -179,3 +218,11 @@ python3.11 scripts/download_speech_models.py --dir models
```
Existing `.env` remains valid because new wake keys have defaults.
For the local voice-chain revision, users should also ensure:
```dotenv
OWNER_SPEECH_PROVIDER=local
OWNER_NOISE_FILTER_ENABLED=1
OWNER_WAKE_DENOISE_ENABLED=0
OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn
```
@@ -52,6 +52,10 @@
10. 实时消费问题:`SoundDeviceAudioTransport.read_frames()` 每次只返回一个队列帧,在音频回调批量积压时会增加 pipeline 对真实麦克风流的追帧成本。
11. 画像门槛问题:`PrimarySpeakerVadRecorder._profile_ready()` 把主说话人画像就绪阈值绑定到 `OWNER_VAD_MIN_DURATION_MS`,默认至少等待 250 ms 后主说话人端点才参与结束判断,短句用户会被迫等普通 VAD 静音或重复说话。
12. 实时转写问题:当前终端只在整段录音结束并完成 final STT 后显示“转写结果”,用户说话期间看不到任何文字反馈,无法判断系统是否已经听到并识别当前句子。
13. 实时字幕质量问题:真人日志中 `实时转写:家``实时转写:家确` 来自旧本地 14M streaming STT partial,最终云端 ASR 虽然较准,但用户看到的实时反馈会被单字噪声和短暂跳变污染。
14. 语音链路一致性问题:当前 `OWNER_SPEECH_PROVIDER=cloud` 时 final STT/TTS 走云端,而 partial 走本地模型;同一轮对话里 partial 和 final 来自不同模型,容易出现“实时字幕和最终转写明显冲突”的体验。
15. 降噪缺失问题:正式问题录音直接把原始麦克风帧送入 VAD、partial STT 和 final STT,背景噪声会同时影响端点、实时字幕和最终识别。
16. 本地模型落后问题:默认 STT 仍是 2023 年 14M 小模型,适合最小验收但不适合作为默认实时字幕质量基线;应升级为 sherpa-onnx 官方 2025 中文 CTC int8 模型。
## 详细需求
@@ -78,6 +82,15 @@
19. 录音期间 SHALL 支持 partial transcript 事件;当本地 streaming STT 产生新的中间文本时,终端 SHALL 立即显示 `实时转写:<文本>`
20. partial transcript SHALL 只作为用户可见反馈,不得直接写入对话上下文;LLM 输入仍以最终 `transcript_final` 文本为准。
21.`OWNER_SPEECH_PROVIDER=cloud` 时,partial transcript SHALL 使用本地 streaming STT,避免对云端 ASR 进行高频请求。
22. 下一版默认语音链路 SHALL 改为“除 LLM 外全本地”:wake、VAD、STT、partial transcript、TTS、噪音过滤均在本机执行;LLM 仍走配置中的云端 OpenAI-compatible endpoint。
23. `.env.example` 和代码默认 SHALL 将 `OWNER_SPEECH_PROVIDER` 设为 `local`,从而默认 final STT 使用本地 sherpa-onnx 模型,TTS 使用 macOS 本地 `say/afplay` provider。
24. 模型 manifest SHALL 默认使用 sherpa-onnx 官方 2025 中文 Zipformer2 CTC int8 模型,关键文件为 `tokens.txt``model.int8.onnx`,不再以旧 14M transducer 作为默认 STT 模型。
25. 模型 manifest SHALL 新增 GTCRN/sherpa-onnx speech denoiser 模型 `denoise/gtcrn_simple.onnx``download_speech_models.py``model-check` 必须把它列为 required file。
26. Pipeline SHALL 新增 `AudioPreprocessStage`。默认 `OWNER_NOISE_FILTER_ENABLED=1` 时,唤醒后的正式问题采集必须先对音频帧降噪,再把同一份降噪后帧送入 VAD、实时 partial STT 和 final STT segment。
27. wake 阶段默认 SHALL 继续使用原始音频帧,避免 denoiser 改变 KWS 特征;仅当用户显式设置 `OWNER_WAKE_DENOISE_ENABLED=1` 时才允许对 wake 帧预处理。
28. 降噪 provider 失败 SHALL 作为结构化可恢复错误进入 `stage_error -> recovering -> standby`,不得把未经标记的半处理音频写入对话上下文。
29. partial transcript SHALL 增加稳定过滤:不得显示单个中文/英文有效字符,不得重复显示同一文本,不得把极短的瞬态跳变作为终端实时字幕输出。
30. final transcript SHALL 是唯一进入 LLM 的用户文本;降噪帧、partial 文本、denoiser metadata 和 ASR raw metadata 均不得进入 `ConversationContext`
### 非功能需求
@@ -88,6 +101,9 @@
5. 测试性:自动化测试必须能注入 fake wake provider,不依赖真实麦克风或真实 KWS 模型。
6. 架构可观测性:所有用户可见状态必须来自 pipeline event bus,终端 reporter 和后续 GUI 只消费事件,不直接嵌入 stage 逻辑。
7. 端点性能:默认配置下,主说话人音色消失后 300 ms 左右应结束采集,并进入 STT;最大录音时长仍作为兜底。
8. 实时字幕质量:本地 partial 默认不显示 1 个有效字符以内的文本,避免 `家``嗯``啊` 这类背景噪声触发可见字幕;最终字幕仍由 final STT 决定。
9. 语音隐私:除 LLM 请求文本外,正式问题原始音频、降噪音频、VAD 特征和临时音色画像均不得上传云端、不得落盘。
10. 降噪性能:GTCRN online denoiser 只能在 capture 阶段运行,目标是不明显拖慢端点;如果 denoiser 不可用,启动/模型检查必须明确失败,而不是静默回退为无降噪。
### 边缘案例
@@ -100,6 +116,11 @@
7. 播放回声误触发:播放期间仍遵循既有音频反馈抑制要求,不能把 TTS 当作新 wake。
8. 背景噪声拖尾:用户停止说话后若仍有非主说话人或噪声,主说话人端点必须允许结束录音。
9. 音色画像不足:如果开头音频太短或能量不足,采集阶段必须回退到普通 VAD 静音端点,不能卡死。
10. 降噪模型缺失:`model-check` 必须报告 `denoise/gtcrn_simple.onnx` 缺失;`run-live` 启动时不得进入“看似可用但未降噪”的状态。
11. 降噪运行时异常:如果 GTCRN provider 在某帧处理失败,当前 turn 必须结构化报错并恢复待机;不得把可能损坏的片段送入 STT 或 LLM。
12. partial 单字误识别:本地 streaming STT 输出 `家``确``a` 等单字时,终端不得显示为 `实时转写`
13. partial 瞬态跳变:本地 streaming STT 从“你是谁”短暂跳到“加”再回到“你是谁”时,不得显示中间极短跳变。
14. CTC 模型缺旧 transducer 文件:当 manifest 类型为 `sherpa-onnx-streaming-zipformer2-ctc` 时,不应再要求 encoder/decoder/joiner 文件存在。
### 输入输出规格
@@ -120,6 +141,10 @@
13. `OWNER_POST_PLAYBACK_DRAIN_MS=0`:ACK 或 TTS 播放完成后只 flush 已积压输入,不额外读取并丢弃新音频。
14. `OWNER_SPEAKER_PROFILE_MIN_MS=120`:主说话人画像参与端点判断的最低有效语音长度。
15. `OWNER_REALTIME_TRANSCRIPT_ENABLED=1`:启用录音期间本地 streaming STT 中间结果显示。
16. `OWNER_SPEECH_PROVIDER=local`:默认本地 STT/TTS,云端只保留 LLM。
17. `OWNER_NOISE_FILTER_ENABLED=1`:正式问题阶段默认启用本地降噪。
18. `OWNER_WAKE_DENOISE_ENABLED=0`wake 阶段默认不启用降噪。
19. `OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn`:第一版本地降噪 provider。
终端输出:
@@ -147,12 +172,15 @@ SoundDeviceAudioTransport
-> LocalWakeWordProvider(sherpa-onnx KeywordSpotter, models/wake, keywords.txt)
-> wake_hit
-> AcknowledgeStage("我在")
-> CaptureStage(primary speaker endpoint, user utterance only)
-> SttProvider(cloud or local, configured by OWNER_SPEECH_PROVIDER)
-> CaptureStage(raw user utterance only)
-> AudioPreprocessStage(sherpa-onnx GTCRN denoise, capture only)
-> PrimarySpeakerEndpoint + VAD(denoised frames)
-> RealtimeSttProvider(local CTC, denoised frames, stable partial filter)
-> SttProvider(local CTC by default, denoised final segment)
-> PipelineEvent(transcript_final)
-> ConversationContext(temporary process history)
-> Cloud LLM
-> TTS
-> Local TTS(MacSayTtsProvider)
-> Speaker
-> standby
```
@@ -168,6 +196,9 @@ SoundDeviceAudioTransport
7. Runtime 将 `user_text` 追加到临时上下文并调用 LLM。
8. TTS 播放后追加 assistant 历史并恢复待机。
9. 所有 stage 同步发出 pipeline events;终端 reporter 只把事件映射为中文文案。
10. 正式问题 capture 收到原始麦克风帧后,先调用 `AudioPreprocessor.process_frame(frame)` 得到降噪帧。
11. VAD、主说话人端点、partial streaming STT 和 final STT segment builder 必须使用同一份降噪帧,保证用户看到的 realtime partial 和最终转写来自一致音频来源。
12. wake listening 默认使用原始帧;后续仅在 `OWNER_WAKE_DENOISE_ENABLED=1` 时把同一预处理接口接到 wake 阶段。
### 接口定义
@@ -192,6 +223,28 @@ TurnController.run_turn(turn_id: int) -> TurnResult
VoiceAssistantPipeline.run(once: bool = False, max_turns: int | None = None) -> RuntimeSummary
```
```text
AudioPreprocessor.load() -> None
AudioPreprocessor.reset() -> None
AudioPreprocessor.process_frame(frame: AudioFrame) -> AudioFrame
AudioPreprocessor.flush() -> list[AudioFrame]
```
```text
SherpaOnnxDenoiserPreprocessor(
models_dir: Path,
provider: "sherpa_onnx_gtcrn",
enabled: bool,
sherpa_module: object | None = None,
)
```
```text
SherpaOnnxSttProvider.load()
manifest type "sherpa-onnx-streaming-transducer" -> OnlineRecognizer.from_transducer(...)
manifest type "sherpa-onnx-streaming-zipformer2-ctc" -> OnlineRecognizer.from_zipformer2_ctc(tokens, model, ...)
```
```text
SherpaOnnxKeywordWakeWordProvider(
models_dir: Path,
@@ -228,6 +281,9 @@ standby
7. 批量读帧:真实 SoundDevice 输入在拿到首帧后立即 drain 当前队列中所有可用帧并返回给 pipeline,使 wake、capture 和 VAD 能在同一个循环内处理积压帧。
8. 快速主说话人结束:画像就绪最低语音长度由 `OWNER_SPEAKER_PROFILE_MIN_MS` 控制,默认 120 ms;一旦画像就绪,主说话人缺席计时达到 `OWNER_SPEAKER_ABSENT_MS` 即结束,不再叠加 `OWNER_VAD_MIN_DURATION_MS`
9. 实时转写显示:CaptureStage 在 `speech_started` 后把已录入的帧同时送入本地 streaming STT session;每当 partial 文本变化时发出 `transcript_partial`,终端显示 `实时转写:<文本>`;最终段落仍交给 configured STT provider 生成 `transcript_final`
10. 捕获阶段降噪:`AudioPreprocessStage` 将 int16 PCM 转为 float32,调用 `sherpa_onnx.OnlineSpeechDenoiser.run(samples, sample_rate)`,把返回的 `DenoisedAudio.samples` 转回 int16 PCM,并在 metadata 中加入 `denoised=True``noise_filter_provider=sherpa_onnx_gtcrn`
11. CTC STT 加载:manifest `stt.type=sherpa-onnx-streaming-zipformer2-ctc` 时,只校验 `tokens``model`,调用 `OnlineRecognizer.from_zipformer2_ctc`;旧 manifest 仍兼容 transducer 路径。
12. partial 稳定过滤:实时字幕会先去除空白和标点,计算有效字符数;有效字符数小于 2 的文本直接忽略;若新文本比上一次已显示文本短且不构成稳定前缀推进,也忽略,避免把瞬时噪声显示给用户。
### 数据库/状态管理变更
@@ -246,6 +302,9 @@ standby
2. 新增 KWS 模型约 15 MB,下载到 ignored `models/wake/`
3. 不新增 Python 运行依赖。
4. `model-check` 变严格:缺 wake 模型会失败。
5. 默认 STT 模型升级为 2025 中文 CTC int8,模型体积和加载时间高于旧 14M 小模型,但换来更稳定的本地 partial/final 一致性。
6. 新增 GTCRN denoiser 单文件模型,下载到 ignored `models/denoise/gtcrn_simple.onnx`
7. 继续使用已安装的 `sherpa-onnx` 包;不新增 Python 依赖,不接入云 ASR/TTS 作为默认路径。
## 风险与权衡
@@ -253,7 +312,7 @@ standby
| --- | --- | --- | --- |
| KWS 关键词拼音格式不匹配模型 | 中 | 高 | 默认写入 sherpa 示例格式;保留 keywords 文件可编辑;测试下载后用真实模型加载;README 标明关键词文件位置 |
| KWS 模型误唤醒或漏唤醒 | 中 | 中 | 暴露 threshold/score 配置;保留状态输出;后续可替换 KWS Provider |
| 唤醒应答期间用户抢说被缓冲清理吞掉 | 中 | 高 | 终端提示顺序改为“唤醒命中 -> 应答中 -> 请说出问题 -> 录音中”,用户只在应答完成后收到提问提示;默认播放后排水从 250 ms 降为 50 ms |
| 唤醒应答期间用户抢说被缓冲清理吞掉 | 中 | 高 | 终端提示顺序改为“唤醒命中 -> 应答中 -> 请说出问题 -> 录音中”,用户只在应答完成后收到提问提示;默认 `OWNER_POST_PLAYBACK_DRAIN_MS=0`,不再额外读取并丢弃播放后的新音频 |
| 本地 VAD 对真实麦克风音量过保守 | 中 | 高 | 默认 VAD provider 改为 `hybrid`,本地模型判断和能量阈值兜底任一命中即认为有语音;保留 `local``energy` 可配置回退 |
| 能量兜底让录音无法及时结束 | 高 | 高 | 将能量兜底限制为“开始录音辅助”,结束录音优先使用主说话人音色消失和本地 VAD 静音 |
| ACK 后额外丢弃音频截断首句 | 高 | 高 | 默认 `OWNER_POST_PLAYBACK_DRAIN_MS=0`;播放结束后只 flush 已积压输入;新增首句保留回归测试 |
@@ -268,6 +327,11 @@ standby
| 终端转写不是逐字流式 | 中 | 低 | 第一版至少在 LLM 前即时显示最终 STT 文本;后续可接入本地 streaming STT partial |
| 模型下载网络失败 | 中 | 中 | 下载脚本保留重试;`model-check` 给明确缺失文件 |
| 误提交模型或 key | 低 | 高 | `.gitignore`、security-check、提交前 `git status --short` |
| 降噪模型 API 与当前 sherpa-onnx 版本不一致 | 中 | 高 | 用本机 `sherpa_onnx 1.13.3` introspection 确认 `OnlineSpeechDenoiser` 构造;新增 fake module 单测;model-check 尝试加载 provider |
| GTCRN 对 wake 特征造成误伤 | 中 | 中 | wake 默认不走降噪,仅正式问题阶段降噪;保留 `OWNER_WAKE_DENOISE_ENABLED=1` 作为显式实验开关 |
| CTC 模型比旧 14M 模型更大导致下载慢 | 中 | 中 | 仍放 `models/` 并跳过已存在文件;README 明确首次下载较久;下载脚本保留重试 |
| partial 过滤过严导致实时字幕少显示 | 中 | 低 | 过滤只影响用户可见 partial,不影响 final STT 和 LLM;后续可配置更细阈值 |
| 降噪失败后用户无法继续本轮 | 低 | 中 | 当前 turn 结构化失败并恢复待机,避免错误音频进入 LLM;下一轮可继续唤醒 |
## 任务分解
@@ -322,6 +386,17 @@ standby
- [ ] 7.5 更新 README、`.env.example`、本地 `.env` 非密钥配置;前置条件:7.2 至 7.4 完成;验收标准:运行说明匹配新 pipeline;测试要点:`--show-config` 不泄露 key;优先级:P0;预计:30 分钟。
- [ ] 7.6 验证并提交“Pipeline 文档验收”模块;前置条件:7.1 至 7.5 完成;验收标准:compileall、unittest、security-check、model-check、device-check、OpenSpec strict 全通过;优先级:P0;预计:30 分钟。
### 10. 本地语音链路、高质量实时转写与噪音过滤
- [ ] 10.1 更新 OpenSpec 描述本地语音链路和降噪阶段;前置条件:真人日志确认旧本地 14M partial 会产生 `家/家确` 等噪声误识别;验收标准:proposal/design/spec/tasks 明确 wake/VAD/STT/partial/TTS/denoise 全本地、LLM 云端、正式问题阶段默认降噪;测试要点:OpenSpec strict;优先级:P0;预计:45 分钟。
- [ ] 10.2 升级 speech model manifest 和下载脚本;前置条件:确认 sherpa-onnx 官方 2025 中文 CTC int8 模型和 GTCRN denoiser URL;验收标准:`models/manifest.json` 默认 STT 指向 CTC `model.int8.onnx`,新增 `denoise/gtcrn_simple.onnx` required file;测试要点:manifest、required files、下载脚本单测/真实下载;优先级:P0;预计:60 分钟。
- [ ] 10.3 实现 `AudioPreprocessStage`;前置条件:10.2 完成;验收标准:新增可注入的 no-op 和 sherpa-onnx GTCRN online denoiser provider,正式问题帧在进入 VAD、partial STT、final STT 前共用同一份降噪后音频;测试要点:fake denoiser 验证 capture/STT 收到降噪后帧;优先级:P0;预计:60 分钟。
- [ ] 10.4 扩展本地 STT Provider 支持 CTC 模型和 partial 稳定过滤;前置条件:10.2 完成;验收标准:`SherpaOnnxSttProvider` 可按 manifest 加载 transducer 或 zipformer2 CTCpartial 不显示单字噪声和短暂跳变;测试要点:fake sherpa CTC、single-char partial filter、transient partial filter;优先级:P0;预计:60 分钟。
- [ ] 10.5 修改 runtime 默认本地语音链路;前置条件:10.3 至 10.4 完成;验收标准:默认 `OWNER_SPEECH_PROVIDER=local`TTS 使用 `MacSayTtsProvider`,云端只用于 LLM`--show-config` 显示噪音过滤配置且不泄露 key;测试要点:build runtime provider 类型、cloud ASR/TTS 不被调用;优先级:P0;预计:45 分钟。
- [ ] 10.6 更新 README、`.env.example` 和本地 `.env` 非密钥配置;前置条件:10.5 完成;验收标准:中文运行说明包含本地语音默认值、降噪开关、模型下载、model-check、run-live 验收;测试要点:命令可复制,`.env` 不进入提交;优先级:P0;预计:35 分钟。
- [ ] 10.7 下载/校验新本地模型并执行门禁;前置条件:10.2 至 10.6 完成;验收标准:下载脚本、compileall、unittest、security-check、model-check、device-check、OpenSpec strict、git diff check 通过或记录真实设备失败原因;测试要点:输出不泄露 key,模型文件不进 Git;优先级:P0;预计:60 分钟。
- [ ] 10.8 提交“本地语音降噪”模块;前置条件:10.7 门禁通过;验收标准:中文 commit 信息为 `[本地语音降噪]:完成本地语音链路和噪音过滤,包含降噪模型、本地ASR和实时字幕稳定策略`,提交后除本地 `.env` 非提交修改外无未提交源码/文档中间状态;优先级:P0;预计:10 分钟。
## Spec Deltas
### 新增能力
@@ -340,6 +415,7 @@ standby
8. `Primary speaker endpointing`:新增本轮临时主说话人音色消失结束录音要求。
9. `Low latency capture and first utterance preservation`:新增 ACK 后不额外丢弃正式问题、批量读帧、独立画像就绪阈值和快速主说话人端点要求。
10. `Realtime partial transcript output`:新增录音期间 partial transcript 事件、终端显示和上下文隔离要求。
11. `Local voice chain and noise filtering`:新增本地 STT/TTS 默认、GTCRN 降噪、CTC 模型、partial 稳定过滤和音频不上传要求。
### 删除项
@@ -359,6 +435,7 @@ standby
6. M6Stage 化 pipeline、事件总线、TurnController、主说话人端点和文档验收分模块提交。
7. M7:低延迟端点与首句保留修正完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
8. M8:录音期间实时转写显示完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
9. M9:本地语音链路、高质量实时字幕和正式问题降噪完成后提交,继续保留真人 `run-live` 验收任务,不在用户确认前归档。
估时:
@@ -64,6 +64,47 @@ The live runtime SHALL display recognized user utterance text in the terminal bo
- **WHEN** STT returns empty text, punctuation-only text, or an invalid transcript
- **THEN** the runtime SHALL NOT emit a misleading transcript as valid user input and SHALL recover to standby without invoking the LLM
### Requirement: Local voice chain and noise filtering
The live runtime SHALL run wake, VAD, STT, realtime transcript, TTS, and capture-stage noise filtering locally by default, while continuing to send only the final user text and session history to the configured cloud LLM.
#### Scenario: Default speech provider is local
- **WHEN** the user starts `run-live` without overriding `OWNER_SPEECH_PROVIDER`
- **THEN** the runtime SHALL use local sherpa-onnx STT and local macOS TTS
- **AND** it SHALL NOT call cloud ASR or cloud TTS providers during the turn
#### Scenario: Capture audio is denoised before downstream stages
- **WHEN** `OWNER_NOISE_FILTER_ENABLED=1` and the user speaks after wake acknowledgement
- **THEN** the capture stage SHALL pass microphone frames through the configured local denoiser before VAD analysis, realtime partial STT, and final STT segment assembly
- **AND** all three downstream consumers SHALL observe the same denoised frame content for a given captured frame
#### Scenario: Wake remains raw by default
- **WHEN** `OWNER_WAKE_DENOISE_ENABLED` is unset or `0`
- **THEN** wake word detection SHALL receive raw microphone frames rather than denoised frames
#### Scenario: Denoiser model is missing
- **WHEN** `models/denoise/gtcrn_simple.onnx` is absent
- **THEN** `model-check` SHALL fail with a structured missing model error
- **AND** `run-live` SHALL NOT enter live microphone listening as if noise filtering were available
#### Scenario: Denoiser runtime fails
- **WHEN** the local denoiser raises an error while processing a formal user utterance frame
- **THEN** the current turn SHALL emit a stage error and recover to standby
- **AND** the runtime SHALL NOT invoke final STT, LLM, or TTS for that failed turn
#### Scenario: Local ASR uses the 2025 CTC model by default
- **WHEN** the default manifest is written by `download_speech_models.py`
- **THEN** the STT provider SHALL be declared as `sherpa-onnx-streaming-zipformer2-ctc`
- **AND** required STT files SHALL be `tokens.txt` and `model.int8.onnx`, not transducer encoder/decoder/joiner files
#### Scenario: Realtime partial filters noise
- **WHEN** local streaming STT emits a single meaningful character or a short transient regression
- **THEN** the terminal reporter SHALL NOT display that text as `实时转写`
- **AND** final STT SHALL remain responsible for the text sent to the LLM
#### Scenario: Audio privacy is preserved
- **WHEN** local noise filtering and local STT run during capture
- **THEN** raw PCM, denoised PCM, VAD features, partial transcript metadata, and temporary speaker profile data SHALL remain process-local and SHALL NOT be sent to the cloud LLM
### Requirement: Wake acknowledgement before recording
The live runtime SHALL provide an audible local acknowledgement after local wake detection and before it starts recording the user's formal question.
@@ -120,14 +161,14 @@ The system SHALL provide project-local speech model preparation and diagnostics
#### Scenario: Models are downloaded
- **WHEN** the user runs `python3.11 scripts/download_speech_models.py --dir models`
- **THEN** the script SHALL create or update a project-local model directory with the files required by the configured wake, VAD, and STT providers
- **THEN** the script SHALL create or update a project-local model directory with the files required by the configured wake, VAD, STT, and denoiser providers
#### Scenario: Model check succeeds
- **WHEN** required wake, VAD, STT dependencies and model files are available
- **WHEN** required wake, VAD, STT, denoiser dependencies and model files are available
- **THEN** `PYTHONPATH=src python3.11 -m owner_voice_pet model-check` SHALL exit successfully and report the model directory and checked providers
#### Scenario: Model check fails
- **WHEN** `sherpa-onnx` is unavailable, a wake model file is missing, a VAD/STT model file is missing, or a model cannot be loaded
- **WHEN** `sherpa-onnx` is unavailable, a wake model file is missing, a VAD/STT/denoiser model file is missing, or a model cannot be loaded
- **THEN** `model-check` SHALL fail with a structured model error and SHALL NOT start live microphone listening
### Requirement: Live repeat voice runtime
@@ -70,3 +70,14 @@
- [x] 9.3 实现本地 streaming STT partial provider;前置条件:本地 STT 模型已由 `model-check` 覆盖;验收标准:`SherpaOnnxSttProvider` 支持 streaming sessioncloud final ASR 模式下仍使用本地 streaming STT 做 partial;测试要点:fake session 与 metadata partial 单测;优先级:P0;预计:60 分钟。
- [x] 9.4 接入 `VoiceAssistantPipeline` 捕获循环;前置条件:9.2 至 9.3 完成;验收标准:用户说话期间持续 feed 已开始录音的帧,partial 在 `speech_started` 后、`transcript_final` 前输出;测试要点:live runtime partial 顺序测试;优先级:P0;预计:45 分钟。
- [x] 9.5 补充配置、README、门禁并提交;前置条件:9.1 至 9.4 完成;验收标准:新增 `OWNER_REALTIME_TRANSCRIPT_ENABLED=1`compileall、unittest、security-check、model-check、device-check、OpenSpec strict 通过后中文 commit;优先级:P0;预计:45 分钟。
## 10. 本地语音链路、高质量实时转写与噪音过滤
- [x] 10.1 更新 OpenSpec 描述本地语音链路和降噪阶段;前置条件:真人日志确认旧本地 14M partial 会产生 `家/家确` 等噪声误识别;验收标准:proposal/design/spec/tasks 明确 wake/VAD/STT/partial/TTS/denoise 全本地、LLM 云端、正式问题阶段默认降噪;测试要点:OpenSpec strict;优先级:P0;预计:45 分钟。
- [x] 10.2 升级 speech model manifest 和下载脚本;前置条件:确认 sherpa-onnx 官方 2025 中文 CTC int8 模型和 GTCRN denoiser URL;验收标准:`models/manifest.json` 默认 STT 指向 CTC `model.int8.onnx`,新增 `denoise/gtcrn_simple.onnx` required file;测试要点:manifest、required files、下载脚本单测/真实下载;优先级:P0;预计:60 分钟。
- [x] 10.3 实现 `AudioPreprocessStage`;前置条件:10.2 完成;验收标准:新增可注入的 no-op 和 sherpa-onnx GTCRN online denoiser provider,正式问题帧在进入 VAD、partial STT、final STT 前共用同一份降噪后音频;测试要点:fake denoiser 验证 capture/STT 收到降噪后帧;优先级:P0;预计:60 分钟。
- [x] 10.4 扩展本地 STT Provider 支持 CTC 模型和 partial 稳定过滤;前置条件:10.2 完成;验收标准:`SherpaOnnxSttProvider` 可按 manifest 加载 transducer 或 zipformer2 CTCpartial 不显示单字噪声和短暂跳变;测试要点:fake sherpa CTC、single-char partial filter、transient partial filter;优先级:P0;预计:60 分钟。
- [x] 10.5 修改 runtime 默认本地语音链路;前置条件:10.3 至 10.4 完成;验收标准:默认 `OWNER_SPEECH_PROVIDER=local`TTS 使用 `MacSayTtsProvider`,云端只用于 LLM`--show-config` 显示噪音过滤配置且不泄露 key;测试要点:build runtime provider 类型、cloud ASR/TTS 不被调用;优先级:P0;预计:45 分钟。
- [x] 10.6 更新 README、`.env.example` 和本地 `.env` 非密钥配置;前置条件:10.5 完成;验收标准:中文运行说明包含本地语音默认值、降噪开关、模型下载、model-check、run-live 验收;测试要点:命令可复制,`.env` 不进入提交;优先级:P0;预计:35 分钟。
- [x] 10.7 下载/校验新本地模型并执行门禁;前置条件:10.2 至 10.6 完成;验收标准:下载脚本、compileall、unittest、security-check、model-check、device-check、OpenSpec strict、git diff check 通过或记录真实设备失败原因;测试要点:输出不泄露 key,模型文件不进 Git;优先级:P0;预计:60 分钟。
- [x] 10.8 提交“本地语音降噪”模块;前置条件:10.7 门禁通过;验收标准:中文 commit 信息为 `[本地语音降噪]:完成本地语音链路和噪音过滤,包含降噪模型、本地ASR和实时字幕稳定策略`,提交后除本地 `.env` 非提交修改外无未提交源码/文档中间状态;优先级:P0;预计:10 分钟。
+13
View File
@@ -16,6 +16,8 @@ if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from owner_voice_pet.speech_models import (
DEFAULT_DENOISER_PATH,
DEFAULT_DENOISER_URL,
DEFAULT_KWS_DIR,
DEFAULT_KWS_KEYWORDS,
DEFAULT_KWS_URL,
@@ -38,6 +40,7 @@ def main(argv: list[str] | None = None) -> int:
download_kws(target, force=args.force)
download_vad(target, force=args.force)
download_stt(target, force=args.force)
download_denoiser(target, force=args.force)
manifest = write_default_manifest(target)
status = check_speech_models(target, require_sherpa=False)
print(f"models_dir={target}")
@@ -112,6 +115,16 @@ def download_stt(models_dir: Path, *, force: bool = False) -> Path:
return output_dir
def download_denoiser(models_dir: Path, *, force: bool = False) -> Path:
output = models_dir / DEFAULT_DENOISER_PATH
if output.exists() and not force:
print(f"skip existing {output}")
return output
output.parent.mkdir(parents=True, exist_ok=True)
download_file(DEFAULT_DENOISER_URL, output)
return output
def download_file(url: str, output: Path) -> None:
tmp_output = output.with_suffix(output.suffix + ".part")
print(f"download {url}")
+3
View File
@@ -2,6 +2,7 @@
from .config import AppConfig
from .assistant_pipeline import TurnController, VoiceAssistantPipeline
from .audio_preprocess import NoopAudioPreprocessor, SherpaOnnxDenoiserPreprocessor
from .events import PipelineEvent, PipelineEventBus
from .models import (
AudioFrame,
@@ -33,6 +34,8 @@ __all__ = [
"AppConfig",
"TurnController",
"VoiceAssistantPipeline",
"NoopAudioPreprocessor",
"SherpaOnnxDenoiserPreprocessor",
"PipelineEvent",
"PipelineEventBus",
"AudioFrame",
+13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from .audio_preprocess import NoopAudioPreprocessor
from .config import AppConfig
from .conversation import ConversationContext
from .events import (
@@ -28,6 +29,7 @@ from .events import (
)
from .models import AudioFrame, AudioSegment, ErrorCode, PipelineState, ProviderError
from .protocols import (
AudioPreprocessor,
AudioTransport,
LlmProvider,
RealtimeSttProvider,
@@ -77,6 +79,7 @@ class TurnController:
transport: AudioTransport,
wakeword: WakeWordProvider,
vad_recorder: VadRecorder,
audio_preprocessor: AudioPreprocessor,
stt: SttProvider,
realtime_stt: RealtimeSttProvider | None,
llm: LlmProvider,
@@ -90,6 +93,7 @@ class TurnController:
self.transport = transport
self.wakeword = wakeword
self.vad_recorder = vad_recorder
self.audio_preprocessor = audio_preprocessor
self.stt = stt
self.realtime_stt = realtime_stt
self.llm = llm
@@ -157,6 +161,7 @@ class TurnController:
def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError:
self.vad_recorder.reset()
self.vad_recorder.provider.reset()
self.audio_preprocessor.reset()
realtime_session = self._start_realtime_transcript()
self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id)
while True:
@@ -164,6 +169,10 @@ class TurnController:
if not frames:
continue
for frame in frames:
try:
frame = self.audio_preprocessor.process_frame(frame)
except ProviderError as exc:
return exc
was_started = self.vad_recorder.started
result = self.vad_recorder.feed(frame)
if not was_started and self.vad_recorder.started:
@@ -310,6 +319,7 @@ class VoiceAssistantPipeline:
llm: LlmProvider,
tts: TtsProvider,
context: ConversationContext,
audio_preprocessor: AudioPreprocessor | None = None,
realtime_stt: RealtimeSttProvider | None = None,
ack_tts: TtsProvider | None = None,
reporter: RuntimeReporter | None = None,
@@ -320,6 +330,7 @@ class VoiceAssistantPipeline:
self.transport = transport
self.wakeword = wakeword
self.vad_recorder = vad_recorder
self.audio_preprocessor = audio_preprocessor or NoopAudioPreprocessor()
self.stt = stt
self.realtime_stt = realtime_stt
self.llm = llm
@@ -336,6 +347,7 @@ class VoiceAssistantPipeline:
transport=transport,
wakeword=wakeword,
vad_recorder=vad_recorder,
audio_preprocessor=self.audio_preprocessor,
stt=stt,
realtime_stt=realtime_stt,
llm=llm,
@@ -349,6 +361,7 @@ class VoiceAssistantPipeline:
def load(self) -> None:
self.wakeword.load()
self.vad_recorder.provider.load()
self.audio_preprocessor.load()
self.stt.load()
if self.realtime_stt is not None and self.realtime_stt is not self.stt:
self.realtime_stt.load()
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .models import AudioFrame, ErrorCode, ProviderError
from .speech_models import denoiser_model_path
class NoopAudioPreprocessor:
def load(self) -> None:
return None
def reset(self) -> None:
return None
def process_frame(self, frame: AudioFrame) -> AudioFrame:
return frame
def flush(self) -> list[AudioFrame]:
return []
class SherpaOnnxDenoiserPreprocessor:
def __init__(
self,
models_dir: str | Path,
*,
sherpa_module: Any | None = None,
) -> None:
self.models_dir = Path(models_dir)
self._sherpa = sherpa_module
self._denoiser: Any | None = None
self.loaded = False
def load(self) -> None:
model = denoiser_model_path(self.models_dir)
if not model.exists():
raise ProviderError(
ErrorCode.NOISE_FILTER_MODEL_MISSING,
f"sherpa-onnx denoiser model is missing: {model}",
False,
"sherpa-onnx-gtcrn",
"audio-preprocess",
)
sherpa_onnx = self._sherpa
if sherpa_onnx is None:
try:
import sherpa_onnx # type: ignore[import-not-found]
except Exception as exc:
raise ProviderError(
ErrorCode.NOISE_FILTER_FAILED,
f"sherpa_onnx is not available: {exc}",
False,
"sherpa-onnx-gtcrn",
"audio-preprocess",
) from exc
try:
gtcrn = sherpa_onnx.OfflineSpeechDenoiserGtcrnModelConfig(model=str(model))
model_config = sherpa_onnx.OfflineSpeechDenoiserModelConfig(
gtcrn=gtcrn,
num_threads=1,
provider="cpu",
)
config = sherpa_onnx.OnlineSpeechDenoiserConfig(model=model_config)
self._denoiser = sherpa_onnx.OnlineSpeechDenoiser(config)
except Exception as exc:
raise ProviderError(
ErrorCode.NOISE_FILTER_FAILED,
f"failed to load sherpa-onnx denoiser: {exc}",
False,
"sherpa-onnx-gtcrn",
"audio-preprocess",
) from exc
self.loaded = True
def reset(self) -> None:
if self._denoiser is not None and hasattr(self._denoiser, "reset"):
self._denoiser.reset()
def process_frame(self, frame: AudioFrame) -> AudioFrame:
if not self.loaded or self._denoiser is None:
raise ProviderError(
ErrorCode.NOISE_FILTER_FAILED,
"sherpa-onnx denoiser is not loaded",
False,
"sherpa-onnx-gtcrn",
"audio-preprocess",
)
try:
import numpy as np
samples = np.frombuffer(frame.pcm, dtype=np.int16).astype(np.float32) / 32768.0
if frame.channels > 1 and samples.size:
samples = samples.reshape(-1, frame.channels).mean(axis=1)
denoised = self._denoiser.run(samples, frame.sample_rate)
output_samples = np.asarray(getattr(denoised, "samples"), dtype=np.float32)
output_sample_rate = int(getattr(denoised, "sample_rate", frame.sample_rate))
clipped = np.clip(output_samples, -1.0, 1.0)
pcm = (clipped * 32767.0).astype(np.int16).tobytes()
except ProviderError:
raise
except Exception as exc:
raise ProviderError(
ErrorCode.NOISE_FILTER_FAILED,
f"sherpa-onnx denoiser failed: {exc}",
True,
"sherpa-onnx-gtcrn",
"audio-preprocess",
) from exc
metadata = dict(frame.metadata)
metadata["denoised"] = True
metadata["noise_filter_provider"] = "sherpa_onnx_gtcrn"
return AudioFrame(
pcm=pcm,
sample_rate=output_sample_rate,
channels=1,
timestamp_ms=frame.timestamp_ms,
frame_id=frame.frame_id,
metadata=metadata,
)
def flush(self) -> list[AudioFrame]:
if self._denoiser is None or not hasattr(self._denoiser, "flush"):
return []
try:
import numpy as np
denoised = self._denoiser.flush()
samples = np.asarray(getattr(denoised, "samples"), dtype=np.float32)
if samples.size == 0:
return []
sample_rate = int(getattr(denoised, "sample_rate", 16000))
pcm = (np.clip(samples, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
except Exception as exc:
raise ProviderError(
ErrorCode.NOISE_FILTER_FAILED,
f"sherpa-onnx denoiser flush failed: {exc}",
True,
"sherpa-onnx-gtcrn",
"audio-preprocess",
) from exc
return [
AudioFrame(
pcm=pcm,
sample_rate=sample_rate,
channels=1,
timestamp_ms=0,
frame_id=0,
metadata={
"denoised": True,
"noise_filter_provider": "sherpa_onnx_gtcrn",
"flush": True,
},
)
]
+8
View File
@@ -6,6 +6,7 @@ import re
import subprocess
from pathlib import Path
from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
from .assets import validate_pet_assets
from .config import AppConfig
from .conversation import ConversationContext
@@ -62,6 +63,9 @@ def main(argv: list[str] | None = None) -> int:
"post_playback_drain_ms": config.post_playback_drain_ms,
"pipeline_mode": config.pipeline_mode,
"endpoint_mode": config.endpoint_mode,
"noise_filter_enabled": config.noise_filter_enabled,
"noise_filter_provider": config.noise_filter_provider,
"wake_denoise_enabled": config.wake_denoise_enabled,
"speaker_profile_ms": config.speaker_profile_ms,
"speaker_profile_min_ms": config.speaker_profile_min_ms,
"speaker_absent_ms": config.speaker_absent_ms,
@@ -113,6 +117,7 @@ def main(argv: list[str] | None = None) -> int:
).load()
SherpaOnnxVadProvider(models_dir).load()
SherpaOnnxSttProvider(str(models_dir)).load()
SherpaOnnxDenoiserPreprocessor(models_dir).load()
provider_load_checked = True
except ProviderError as exc:
errors.append(exc)
@@ -166,6 +171,9 @@ def main(argv: list[str] | None = None) -> int:
post_playback_drain_ms=config.post_playback_drain_ms,
pipeline_mode=config.pipeline_mode,
endpoint_mode=config.endpoint_mode,
noise_filter_enabled=config.noise_filter_enabled,
noise_filter_provider=config.noise_filter_provider,
wake_denoise_enabled=config.wake_denoise_enabled,
speaker_profile_ms=config.speaker_profile_ms,
speaker_profile_min_ms=config.speaker_profile_min_ms,
speaker_absent_ms=config.speaker_absent_ms,
+18 -2
View File
@@ -29,6 +29,9 @@ class AppConfig:
post_playback_drain_ms: int = 0
pipeline_mode: str = "live_turn_based"
endpoint_mode: str = "primary_speaker"
noise_filter_enabled: bool = True
noise_filter_provider: str = "sherpa_onnx_gtcrn"
wake_denoise_enabled: bool = False
speaker_profile_ms: int = 600
speaker_profile_min_ms: int = 120
speaker_absent_ms: int = 300
@@ -40,7 +43,7 @@ class AppConfig:
vad_end_silence_ms: int = 350
vad_no_speech_timeout_ms: int = 5000
vad_max_recording_ms: int = 12000
speech_provider: str = "cloud"
speech_provider: str = "local"
asr_model: str = "mimo-v2.5-asr"
tts_model: str = "mimo-v2.5-tts"
tts_voice: str = "mimo_default"
@@ -80,6 +83,9 @@ class AppConfig:
post_playback_drain_ms=int(get("POST_PLAYBACK_DRAIN_MS", "0") or "0"),
pipeline_mode=(get("PIPELINE_MODE", "live_turn_based") or "live_turn_based").lower(),
endpoint_mode=(get("ENDPOINT_MODE", "primary_speaker") or "primary_speaker").lower(),
noise_filter_enabled=(get("NOISE_FILTER_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
noise_filter_provider=(get("NOISE_FILTER_PROVIDER", "sherpa_onnx_gtcrn") or "sherpa_onnx_gtcrn").lower(),
wake_denoise_enabled=(get("WAKE_DENOISE_ENABLED", "0") or "0").lower() in {"1", "true", "yes"},
speaker_profile_ms=int(get("SPEAKER_PROFILE_MS", "600") or "600"),
speaker_profile_min_ms=int(get("SPEAKER_PROFILE_MIN_MS", "120") or "120"),
speaker_absent_ms=int(get("SPEAKER_ABSENT_MS", "300") or "300"),
@@ -93,7 +99,7 @@ class AppConfig:
vad_end_silence_ms=int(get("VAD_END_SILENCE_MS", "350") or "350"),
vad_no_speech_timeout_ms=int(get("VAD_NO_SPEECH_TIMEOUT_MS", "5000") or "5000"),
vad_max_recording_ms=int(get("VAD_MAX_RECORDING_MS", "12000") or "12000"),
speech_provider=(get("SPEECH_PROVIDER", "cloud") or "cloud").lower(),
speech_provider=(get("SPEECH_PROVIDER", "local") or "local").lower(),
asr_model=get("ASR_MODEL", "mimo-v2.5-asr") or "mimo-v2.5-asr",
tts_model=get("TTS_MODEL", "mimo-v2.5-tts") or "mimo-v2.5-tts",
tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
@@ -189,6 +195,16 @@ class AppConfig:
"startup",
)
)
if self.noise_filter_provider not in {"sherpa_onnx_gtcrn"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_NOISE_FILTER_PROVIDER must be sherpa_onnx_gtcrn",
False,
"config",
"startup",
)
)
if self.post_playback_drain_ms < 0:
errors.append(
ProviderError(
+2
View File
@@ -41,6 +41,8 @@ class ErrorCode(str, Enum):
TTS_MODEL_MISSING = "TTS_MODEL_MISSING"
TTS_SYNTHESIS_FAILED = "TTS_SYNTHESIS_FAILED"
TTS_EMPTY_AUDIO = "TTS_EMPTY_AUDIO"
NOISE_FILTER_MODEL_MISSING = "NOISE_FILTER_MODEL_MISSING"
NOISE_FILTER_FAILED = "NOISE_FILTER_FAILED"
ASSET_MISSING = "ASSET_MISSING"
VALIDATION_FAILED = "VALIDATION_FAILED"
+14
View File
@@ -49,6 +49,20 @@ class WakeWordProvider(Protocol):
...
class AudioPreprocessor(Protocol):
def load(self) -> None:
...
def reset(self) -> None:
...
def process_frame(self, frame: AudioFrame) -> AudioFrame:
...
def flush(self) -> list[AudioFrame]:
...
class VadProvider(Protocol):
def load(self) -> None:
...
+7
View File
@@ -4,6 +4,7 @@ import sys
from dataclasses import dataclass, field
from typing import Protocol
from .audio_preprocess import NoopAudioPreprocessor, SherpaOnnxDenoiserPreprocessor
from .config import AppConfig
from .assistant_pipeline import VoiceAssistantPipeline
from .conversation import ConversationContext
@@ -369,6 +370,11 @@ def build_live_runtime(config: AppConfig, reporter: RuntimeReporter | None = Non
"min_rms": config.speaker_min_rms,
}
)
audio_preprocessor = (
SherpaOnnxDenoiserPreprocessor(config.speech_models_dir)
if config.noise_filter_enabled
else NoopAudioPreprocessor()
)
return VoiceAssistantPipeline(
config=config,
transport=SoundDeviceAudioTransport(output_device=config.audio_output_device),
@@ -380,6 +386,7 @@ def build_live_runtime(config: AppConfig, reporter: RuntimeReporter | None = Non
score=config.wake_kws_score,
),
vad_recorder=recorder_cls(**recorder_kwargs),
audio_preprocessor=audio_preprocessor,
stt=stt,
realtime_stt=realtime_stt,
llm=OpenAICompatibleLlmProvider(config),
+48 -12
View File
@@ -11,15 +11,20 @@ from .models import ErrorCode, ProviderError
DEFAULT_VAD_URL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"
DEFAULT_STT_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2"
"sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30.tar.bz2"
)
DEFAULT_STT_DIR = "sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
DEFAULT_STT_DIR = "sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30"
DEFAULT_KWS_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/"
"sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile.tar.bz2"
)
DEFAULT_KWS_DIR = "sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile"
DEFAULT_KWS_KEYWORDS = "x iǎo j ié x iǎo j ié @小杰小杰\n"
DEFAULT_DENOISER_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speech-enhancement-models/gtcrn_simple.onnx"
)
DEFAULT_DENOISER_PATH = "denoise/gtcrn_simple.onnx"
REQUIRED_MODEL_FILES = (
f"wake/{DEFAULT_KWS_DIR}/tokens.txt",
@@ -29,9 +34,8 @@ REQUIRED_MODEL_FILES = (
"wake/keywords.txt",
"vad/silero_vad.onnx",
f"stt/{DEFAULT_STT_DIR}/tokens.txt",
f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx",
f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx",
f"stt/{DEFAULT_STT_DIR}/model.int8.onnx",
DEFAULT_DENOISER_PATH,
)
@@ -68,6 +72,7 @@ def default_manifest() -> dict[str, Any]:
"wake": DEFAULT_KWS_URL,
"vad": DEFAULT_VAD_URL,
"stt": DEFAULT_STT_URL,
"denoiser": DEFAULT_DENOISER_URL,
},
"providers": {
"wake": {
@@ -84,12 +89,14 @@ def default_manifest() -> dict[str, Any]:
"path": "vad/silero_vad.onnx",
},
"stt": {
"type": "sherpa-onnx-streaming-transducer",
"type": "sherpa-onnx-streaming-zipformer2-ctc",
"model_dir": f"stt/{DEFAULT_STT_DIR}",
"tokens": f"stt/{DEFAULT_STT_DIR}/tokens.txt",
"encoder": f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
"decoder": f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx",
"joiner": f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx",
"model": f"stt/{DEFAULT_STT_DIR}/model.int8.onnx",
},
"denoiser": {
"type": "sherpa-onnx-gtcrn",
"path": DEFAULT_DENOISER_PATH,
},
},
"required_files": list(REQUIRED_MODEL_FILES),
@@ -127,6 +134,13 @@ def vad_model_path(models_dir: str | Path) -> Path:
return root / str(path)
def denoiser_model_path(models_dir: str | Path) -> Path:
root = Path(models_dir)
manifest = load_manifest(root)
path = manifest.get("providers", {}).get("denoiser", {}).get("path", DEFAULT_DENOISER_PATH)
return root / str(path)
def wake_model_paths(models_dir: str | Path) -> dict[str, Path]:
root = Path(models_dir)
manifest = load_manifest(root)
@@ -144,12 +158,21 @@ def wake_model_paths(models_dir: str | Path) -> dict[str, Path]:
}
def stt_model_paths(model_path: str | Path) -> dict[str, Path]:
def stt_model_paths(model_path: str | Path) -> dict[str, Any]:
root = Path(model_path)
if (root / "manifest.json").exists() or (root / "stt").exists():
manifest = load_manifest(root)
stt = manifest.get("providers", {}).get("stt", {})
stt_type = str(stt.get("type", "sherpa-onnx-streaming-transducer"))
if stt_type == "sherpa-onnx-streaming-zipformer2-ctc":
return {
"type": stt_type,
"model_dir": root / str(stt.get("model_dir", f"stt/{DEFAULT_STT_DIR}")),
"tokens": root / str(stt.get("tokens", f"stt/{DEFAULT_STT_DIR}/tokens.txt")),
"model": root / str(stt.get("model", f"stt/{DEFAULT_STT_DIR}/model.int8.onnx")),
}
return {
"type": stt_type,
"model_dir": root / str(stt.get("model_dir", f"stt/{DEFAULT_STT_DIR}")),
"tokens": root / str(stt.get("tokens", f"stt/{DEFAULT_STT_DIR}/tokens.txt")),
"encoder": root / str(stt.get("encoder", f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx")),
@@ -157,6 +180,7 @@ def stt_model_paths(model_path: str | Path) -> dict[str, Path]:
"joiner": root / str(stt.get("joiner", f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx")),
}
return {
"type": "sherpa-onnx-streaming-transducer",
"model_dir": root,
"tokens": root / "tokens.txt",
"encoder": root / "encoder-epoch-99-avg-1.int8.onnx",
@@ -195,11 +219,23 @@ def model_status_errors(status: SpeechModelStatus) -> list[ProviderError]:
"model-check",
)
)
if status.missing_files:
denoise_missing = tuple(item for item in status.missing_files if item.startswith("denoise/"))
speech_missing = tuple(item for item in status.missing_files if not item.startswith("denoise/"))
if speech_missing:
errors.append(
ProviderError(
ErrorCode.STT_MODEL_MISSING,
"missing speech model files: " + ", ".join(status.missing_files),
"missing speech model files: " + ", ".join(speech_missing),
False,
"speech-models",
"model-check",
)
)
if denoise_missing:
errors.append(
ProviderError(
ErrorCode.NOISE_FILTER_MODEL_MISSING,
"missing denoiser model files: " + ", ".join(denoise_missing),
False,
"speech-models",
"model-check",
+46 -13
View File
@@ -17,12 +17,28 @@ from .models import AudioFrame, AudioSegment, ErrorCode, ProviderError, Transcri
from .speech_models import stt_model_paths
_MEANINGFUL_TEXT = re.compile(r"[\w\u4e00-\u9fff]", re.UNICODE)
_PARTIAL_MIN_MEANINGFUL_CHARS = 3
def is_valid_transcript_text(text: str) -> bool:
return bool(_MEANINGFUL_TEXT.search(text.strip()))
def _meaningful_text_length(text: str) -> int:
return len(_MEANINGFUL_TEXT.findall(text.strip()))
def should_emit_partial_transcript(text: str, last_text: str) -> bool:
normalized = text.strip()
if not is_valid_transcript_text(normalized) or _meaningful_text_length(normalized) < _PARTIAL_MIN_MEANINGFUL_CHARS:
return False
if normalized == last_text:
return False
if not last_text:
return True
return normalized.startswith(last_text)
class MetadataSttProvider:
def __init__(self, language: str = "zh") -> None:
self.language = language
@@ -73,7 +89,7 @@ class MetadataRealtimeTranscriptSession:
or frame.metadata.get("transcript")
or ""
).strip()
if not is_valid_transcript_text(text) or text == self._last_text:
if not should_emit_partial_transcript(text, self._last_text):
return None
self._last_text = text
return Transcript(
@@ -197,7 +213,11 @@ class SherpaOnnxSttProvider:
"stt",
)
paths = stt_model_paths(self.model_path)
missing = [name for name, path in paths.items() if name != "model_dir" and not path.exists()]
missing = [
name
for name, path in paths.items()
if name not in {"type", "model_dir"} and isinstance(path, Path) and not path.exists()
]
if missing:
raise ProviderError(
ErrorCode.STT_MODEL_MISSING,
@@ -219,16 +239,29 @@ class SherpaOnnxSttProvider:
"stt",
) from exc
try:
self._recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(
tokens=str(paths["tokens"]),
encoder=str(paths["encoder"]),
decoder=str(paths["decoder"]),
joiner=str(paths["joiner"]),
num_threads=1,
decoding_method="greedy_search",
enable_endpoint_detection=True,
provider="cpu",
)
model_type = str(paths.get("type", "sherpa-onnx-streaming-transducer"))
if model_type == "sherpa-onnx-streaming-zipformer2-ctc":
self._recognizer = sherpa_onnx.OnlineRecognizer.from_zipformer2_ctc(
tokens=str(paths["tokens"]),
model=str(paths["model"]),
num_threads=1,
sample_rate=16000,
feature_dim=80,
enable_endpoint_detection=True,
decoding_method="greedy_search",
provider="cpu",
)
else:
self._recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(
tokens=str(paths["tokens"]),
encoder=str(paths["encoder"]),
decoder=str(paths["decoder"]),
joiner=str(paths["joiner"]),
num_threads=1,
decoding_method="greedy_search",
enable_endpoint_detection=True,
provider="cpu",
)
except Exception as exc:
raise ProviderError(
ErrorCode.STT_TRANSCRIBE_FAILED,
@@ -331,7 +364,7 @@ class SherpaOnnxRealtimeTranscriptSession:
"sherpa-onnx-stt",
"stt",
) from exc
if not is_valid_transcript_text(text) or text == self._last_text:
if not should_emit_partial_transcript(text, self._last_text):
return None
self._last_text = text
return Transcript(
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from owner_voice_pet.audio_preprocess import SherpaOnnxDenoiserPreprocessor
from owner_voice_pet.models import AudioFrame, ErrorCode, ProviderError
from owner_voice_pet.speech_models import DEFAULT_DENOISER_PATH
class FakeDenoisedAudio:
def __init__(self, samples, sample_rate: int) -> None:
self.samples = samples
self.sample_rate = sample_rate
class FakeSherpa:
class OfflineSpeechDenoiserGtcrnModelConfig:
def __init__(self, model: str) -> None:
self.model = model
class OfflineSpeechDenoiserModelConfig:
def __init__(self, gtcrn, num_threads: int, provider: str) -> None:
self.gtcrn = gtcrn
self.num_threads = num_threads
self.provider = provider
class OnlineSpeechDenoiserConfig:
def __init__(self, model) -> None:
self.model = model
class OnlineSpeechDenoiser:
def __init__(self, config) -> None:
self.config = config
self.reset_called = False
def run(self, samples, sample_rate: int):
import numpy as np
return FakeDenoisedAudio(np.zeros_like(samples, dtype=np.float32), sample_rate)
def reset(self) -> None:
self.reset_called = True
class FailingSherpa(FakeSherpa):
class OnlineSpeechDenoiser(FakeSherpa.OnlineSpeechDenoiser):
def run(self, samples, sample_rate: int):
raise RuntimeError("denoise failed")
class AudioPreprocessTests(unittest.TestCase):
def test_denoiser_missing_model_is_structured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
provider = SherpaOnnxDenoiserPreprocessor(tmp, sherpa_module=FakeSherpa)
with self.assertRaises(ProviderError) as raised:
provider.load()
self.assertEqual(raised.exception.code, ErrorCode.NOISE_FILTER_MODEL_MISSING)
def test_denoiser_processes_frame_and_marks_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
model = Path(tmp) / DEFAULT_DENOISER_PATH
model.parent.mkdir(parents=True)
model.write_bytes(b"fake")
provider = SherpaOnnxDenoiserPreprocessor(tmp, sherpa_module=FakeSherpa)
provider.load()
frame = AudioFrame(
b"\xff\x7f",
16000,
1,
100,
7,
{"duration_ms": 20, "speech": True},
)
processed = provider.process_frame(frame)
self.assertNotEqual(processed.pcm, frame.pcm)
self.assertTrue(processed.metadata["denoised"])
self.assertEqual(processed.metadata["noise_filter_provider"], "sherpa_onnx_gtcrn")
self.assertEqual(processed.timestamp_ms, 100)
self.assertEqual(processed.frame_id, 7)
def test_denoiser_runtime_failure_is_structured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
model = Path(tmp) / DEFAULT_DENOISER_PATH
model.parent.mkdir(parents=True)
model.write_bytes(b"fake")
provider = SherpaOnnxDenoiserPreprocessor(tmp, sherpa_module=FailingSherpa)
provider.load()
with self.assertRaises(ProviderError) as raised:
provider.process_frame(AudioFrame(b"\xff\x7f", 16000, 1, 0, 0))
self.assertEqual(raised.exception.code, ErrorCode.NOISE_FILTER_FAILED)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -65,10 +65,12 @@ class CliAcceptanceTests(unittest.TestCase):
patch("owner_voice_pet.cli.SherpaOnnxKeywordWakeWordProvider") as wake_cls,
patch("owner_voice_pet.cli.SherpaOnnxVadProvider") as vad_cls,
patch("owner_voice_pet.cli.SherpaOnnxSttProvider") as stt_cls,
patch("owner_voice_pet.cli.SherpaOnnxDenoiserPreprocessor") as denoiser_cls,
):
wake_cls.return_value.load.return_value = None
vad_cls.return_value.load.return_value = None
stt_cls.return_value.load.return_value = None
denoiser_cls.return_value.load.return_value = None
code, data = self.call("model-check", "--models-dir", str(root))
self.assertEqual(code, 0)
self.assertTrue(data["ok"])
+54 -2
View File
@@ -24,7 +24,7 @@ from owner_voice_pet.llm import MockLlmProvider
from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript
from owner_voice_pet.assistant_pipeline import VoiceAssistantPipeline
from owner_voice_pet.runtime import build_live_runtime
from owner_voice_pet.stt import MetadataSttProvider
from owner_voice_pet.stt import MetadataSttProvider, SherpaOnnxSttProvider
from owner_voice_pet.transport import MemoryAudioTransport
from owner_voice_pet.tts import SineTtsProvider
from owner_voice_pet.vad import EnergyVadProvider, PrimarySpeakerVadRecorder, VadRecorder
@@ -97,10 +97,43 @@ class RecordingReporter:
self.errors.append(f"{stage}:{code}:{message}")
class MarkerAudioPreprocessor:
def __init__(self, partial_text: str = "降噪后问题") -> None:
self.partial_text = partial_text
self.loaded = False
self.reset_calls = 0
self.frames: list[AudioFrame] = []
def load(self) -> None:
self.loaded = True
def reset(self) -> None:
self.reset_calls += 1
def process_frame(self, frame: AudioFrame) -> AudioFrame:
metadata = dict(frame.metadata)
metadata["denoised"] = True
metadata["partial_transcript"] = self.partial_text
processed = AudioFrame(
b"\x01\x00",
frame.sample_rate,
frame.channels,
frame.timestamp_ms,
frame.frame_id,
metadata,
)
self.frames.append(processed)
return processed
def flush(self) -> list[AudioFrame]:
return []
def make_runtime(
texts: list[str],
context: ConversationContext | None = None,
partial_texts: list[list[str]] | None = None,
audio_preprocessor: MarkerAudioPreprocessor | None = None,
) -> tuple[VoiceAssistantPipeline, QueueSttProvider, MockLlmProvider, MemoryAudioTransport, RecordingReporter]:
frames = []
for idx, _text in enumerate(texts):
@@ -121,6 +154,7 @@ def make_runtime(
wakeword=KeywordWakeWordProvider(),
vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40),
stt=stt,
audio_preprocessor=audio_preprocessor,
realtime_stt=MetadataSttProvider() if partial_texts is not None else None,
llm=llm,
tts=tts,
@@ -200,7 +234,7 @@ class LiveRuntimeTests(unittest.TestCase):
runtime, _, llm, _, reporter = make_runtime(["第一问"], partial_texts=[["第一", "第一问"]])
runtime.run(max_turns=1)
self.assertEqual(reporter.partials, ["第一", "第一"])
self.assertEqual(reporter.partials, ["第一问"])
self.assertEqual(reporter.transcripts, ["第一问"])
self.assertEqual(llm.calls[0][-1].content, "第一问")
event_types = [event.type for event in runtime.event_bus.events]
@@ -208,6 +242,22 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(SPEECH_ENDED))
self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(TRANSCRIPT_FINAL))
def test_capture_uses_denoised_frames_for_partial_and_final_stt(self) -> None:
preprocessor = MarkerAudioPreprocessor(partial_text="降噪后问题")
runtime, stt, _, _, reporter = make_runtime(
["第一问"],
partial_texts=[["原始噪声", "原始噪声"]],
audio_preprocessor=preprocessor,
)
runtime.run(max_turns=1)
self.assertTrue(preprocessor.loaded)
self.assertGreaterEqual(preprocessor.reset_calls, 1)
self.assertEqual(reporter.partials, ["降噪后问题"])
self.assertEqual(len(stt.calls), 1)
self.assertTrue(stt.calls[0].metadata["denoised"])
self.assertIn(b"\x01\x00", stt.calls[0].pcm)
def test_wake_keyword_does_not_pollute_llm_user_message(self) -> None:
runtime, _, llm, _, _ = make_runtime(["第一问"])
runtime.run(max_turns=1)
@@ -218,6 +268,8 @@ class LiveRuntimeTests(unittest.TestCase):
def test_live_runtime_uses_primary_speaker_endpoint_by_default(self) -> None:
runtime = build_live_runtime(AppConfig(llm_api_key="secret"))
self.assertIsInstance(runtime.vad_recorder, PrimarySpeakerVadRecorder)
self.assertEqual(runtime.config.speech_provider, "local")
self.assertIsInstance(runtime.stt, SherpaOnnxSttProvider)
self.assertIsNotNone(runtime.realtime_stt)
+41 -1
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from owner_voice_pet.config import AppConfig
from owner_voice_pet.models import (
@@ -12,6 +14,12 @@ from owner_voice_pet.models import (
PipelineState,
ProviderError,
)
from owner_voice_pet.speech_models import (
DEFAULT_DENOISER_PATH,
default_manifest,
denoiser_model_path,
stt_model_paths,
)
class ModelsConfigTests(unittest.TestCase):
@@ -77,6 +85,9 @@ class ModelsConfigTests(unittest.TestCase):
self.assertEqual(config.post_playback_drain_ms, 0)
self.assertEqual(config.pipeline_mode, "live_turn_based")
self.assertEqual(config.endpoint_mode, "primary_speaker")
self.assertTrue(config.noise_filter_enabled)
self.assertEqual(config.noise_filter_provider, "sherpa_onnx_gtcrn")
self.assertFalse(config.wake_denoise_enabled)
self.assertEqual(config.speaker_profile_ms, 600)
self.assertEqual(config.speaker_profile_min_ms, 120)
self.assertEqual(config.speaker_absent_ms, 300)
@@ -88,7 +99,7 @@ class ModelsConfigTests(unittest.TestCase):
self.assertEqual(config.vad_end_silence_ms, 350)
self.assertEqual(config.vad_no_speech_timeout_ms, 5000)
self.assertEqual(config.vad_max_recording_ms, 12000)
self.assertEqual(config.speech_provider, "cloud")
self.assertEqual(config.speech_provider, "local")
self.assertEqual(config.asr_model, "mimo-v2.5-asr")
self.assertEqual(config.tts_model, "mimo-v2.5-tts")
self.assertEqual(config.tts_voice, "mimo_default")
@@ -117,6 +128,11 @@ class ModelsConfigTests(unittest.TestCase):
errors = config.validate_basic()
self.assertTrue(any("OWNER_ENDPOINT_MODE" in error.message for error in errors))
def test_noise_filter_provider_must_be_gtcrn(self) -> None:
config = AppConfig(noise_filter_provider="invalid")
errors = config.validate_basic()
self.assertTrue(any("OWNER_NOISE_FILTER_PROVIDER" in error.message for error in errors))
def test_speaker_similarity_threshold_range_is_validated(self) -> None:
config = AppConfig(speaker_similarity_threshold=1.5)
errors = config.validate_basic()
@@ -153,6 +169,30 @@ class ModelsConfigTests(unittest.TestCase):
message = Message(role="user", content="你好", created_at=1.0)
self.assertEqual(message.role, "user")
def test_default_manifest_uses_ctc_stt_and_denoiser(self) -> None:
manifest = default_manifest()
stt = manifest["providers"]["stt"]
denoiser = manifest["providers"]["denoiser"]
self.assertEqual(stt["type"], "sherpa-onnx-streaming-zipformer2-ctc")
self.assertTrue(stt["model"].endswith("model.int8.onnx"))
self.assertEqual(denoiser["path"], DEFAULT_DENOISER_PATH)
self.assertIn(DEFAULT_DENOISER_PATH, manifest["required_files"])
def test_model_path_helpers_support_ctc_manifest_and_denoiser(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest_path = root / "manifest.json"
manifest_path.write_text(
json.dumps(default_manifest(), ensure_ascii=False),
encoding="utf-8",
)
paths = stt_model_paths(root)
self.assertEqual(paths["type"], "sherpa-onnx-streaming-zipformer2-ctc")
self.assertTrue(str(paths["model"]).endswith("model.int8.onnx"))
self.assertTrue(str(denoiser_model_path(root)).endswith(DEFAULT_DENOISER_PATH))
if __name__ == "__main__":
unittest.main()
+68 -7
View File
@@ -7,7 +7,13 @@ from pathlib import Path
from owner_voice_pet.models import AudioFrame, AudioSegment, ErrorCode, ProviderError
from owner_voice_pet.config import AppConfig
from owner_voice_pet.stt import CloudAsrSttProvider, MetadataSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text
from owner_voice_pet.stt import (
CloudAsrSttProvider,
MetadataSttProvider,
SherpaOnnxSttProvider,
is_valid_transcript_text,
should_emit_partial_transcript,
)
from owner_voice_pet.speech_models import stt_model_paths
from owner_voice_pet.vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, VadRecorder
from owner_voice_pet.wakeword import (
@@ -241,6 +247,13 @@ class WakeVadSttTests(unittest.TestCase):
self.assertTrue(is_valid_transcript_text("hello"))
self.assertFalse(is_valid_transcript_text("?! 。"))
def test_partial_transcript_filter_rejects_short_noise(self) -> None:
self.assertFalse(should_emit_partial_transcript("", ""))
self.assertFalse(should_emit_partial_transcript("家确", ""))
self.assertTrue(should_emit_partial_transcript("你是谁", ""))
self.assertFalse(should_emit_partial_transcript("", "你是谁"))
self.assertTrue(should_emit_partial_transcript("你是谁呀", "你是谁"))
def test_sherpa_stt_missing_model_is_structured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
provider = SherpaOnnxSttProvider(f"{tmp}/missing")
@@ -260,7 +273,7 @@ class WakeVadSttTests(unittest.TestCase):
def accept_waveform(self, sample_rate, samples) -> None:
self.ready = True
self.text = "" if not self.text else "你好"
self.text = "" if not self.text else "你好"
class FakeRecognizer:
def create_stream(self):
@@ -286,7 +299,7 @@ class WakeVadSttTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
paths = stt_model_paths(Path(tmp))
for name, path in paths.items():
if name == "model_dir":
if name in {"type", "model_dir"}:
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
@@ -296,11 +309,59 @@ class WakeVadSttTests(unittest.TestCase):
first = session.accept_frame(make_frame(1, 0, speech=True))
second = session.accept_frame(make_frame(2, 20, speech=True))
self.assertIsNotNone(first)
self.assertIsNone(first)
self.assertIsNotNone(second)
assert first is not None and second is not None
self.assertEqual(first.text, "")
self.assertEqual(second.text, "你好")
assert second is not None
self.assertEqual(second.text, "好吗")
def test_sherpa_stt_loads_ctc_manifest(self) -> None:
class FakeRecognizer:
def create_stream(self):
return object()
class FakeOnlineRecognizer:
ctc_kwargs = None
@staticmethod
def from_zipformer2_ctc(**kwargs):
FakeOnlineRecognizer.ctc_kwargs = kwargs
return FakeRecognizer()
class FakeSherpa:
OnlineRecognizer = FakeOnlineRecognizer
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
stt_dir = root / "stt" / "ctc"
stt_dir.mkdir(parents=True)
(stt_dir / "tokens.txt").write_text("你 1\n", encoding="utf-8")
(stt_dir / "model.int8.onnx").write_bytes(b"fake")
(root / "manifest.json").write_text(
json.dumps(
{
"providers": {
"stt": {
"type": "sherpa-onnx-streaming-zipformer2-ctc",
"model_dir": "stt/ctc",
"tokens": "stt/ctc/tokens.txt",
"model": "stt/ctc/model.int8.onnx",
}
},
"required_files": [
"stt/ctc/tokens.txt",
"stt/ctc/model.int8.onnx",
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
provider = SherpaOnnxSttProvider(tmp, sherpa_module=FakeSherpa)
provider.load()
self.assertIsNotNone(FakeOnlineRecognizer.ctc_kwargs)
assert FakeOnlineRecognizer.ctc_kwargs is not None
self.assertTrue(FakeOnlineRecognizer.ctc_kwargs["model"].endswith("model.int8.onnx"))
def test_cloud_asr_posts_audio_transcription_request(self) -> None:
class FakeResponse: