8.6 KiB
补齐真实可重复对话的实时语音运行版技术设计
Context
add-voice-pet-pipeline 已归档并生成 voice-pet-pipeline 主规范。当前仓库已有 Python 包、CLI acceptance、.env 配置读取、Provider 协议、mock 测试、桌宠资产和 OpenSpec 主规范,但真实运行能力仍不完整:没有 run-live 常驻命令,没有真实麦克风输入和扬声器输出,没有本地模型下载/检查,没有证明同一运行进程内可以连续多轮携带历史。
本设计聚焦第一版“无 GUI 的真实实时语音版”。GUI 桌宠窗口、长期记忆、播放中打断、跨设备 Transport 和云端 STT/TTS 均不在本变更范围内。
Goals
- 提供
owner_voice_pet run-live,默认常驻重复语音对话。 - 提供
--once,便于单轮真实验收和自动化测试。 - 使用
.env作为默认配置来源,避免要求用户导出 shell 环境变量。 - 使用
sounddevice真实读取本机麦克风。 - 使用
sherpa-onnx本地 VAD/STT 模型,模型存放在项目models/。 - 使用 macOS
say/afplay先保证本地 TTS 真实播出。 - 在同一个
run-live进程内保留最近 user/assistant 历史,进程退出即丢弃。 - 新增自动化测试证明两轮 runtime、临时上下文、进程隔离和错误恢复。
Non-Goals
- 不实现 GUI 桌宠窗口实时联动。
- 不实现长期记忆、数据库或跨进程会话恢复。
- 不实现播放中用户打断。
- 不把模型文件提交到 Git。
- 不把 acceptance fixture 当作
run-live默认输入。 - 不要求自动化测试依赖真实麦克风、扬声器或真实模型。
Architecture
CLI
-> AppConfig.from_dotenv(".env")
-> LiveVoiceRuntimeFactory
-> SoundDeviceAudioTransport
-> WakeDetector("小杰小杰")
-> SherpaOnnxVadProvider
-> SherpaOnnxSttProvider
-> OpenAI/NewAPI LlmProvider
-> MacOsSayTtsProvider
-> ConversationContext(empty per process)
-> TerminalRuntimeReporter
LiveVoiceRuntime.run()
-> standby loop
-> wait_wake()
-> record_utterance_with_vad()
-> transcribe()
-> build messages with temporary history
-> generate reply
-> synthesize/play
-> append assistant
-> recover to standby
Runtime Lifecycle
- Parse CLI args.
- Load
.envand merge only explicitly supported defaults. - Validate LLM config.
- Validate models when live mode requires local VAD/STT.
- Validate audio devices.
- Create one
ConversationContextfor the process. - Enter loop:
- report
待机; - wait for wake word;
- report
唤醒命中; - record utterance with VAD;
- report
转写中; - transcribe;
- append user to context;
- report
思考中; - call LLM with system + retained history + current user;
- report
播放中; - synthesize and play;
- append assistant to context;
- report
恢复待机; - break only if
--once.
- report
- On Ctrl-C, close audio streams and exit without persisting context.
Interfaces
LiveVoiceRuntime
class LiveVoiceRuntime:
def run(self, *, once: bool = False) -> RuntimeSummary: ...
def run_turn(self, turn_id: int) -> TurnResult: ...
def shutdown(self) -> None: ...
run() owns the loop. run_turn() owns one wake -> record -> STT -> LLM -> TTS/play -> standby cycle. shutdown() closes transport streams and removes temporary TTS files.
RuntimeReporter
class RuntimeReporter(Protocol):
def status(self, state: str, message: str, *, turn_id: int | None = None) -> None: ...
def error(self, stage: str, code: str, message: str, *, turn_id: int | None = None) -> None: ...
The default implementation prints short Chinese status lines to stdout/stderr and redacts credentials.
SpeechModelManifest
@dataclass(frozen=True)
class SpeechModelManifest:
models_dir: Path
vad_model_path: Path
stt_model_dir: Path
sample_rate: int = 16000
The manifest is used by download_speech_models.py, model-check, and live provider construction.
SoundDeviceAudioTransport
class SoundDeviceAudioTransport:
def open_input(self, sample_rate: int, channels: int, device_id: int | None = None) -> None: ...
def read_chunk(self, timeout_s: float) -> AudioFrame: ...
def record_until_endpoint(self, vad: VadProvider, limits: RecordingLimits) -> AudioSegment: ...
def play_pcm(self, segment: AudioSegment) -> PlaybackResult: ...
def close(self) -> None: ...
The implementation keeps sounddevice imports optional so unit tests and non-audio commands still import without audio dependencies.
MacOsSayTtsProvider
class MacOsSayTtsProvider:
def synthesize_to_file(self, text: str) -> Path: ...
def synthesize(self, text: str) -> AudioSegment: ...
First version may return a file-backed audio segment or call say -o <tmpfile> --data-format=LEF32@16000. Playback may use afplay.
State Machine
initializing
-> standby
-> wake_listening
-> wake_hit
-> recording
-> transcribing
-> thinking
-> speaking
-> recovering_to_standby
-> standby
recoverable turn error
-> error
-> recovering_to_standby
-> standby
fatal startup error
-> startup_failed
-> exit
Fatal startup errors include invalid .env, missing required model directory, no audio input device, and no usable live audio dependency. Recoverable turn errors include no speech after wake, empty STT text, LLM timeout, TTS failure, and playback failure.
Temporary Context Design
Each LiveVoiceRuntime receives a newly constructed ConversationContext. This object:
- starts with only system prompt behavior;
- appends user text after valid STT;
- appends assistant text after non-empty LLM reply;
- builds messages for every LLM request using retained history;
- truncates by
context_max_messagesandcontext_max_chars; - is never serialized;
- is never stored in a module-level global;
- is lost when the process exits.
Test requirements:
- A fake two-turn runtime must prove the second LLM call includes first turn user/assistant.
- A new runtime instance must start with no prior messages.
- Context truncation must still preserve current user input.
Wake/VAD/STT Design
First implementation can use one of two local strategies:
- Preferred: sherpa-onnx VAD for endpointing plus sherpa-onnx STT for short wake windows and user utterances.
- Future replacement: dedicated local KWS provider for “小杰小杰”.
The implementation must not send raw audio to the cloud for wake, VAD, or STT. If a dedicated wake model is unavailable, wake detection may transcribe short local segments and search for the normalized wake word.
Model Download Design
scripts/download_speech_models.py --dir models creates:
models/
manifest.json
vad/
...
stt/
...
manifest.json records model names, source URLs, expected key files, and sample rate. The script must be idempotent: if files exist and pass checks, it should skip or report already present.
Error Handling
All live errors must convert to structured codes:
LIVE_CONFIG_INVALIDLIVE_MODEL_UNAVAILABLELIVE_AUDIO_DEVICE_UNAVAILABLELIVE_WAKE_TIMEOUTLIVE_NO_SPEECHLIVE_STT_EMPTYLIVE_LLM_FAILEDLIVE_TTS_FAILEDLIVE_PLAYBACK_FAILEDLIVE_INTERRUPTED
Startup errors return non-zero CLI exit codes. Turn-level errors in default loop log and return to standby. In --once, turn-level errors return non-zero except no-speech or empty transcript may return a documented recoverable code.
Testing Strategy
- Unit tests use fake audio/provider objects and do not require live audio devices.
test_live_runtime_repeats_two_turnsverifies two STT, two LLM, two TTS, two playback events and final standby.test_live_runtime_temporary_contextverifies second LLM messages include first user/assistant.test_live_runtime_process_local_contextverifies a new runtime has empty history.test_model_checkverifies missing/valid manifest behavior.test_device_checkmocks sounddevice device query.- Existing tests for acceptance, assets, security, config, transport, wake/VAD/STT remain passing.
Migration
No persistent data migration is needed. Existing .env remains valid. New optional .env keys receive defaults. Existing acceptance command remains available.
Open Questions
- Which exact sherpa-onnx Chinese STT model gives the best latency/accuracy tradeoff on this Mac.
- Whether future wake detection should use a dedicated KWS model instead of short-window STT.
- Whether TTS should later move from macOS
sayto sherpa-onnx TTS for consistent voice and offline packaging.