From e74ec28e7d86d3481a19f8d6af4c20d6d73f0f8c Mon Sep 17 00:00:00 2001 From: mkbk Date: Wed, 17 Jun 2026 21:37:07 +0800 Subject: [PATCH] =?UTF-8?q?[Pipeline=20=E6=96=87=E6=A1=A3=E9=AA=8C?= =?UTF-8?q?=E6=94=B6]=EF=BC=9A=E5=AE=8C=E6=88=90=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E5=8A=A9=E6=89=8B=E5=BC=8F=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=8C=E5=8C=85=E5=90=AB=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E9=AA=8C=E6=94=B6=E5=91=BD=E4=BB=A4=E5=92=8COpenSpec?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +++++++ README.md | 17 ++++++++++++++--- .../tasks.md | 4 ++-- src/owner_voice_pet/cli.py | 14 ++++++++++++++ tests/test_cli_acceptance.py | 3 +++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 49c1bd7..7dab417 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ OWNER_WAKE_KWS_THRESHOLD=0.15 OWNER_WAKE_KWS_SCORE=1.0 OWNER_WAKE_ACK_TEXT=我在 OWNER_POST_PLAYBACK_DRAIN_MS=50 +OWNER_PIPELINE_MODE=live_turn_based +OWNER_ENDPOINT_MODE=primary_speaker +OWNER_SPEAKER_PROFILE_MS=600 +OWNER_SPEAKER_ABSENT_MS=300 +OWNER_SPEAKER_SIMILARITY_THRESHOLD=0.70 +OWNER_SPEAKER_MIN_RMS=0.012 OWNER_VAD_PROVIDER=hybrid OWNER_VAD_THRESHOLD=0.5 OWNER_VAD_MIN_DURATION_MS=250 @@ -23,6 +29,7 @@ OWNER_ASR_MODEL=mimo-v2.5-asr OWNER_TTS_MODEL=mimo-v2.5-tts OWNER_TTS_VOICE=mimo_default OWNER_SPEECH_MODELS_DIR=models +OWNER_CONTEXT_MODE=session_memory OWNER_CONTEXT_MAX_MESSAGES=12 OWNER_CONTEXT_MAX_CHARS=12000 OWNER_WAKE_WORD=小杰小杰 diff --git a/README.md b/README.md index 1fe5924..6d311bb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 这是一个 Python 语音桌宠运行程序。当前第一版先提供无 GUI 的真实实时语音循环: -`小杰小杰` 唤醒 -> 麦克风录音 -> VAD 切分 -> ASR 转文字 -> 携带本次进程内临时历史调用 LLM -> TTS 生成语音 -> 本机扬声器播放 -> 回到待机继续监听。 +`小杰小杰` 唤醒 -> 本地应答“我在” -> 主说话人端点采集问题 -> ASR 转文字 -> 携带本次进程内临时历史调用 LLM -> TTS 生成语音 -> 本机扬声器播放 -> 回到待机继续监听。 ## 当前能力 @@ -14,6 +14,8 @@ - 默认云端语音模型:`mimo-v2.5-asr`、`mimo-v2.5-tts`。 - 本地设备:`sounddevice` 读取麦克风,扬声器或 `afplay` 播放。 - 本地模型:`models/` 存放 `sherpa-onnx` wake/VAD/STT 模型,目录不提交 Git。 +- Pipeline:`run-live` 使用 stage 化 `VoiceAssistantPipeline`,通过事件总线输出终端状态。 +- 主说话人端点:默认 `OWNER_ENDPOINT_MODE=primary_speaker`,本轮音色消失后结束录音,避免背景噪声拖慢 STT。 - 临时上下文:同一次 `run-live` 进程内携带最近 user/assistant 历史,退出即清空。 ## 首次准备 @@ -38,6 +40,12 @@ OWNER_WAKE_KWS_THRESHOLD=0.15 OWNER_WAKE_KWS_SCORE=1.0 OWNER_WAKE_ACK_TEXT=我在 OWNER_POST_PLAYBACK_DRAIN_MS=50 +OWNER_PIPELINE_MODE=live_turn_based +OWNER_ENDPOINT_MODE=primary_speaker +OWNER_SPEAKER_PROFILE_MS=600 +OWNER_SPEAKER_ABSENT_MS=300 +OWNER_SPEAKER_SIMILARITY_THRESHOLD=0.70 +OWNER_SPEAKER_MIN_RMS=0.012 OWNER_VAD_PROVIDER=hybrid OWNER_VAD_THRESHOLD=0.5 OWNER_VAD_MIN_DURATION_MS=250 @@ -48,6 +56,7 @@ OWNER_SPEECH_PROVIDER=cloud OWNER_ASR_MODEL=mimo-v2.5-asr OWNER_TTS_MODEL=mimo-v2.5-tts OWNER_TTS_VOICE=mimo_default +OWNER_CONTEXT_MODE=session_memory ``` `OWNER_WAKE_PROVIDER=local_kws` 表示唤醒词“小杰小杰”由本地模型检测。唤醒命中后会先本地播报 `OWNER_WAKE_ACK_TEXT=我在`,再开始听取问题。`OWNER_SPEECH_PROVIDER=cloud` 只影响唤醒后的正式问题 ASR/TTS:它会把 VAD 切出来的用户问题片段发送到云端 ASR,不会上传连续麦克风流,也不会用云端判断唤醒。改成 `local` 时使用项目 `models/` 下的本地语音模型路径。 @@ -65,7 +74,9 @@ python3.11 scripts/download_speech_models.py --dir models 本地唤醒关键词文件位于 `models/wake/keywords.txt`。默认 `OWNER_WAKE_KWS_THRESHOLD=0.15` 已偏向灵敏;如果真人唤醒仍不灵敏,可以继续降到 `0.10`,若误唤醒变多再回调到 `0.20`。 -默认 `OWNER_VAD_PROVIDER=hybrid` 会同时使用本地 `sherpa-onnx` VAD 和能量阈值兜底,避免本地 VAD 对麦克风音量过保守导致 `VAD_TIMEOUT_NO_SPEECH`。如果唤醒后你已经停说但还长时间显示“录音中”,优先调小 `OWNER_VAD_END_SILENCE_MS`,例如 `250`;如果房间噪声较大,再略调高 `OWNER_VAD_THRESHOLD`,例如 `0.6`。 +默认 `OWNER_ENDPOINT_MODE=primary_speaker` 会在本轮问题开头建立临时音色画像;当这个主说话人音色连续消失 `OWNER_SPEAKER_ABSENT_MS=300` 毫秒后,直接结束录音进入转写。它不保存长期声纹、不做主人注册、不跨进程记忆。 + +默认 `OWNER_VAD_PROVIDER=hybrid` 会同时使用本地 `sherpa-onnx` VAD 和能量阈值兜底,帮助开始录音;结束录音优先由主说话人端点控制。如果你想临时回退旧行为,可以设置 `OWNER_ENDPOINT_MODE=vad`。如果唤醒后你已经停说但还长时间显示“录音中”,优先调小 `OWNER_SPEAKER_ABSENT_MS`,例如 `250`;如果句尾容易被切掉,再调高到 `400`。 ## 设备检查 @@ -89,7 +100,7 @@ python3.11 scripts/download_speech_models.py --dir models .venv/bin/python -m owner_voice_pet run-live ``` -运行后终端会显示待机、唤醒命中、应答中、请说出问题、录音中、转写中、转写结果、思考中、播放中、恢复待机等状态。说“小杰小杰”,听到“我在”且看到“请说出问题”后再提问;听到回复后可以再次说“小杰小杰”继续下一轮。本次进程内会携带临时历史,程序退出后不保存。 +运行后终端状态来自 pipeline event bus,会显示待机、唤醒命中、应答中、请说出问题、录音中、检测到用户语音、用户语音结束、转写中、转写结果、思考中、播放中、恢复待机等状态。说“小杰小杰”,听到“我在”且看到“请说出问题”后再提问;听到回复后可以再次说“小杰小杰”继续下一轮。本次进程内会携带临时历史,程序退出后不保存。 ## 验证 diff --git a/openspec/changes/separate-wake-and-realtime-transcript/tasks.md b/openspec/changes/separate-wake-and-realtime-transcript/tasks.md index 96993d7..d372ca5 100644 --- a/openspec/changes/separate-wake-and-realtime-transcript/tasks.md +++ b/openspec/changes/separate-wake-and-realtime-transcript/tasks.md @@ -52,5 +52,5 @@ - [x] 7.2 实现 pipeline event bus 和终端事件映射;前置条件:7.1 完成;验收标准:所有 live 用户可见状态由事件产生;测试要点:事件顺序和终端文案测试;优先级:P0;预计:60 分钟。 - [x] 7.3 实现 `TurnController` 和 `VoiceAssistantPipeline`;前置条件:7.2 完成;验收标准:`run-live` 使用统一 pipeline,成功/失败 turn 均恢复待机;测试要点:两轮 fake runtime、错误恢复、上下文回归;优先级:P0;预计:60 分钟。 - [x] 7.4 实现本轮主说话人端点;前置条件:7.3 完成;验收标准:主说话人音色消失约 300 ms 后结束采集;测试要点:一次提问后背景噪声不拖尾、短暂停顿不断句、画像不足回退;优先级:P0;预计:60 分钟。 -- [ ] 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 分钟。 +- [x] 7.5 更新 README、`.env.example`、本地 `.env` 非密钥配置;前置条件:7.2 至 7.4 完成;验收标准:运行说明匹配新 pipeline;测试要点:`--show-config` 不泄露 key;优先级:P0;预计:30 分钟。 +- [x] 7.6 验证并提交“Pipeline 文档验收”模块;前置条件:7.1 至 7.5 完成;验收标准:compileall、unittest、security-check、model-check、device-check、OpenSpec strict 全通过;优先级:P0;预计:30 分钟。 diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 4916406..e66f42e 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -59,6 +59,12 @@ def main(argv: list[str] | None = None) -> int: "wake_kws_score": config.wake_kws_score, "wake_ack_text": config.wake_ack_text, "post_playback_drain_ms": config.post_playback_drain_ms, + "pipeline_mode": config.pipeline_mode, + "endpoint_mode": config.endpoint_mode, + "speaker_profile_ms": config.speaker_profile_ms, + "speaker_absent_ms": config.speaker_absent_ms, + "speaker_similarity_threshold": config.speaker_similarity_threshold, + "speaker_min_rms": config.speaker_min_rms, "vad_provider": config.vad_provider, "vad_threshold": config.vad_threshold, "vad_min_duration_ms": config.vad_min_duration_ms, @@ -70,6 +76,7 @@ def main(argv: list[str] | None = None) -> int: "tts_model": config.tts_model, "tts_voice": config.tts_voice, "speech_models_dir": str(config.speech_models_dir), + "context_mode": config.context_mode, }, ensure_ascii=False, sort_keys=True, @@ -154,6 +161,12 @@ def main(argv: list[str] | None = None) -> int: wake_kws_score=config.wake_kws_score, wake_ack_text=config.wake_ack_text, post_playback_drain_ms=config.post_playback_drain_ms, + pipeline_mode=config.pipeline_mode, + endpoint_mode=config.endpoint_mode, + speaker_profile_ms=config.speaker_profile_ms, + speaker_absent_ms=config.speaker_absent_ms, + speaker_similarity_threshold=config.speaker_similarity_threshold, + speaker_min_rms=config.speaker_min_rms, vad_provider=config.vad_provider, vad_threshold=config.vad_threshold, vad_min_duration_ms=config.vad_min_duration_ms, @@ -165,6 +178,7 @@ def main(argv: list[str] | None = None) -> int: tts_model=config.tts_model, tts_voice=config.tts_voice, speech_models_dir=config.speech_models_dir, + context_mode=config.context_mode, context_max_messages=config.context_max_messages, context_max_chars=config.context_max_chars, ) diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index 62fc7e0..ee45142 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -42,6 +42,9 @@ class CliAcceptanceTests(unittest.TestCase): self.assertEqual(code, 0) self.assertTrue(data["llm_api_key_present"]) self.assertEqual(data["llm_model"], "file-model") + self.assertEqual(data["pipeline_mode"], "live_turn_based") + self.assertEqual(data["endpoint_mode"], "primary_speaker") + self.assertEqual(data["context_mode"], "session_memory") self.assertNotIn("secret-value", str(data)) def test_security_check_command_has_no_leaks(self) -> None: