[本地语音降噪]:完成本地语音链路和噪音过滤,包含降噪模型、本地ASR和实时字幕稳定策略
This commit is contained in:
+4
-1
@@ -15,6 +15,9 @@ OWNER_WAKE_ACK_TEXT=我在
|
|||||||
OWNER_POST_PLAYBACK_DRAIN_MS=0
|
OWNER_POST_PLAYBACK_DRAIN_MS=0
|
||||||
OWNER_PIPELINE_MODE=live_turn_based
|
OWNER_PIPELINE_MODE=live_turn_based
|
||||||
OWNER_ENDPOINT_MODE=primary_speaker
|
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_MS=600
|
||||||
OWNER_SPEAKER_PROFILE_MIN_MS=120
|
OWNER_SPEAKER_PROFILE_MIN_MS=120
|
||||||
OWNER_SPEAKER_ABSENT_MS=300
|
OWNER_SPEAKER_ABSENT_MS=300
|
||||||
@@ -26,7 +29,7 @@ OWNER_VAD_MIN_DURATION_MS=250
|
|||||||
OWNER_VAD_END_SILENCE_MS=350
|
OWNER_VAD_END_SILENCE_MS=350
|
||||||
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
|
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
|
||||||
OWNER_VAD_MAX_RECORDING_MS=12000
|
OWNER_VAD_MAX_RECORDING_MS=12000
|
||||||
OWNER_SPEECH_PROVIDER=cloud
|
OWNER_SPEECH_PROVIDER=local
|
||||||
OWNER_ASR_MODEL=mimo-v2.5-asr
|
OWNER_ASR_MODEL=mimo-v2.5-asr
|
||||||
OWNER_TTS_MODEL=mimo-v2.5-tts
|
OWNER_TTS_MODEL=mimo-v2.5-tts
|
||||||
OWNER_TTS_VOICE=mimo_default
|
OWNER_TTS_VOICE=mimo_default
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
这是一个 Python 语音桌宠运行程序。当前第一版先提供无 GUI 的真实实时语音循环:
|
这是一个 Python 语音桌宠运行程序。当前第一版先提供无 GUI 的真实实时语音循环:
|
||||||
|
|
||||||
`小杰小杰` 唤醒 -> 本地应答“我在” -> 主说话人端点采集问题 -> ASR 转文字 -> 携带本次进程内临时历史调用 LLM -> TTS 生成语音 -> 本机扬声器播放 -> 回到待机继续监听。
|
`小杰小杰` 本地唤醒 -> 本地应答“我在” -> 本地降噪 -> 主说话人端点采集问题 -> 本地 STT/实时字幕 -> 携带本次进程内临时历史调用云端 LLM -> 本地 TTS -> 本机扬声器播放 -> 回到待机继续监听。
|
||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
|
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
|
||||||
- `.env` 直接读取配置,不要求导出 shell 环境变量。
|
- `.env` 直接读取配置,不要求导出 shell 环境变量。
|
||||||
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
|
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
|
||||||
- `OWNER_SPEECH_PROVIDER=cloud|local`:选择云端语音模型或本地语音模型。
|
- `OWNER_SPEECH_PROVIDER=local`:默认除 LLM 外全用本地语音链路;`cloud` 仅作为显式兼容选项。
|
||||||
- 默认云端语音模型:`mimo-v2.5-asr`、`mimo-v2.5-tts`。
|
- 本地语音默认:wake/VAD/STT/实时字幕/TTS/降噪全部在本机执行,LLM 继续走 `.env` 中的云端配置。
|
||||||
- 本地设备:`sounddevice` 读取麦克风,扬声器或 `afplay` 播放。
|
- 本地设备:`sounddevice` 读取麦克风,扬声器或 `afplay` 播放。
|
||||||
- 本地模型:`models/` 存放 `sherpa-onnx` wake/VAD/STT 模型,目录不提交 Git。
|
- 本地模型:`models/` 存放 `sherpa-onnx` wake/VAD/STT/denoiser 模型,目录不提交 Git。
|
||||||
- Pipeline:`run-live` 使用 stage 化 `VoiceAssistantPipeline`,通过事件总线输出终端状态。
|
- Pipeline:`run-live` 使用 stage 化 `VoiceAssistantPipeline`,通过事件总线输出终端状态。
|
||||||
- 主说话人端点:默认 `OWNER_ENDPOINT_MODE=primary_speaker`,本轮音色消失后结束录音,避免背景噪声拖慢 STT。
|
- 主说话人端点:默认 `OWNER_ENDPOINT_MODE=primary_speaker`,本轮音色消失后结束录音,避免背景噪声拖慢 STT。
|
||||||
- 临时上下文:同一次 `run-live` 进程内携带最近 user/assistant 历史,退出即清空。
|
- 临时上下文:同一次 `run-live` 进程内携带最近 user/assistant 历史,退出即清空。
|
||||||
@@ -43,6 +43,9 @@ OWNER_WAKE_ACK_TEXT=我在
|
|||||||
OWNER_POST_PLAYBACK_DRAIN_MS=0
|
OWNER_POST_PLAYBACK_DRAIN_MS=0
|
||||||
OWNER_PIPELINE_MODE=live_turn_based
|
OWNER_PIPELINE_MODE=live_turn_based
|
||||||
OWNER_ENDPOINT_MODE=primary_speaker
|
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_MS=600
|
||||||
OWNER_SPEAKER_PROFILE_MIN_MS=120
|
OWNER_SPEAKER_PROFILE_MIN_MS=120
|
||||||
OWNER_SPEAKER_ABSENT_MS=300
|
OWNER_SPEAKER_ABSENT_MS=300
|
||||||
@@ -54,20 +57,22 @@ OWNER_VAD_MIN_DURATION_MS=250
|
|||||||
OWNER_VAD_END_SILENCE_MS=350
|
OWNER_VAD_END_SILENCE_MS=350
|
||||||
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
|
OWNER_VAD_NO_SPEECH_TIMEOUT_MS=5000
|
||||||
OWNER_VAD_MAX_RECORDING_MS=12000
|
OWNER_VAD_MAX_RECORDING_MS=12000
|
||||||
OWNER_SPEECH_PROVIDER=cloud
|
OWNER_SPEECH_PROVIDER=local
|
||||||
OWNER_ASR_MODEL=mimo-v2.5-asr
|
OWNER_ASR_MODEL=mimo-v2.5-asr
|
||||||
OWNER_TTS_MODEL=mimo-v2.5-tts
|
OWNER_TTS_MODEL=mimo-v2.5-tts
|
||||||
OWNER_TTS_VOICE=mimo_default
|
OWNER_TTS_VOICE=mimo_default
|
||||||
OWNER_CONTEXT_MODE=session_memory
|
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
|
```bash
|
||||||
python3.11 scripts/download_speech_models.py --dir models
|
python3.11 scripts/download_speech_models.py --dir models
|
||||||
@@ -76,6 +81,8 @@ python3.11 scripts/download_speech_models.py --dir models
|
|||||||
|
|
||||||
`models/` 已在 `.gitignore` 中,不会提交大模型文件。
|
`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`。
|
本地唤醒关键词文件位于 `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` 毫秒后,直接结束录音进入转写。它不保存长期声纹、不做主人注册、不跨进程记忆。
|
默认 `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
|
.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。
|
- API key 只写入本地 `.env`,`.env` 不提交 Git。
|
||||||
- `models/`、`.venv/` 和临时音频文件不提交 Git。
|
- `models/`、`.venv/` 和临时音频文件不提交 Git。
|
||||||
- 默认不持久化麦克风原始音频。
|
- 默认不持久化麦克风原始音频。
|
||||||
- 云端 ASR 只接收 VAD 切分后的语音片段。
|
- 默认不使用云端 ASR/TTS;云端 LLM 只接收最终用户文本和本次运行内临时历史。
|
||||||
- `security-check` 会扫描已跟踪文本文件中的 `sk-...` 和 `tp-...` 形式密钥。
|
- `security-check` 会扫描已跟踪文本文件中的 `sk-...` 和 `tp-...` 形式密钥。
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ run-live
|
|||||||
-> SoundDeviceAudioTransport
|
-> SoundDeviceAudioTransport
|
||||||
-> SherpaOnnxKeywordWakeWordProvider(local models/wake)
|
-> SherpaOnnxKeywordWakeWordProvider(local models/wake)
|
||||||
-> AcknowledgeStage(local "我在")
|
-> AcknowledgeStage(local "我在")
|
||||||
-> CaptureStage(primary speaker endpoint by default)
|
-> CaptureStage(raw mic frames)
|
||||||
-> CloudAsrSttProvider or SherpaOnnxSttProvider
|
-> AudioPreprocessStage(local GTCRN denoise, capture only)
|
||||||
|
-> PrimarySpeakerEndpoint/VAD(denoised frames)
|
||||||
|
-> SherpaOnnxSttProvider(local CTC partial/final by default)
|
||||||
-> DialogStage(session memory)
|
-> DialogStage(session memory)
|
||||||
-> ConversationContext
|
-> ConversationContext
|
||||||
-> OpenAICompatibleLlmProvider
|
-> OpenAICompatibleLlmProvider
|
||||||
-> CloudTtsProvider or MacSayTtsProvider
|
-> MacSayTtsProvider
|
||||||
-> speaker playback
|
-> speaker playback
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,11 +53,12 @@ run-live
|
|||||||
7. On wake hit, reset wake stream and VAD recorder.
|
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.
|
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`.
|
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.
|
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. Emit transcript event to terminal.
|
11. Transcribe user utterance with the configured STT provider. The default is local CTC STT; cloud STT remains an explicit compatibility option.
|
||||||
12. Append user text and call LLM.
|
12. Emit transcript event to terminal.
|
||||||
13. Synthesize/play reply.
|
13. Append final user text and call LLM.
|
||||||
14. Append assistant reply and return to standby.
|
14. Synthesize/play reply with local TTS by default.
|
||||||
|
15. Append assistant reply and return to standby.
|
||||||
|
|
||||||
## Interfaces
|
## 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`.
|
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`
|
### `TurnController`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -136,7 +162,11 @@ models/
|
|||||||
vad/
|
vad/
|
||||||
silero_vad.onnx
|
silero_vad.onnx
|
||||||
stt/
|
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
|
## 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.
|
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.
|
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 文本写入上下文。
|
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
|
## Testing Strategy
|
||||||
|
|
||||||
@@ -168,6 +202,11 @@ models/
|
|||||||
11. Transport batching test validates that queued SoundDevice frames are returned together.
|
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.
|
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.
|
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
|
## Migration
|
||||||
|
|
||||||
@@ -179,3 +218,11 @@ python3.11 scripts/download_speech_models.py --dir models
|
|||||||
```
|
```
|
||||||
|
|
||||||
Existing `.env` remains valid because new wake keys have defaults.
|
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 对真实麦克风流的追帧成本。
|
10. 实时消费问题:`SoundDeviceAudioTransport.read_frames()` 每次只返回一个队列帧,在音频回调批量积压时会增加 pipeline 对真实麦克风流的追帧成本。
|
||||||
11. 画像门槛问题:`PrimarySpeakerVadRecorder._profile_ready()` 把主说话人画像就绪阈值绑定到 `OWNER_VAD_MIN_DURATION_MS`,默认至少等待 250 ms 后主说话人端点才参与结束判断,短句用户会被迫等普通 VAD 静音或重复说话。
|
11. 画像门槛问题:`PrimarySpeakerVadRecorder._profile_ready()` 把主说话人画像就绪阈值绑定到 `OWNER_VAD_MIN_DURATION_MS`,默认至少等待 250 ms 后主说话人端点才参与结束判断,短句用户会被迫等普通 VAD 静音或重复说话。
|
||||||
12. 实时转写问题:当前终端只在整段录音结束并完成 final STT 后显示“转写结果”,用户说话期间看不到任何文字反馈,无法判断系统是否已经听到并识别当前句子。
|
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 立即显示 `实时转写:<文本>`。
|
19. 录音期间 SHALL 支持 partial transcript 事件;当本地 streaming STT 产生新的中间文本时,终端 SHALL 立即显示 `实时转写:<文本>`。
|
||||||
20. partial transcript SHALL 只作为用户可见反馈,不得直接写入对话上下文;LLM 输入仍以最终 `transcript_final` 文本为准。
|
20. partial transcript SHALL 只作为用户可见反馈,不得直接写入对话上下文;LLM 输入仍以最终 `transcript_final` 文本为准。
|
||||||
21. 当 `OWNER_SPEECH_PROVIDER=cloud` 时,partial transcript SHALL 使用本地 streaming STT,避免对云端 ASR 进行高频请求。
|
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 模型。
|
5. 测试性:自动化测试必须能注入 fake wake provider,不依赖真实麦克风或真实 KWS 模型。
|
||||||
6. 架构可观测性:所有用户可见状态必须来自 pipeline event bus,终端 reporter 和后续 GUI 只消费事件,不直接嵌入 stage 逻辑。
|
6. 架构可观测性:所有用户可见状态必须来自 pipeline event bus,终端 reporter 和后续 GUI 只消费事件,不直接嵌入 stage 逻辑。
|
||||||
7. 端点性能:默认配置下,主说话人音色消失后 300 ms 左右应结束采集,并进入 STT;最大录音时长仍作为兜底。
|
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。
|
7. 播放回声误触发:播放期间仍遵循既有音频反馈抑制要求,不能把 TTS 当作新 wake。
|
||||||
8. 背景噪声拖尾:用户停止说话后若仍有非主说话人或噪声,主说话人端点必须允许结束录音。
|
8. 背景噪声拖尾:用户停止说话后若仍有非主说话人或噪声,主说话人端点必须允许结束录音。
|
||||||
9. 音色画像不足:如果开头音频太短或能量不足,采集阶段必须回退到普通 VAD 静音端点,不能卡死。
|
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 已积压输入,不额外读取并丢弃新音频。
|
13. `OWNER_POST_PLAYBACK_DRAIN_MS=0`:ACK 或 TTS 播放完成后只 flush 已积压输入,不额外读取并丢弃新音频。
|
||||||
14. `OWNER_SPEAKER_PROFILE_MIN_MS=120`:主说话人画像参与端点判断的最低有效语音长度。
|
14. `OWNER_SPEAKER_PROFILE_MIN_MS=120`:主说话人画像参与端点判断的最低有效语音长度。
|
||||||
15. `OWNER_REALTIME_TRANSCRIPT_ENABLED=1`:启用录音期间本地 streaming STT 中间结果显示。
|
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)
|
-> LocalWakeWordProvider(sherpa-onnx KeywordSpotter, models/wake, keywords.txt)
|
||||||
-> wake_hit
|
-> wake_hit
|
||||||
-> AcknowledgeStage("我在")
|
-> AcknowledgeStage("我在")
|
||||||
-> CaptureStage(primary speaker endpoint, user utterance only)
|
-> CaptureStage(raw user utterance only)
|
||||||
-> SttProvider(cloud or local, configured by OWNER_SPEECH_PROVIDER)
|
-> 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)
|
-> PipelineEvent(transcript_final)
|
||||||
-> ConversationContext(temporary process history)
|
-> ConversationContext(temporary process history)
|
||||||
-> Cloud LLM
|
-> Cloud LLM
|
||||||
-> TTS
|
-> Local TTS(MacSayTtsProvider)
|
||||||
-> Speaker
|
-> Speaker
|
||||||
-> standby
|
-> standby
|
||||||
```
|
```
|
||||||
@@ -168,6 +196,9 @@ SoundDeviceAudioTransport
|
|||||||
7. Runtime 将 `user_text` 追加到临时上下文并调用 LLM。
|
7. Runtime 将 `user_text` 追加到临时上下文并调用 LLM。
|
||||||
8. TTS 播放后追加 assistant 历史并恢复待机。
|
8. TTS 播放后追加 assistant 历史并恢复待机。
|
||||||
9. 所有 stage 同步发出 pipeline events;终端 reporter 只把事件映射为中文文案。
|
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
|
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
|
```text
|
||||||
SherpaOnnxKeywordWakeWordProvider(
|
SherpaOnnxKeywordWakeWordProvider(
|
||||||
models_dir: Path,
|
models_dir: Path,
|
||||||
@@ -228,6 +281,9 @@ standby
|
|||||||
7. 批量读帧:真实 SoundDevice 输入在拿到首帧后立即 drain 当前队列中所有可用帧并返回给 pipeline,使 wake、capture 和 VAD 能在同一个循环内处理积压帧。
|
7. 批量读帧:真实 SoundDevice 输入在拿到首帧后立即 drain 当前队列中所有可用帧并返回给 pipeline,使 wake、capture 和 VAD 能在同一个循环内处理积压帧。
|
||||||
8. 快速主说话人结束:画像就绪最低语音长度由 `OWNER_SPEAKER_PROFILE_MIN_MS` 控制,默认 120 ms;一旦画像就绪,主说话人缺席计时达到 `OWNER_SPEAKER_ABSENT_MS` 即结束,不再叠加 `OWNER_VAD_MIN_DURATION_MS`。
|
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`。
|
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/`。
|
2. 新增 KWS 模型约 15 MB,下载到 ignored `models/wake/`。
|
||||||
3. 不新增 Python 运行依赖。
|
3. 不新增 Python 运行依赖。
|
||||||
4. `model-check` 变严格:缺 wake 模型会失败。
|
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 关键词拼音格式不匹配模型 | 中 | 高 | 默认写入 sherpa 示例格式;保留 keywords 文件可编辑;测试下载后用真实模型加载;README 标明关键词文件位置 |
|
||||||
| KWS 模型误唤醒或漏唤醒 | 中 | 中 | 暴露 threshold/score 配置;保留状态输出;后续可替换 KWS Provider |
|
| KWS 模型误唤醒或漏唤醒 | 中 | 中 | 暴露 threshold/score 配置;保留状态输出;后续可替换 KWS Provider |
|
||||||
| 唤醒应答期间用户抢说被缓冲清理吞掉 | 中 | 高 | 终端提示顺序改为“唤醒命中 -> 应答中 -> 请说出问题 -> 录音中”,用户只在应答完成后收到提问提示;默认播放后排水从 250 ms 降为 50 ms |
|
| 唤醒应答期间用户抢说被缓冲清理吞掉 | 中 | 高 | 终端提示顺序改为“唤醒命中 -> 应答中 -> 请说出问题 -> 录音中”,用户只在应答完成后收到提问提示;默认 `OWNER_POST_PLAYBACK_DRAIN_MS=0`,不再额外读取并丢弃播放后的新音频 |
|
||||||
| 本地 VAD 对真实麦克风音量过保守 | 中 | 高 | 默认 VAD provider 改为 `hybrid`,本地模型判断和能量阈值兜底任一命中即认为有语音;保留 `local` 和 `energy` 可配置回退 |
|
| 本地 VAD 对真实麦克风音量过保守 | 中 | 高 | 默认 VAD provider 改为 `hybrid`,本地模型判断和能量阈值兜底任一命中即认为有语音;保留 `local` 和 `energy` 可配置回退 |
|
||||||
| 能量兜底让录音无法及时结束 | 高 | 高 | 将能量兜底限制为“开始录音辅助”,结束录音优先使用主说话人音色消失和本地 VAD 静音 |
|
| 能量兜底让录音无法及时结束 | 高 | 高 | 将能量兜底限制为“开始录音辅助”,结束录音优先使用主说话人音色消失和本地 VAD 静音 |
|
||||||
| ACK 后额外丢弃音频截断首句 | 高 | 高 | 默认 `OWNER_POST_PLAYBACK_DRAIN_MS=0`;播放结束后只 flush 已积压输入;新增首句保留回归测试 |
|
| ACK 后额外丢弃音频截断首句 | 高 | 高 | 默认 `OWNER_POST_PLAYBACK_DRAIN_MS=0`;播放结束后只 flush 已积压输入;新增首句保留回归测试 |
|
||||||
@@ -268,6 +327,11 @@ standby
|
|||||||
| 终端转写不是逐字流式 | 中 | 低 | 第一版至少在 LLM 前即时显示最终 STT 文本;后续可接入本地 streaming STT partial |
|
| 终端转写不是逐字流式 | 中 | 低 | 第一版至少在 LLM 前即时显示最终 STT 文本;后续可接入本地 streaming STT partial |
|
||||||
| 模型下载网络失败 | 中 | 中 | 下载脚本保留重试;`model-check` 给明确缺失文件 |
|
| 模型下载网络失败 | 中 | 中 | 下载脚本保留重试;`model-check` 给明确缺失文件 |
|
||||||
| 误提交模型或 key | 低 | 高 | `.gitignore`、security-check、提交前 `git status --short` |
|
| 误提交模型或 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.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 分钟。
|
- [ ] 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 CTC,partial 不显示单字噪声和短暂跳变;测试要点: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
|
## Spec Deltas
|
||||||
|
|
||||||
### 新增能力
|
### 新增能力
|
||||||
@@ -340,6 +415,7 @@ standby
|
|||||||
8. `Primary speaker endpointing`:新增本轮临时主说话人音色消失结束录音要求。
|
8. `Primary speaker endpointing`:新增本轮临时主说话人音色消失结束录音要求。
|
||||||
9. `Low latency capture and first utterance preservation`:新增 ACK 后不额外丢弃正式问题、批量读帧、独立画像就绪阈值和快速主说话人端点要求。
|
9. `Low latency capture and first utterance preservation`:新增 ACK 后不额外丢弃正式问题、批量读帧、独立画像就绪阈值和快速主说话人端点要求。
|
||||||
10. `Realtime partial transcript output`:新增录音期间 partial transcript 事件、终端显示和上下文隔离要求。
|
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. M6:Stage 化 pipeline、事件总线、TurnController、主说话人端点和文档验收分模块提交。
|
6. M6:Stage 化 pipeline、事件总线、TurnController、主说话人端点和文档验收分模块提交。
|
||||||
7. M7:低延迟端点与首句保留修正完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
|
7. M7:低延迟端点与首句保留修正完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
|
||||||
8. M8:录音期间实时转写显示完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
|
8. M8:录音期间实时转写显示完成后提交,保留真人 `run-live` 验收任务,不在用户确认前归档。
|
||||||
|
9. M9:本地语音链路、高质量实时字幕和正式问题降噪完成后提交,继续保留真人 `run-live` 验收任务,不在用户确认前归档。
|
||||||
|
|
||||||
估时:
|
估时:
|
||||||
|
|
||||||
|
|||||||
+44
-3
@@ -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
|
- **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
|
- **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
|
### 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.
|
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
|
#### Scenario: Models are downloaded
|
||||||
- **WHEN** the user runs `python3.11 scripts/download_speech_models.py --dir models`
|
- **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
|
#### 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
|
- **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
|
#### 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
|
- **THEN** `model-check` SHALL fail with a structured model error and SHALL NOT start live microphone listening
|
||||||
|
|
||||||
### Requirement: Live repeat voice runtime
|
### Requirement: Live repeat voice runtime
|
||||||
|
|||||||
@@ -70,3 +70,14 @@
|
|||||||
- [x] 9.3 实现本地 streaming STT partial provider;前置条件:本地 STT 模型已由 `model-check` 覆盖;验收标准:`SherpaOnnxSttProvider` 支持 streaming session,cloud final ASR 模式下仍使用本地 streaming STT 做 partial;测试要点:fake session 与 metadata partial 单测;优先级:P0;预计:60 分钟。
|
- [x] 9.3 实现本地 streaming STT partial provider;前置条件:本地 STT 模型已由 `model-check` 覆盖;验收标准:`SherpaOnnxSttProvider` 支持 streaming session,cloud 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.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 分钟。
|
- [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 CTC,partial 不显示单字噪声和短暂跳变;测试要点: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 分钟。
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ if str(SRC_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(SRC_DIR))
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
from owner_voice_pet.speech_models import (
|
from owner_voice_pet.speech_models import (
|
||||||
|
DEFAULT_DENOISER_PATH,
|
||||||
|
DEFAULT_DENOISER_URL,
|
||||||
DEFAULT_KWS_DIR,
|
DEFAULT_KWS_DIR,
|
||||||
DEFAULT_KWS_KEYWORDS,
|
DEFAULT_KWS_KEYWORDS,
|
||||||
DEFAULT_KWS_URL,
|
DEFAULT_KWS_URL,
|
||||||
@@ -38,6 +40,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
download_kws(target, force=args.force)
|
download_kws(target, force=args.force)
|
||||||
download_vad(target, force=args.force)
|
download_vad(target, force=args.force)
|
||||||
download_stt(target, force=args.force)
|
download_stt(target, force=args.force)
|
||||||
|
download_denoiser(target, force=args.force)
|
||||||
manifest = write_default_manifest(target)
|
manifest = write_default_manifest(target)
|
||||||
status = check_speech_models(target, require_sherpa=False)
|
status = check_speech_models(target, require_sherpa=False)
|
||||||
print(f"models_dir={target}")
|
print(f"models_dir={target}")
|
||||||
@@ -112,6 +115,16 @@ def download_stt(models_dir: Path, *, force: bool = False) -> Path:
|
|||||||
return output_dir
|
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:
|
def download_file(url: str, output: Path) -> None:
|
||||||
tmp_output = output.with_suffix(output.suffix + ".part")
|
tmp_output = output.with_suffix(output.suffix + ".part")
|
||||||
print(f"download {url}")
|
print(f"download {url}")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .config import AppConfig
|
from .config import AppConfig
|
||||||
from .assistant_pipeline import TurnController, VoiceAssistantPipeline
|
from .assistant_pipeline import TurnController, VoiceAssistantPipeline
|
||||||
|
from .audio_preprocess import NoopAudioPreprocessor, SherpaOnnxDenoiserPreprocessor
|
||||||
from .events import PipelineEvent, PipelineEventBus
|
from .events import PipelineEvent, PipelineEventBus
|
||||||
from .models import (
|
from .models import (
|
||||||
AudioFrame,
|
AudioFrame,
|
||||||
@@ -33,6 +34,8 @@ __all__ = [
|
|||||||
"AppConfig",
|
"AppConfig",
|
||||||
"TurnController",
|
"TurnController",
|
||||||
"VoiceAssistantPipeline",
|
"VoiceAssistantPipeline",
|
||||||
|
"NoopAudioPreprocessor",
|
||||||
|
"SherpaOnnxDenoiserPreprocessor",
|
||||||
"PipelineEvent",
|
"PipelineEvent",
|
||||||
"PipelineEventBus",
|
"PipelineEventBus",
|
||||||
"AudioFrame",
|
"AudioFrame",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .audio_preprocess import NoopAudioPreprocessor
|
||||||
from .config import AppConfig
|
from .config import AppConfig
|
||||||
from .conversation import ConversationContext
|
from .conversation import ConversationContext
|
||||||
from .events import (
|
from .events import (
|
||||||
@@ -28,6 +29,7 @@ from .events import (
|
|||||||
)
|
)
|
||||||
from .models import AudioFrame, AudioSegment, ErrorCode, PipelineState, ProviderError
|
from .models import AudioFrame, AudioSegment, ErrorCode, PipelineState, ProviderError
|
||||||
from .protocols import (
|
from .protocols import (
|
||||||
|
AudioPreprocessor,
|
||||||
AudioTransport,
|
AudioTransport,
|
||||||
LlmProvider,
|
LlmProvider,
|
||||||
RealtimeSttProvider,
|
RealtimeSttProvider,
|
||||||
@@ -77,6 +79,7 @@ class TurnController:
|
|||||||
transport: AudioTransport,
|
transport: AudioTransport,
|
||||||
wakeword: WakeWordProvider,
|
wakeword: WakeWordProvider,
|
||||||
vad_recorder: VadRecorder,
|
vad_recorder: VadRecorder,
|
||||||
|
audio_preprocessor: AudioPreprocessor,
|
||||||
stt: SttProvider,
|
stt: SttProvider,
|
||||||
realtime_stt: RealtimeSttProvider | None,
|
realtime_stt: RealtimeSttProvider | None,
|
||||||
llm: LlmProvider,
|
llm: LlmProvider,
|
||||||
@@ -90,6 +93,7 @@ class TurnController:
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.wakeword = wakeword
|
self.wakeword = wakeword
|
||||||
self.vad_recorder = vad_recorder
|
self.vad_recorder = vad_recorder
|
||||||
|
self.audio_preprocessor = audio_preprocessor
|
||||||
self.stt = stt
|
self.stt = stt
|
||||||
self.realtime_stt = realtime_stt
|
self.realtime_stt = realtime_stt
|
||||||
self.llm = llm
|
self.llm = llm
|
||||||
@@ -157,6 +161,7 @@ class TurnController:
|
|||||||
def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError:
|
def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError:
|
||||||
self.vad_recorder.reset()
|
self.vad_recorder.reset()
|
||||||
self.vad_recorder.provider.reset()
|
self.vad_recorder.provider.reset()
|
||||||
|
self.audio_preprocessor.reset()
|
||||||
realtime_session = self._start_realtime_transcript()
|
realtime_session = self._start_realtime_transcript()
|
||||||
self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id)
|
self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id)
|
||||||
while True:
|
while True:
|
||||||
@@ -164,6 +169,10 @@ class TurnController:
|
|||||||
if not frames:
|
if not frames:
|
||||||
continue
|
continue
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
|
try:
|
||||||
|
frame = self.audio_preprocessor.process_frame(frame)
|
||||||
|
except ProviderError as exc:
|
||||||
|
return exc
|
||||||
was_started = self.vad_recorder.started
|
was_started = self.vad_recorder.started
|
||||||
result = self.vad_recorder.feed(frame)
|
result = self.vad_recorder.feed(frame)
|
||||||
if not was_started and self.vad_recorder.started:
|
if not was_started and self.vad_recorder.started:
|
||||||
@@ -310,6 +319,7 @@ class VoiceAssistantPipeline:
|
|||||||
llm: LlmProvider,
|
llm: LlmProvider,
|
||||||
tts: TtsProvider,
|
tts: TtsProvider,
|
||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
|
audio_preprocessor: AudioPreprocessor | None = None,
|
||||||
realtime_stt: RealtimeSttProvider | None = None,
|
realtime_stt: RealtimeSttProvider | None = None,
|
||||||
ack_tts: TtsProvider | None = None,
|
ack_tts: TtsProvider | None = None,
|
||||||
reporter: RuntimeReporter | None = None,
|
reporter: RuntimeReporter | None = None,
|
||||||
@@ -320,6 +330,7 @@ class VoiceAssistantPipeline:
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.wakeword = wakeword
|
self.wakeword = wakeword
|
||||||
self.vad_recorder = vad_recorder
|
self.vad_recorder = vad_recorder
|
||||||
|
self.audio_preprocessor = audio_preprocessor or NoopAudioPreprocessor()
|
||||||
self.stt = stt
|
self.stt = stt
|
||||||
self.realtime_stt = realtime_stt
|
self.realtime_stt = realtime_stt
|
||||||
self.llm = llm
|
self.llm = llm
|
||||||
@@ -336,6 +347,7 @@ class VoiceAssistantPipeline:
|
|||||||
transport=transport,
|
transport=transport,
|
||||||
wakeword=wakeword,
|
wakeword=wakeword,
|
||||||
vad_recorder=vad_recorder,
|
vad_recorder=vad_recorder,
|
||||||
|
audio_preprocessor=self.audio_preprocessor,
|
||||||
stt=stt,
|
stt=stt,
|
||||||
realtime_stt=realtime_stt,
|
realtime_stt=realtime_stt,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
@@ -349,6 +361,7 @@ class VoiceAssistantPipeline:
|
|||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
self.wakeword.load()
|
self.wakeword.load()
|
||||||
self.vad_recorder.provider.load()
|
self.vad_recorder.provider.load()
|
||||||
|
self.audio_preprocessor.load()
|
||||||
self.stt.load()
|
self.stt.load()
|
||||||
if self.realtime_stt is not None and self.realtime_stt is not self.stt:
|
if self.realtime_stt is not None and self.realtime_stt is not self.stt:
|
||||||
self.realtime_stt.load()
|
self.realtime_stt.load()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
|
||||||
from .assets import validate_pet_assets
|
from .assets import validate_pet_assets
|
||||||
from .config import AppConfig
|
from .config import AppConfig
|
||||||
from .conversation import ConversationContext
|
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,
|
"post_playback_drain_ms": config.post_playback_drain_ms,
|
||||||
"pipeline_mode": config.pipeline_mode,
|
"pipeline_mode": config.pipeline_mode,
|
||||||
"endpoint_mode": config.endpoint_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_ms": config.speaker_profile_ms,
|
||||||
"speaker_profile_min_ms": config.speaker_profile_min_ms,
|
"speaker_profile_min_ms": config.speaker_profile_min_ms,
|
||||||
"speaker_absent_ms": config.speaker_absent_ms,
|
"speaker_absent_ms": config.speaker_absent_ms,
|
||||||
@@ -113,6 +117,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
).load()
|
).load()
|
||||||
SherpaOnnxVadProvider(models_dir).load()
|
SherpaOnnxVadProvider(models_dir).load()
|
||||||
SherpaOnnxSttProvider(str(models_dir)).load()
|
SherpaOnnxSttProvider(str(models_dir)).load()
|
||||||
|
SherpaOnnxDenoiserPreprocessor(models_dir).load()
|
||||||
provider_load_checked = True
|
provider_load_checked = True
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
errors.append(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,
|
post_playback_drain_ms=config.post_playback_drain_ms,
|
||||||
pipeline_mode=config.pipeline_mode,
|
pipeline_mode=config.pipeline_mode,
|
||||||
endpoint_mode=config.endpoint_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_ms=config.speaker_profile_ms,
|
||||||
speaker_profile_min_ms=config.speaker_profile_min_ms,
|
speaker_profile_min_ms=config.speaker_profile_min_ms,
|
||||||
speaker_absent_ms=config.speaker_absent_ms,
|
speaker_absent_ms=config.speaker_absent_ms,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class AppConfig:
|
|||||||
post_playback_drain_ms: int = 0
|
post_playback_drain_ms: int = 0
|
||||||
pipeline_mode: str = "live_turn_based"
|
pipeline_mode: str = "live_turn_based"
|
||||||
endpoint_mode: str = "primary_speaker"
|
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_ms: int = 600
|
||||||
speaker_profile_min_ms: int = 120
|
speaker_profile_min_ms: int = 120
|
||||||
speaker_absent_ms: int = 300
|
speaker_absent_ms: int = 300
|
||||||
@@ -40,7 +43,7 @@ class AppConfig:
|
|||||||
vad_end_silence_ms: int = 350
|
vad_end_silence_ms: int = 350
|
||||||
vad_no_speech_timeout_ms: int = 5000
|
vad_no_speech_timeout_ms: int = 5000
|
||||||
vad_max_recording_ms: int = 12000
|
vad_max_recording_ms: int = 12000
|
||||||
speech_provider: str = "cloud"
|
speech_provider: str = "local"
|
||||||
asr_model: str = "mimo-v2.5-asr"
|
asr_model: str = "mimo-v2.5-asr"
|
||||||
tts_model: str = "mimo-v2.5-tts"
|
tts_model: str = "mimo-v2.5-tts"
|
||||||
tts_voice: str = "mimo_default"
|
tts_voice: str = "mimo_default"
|
||||||
@@ -80,6 +83,9 @@ class AppConfig:
|
|||||||
post_playback_drain_ms=int(get("POST_PLAYBACK_DRAIN_MS", "0") or "0"),
|
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(),
|
pipeline_mode=(get("PIPELINE_MODE", "live_turn_based") or "live_turn_based").lower(),
|
||||||
endpoint_mode=(get("ENDPOINT_MODE", "primary_speaker") or "primary_speaker").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_ms=int(get("SPEAKER_PROFILE_MS", "600") or "600"),
|
||||||
speaker_profile_min_ms=int(get("SPEAKER_PROFILE_MIN_MS", "120") or "120"),
|
speaker_profile_min_ms=int(get("SPEAKER_PROFILE_MIN_MS", "120") or "120"),
|
||||||
speaker_absent_ms=int(get("SPEAKER_ABSENT_MS", "300") or "300"),
|
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_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_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"),
|
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",
|
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_model=get("TTS_MODEL", "mimo-v2.5-tts") or "mimo-v2.5-tts",
|
||||||
tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
|
tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
|
||||||
@@ -189,6 +195,16 @@ class AppConfig:
|
|||||||
"startup",
|
"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:
|
if self.post_playback_drain_ms < 0:
|
||||||
errors.append(
|
errors.append(
|
||||||
ProviderError(
|
ProviderError(
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class ErrorCode(str, Enum):
|
|||||||
TTS_MODEL_MISSING = "TTS_MODEL_MISSING"
|
TTS_MODEL_MISSING = "TTS_MODEL_MISSING"
|
||||||
TTS_SYNTHESIS_FAILED = "TTS_SYNTHESIS_FAILED"
|
TTS_SYNTHESIS_FAILED = "TTS_SYNTHESIS_FAILED"
|
||||||
TTS_EMPTY_AUDIO = "TTS_EMPTY_AUDIO"
|
TTS_EMPTY_AUDIO = "TTS_EMPTY_AUDIO"
|
||||||
|
NOISE_FILTER_MODEL_MISSING = "NOISE_FILTER_MODEL_MISSING"
|
||||||
|
NOISE_FILTER_FAILED = "NOISE_FILTER_FAILED"
|
||||||
ASSET_MISSING = "ASSET_MISSING"
|
ASSET_MISSING = "ASSET_MISSING"
|
||||||
VALIDATION_FAILED = "VALIDATION_FAILED"
|
VALIDATION_FAILED = "VALIDATION_FAILED"
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
class VadProvider(Protocol):
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .audio_preprocess import NoopAudioPreprocessor, SherpaOnnxDenoiserPreprocessor
|
||||||
from .config import AppConfig
|
from .config import AppConfig
|
||||||
from .assistant_pipeline import VoiceAssistantPipeline
|
from .assistant_pipeline import VoiceAssistantPipeline
|
||||||
from .conversation import ConversationContext
|
from .conversation import ConversationContext
|
||||||
@@ -369,6 +370,11 @@ def build_live_runtime(config: AppConfig, reporter: RuntimeReporter | None = Non
|
|||||||
"min_rms": config.speaker_min_rms,
|
"min_rms": config.speaker_min_rms,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
audio_preprocessor = (
|
||||||
|
SherpaOnnxDenoiserPreprocessor(config.speech_models_dir)
|
||||||
|
if config.noise_filter_enabled
|
||||||
|
else NoopAudioPreprocessor()
|
||||||
|
)
|
||||||
return VoiceAssistantPipeline(
|
return VoiceAssistantPipeline(
|
||||||
config=config,
|
config=config,
|
||||||
transport=SoundDeviceAudioTransport(output_device=config.audio_output_device),
|
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,
|
score=config.wake_kws_score,
|
||||||
),
|
),
|
||||||
vad_recorder=recorder_cls(**recorder_kwargs),
|
vad_recorder=recorder_cls(**recorder_kwargs),
|
||||||
|
audio_preprocessor=audio_preprocessor,
|
||||||
stt=stt,
|
stt=stt,
|
||||||
realtime_stt=realtime_stt,
|
realtime_stt=realtime_stt,
|
||||||
llm=OpenAICompatibleLlmProvider(config),
|
llm=OpenAICompatibleLlmProvider(config),
|
||||||
|
|||||||
@@ -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_VAD_URL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"
|
||||||
DEFAULT_STT_URL = (
|
DEFAULT_STT_URL = (
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
|
"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 = (
|
DEFAULT_KWS_URL = (
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/"
|
"https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/"
|
||||||
"sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile.tar.bz2"
|
"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_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_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 = (
|
REQUIRED_MODEL_FILES = (
|
||||||
f"wake/{DEFAULT_KWS_DIR}/tokens.txt",
|
f"wake/{DEFAULT_KWS_DIR}/tokens.txt",
|
||||||
@@ -29,9 +34,8 @@ REQUIRED_MODEL_FILES = (
|
|||||||
"wake/keywords.txt",
|
"wake/keywords.txt",
|
||||||
"vad/silero_vad.onnx",
|
"vad/silero_vad.onnx",
|
||||||
f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
||||||
f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
|
f"stt/{DEFAULT_STT_DIR}/model.int8.onnx",
|
||||||
f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx",
|
DEFAULT_DENOISER_PATH,
|
||||||
f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ def default_manifest() -> dict[str, Any]:
|
|||||||
"wake": DEFAULT_KWS_URL,
|
"wake": DEFAULT_KWS_URL,
|
||||||
"vad": DEFAULT_VAD_URL,
|
"vad": DEFAULT_VAD_URL,
|
||||||
"stt": DEFAULT_STT_URL,
|
"stt": DEFAULT_STT_URL,
|
||||||
|
"denoiser": DEFAULT_DENOISER_URL,
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"wake": {
|
"wake": {
|
||||||
@@ -84,12 +89,14 @@ def default_manifest() -> dict[str, Any]:
|
|||||||
"path": "vad/silero_vad.onnx",
|
"path": "vad/silero_vad.onnx",
|
||||||
},
|
},
|
||||||
"stt": {
|
"stt": {
|
||||||
"type": "sherpa-onnx-streaming-transducer",
|
"type": "sherpa-onnx-streaming-zipformer2-ctc",
|
||||||
"model_dir": f"stt/{DEFAULT_STT_DIR}",
|
"model_dir": f"stt/{DEFAULT_STT_DIR}",
|
||||||
"tokens": f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
"tokens": f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
||||||
"encoder": f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
|
"model": f"stt/{DEFAULT_STT_DIR}/model.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",
|
"denoiser": {
|
||||||
|
"type": "sherpa-onnx-gtcrn",
|
||||||
|
"path": DEFAULT_DENOISER_PATH,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required_files": list(REQUIRED_MODEL_FILES),
|
"required_files": list(REQUIRED_MODEL_FILES),
|
||||||
@@ -127,6 +134,13 @@ def vad_model_path(models_dir: str | Path) -> Path:
|
|||||||
return root / str(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]:
|
def wake_model_paths(models_dir: str | Path) -> dict[str, Path]:
|
||||||
root = Path(models_dir)
|
root = Path(models_dir)
|
||||||
manifest = load_manifest(root)
|
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)
|
root = Path(model_path)
|
||||||
if (root / "manifest.json").exists() or (root / "stt").exists():
|
if (root / "manifest.json").exists() or (root / "stt").exists():
|
||||||
manifest = load_manifest(root)
|
manifest = load_manifest(root)
|
||||||
stt = manifest.get("providers", {}).get("stt", {})
|
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 {
|
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}")),
|
"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")),
|
"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")),
|
"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")),
|
"joiner": root / str(stt.get("joiner", f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx")),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
"type": "sherpa-onnx-streaming-transducer",
|
||||||
"model_dir": root,
|
"model_dir": root,
|
||||||
"tokens": root / "tokens.txt",
|
"tokens": root / "tokens.txt",
|
||||||
"encoder": root / "encoder-epoch-99-avg-1.int8.onnx",
|
"encoder": root / "encoder-epoch-99-avg-1.int8.onnx",
|
||||||
@@ -195,11 +219,23 @@ def model_status_errors(status: SpeechModelStatus) -> list[ProviderError]:
|
|||||||
"model-check",
|
"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(
|
errors.append(
|
||||||
ProviderError(
|
ProviderError(
|
||||||
ErrorCode.STT_MODEL_MISSING,
|
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,
|
False,
|
||||||
"speech-models",
|
"speech-models",
|
||||||
"model-check",
|
"model-check",
|
||||||
|
|||||||
@@ -17,12 +17,28 @@ from .models import AudioFrame, AudioSegment, ErrorCode, ProviderError, Transcri
|
|||||||
from .speech_models import stt_model_paths
|
from .speech_models import stt_model_paths
|
||||||
|
|
||||||
_MEANINGFUL_TEXT = re.compile(r"[\w\u4e00-\u9fff]", re.UNICODE)
|
_MEANINGFUL_TEXT = re.compile(r"[\w\u4e00-\u9fff]", re.UNICODE)
|
||||||
|
_PARTIAL_MIN_MEANINGFUL_CHARS = 3
|
||||||
|
|
||||||
|
|
||||||
def is_valid_transcript_text(text: str) -> bool:
|
def is_valid_transcript_text(text: str) -> bool:
|
||||||
return bool(_MEANINGFUL_TEXT.search(text.strip()))
|
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:
|
class MetadataSttProvider:
|
||||||
def __init__(self, language: str = "zh") -> None:
|
def __init__(self, language: str = "zh") -> None:
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -73,7 +89,7 @@ class MetadataRealtimeTranscriptSession:
|
|||||||
or frame.metadata.get("transcript")
|
or frame.metadata.get("transcript")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).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
|
return None
|
||||||
self._last_text = text
|
self._last_text = text
|
||||||
return Transcript(
|
return Transcript(
|
||||||
@@ -197,7 +213,11 @@ class SherpaOnnxSttProvider:
|
|||||||
"stt",
|
"stt",
|
||||||
)
|
)
|
||||||
paths = stt_model_paths(self.model_path)
|
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:
|
if missing:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
ErrorCode.STT_MODEL_MISSING,
|
ErrorCode.STT_MODEL_MISSING,
|
||||||
@@ -219,6 +239,19 @@ class SherpaOnnxSttProvider:
|
|||||||
"stt",
|
"stt",
|
||||||
) from exc
|
) from exc
|
||||||
try:
|
try:
|
||||||
|
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(
|
self._recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
tokens=str(paths["tokens"]),
|
tokens=str(paths["tokens"]),
|
||||||
encoder=str(paths["encoder"]),
|
encoder=str(paths["encoder"]),
|
||||||
@@ -331,7 +364,7 @@ class SherpaOnnxRealtimeTranscriptSession:
|
|||||||
"sherpa-onnx-stt",
|
"sherpa-onnx-stt",
|
||||||
"stt",
|
"stt",
|
||||||
) from exc
|
) 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
|
return None
|
||||||
self._last_text = text
|
self._last_text = text
|
||||||
return Transcript(
|
return Transcript(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -65,10 +65,12 @@ class CliAcceptanceTests(unittest.TestCase):
|
|||||||
patch("owner_voice_pet.cli.SherpaOnnxKeywordWakeWordProvider") as wake_cls,
|
patch("owner_voice_pet.cli.SherpaOnnxKeywordWakeWordProvider") as wake_cls,
|
||||||
patch("owner_voice_pet.cli.SherpaOnnxVadProvider") as vad_cls,
|
patch("owner_voice_pet.cli.SherpaOnnxVadProvider") as vad_cls,
|
||||||
patch("owner_voice_pet.cli.SherpaOnnxSttProvider") as stt_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
|
wake_cls.return_value.load.return_value = None
|
||||||
vad_cls.return_value.load.return_value = None
|
vad_cls.return_value.load.return_value = None
|
||||||
stt_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))
|
code, data = self.call("model-check", "--models-dir", str(root))
|
||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertTrue(data["ok"])
|
self.assertTrue(data["ok"])
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from owner_voice_pet.llm import MockLlmProvider
|
|||||||
from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript
|
from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript
|
||||||
from owner_voice_pet.assistant_pipeline import VoiceAssistantPipeline
|
from owner_voice_pet.assistant_pipeline import VoiceAssistantPipeline
|
||||||
from owner_voice_pet.runtime import build_live_runtime
|
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.transport import MemoryAudioTransport
|
||||||
from owner_voice_pet.tts import SineTtsProvider
|
from owner_voice_pet.tts import SineTtsProvider
|
||||||
from owner_voice_pet.vad import EnergyVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
from owner_voice_pet.vad import EnergyVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
||||||
@@ -97,10 +97,43 @@ class RecordingReporter:
|
|||||||
self.errors.append(f"{stage}:{code}:{message}")
|
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(
|
def make_runtime(
|
||||||
texts: list[str],
|
texts: list[str],
|
||||||
context: ConversationContext | None = None,
|
context: ConversationContext | None = None,
|
||||||
partial_texts: list[list[str]] | None = None,
|
partial_texts: list[list[str]] | None = None,
|
||||||
|
audio_preprocessor: MarkerAudioPreprocessor | None = None,
|
||||||
) -> tuple[VoiceAssistantPipeline, QueueSttProvider, MockLlmProvider, MemoryAudioTransport, RecordingReporter]:
|
) -> tuple[VoiceAssistantPipeline, QueueSttProvider, MockLlmProvider, MemoryAudioTransport, RecordingReporter]:
|
||||||
frames = []
|
frames = []
|
||||||
for idx, _text in enumerate(texts):
|
for idx, _text in enumerate(texts):
|
||||||
@@ -121,6 +154,7 @@ def make_runtime(
|
|||||||
wakeword=KeywordWakeWordProvider(),
|
wakeword=KeywordWakeWordProvider(),
|
||||||
vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40),
|
vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40),
|
||||||
stt=stt,
|
stt=stt,
|
||||||
|
audio_preprocessor=audio_preprocessor,
|
||||||
realtime_stt=MetadataSttProvider() if partial_texts is not None else None,
|
realtime_stt=MetadataSttProvider() if partial_texts is not None else None,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
tts=tts,
|
tts=tts,
|
||||||
@@ -200,7 +234,7 @@ class LiveRuntimeTests(unittest.TestCase):
|
|||||||
runtime, _, llm, _, reporter = make_runtime(["第一问"], partial_texts=[["第一", "第一问"]])
|
runtime, _, llm, _, reporter = make_runtime(["第一问"], partial_texts=[["第一", "第一问"]])
|
||||||
runtime.run(max_turns=1)
|
runtime.run(max_turns=1)
|
||||||
|
|
||||||
self.assertEqual(reporter.partials, ["第一", "第一问"])
|
self.assertEqual(reporter.partials, ["第一问"])
|
||||||
self.assertEqual(reporter.transcripts, ["第一问"])
|
self.assertEqual(reporter.transcripts, ["第一问"])
|
||||||
self.assertEqual(llm.calls[0][-1].content, "第一问")
|
self.assertEqual(llm.calls[0][-1].content, "第一问")
|
||||||
event_types = [event.type for event in runtime.event_bus.events]
|
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(SPEECH_ENDED))
|
||||||
self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(TRANSCRIPT_FINAL))
|
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:
|
def test_wake_keyword_does_not_pollute_llm_user_message(self) -> None:
|
||||||
runtime, _, llm, _, _ = make_runtime(["第一问"])
|
runtime, _, llm, _, _ = make_runtime(["第一问"])
|
||||||
runtime.run(max_turns=1)
|
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:
|
def test_live_runtime_uses_primary_speaker_endpoint_by_default(self) -> None:
|
||||||
runtime = build_live_runtime(AppConfig(llm_api_key="secret"))
|
runtime = build_live_runtime(AppConfig(llm_api_key="secret"))
|
||||||
self.assertIsInstance(runtime.vad_recorder, PrimarySpeakerVadRecorder)
|
self.assertIsInstance(runtime.vad_recorder, PrimarySpeakerVadRecorder)
|
||||||
|
self.assertEqual(runtime.config.speech_provider, "local")
|
||||||
|
self.assertIsInstance(runtime.stt, SherpaOnnxSttProvider)
|
||||||
self.assertIsNotNone(runtime.realtime_stt)
|
self.assertIsNotNone(runtime.realtime_stt)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from owner_voice_pet.config import AppConfig
|
from owner_voice_pet.config import AppConfig
|
||||||
from owner_voice_pet.models import (
|
from owner_voice_pet.models import (
|
||||||
@@ -12,6 +14,12 @@ from owner_voice_pet.models import (
|
|||||||
PipelineState,
|
PipelineState,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
)
|
)
|
||||||
|
from owner_voice_pet.speech_models import (
|
||||||
|
DEFAULT_DENOISER_PATH,
|
||||||
|
default_manifest,
|
||||||
|
denoiser_model_path,
|
||||||
|
stt_model_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelsConfigTests(unittest.TestCase):
|
class ModelsConfigTests(unittest.TestCase):
|
||||||
@@ -77,6 +85,9 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(config.post_playback_drain_ms, 0)
|
self.assertEqual(config.post_playback_drain_ms, 0)
|
||||||
self.assertEqual(config.pipeline_mode, "live_turn_based")
|
self.assertEqual(config.pipeline_mode, "live_turn_based")
|
||||||
self.assertEqual(config.endpoint_mode, "primary_speaker")
|
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_ms, 600)
|
||||||
self.assertEqual(config.speaker_profile_min_ms, 120)
|
self.assertEqual(config.speaker_profile_min_ms, 120)
|
||||||
self.assertEqual(config.speaker_absent_ms, 300)
|
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_end_silence_ms, 350)
|
||||||
self.assertEqual(config.vad_no_speech_timeout_ms, 5000)
|
self.assertEqual(config.vad_no_speech_timeout_ms, 5000)
|
||||||
self.assertEqual(config.vad_max_recording_ms, 12000)
|
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.asr_model, "mimo-v2.5-asr")
|
||||||
self.assertEqual(config.tts_model, "mimo-v2.5-tts")
|
self.assertEqual(config.tts_model, "mimo-v2.5-tts")
|
||||||
self.assertEqual(config.tts_voice, "mimo_default")
|
self.assertEqual(config.tts_voice, "mimo_default")
|
||||||
@@ -117,6 +128,11 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
errors = config.validate_basic()
|
errors = config.validate_basic()
|
||||||
self.assertTrue(any("OWNER_ENDPOINT_MODE" in error.message for error in errors))
|
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:
|
def test_speaker_similarity_threshold_range_is_validated(self) -> None:
|
||||||
config = AppConfig(speaker_similarity_threshold=1.5)
|
config = AppConfig(speaker_similarity_threshold=1.5)
|
||||||
errors = config.validate_basic()
|
errors = config.validate_basic()
|
||||||
@@ -153,6 +169,30 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
message = Message(role="user", content="你好", created_at=1.0)
|
message = Message(role="user", content="你好", created_at=1.0)
|
||||||
self.assertEqual(message.role, "user")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
from owner_voice_pet.models import AudioFrame, AudioSegment, ErrorCode, ProviderError
|
from owner_voice_pet.models import AudioFrame, AudioSegment, ErrorCode, ProviderError
|
||||||
from owner_voice_pet.config import AppConfig
|
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.speech_models import stt_model_paths
|
||||||
from owner_voice_pet.vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
from owner_voice_pet.vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
||||||
from owner_voice_pet.wakeword import (
|
from owner_voice_pet.wakeword import (
|
||||||
@@ -241,6 +247,13 @@ class WakeVadSttTests(unittest.TestCase):
|
|||||||
self.assertTrue(is_valid_transcript_text("hello"))
|
self.assertTrue(is_valid_transcript_text("hello"))
|
||||||
self.assertFalse(is_valid_transcript_text("?! 。"))
|
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:
|
def test_sherpa_stt_missing_model_is_structured(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
provider = SherpaOnnxSttProvider(f"{tmp}/missing")
|
provider = SherpaOnnxSttProvider(f"{tmp}/missing")
|
||||||
@@ -260,7 +273,7 @@ class WakeVadSttTests(unittest.TestCase):
|
|||||||
|
|
||||||
def accept_waveform(self, sample_rate, samples) -> None:
|
def accept_waveform(self, sample_rate, samples) -> None:
|
||||||
self.ready = True
|
self.ready = True
|
||||||
self.text = "你" if not self.text else "你好"
|
self.text = "你" if not self.text else "你好吗"
|
||||||
|
|
||||||
class FakeRecognizer:
|
class FakeRecognizer:
|
||||||
def create_stream(self):
|
def create_stream(self):
|
||||||
@@ -286,7 +299,7 @@ class WakeVadSttTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
paths = stt_model_paths(Path(tmp))
|
paths = stt_model_paths(Path(tmp))
|
||||||
for name, path in paths.items():
|
for name, path in paths.items():
|
||||||
if name == "model_dir":
|
if name in {"type", "model_dir"}:
|
||||||
continue
|
continue
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text("fake", encoding="utf-8")
|
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))
|
first = session.accept_frame(make_frame(1, 0, speech=True))
|
||||||
second = session.accept_frame(make_frame(2, 20, speech=True))
|
second = session.accept_frame(make_frame(2, 20, speech=True))
|
||||||
|
|
||||||
self.assertIsNotNone(first)
|
self.assertIsNone(first)
|
||||||
self.assertIsNotNone(second)
|
self.assertIsNotNone(second)
|
||||||
assert first is not None and second is not None
|
assert second is not None
|
||||||
self.assertEqual(first.text, "你")
|
self.assertEqual(second.text, "你好吗")
|
||||||
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:
|
def test_cloud_asr_posts_audio_transcription_request(self) -> None:
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
|
|||||||
Reference in New Issue
Block a user