[文档、验收与归档]:完成真实运行说明和OpenSpec归档,包含README、最终门禁和主规范更新

This commit is contained in:
mkbk
2026-06-17 20:17:04 +08:00
parent 4b7cd18a0f
commit 445eebeaec
15 changed files with 320 additions and 143 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
OWNER_LLM_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 OWNER_LLM_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
OWNER_LLM_API_KEY= OWNER_LLM_API_KEY=
OWNER_LLM_MODEL=gpt-5.4-mini OWNER_LLM_MODEL=mimo-v2.5
OWNER_LLM_API_STYLE=chat_completions OWNER_LLM_API_STYLE=chat_completions
OWNER_AUDIO_INPUT_DEVICE= OWNER_AUDIO_INPUT_DEVICE=
OWNER_AUDIO_OUTPUT_DEVICE= OWNER_AUDIO_OUTPUT_DEVICE=
@@ -9,7 +9,7 @@ OWNER_LOG_DIR=logs
OWNER_SPEECH_PROVIDER=cloud OWNER_SPEECH_PROVIDER=cloud
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=alloy OWNER_TTS_VOICE=mimo_default
OWNER_SPEECH_MODELS_DIR=models OWNER_SPEECH_MODELS_DIR=models
OWNER_CONTEXT_MAX_MESSAGES=12 OWNER_CONTEXT_MAX_MESSAGES=12
OWNER_CONTEXT_MAX_CHARS=12000 OWNER_CONTEXT_MAX_CHARS=12000
+80 -35
View File
@@ -1,57 +1,102 @@
# Owner 语音桌宠 # Owner 语音桌宠
这是 `add-voice-pet-pipeline` OpenSpec 变更交付的 Python 桌宠语音 Pipeline。 这是一个 Python 语音桌宠运行程序。当前第一版先提供无 GUI 的真实实时语音循环:
当前实现覆盖: `小杰小杰` 唤醒 -> 麦克风录音 -> VAD 切分 -> ASR 转文字 -> 携带本次进程内临时历史调用 LLM -> TTS 生成语音 -> 本机扬声器播放 -> 回到待机继续监听。
- “小杰小杰”唤醒词入口 ## 当前能力
- 本机音频 Transport 抽象和文件/内存回放测试通道
- VAD 人声端点检测
- 本地 STT Provider 边界和测试 Provider
- OpenAI/NewAPI 兼容 LLM Provider
- 本地 TTS Provider 边界和测试 TTS
- 桌宠状态映射、无 GUI fallback、透明 PNG 状态资产
- OpenSpec 主规范、自动化测试、CLI 验收和安全扫描
## 配置 - `owner_voice_pet run-live`:真实常驻语音循环。
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
- `.env` 直接读取配置,不要求导出 shell 环境变量。
- `OWNER_SPEECH_PROVIDER=cloud|local`:选择云端语音模型或本地语音模型。
- 默认云端语音模型:`mimo-v2.5-asr``mimo-v2.5-tts`
- 本地设备:`sounddevice` 读取麦克风,扬声器或 `afplay` 播放。
- 本地模型:`models/` 存放 `sherpa-onnx` VAD/STT 模型,目录不提交 Git。
- 临时上下文:同一次 `run-live` 进程内携带最近 user/assistant 历史,退出即清空。
密钥不得写入仓库。运行时默认读取项目根目录的 `.env` 文件: ## 首次准备
- `OWNER_LLM_BASE_URL`
- `OWNER_LLM_API_KEY`
- `OWNER_LLM_MODEL`
- `OWNER_LLM_API_STYLE`
默认配置面向当前 NewAPI
```bash ```bash
python3.11 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -e '.[speech]'
cp .env.example .env cp .env.example .env
# 然后编辑 .env,填入 OWNER_LLM_API_KEY
OWNER_LLM_BASE_URL=https://newapi.mkbk.shop
OWNER_LLM_MODEL=gpt-5.4-mini
OWNER_LLM_API_STYLE=chat_completions
``` ```
## 验收命令 然后编辑 `.env`,填写本地密钥和模型配置。当前默认示例:
```dotenv
OWNER_LLM_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
OWNER_LLM_API_KEY=
OWNER_LLM_MODEL=mimo-v2.5
OWNER_LLM_API_STYLE=chat_completions
OWNER_SPEECH_PROVIDER=cloud
OWNER_ASR_MODEL=mimo-v2.5-asr
OWNER_TTS_MODEL=mimo-v2.5-tts
OWNER_TTS_VOICE=mimo_default
```
`OWNER_SPEECH_PROVIDER=cloud` 会把 VAD 切出来的语音片段发送到云端 ASR;不会上传连续麦克风流。改成 `local` 时使用项目 `models/` 下的本地语音模型路径。
## 本地模型
即使默认走云端语音,也可以准备本地模型,便于切换到 `OWNER_SPEECH_PROVIDER=local`
```bash ```bash
PYTHONPATH=src python3.11 -m compileall src tests scripts python3.11 scripts/download_speech_models.py --dir models
PYTHONPATH=src python3.11 -m unittest discover -s tests .venv/bin/python -m owner_voice_pet model-check --models-dir models
PYTHONPATH=src python3.11 -m owner_voice_pet acceptance ```
PYTHONPATH=src python3.11 -m owner_voice_pet validate-assets
PYTHONPATH=src python3.11 -m owner_voice_pet security-check `models/` 已在 `.gitignore` 中,不会提交大模型文件。
## 设备检查
```bash
.venv/bin/python -m owner_voice_pet device-check
```
输出里 `ok=true` 表示已检测到可用麦克风和扬声器。
## 运行
单轮验收:
```bash
.venv/bin/python -m owner_voice_pet run-live --once
```
常驻重复对话:
```bash
.venv/bin/python -m owner_voice_pet run-live
```
运行后终端会显示待机、唤醒命中、录音中、转写中、思考中、播放中、恢复待机等状态。说“小杰小杰”后提问;听到回复后可以再次说“小杰小杰”继续下一轮。本次进程内会携带临时历史,程序退出后不保存。
## 验证
```bash
.venv/bin/python -m compileall src tests scripts
.venv/bin/python -m unittest discover -s tests
.venv/bin/python -m owner_voice_pet acceptance
.venv/bin/python -m owner_voice_pet validate-assets
.venv/bin/python -m owner_voice_pet security-check
.venv/bin/python -m owner_voice_pet model-check --models-dir models
.venv/bin/python -m owner_voice_pet device-check
openspec validate --all --strict openspec validate --all --strict
``` ```
真实 NewAPI smoke 测试示例 LLM smoke
```bash ```bash
PYTHONPATH=src python3.11 -m owner_voice_pet llm-smoke --no-stream --message '用一句中文回复:小杰在线。' .venv/bin/python -m owner_voice_pet llm-smoke --no-stream --message '用一句中文回复:小杰在线。'
``` ```
## 安全约束 ## 安全约束
- API key 只从环境变量读取 - API key 只写入本地 `.env``.env` 不提交 Git
- 仓库内不保存真实密钥 - `models/``.venv/` 和临时音频文件不提交 Git
- 默认不持久化原始麦克风音频。 - 默认不持久化麦克风原始音频。
- `security-check` 会扫描已跟踪文本文件中的 `sk-...` 形式密钥 - 云端 ASR 只接收 VAD 切分后的语音片段
- `security-check` 会扫描已跟踪文本文件中的 `sk-...``tp-...` 形式密钥。
@@ -186,7 +186,7 @@ Test requirements:
First implementation can use one of two strategies: First implementation can use one of two strategies:
1. Default cloud mode: local VAD cuts speech segments, `mimo-v2.5-asr` transcribes wake and user segments through NewAPI-compatible `/v1/audio/transcriptions`. 1. Default cloud mode: local VAD cuts speech segments, `mimo-v2.5-asr` transcribes wake and user segments through NewAPI-compatible `/v1/chat/completions` audio input messages.
2. Local mode: sherpa-onnx VAD/STT uses project-local model assets under `models/`. 2. Local mode: sherpa-onnx VAD/STT uses project-local model assets under `models/`.
3. Future replacement: dedicated local KWS provider for “小杰小杰”. 3. Future replacement: dedicated local KWS provider for “小杰小杰”.
@@ -138,7 +138,7 @@
#### `.env` 输入 #### `.env` 输入
1. `OWNER_LLM_BASE_URL`:默认 `https://newapi.mkbk.shop`,作为 OpenAI/NewAPI 兼容接口 base URL。 1. `OWNER_LLM_BASE_URL`:默认 `https://token-plan-cn.xiaomimimo.com/v1`,作为 OpenAI/NewAPI 兼容接口 base URL。
2. `OWNER_LLM_API_KEY`:真实密钥,只允许存在于 `.env` 或用户本地未提交配置。 2. `OWNER_LLM_API_KEY`:真实密钥,只允许存在于 `.env` 或用户本地未提交配置。
3. `OWNER_LLM_MODEL`:云端模型名,通过 `.env` 配置指定。 3. `OWNER_LLM_MODEL`:云端模型名,通过 `.env` 配置指定。
4. `OWNER_LLM_API_STYLE`:第一版保留 `chat_completions` 或现有兼容值。 4. `OWNER_LLM_API_STYLE`:第一版保留 `chat_completions` 或现有兼容值。
@@ -138,7 +138,7 @@ The system SHALL use the local microphone as the first-version input Transport a
- **WHEN** the microphone or speaker is missing, denied, unsupported, or inaccessible through `sounddevice` - **WHEN** the microphone or speaker is missing, denied, unsupported, or inaccessible through `sounddevice`
- **THEN** the system SHALL expose a recoverable Transport error with a stable error code and SHALL NOT silently fall back to fixture audio in live mode - **THEN** the system SHALL expose a recoverable Transport error with a stable error code and SHALL NOT silently fall back to fixture audio in live mode
### Requirement: Configured STT transcription ### Requirement: Local STT transcription
The system SHALL transcribe captured user utterances through the configured STT provider; cloud mode SHALL use NewAPI-compatible ASR and local mode SHALL use project-local `sherpa-onnx` model assets under `models/`. The system SHALL transcribe captured user utterances through the configured STT provider; cloud mode SHALL use NewAPI-compatible ASR and local mode SHALL use project-local `sherpa-onnx` model assets under `models/`.
#### Scenario: STT succeeds #### Scenario: STT succeeds
@@ -153,7 +153,7 @@ The system SHALL transcribe captured user utterances through the configured STT
- **WHEN** the STT provider raises an error, cloud ASR fails, or local model loading fails - **WHEN** the STT provider raises an error, cloud ASR fails, or local model loading fails
- **THEN** the system SHALL emit an STT or model error code and SHALL recover to a state where future wake attempts are possible if startup can continue safely - **THEN** the system SHALL emit an STT or model error code and SHALL recover to a state where future wake attempts are possible if startup can continue safely
### Requirement: Configured TTS synthesis and playback ### Requirement: Local TTS synthesis and playback
The system SHALL synthesize assistant replies through the configured TTS provider and SHALL play synthesized speech through the local system output path; cloud mode SHALL use NewAPI-compatible TTS and local mode SHALL use a local playback-capable TTS provider. The system SHALL synthesize assistant replies through the configured TTS provider and SHALL play synthesized speech through the local system output path; cloud mode SHALL use NewAPI-compatible TTS and local mode SHALL use a local playback-capable TTS provider.
#### Scenario: Reply text is ready for speech #### Scenario: Reply text is ready for speech
@@ -45,10 +45,10 @@
## 6. 文档、真实验收、归档 ## 6. 文档、真实验收、归档
- [ ] 6.1 更新 README 中文运行说明;前置条件:CLI 和脚本完成;验收标准:包含 `.venv``.env`、模型下载、model-check、device-check、run-live、--once;测试要点:命令可复制执行;优先级:P0;预计:45 分钟。 - [x] 6.1 更新 README 中文运行说明;前置条件:CLI 和脚本完成;验收标准:包含 `.venv``.env`、模型下载、model-check、device-check、run-live、--once;测试要点:命令可复制执行;优先级:P0;预计:45 分钟。
- [ ] 6.2 执行本地模型验收;前置条件:模型已下载;验收标准:`python3.11 scripts/download_speech_models.py --dir models``model-check` 通过或给出明确设备/模型失败证据;测试要点:输出不泄露 key;优先级:P0;预计:60 分钟。 - [x] 6.2 执行本地模型验收;前置条件:模型已下载;验收标准:`python3.11 scripts/download_speech_models.py --dir models``model-check` 通过或给出明确设备/模型失败证据;测试要点:输出不泄露 key;优先级:P0;预计:60 分钟。
- [ ] 6.3 执行设备验收;前置条件:sounddevice 安装;验收标准:`device-check` 通过或输出明确权限/设备原因;测试要点:真实设备清单;优先级:P0;预计:30 分钟。 - [x] 6.3 执行设备验收;前置条件:sounddevice 安装;验收标准:`device-check` 通过或输出明确权限/设备原因;测试要点:真实设备清单;优先级:P0;预计:30 分钟。
- [ ] 6.4 执行真实运行验收;前置条件:`.env`、模型、设备齐全;验收标准:`run-live` 可被人工唤醒并完成至少两轮;第二轮可引用第一轮临时历史;测试要点:终端状态和听到播放;优先级:P0;预计:60 分钟。 - [x] 6.4 执行真实运行验收;前置条件:`.env`、模型、设备齐全;验收标准:`run-live` 可被人工唤醒并完成至少两轮;第二轮可引用第一轮临时历史;测试要点:终端状态和听到播放;优先级:P0;预计:60 分钟。
- [ ] 6.5 执行最终自动化门禁;前置条件:全部实现完成;验收标准:compileall、unittest、security-check、OpenSpec validate 全通过;测试要点:`git status --short` 没有未提交源码/文档变更;优先级:P0;预计:45 分钟。 - [x] 6.5 执行最终自动化门禁;前置条件:全部实现完成;验收标准:compileall、unittest、security-check、OpenSpec validate 全通过;测试要点:`git status --short` 没有未提交源码/文档变更;优先级:P0;预计:45 分钟。
- [ ] 6.6 归档 `complete-live-repeat-voice-runtime`;前置条件:任务全完成并验证通过;验收标准:主 spec 更新,change 移入 archive`openspec validate --all --strict` 通过;测试要点:归档后 spec 包含 live runtime 要求;优先级:P0;预计:30 分钟。 - [x] 6.6 归档 `complete-live-repeat-voice-runtime`;前置条件:任务全完成并验证通过;验收标准:主 spec 更新,change 移入 archive`openspec validate --all --strict` 通过;测试要点:归档后 spec 包含 live runtime 要求;优先级:P0;预计:30 分钟。
- [ ] 6.7 最终提交“文档、验收与归档”模块;前置条件:6.1 至 6.6 完成;验收标准:最终门禁通过后 commit,工作树干净;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。 - [x] 6.7 最终提交“文档、验收与归档”模块;前置条件:6.1 至 6.6 完成;验收标准:最终门禁通过后 commit,工作树干净;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。
+148 -39
View File
@@ -15,19 +15,19 @@ The system SHALL be specified as a local Python desktop pet application that own
- **THEN** the repository SHALL contain Python source code, automated tests, validation commands, and project-local pet assets aligned with the planning artifacts - **THEN** the repository SHALL contain Python source code, automated tests, validation commands, and project-local pet assets aligned with the planning artifacts
### Requirement: Local microphone and speaker transport ### Requirement: Local microphone and speaker transport
The system SHALL use the local microphone as the first-version input Transport and the local system speaker as the first-version output Transport. The system SHALL use the local microphone as the first-version input Transport and the local system speaker as the first-version output Transport, and `run-live` SHALL exercise those real local devices by default.
#### Scenario: Microphone input is available #### Scenario: Microphone input is available
- **WHEN** the configured microphone is available and permitted - **WHEN** the configured microphone is available and permitted
- **THEN** the Transport SHALL provide streaming audio frames to the wakeword and VAD stages - **THEN** the live Transport SHALL provide streaming audio frames to wake detection, VAD, and STT stages
#### Scenario: Speaker output is available #### Scenario: Speaker output is available
- **WHEN** TTS returns a valid audio segment and the configured speaker is available - **WHEN** TTS returns a valid local audio segment or audio file and the configured speaker is available
- **THEN** the Transport SHALL play the audio segment through the local speaker - **THEN** the live Transport or macOS playback command SHALL play the audio through the local speaker
#### Scenario: Audio device is unavailable #### Scenario: Audio device is unavailable
- **WHEN** the microphone or speaker is missing, denied, or unsupported - **WHEN** the microphone or speaker is missing, denied, unsupported, or inaccessible through `sounddevice`
- **THEN** the system SHALL expose a recoverable Transport error with a stable error code - **THEN** the system SHALL expose a recoverable Transport error with a stable error code and SHALL NOT silently fall back to fixture audio in live mode
### Requirement: Wake word detection ### Requirement: Wake word detection
The system SHALL listen locally for the Chinese wake word “小杰小杰” before accepting user speech for a conversation turn. The system SHALL listen locally for the Chinese wake word “小杰小杰” before accepting user speech for a conversation turn.
@@ -64,37 +64,41 @@ After wakeword detection, the system SHALL use VAD to identify when the user sta
- **THEN** the pipeline SHALL end the segment, mark the end reason, and continue to STT with the captured audio - **THEN** the pipeline SHALL end the segment, mark the end reason, and continue to STT with the captured audio
### Requirement: Local STT transcription ### Requirement: Local STT transcription
The system SHALL transcribe the captured user utterance through a local STT provider, with `sherpa-onnx` as the recommended first implementation candidate. The system SHALL transcribe captured user utterances through the configured STT provider; cloud mode SHALL use NewAPI-compatible ASR and local mode SHALL use project-local `sherpa-onnx` model assets under `models/`.
#### Scenario: STT succeeds #### Scenario: STT succeeds
- **WHEN** STT returns non-empty text for a captured audio segment - **WHEN** configured STT returns non-empty text for a captured live audio segment
- **THEN** the pipeline SHALL add the trimmed text as a user message to the conversation context - **THEN** the pipeline SHALL add the trimmed text as a user message to the current process conversation context
#### Scenario: STT returns empty text #### Scenario: STT returns empty text
- **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 pipeline SHALL skip LLM invocation and return to wake listening with a recoverable status - **THEN** the live runtime SHALL skip LLM invocation and return to standby with a recoverable status
#### Scenario: STT provider fails #### Scenario: STT provider fails
- **WHEN** the STT provider raises an error or cannot load its local model - **WHEN** the STT provider raises an error, cloud ASR fails, or local model loading fails
- **THEN** the system SHALL emit an STT error code and SHALL recover to a state where future wake attempts are possible - **THEN** the system SHALL emit an STT or model error code and SHALL recover to a state where future wake attempts are possible if startup can continue safely
### Requirement: Conversation context management ### Requirement: Conversation context management
The system SHALL maintain an in-memory conversation context for the active desktop pet session. The system SHALL maintain an in-memory conversation context for the active desktop pet session, and live repeated voice runtime SHALL define that session as the current `run-live` process only.
#### Scenario: User transcript is accepted #### Scenario: User transcript is accepted
- **WHEN** a valid STT transcript is produced - **WHEN** a valid STT transcript is produced during a live turn
- **THEN** the system SHALL append it to the context as a user message before invoking the LLM - **THEN** the system SHALL append it to the current process context as a user message before invoking the LLM
#### Scenario: Assistant reply completes #### Scenario: Assistant reply completes
- **WHEN** the LLM and TTS stages complete a reply - **WHEN** the LLM and TTS stages complete a non-empty reply during a live turn
- **THEN** the system SHALL append the assistant text to the context - **THEN** the system SHALL append the assistant text to the current process context
#### Scenario: Context exceeds configured budget #### Scenario: Context exceeds configured budget
- **WHEN** the context exceeds the configured message, character, or token budget - **WHEN** the context exceeds the configured message or character budget
- **THEN** the system SHALL preserve the system prompt and most recent conversation turns while removing older ordinary messages - **THEN** the system SHALL preserve the system prompt and most recent conversation turns while removing older ordinary messages
#### Scenario: Runtime exits
- **WHEN** the `run-live` process exits
- **THEN** the system SHALL discard the context and SHALL NOT persist it across process restarts
### Requirement: Cloud LLM streaming reply ### Requirement: Cloud LLM streaming reply
The system SHALL use a cloud LLM provider for reply generation, with OpenAI Responses API streaming as the recommended first implementation. The system SHALL use a cloud LLM provider for reply generation, with an OpenAI/NewAPI-compatible chat completions endpoint as the first-version runtime target.
#### Scenario: LLM stream begins #### Scenario: LLM stream begins
- **WHEN** the LLM provider receives valid context messages and configuration - **WHEN** the LLM provider receives valid context messages and configuration
@@ -102,26 +106,26 @@ The system SHALL use a cloud LLM provider for reply generation, with OpenAI Resp
#### Scenario: LLM model is configured #### Scenario: LLM model is configured
- **WHEN** the system starts - **WHEN** the system starts
- **THEN** the LLM model name SHALL be read from configuration and SHALL NOT be hard-coded in pipeline logic - **THEN** the LLM base URL, API key, and model name SHALL be read from `.env` or local uncommitted configuration and SHALL NOT be hard-coded in pipeline logic
#### Scenario: LLM request fails #### Scenario: LLM request fails
- **WHEN** the LLM provider times out, is rate limited, lacks credentials, or encounters a network error - **WHEN** the LLM provider times out, is rate limited, lacks credentials, or encounters a network error
- **THEN** the pipeline SHALL emit a structured LLM error and transition through error recovery to wake listening - **THEN** the pipeline SHALL emit a structured LLM error and transition through error recovery to wake listening
### Requirement: Local TTS synthesis and playback ### Requirement: Local TTS synthesis and playback
The system SHALL synthesize assistant replies through a local TTS provider, with `sherpa-onnx` as the recommended first implementation candidate. The system SHALL synthesize assistant replies through the configured TTS provider and SHALL play synthesized speech through the local system output path; cloud mode SHALL use NewAPI-compatible TTS and local mode SHALL use a local playback-capable TTS provider.
#### Scenario: Sentence is ready for speech #### Scenario: Reply text is ready for speech
- **WHEN** the LLM stream produces a complete sentence or configured text chunk - **WHEN** the LLM produces a non-empty reply for a live turn
- **THEN** the TTS provider SHALL synthesize that text into an audio segment - **THEN** the TTS provider SHALL synthesize that text into locally playable speech
#### Scenario: First TTS segment is ready #### Scenario: Playback starts
- **WHEN** the first synthesized audio segment is available - **WHEN** the first synthesized audio output is available
- **THEN** the output Transport SHALL begin playback without waiting for every remaining LLM delta - **THEN** the output Transport or macOS playback command SHALL begin playback through the local speaker
#### Scenario: TTS fails #### Scenario: TTS fails
- **WHEN** TTS returns empty audio or raises an error - **WHEN** TTS returns empty audio, fails to create an audio file, or playback command fails
- **THEN** the pipeline SHALL emit a structured TTS error and SHALL recover without terminating the application - **THEN** the live runtime SHALL emit a structured TTS or playback error and SHALL recover without terminating default loop mode
### Requirement: Pipeline state machine ### Requirement: Pipeline state machine
The system SHALL expose deterministic pipeline states for `idle`, `wake_listening`, `speech_detecting`, `recording`, `transcribing`, `thinking`, `speaking`, `interrupted`, and `error_recovering`. The system SHALL expose deterministic pipeline states for `idle`, `wake_listening`, `speech_detecting`, `recording`, `transcribing`, `thinking`, `speaking`, `interrupted`, and `error_recovering`.
@@ -222,7 +226,7 @@ The system SHALL protect credentials and minimize audio persistence.
#### Scenario: API key is required #### Scenario: API key is required
- **WHEN** the LLM provider needs an OpenAI API key - **WHEN** the LLM provider needs an OpenAI API key
- **THEN** the key SHALL be loaded from environment or local uncommitted configuration and SHALL NOT be committed - **THEN** the key SHALL be loaded from `.env` or local uncommitted configuration and SHALL NOT be committed
#### Scenario: Audio is processed #### Scenario: Audio is processed
- **WHEN** user speech is captured for STT - **WHEN** user speech is captured for STT
@@ -233,19 +237,23 @@ The system SHALL protect credentials and minimize audio persistence.
- **THEN** it SHALL send transcript text and necessary conversation context, not raw microphone audio - **THEN** it SHALL send transcript text and necessary conversation context, not raw microphone audio
### Requirement: Testability ### Requirement: Testability
The system SHALL be designed so each stage can be tested with mock providers and file-based audio fixtures. The system SHALL be designed so each stage can be tested with mock providers, file-based audio fixtures, and fake live runtime components without requiring real devices in automated tests.
#### Scenario: Pipeline is unit tested #### Scenario: Repeated runtime is unit tested
- **WHEN** mock providers produce deterministic events - **WHEN** fake live providers produce two deterministic turns
- **THEN** tests SHALL verify state transitions, context updates, and error recovery - **THEN** tests SHALL verify two STT calls, two LLM calls, two TTS calls, two playback calls, and final return to standby
#### Scenario: Audio fixture is replayed #### Scenario: Temporary context is unit tested
- **WHEN** a file-based audio fixture containing “小杰小杰” and user speech is replayed in a test Transport - **WHEN** fake live providers run two turns in one runtime instance
- **THEN** the pipeline SHALL be testable without using a live microphone - **THEN** tests SHALL verify the second LLM request includes the first turn user and assistant messages
#### Scenario: Process-local context is unit tested
- **WHEN** a second runtime instance is created after a first instance has conversation history
- **THEN** tests SHALL verify the second instance starts with no user/assistant history
#### Scenario: OpenSpec planning validation runs #### Scenario: OpenSpec planning validation runs
- **WHEN** this planning change is complete - **WHEN** this change is complete before implementation
- **THEN** `openspec validate add-voice-pet-pipeline --strict` and `openspec validate --all --strict` SHALL pass - **THEN** `openspec validate complete-live-repeat-voice-runtime --strict` and `openspec validate --all --strict` SHALL pass
### Requirement: Module-level git commit gates ### Requirement: Module-level git commit gates
The system implementation process SHALL require an immediate Git commit after each major module or milestone is completed, where a major module means a top-level task group from `tasks.md` or a phase milestone from `proposal.md`. The system implementation process SHALL require an immediate Git commit after each major module or milestone is completed, where a major module means a top-level task group from `tasks.md` or a phase milestone from `proposal.md`.
@@ -266,3 +274,104 @@ The system implementation process SHALL require an immediate Git commit after ea
- **WHEN** module-related changes remain unstaged or uncommitted after the module is completed - **WHEN** module-related changes remain unstaged or uncommitted after the module is completed
- **THEN** the implementer SHALL include those changes in the module commit or explicitly separate unrelated external changes before continuing to the next module - **THEN** the implementer SHALL include those changes in the module commit or explicitly separate unrelated external changes before continuing to the next module
### Requirement: Live repeat voice runtime
The system SHALL provide a `run-live` command that performs real repeated voice conversation with local microphone input, configured speech recognition and speech synthesis providers, cloud LLM reply generation, local speaker playback, and automatic return to standby.
#### Scenario: Live runtime starts in standby
- **WHEN** the user runs `PYTHONPATH=src python3.11 -m owner_voice_pet run-live`
- **THEN** the system SHALL load `.env`, validate required live dependencies, initialize local audio/model providers, and enter a standby listening loop
#### Scenario: Live runtime completes two turns
- **WHEN** the user wakes the system with “小杰小杰”, asks a question, hears the reply, then wakes it again and asks another question
- **THEN** the system SHALL complete wake, recording, STT, LLM, TTS, playback for both turns and SHALL return to standby after each turn
#### Scenario: Once mode completes one turn
- **WHEN** the user runs `PYTHONPATH=src python3.11 -m owner_voice_pet run-live --once`
- **THEN** the system SHALL run at most one wake-to-playback turn and exit after the turn completes or fails with a documented live error
### Requirement: Temporary in-process conversation history
The live runtime SHALL maintain conversation history only in memory for the current process and SHALL include retained user/assistant history in later LLM requests during that same process.
#### Scenario: Second turn uses first turn history
- **WHEN** the first live turn appends a user transcript and an assistant reply
- **AND** a second live turn sends an LLM request
- **THEN** the second request SHALL include the first turn user message and assistant message unless configured context limits require truncation
#### Scenario: New runtime starts empty
- **WHEN** a new `run-live` process or new live runtime instance starts
- **THEN** it SHALL NOT load user/assistant history from a previous process or previous runtime instance
#### Scenario: Process exits
- **WHEN** the live runtime exits for any reason
- **THEN** conversation history SHALL be discarded and SHALL NOT be written to disk, database, logs, or model files
### Requirement: Local speech model management
The system SHALL provide project-local speech model preparation and diagnostics for live VAD/STT operation, storing downloaded model artifacts under `models/` without committing them to Git.
#### Scenario: Models are downloaded
- **WHEN** the user runs `python3.11 scripts/download_speech_models.py --dir models`
- **THEN** the script SHALL create or update a project-local model directory with the files required by the configured VAD/STT providers
#### Scenario: Model check succeeds
- **WHEN** required dependencies and model files are available
- **THEN** `PYTHONPATH=src python3.11 -m owner_voice_pet model-check` SHALL exit successfully and report the model directory and checked providers
#### Scenario: Model check fails
- **WHEN** `sherpa-onnx` is unavailable, a model file is missing, or a model cannot be loaded
- **THEN** `model-check` SHALL fail with a structured model error and SHALL NOT start live microphone listening
### Requirement: Configurable speech provider mode
The system SHALL read `OWNER_SPEECH_PROVIDER` from `.env` to choose between cloud speech providers and local speech providers for first-version live runtime.
#### Scenario: Cloud speech provider is selected
- **WHEN** `OWNER_SPEECH_PROVIDER=cloud`
- **THEN** live ASR SHALL use the configured cloud model from `OWNER_ASR_MODEL`, live TTS SHALL use the configured cloud model from `OWNER_TTS_MODEL`, and the implementation SHALL default those models to `mimo-v2.5-asr` and `mimo-v2.5-tts`
#### Scenario: Local speech provider is selected
- **WHEN** `OWNER_SPEECH_PROVIDER=local`
- **THEN** live ASR/VAD SHALL use project-local speech model assets and local TTS SHALL use a local playback-capable provider
#### Scenario: Speech provider is invalid
- **WHEN** `OWNER_SPEECH_PROVIDER` is neither `cloud` nor `local`
- **THEN** startup validation SHALL fail with a structured configuration error
### Requirement: Live audio device readiness
The system SHALL provide live audio device diagnostics and SHALL use `sounddevice` for first-version real microphone and speaker access.
#### Scenario: Device check succeeds
- **WHEN** `sounddevice` can be imported and at least one input and one output device are available
- **THEN** `PYTHONPATH=src python3.11 -m owner_voice_pet device-check` SHALL exit successfully and report usable audio devices
#### Scenario: Device check fails
- **WHEN** `sounddevice` is missing, device query fails, microphone permission is denied, or no usable input/output device exists
- **THEN** `device-check` SHALL fail with a structured audio device error
#### Scenario: Live runtime uses real devices
- **WHEN** `run-live` starts successfully
- **THEN** it SHALL use the local microphone as its default input and the local speaker or macOS playback command as its default output rather than fixture audio
### Requirement: Live terminal state reporting
The live runtime SHALL emit concise Chinese terminal status messages for observable runtime states.
#### Scenario: Normal turn status
- **WHEN** a live turn succeeds
- **THEN** terminal output SHALL include states equivalent to standby, wake hit, recording, transcribing, thinking, speaking, and returning to standby
#### Scenario: Recoverable error status
- **WHEN** a live turn encounters empty STT, LLM failure, TTS failure, or playback failure
- **THEN** terminal output SHALL include the failing stage and a stable error code or recoverable explanation
### Requirement: Live runtime error recovery
The live runtime SHALL recover from turn-level failures and continue listening in default loop mode.
#### Scenario: LLM fails during default loop
- **WHEN** the LLM provider times out, is rate limited, or returns a network error during a live turn
- **THEN** the system SHALL report `LIVE_LLM_FAILED` or an equivalent structured error and SHALL return to standby without terminating the process
#### Scenario: TTS or playback fails during default loop
- **WHEN** local TTS or audio playback fails during a live turn
- **THEN** the system SHALL report the failure and SHALL return to standby without terminating the process
#### Scenario: Startup dependency fails
- **WHEN** `.env`, required models, or audio devices are unavailable at startup
- **THEN** the system SHALL exit with a documented non-zero status instead of entering a fake live loop
+4 -4
View File
@@ -13,7 +13,7 @@ class AppConfig:
channels: int = 1 channels: int = 1
llm_base_url: str = "https://token-plan-cn.xiaomimimo.com/v1" llm_base_url: str = "https://token-plan-cn.xiaomimimo.com/v1"
llm_api_key: str | None = None llm_api_key: str | None = None
llm_model: str = "gpt-5.4-mini" llm_model: str = "mimo-v2.5"
llm_api_style: str = "chat_completions" llm_api_style: str = "chat_completions"
llm_stream: bool = True llm_stream: bool = True
audio_input_device: str | None = None audio_input_device: str | None = None
@@ -23,7 +23,7 @@ class AppConfig:
speech_provider: str = "cloud" speech_provider: str = "cloud"
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 = "alloy" tts_voice: str = "mimo_default"
speech_models_dir: Path = Path("models") speech_models_dir: Path = Path("models")
context_max_messages: int = 12 context_max_messages: int = 12
context_max_chars: int = 12000 context_max_chars: int = 12000
@@ -42,7 +42,7 @@ class AppConfig:
channels=int(get("CHANNELS", "1") or "1"), channels=int(get("CHANNELS", "1") or "1"),
llm_base_url=(get("LLM_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1") or "").rstrip("/"), llm_base_url=(get("LLM_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1") or "").rstrip("/"),
llm_api_key=get("LLM_API_KEY"), llm_api_key=get("LLM_API_KEY"),
llm_model=get("LLM_MODEL", "gpt-5.4-mini") or "gpt-5.4-mini", llm_model=get("LLM_MODEL", "mimo-v2.5") or "mimo-v2.5",
llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions", llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions",
llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"}, llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"},
audio_input_device=get("AUDIO_INPUT_DEVICE"), audio_input_device=get("AUDIO_INPUT_DEVICE"),
@@ -52,7 +52,7 @@ class AppConfig:
speech_provider=(get("SPEECH_PROVIDER", "cloud") or "cloud").lower(), speech_provider=(get("SPEECH_PROVIDER", "cloud") or "cloud").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", "alloy") or "alloy", tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"), speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"),
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"), context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"), context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
+23 -36
View File
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import io import io
import base64
import json import json
import re import re
import socket import socket
import urllib.error import urllib.error
import urllib.request import urllib.request
import uuid
import wave import wave
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -84,19 +84,28 @@ class CloudAsrSttProvider:
"newapi-asr", "newapi-asr",
"stt", "stt",
) )
wav_bytes = _segment_to_wav_bytes(segment) audio_data = base64.b64encode(_segment_to_wav_bytes(segment)).decode("ascii")
boundary = "owner-voice-pet-" + uuid.uuid4().hex payload = {
body = _multipart_form_data( "model": self.config.asr_model,
boundary, "messages": [
fields={"model": self.config.asr_model, "response_format": "json"}, {
files={"file": ("utterance.wav", "audio/wav", wav_bytes)}, "role": "user",
) "content": [
{
"type": "input_audio",
"input_audio": {"data": f"data:audio/wav;base64,{audio_data}"},
}
],
}
],
"asr_options": {"language": "auto"},
}
request = urllib.request.Request( request = urllib.request.Request(
self.config.api_url("/v1/audio/transcriptions"), self.config.api_url("/v1/chat/completions"),
data=body, data=json.dumps(payload).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {self.config.llm_api_key}", "Authorization": f"Bearer {self.config.llm_api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}", "Content-Type": "application/json",
}, },
method="POST", method="POST",
) )
@@ -119,7 +128,9 @@ class CloudAsrSttProvider:
"newapi-asr", "newapi-asr",
"stt", "stt",
) from exc ) from exc
text = str(payload.get("text") or "").strip() choices = payload.get("choices") or []
message = (choices[0].get("message") if choices else {}) or {}
text = str(message.get("content") or payload.get("text") or "").strip()
if not is_valid_transcript_text(text): if not is_valid_transcript_text(text):
raise ProviderError( raise ProviderError(
ErrorCode.STT_EMPTY_TRANSCRIPT, ErrorCode.STT_EMPTY_TRANSCRIPT,
@@ -261,27 +272,3 @@ def _segment_to_wav_bytes(segment: AudioSegment) -> bytes:
wav.setframerate(segment.sample_rate) wav.setframerate(segment.sample_rate)
wav.writeframes(segment.pcm) wav.writeframes(segment.pcm)
return buffer.getvalue() return buffer.getvalue()
def _multipart_form_data(
boundary: str,
*,
fields: dict[str, str],
files: dict[str, tuple[str, str, bytes]],
) -> bytes:
body = bytearray()
for name, value in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(value.encode("utf-8"))
body.extend(b"\r\n")
for name, (filename, content_type, data) in files.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
)
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode())
body.extend(data)
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
return bytes(body)
+23 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import math import math
import base64
import json import json
import socket import socket
import struct import struct
@@ -166,12 +167,12 @@ class CloudTtsProvider:
) )
payload = { payload = {
"model": self.config.tts_model, "model": self.config.tts_model,
"input": clean, "messages": [{"role": "assistant", "content": clean}],
"voice": self.config.tts_voice, "audio": {"format": "wav", "voice": self.config.tts_voice},
"response_format": "mp3", "stream": False,
} }
request = urllib.request.Request( request = urllib.request.Request(
self.config.api_url("/v1/audio/speech"), self.config.api_url("/v1/chat/completions"),
data=json.dumps(payload).encode("utf-8"), data=json.dumps(payload).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {self.config.llm_api_key}", "Authorization": f"Bearer {self.config.llm_api_key}",
@@ -181,7 +182,7 @@ class CloudTtsProvider:
) )
try: try:
with self.urlopen(request, timeout=self.timeout_s) as response: with self.urlopen(request, timeout=self.timeout_s) as response:
data = response.read() payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
raise ProviderError( raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED, ErrorCode.TTS_SYNTHESIS_FAILED,
@@ -190,7 +191,7 @@ class CloudTtsProvider:
"newapi-tts", "newapi-tts",
"tts", "tts",
) from exc ) from exc
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc: except (urllib.error.URLError, TimeoutError, socket.timeout, json.JSONDecodeError) as exc:
raise ProviderError( raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED, ErrorCode.TTS_SYNTHESIS_FAILED,
f"cloud TTS request failed: {exc}", f"cloud TTS request failed: {exc}",
@@ -198,7 +199,11 @@ class CloudTtsProvider:
"newapi-tts", "newapi-tts",
"tts", "tts",
) from exc ) from exc
if not data: choices = payload.get("choices") or []
message = (choices[0].get("message") if choices else {}) or {}
audio = message.get("audio") or {}
encoded = audio.get("data")
if not encoded:
raise ProviderError( raise ProviderError(
ErrorCode.TTS_EMPTY_AUDIO, ErrorCode.TTS_EMPTY_AUDIO,
"cloud TTS returned empty audio", "cloud TTS returned empty audio",
@@ -206,4 +211,14 @@ class CloudTtsProvider:
"newapi-tts", "newapi-tts",
"tts", "tts",
) )
return AudioSegment(data, 16000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "mp3"}) try:
data = base64.b64decode(encoded)
except (TypeError, ValueError) as exc:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
f"cloud TTS returned invalid base64 audio: {exc}",
True,
"newapi-tts",
"tts",
) from exc
return AudioSegment(data, 24000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "wav"})
+1 -1
View File
@@ -72,7 +72,7 @@ class ModelsConfigTests(unittest.TestCase):
self.assertEqual(config.speech_provider, "cloud") self.assertEqual(config.speech_provider, "cloud")
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, "alloy") self.assertEqual(config.tts_voice, "mimo_default")
self.assertEqual(str(config.speech_models_dir), "models") self.assertEqual(str(config.speech_models_dir), "models")
self.assertTrue(config.llm_stream) self.assertTrue(config.llm_stream)
self.assertEqual(config.validate_basic(), []) self.assertEqual(config.validate_basic(), [])
+18 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import base64
import json import json
import unittest import unittest
@@ -75,7 +76,17 @@ class PipelineLlmTtsTests(unittest.TestCase):
return None return None
def read(self) -> bytes: def read(self) -> bytes:
return b"fake-mp3" return json.dumps(
{
"choices": [
{
"message": {
"audio": {"data": base64.b64encode(b"fake-wav").decode("ascii")}
}
}
]
}
).encode()
requests = [] requests = []
@@ -90,11 +101,14 @@ class PipelineLlmTtsTests(unittest.TestCase):
provider.load() provider.load()
segment = provider.synthesize("你好") segment = provider.synthesize("你好")
self.assertEqual(segment.metadata["format"], "mp3") self.assertEqual(segment.metadata["format"], "wav")
self.assertEqual(segment.pcm, b"fake-mp3") self.assertEqual(segment.pcm, b"fake-wav")
body = json.loads(requests[0].data.decode()) body = json.loads(requests[0].data.decode())
self.assertEqual(body["model"], "mimo-v2.5-tts") self.assertEqual(body["model"], "mimo-v2.5-tts")
self.assertIn("/v1/audio/speech", requests[0].full_url) self.assertEqual(body["messages"][0]["role"], "assistant")
self.assertEqual(body["messages"][0]["content"], "你好")
self.assertEqual(body["audio"]["format"], "wav")
self.assertIn("/v1/chat/completions", requests[0].full_url)
def test_pipeline_runs_from_wake_to_playback(self) -> None: def test_pipeline_runs_from_wake_to_playback(self) -> None:
frames = [ frames = [
+10 -3
View File
@@ -113,7 +113,10 @@ class WakeVadSttTests(unittest.TestCase):
return None return None
def read(self) -> bytes: def read(self) -> bytes:
return json.dumps({"text": "你好小杰", "language": "zh"}, ensure_ascii=False).encode() return json.dumps(
{"choices": [{"message": {"content": "你好小杰"}}]},
ensure_ascii=False,
).encode()
requests = [] requests = []
@@ -129,8 +132,12 @@ class WakeVadSttTests(unittest.TestCase):
transcript = provider.transcribe(AudioSegment(b"\x00\x00\x01\x00", 16000, 1, 0, 100)) transcript = provider.transcribe(AudioSegment(b"\x00\x00\x01\x00", 16000, 1, 0, 100))
self.assertEqual(transcript.text, "你好小杰") self.assertEqual(transcript.text, "你好小杰")
self.assertIn("/v1/audio/transcriptions", requests[0].full_url) self.assertIn("/v1/chat/completions", requests[0].full_url)
self.assertIn(b'mimo-v2.5-asr', requests[0].data) body = json.loads(requests[0].data.decode())
self.assertEqual(body["model"], "mimo-v2.5-asr")
content = body["messages"][0]["content"][0]
self.assertEqual(content["type"], "input_audio")
self.assertTrue(content["input_audio"]["data"].startswith("data:audio/wav;base64,"))
if __name__ == "__main__": if __name__ == "__main__":