diff --git a/openspec/changes/complete-live-repeat-voice-runtime/tasks.md b/openspec/changes/complete-live-repeat-voice-runtime/tasks.md index f5126f6..137d2a2 100644 --- a/openspec/changes/complete-live-repeat-voice-runtime/tasks.md +++ b/openspec/changes/complete-live-repeat-voice-runtime/tasks.md @@ -19,11 +19,11 @@ ## 3. 本机音频 Transport 与设备检查 -- [ ] 3.1 实现 `device-check` CLI;前置条件:sounddevice 依赖可导入或可诊断;验收标准:输出输入/输出设备可用性和失败原因;测试要点:mock sounddevice 成功/失败;优先级:P0;预计:45 分钟。 -- [ ] 3.2 完善 `SoundDeviceAudioTransport` 输入流;前置条件:现有 Transport 协议已读;验收标准:可打开麦克风并读取 16 kHz 单声道帧;测试要点:fake sounddevice 测试帧队列和关闭;优先级:P0;预计:60 分钟。 -- [ ] 3.3 完善 `SoundDeviceAudioTransport` 播放路径;前置条件:AudioSegment 播放格式确定;验收标准:可播放 PCM 或交给 `afplay` 播放临时文件;测试要点:播放成功/失败返回结构化结果;优先级:P0;预计:60 分钟。 -- [ ] 3.4 增加设备和 Transport 错误恢复测试;前置条件:3.1 至 3.3 完成;验收标准:无设备、缺依赖、播放失败均不抛未捕获异常;测试要点:unittest 覆盖;优先级:P0;预计:45 分钟。 -- [ ] 3.5 验证并提交“本机音频 Transport”模块;前置条件:3.1 至 3.4 完成;验收标准:compileall、transport tests、device-check、OpenSpec strict 通过后 commit;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。 +- [x] 3.1 实现 `device-check` CLI;前置条件:sounddevice 依赖可导入或可诊断;验收标准:输出输入/输出设备可用性和失败原因;测试要点:mock sounddevice 成功/失败;优先级:P0;预计:45 分钟。 +- [x] 3.2 完善 `SoundDeviceAudioTransport` 输入流;前置条件:现有 Transport 协议已读;验收标准:可打开麦克风并读取 16 kHz 单声道帧;测试要点:fake sounddevice 测试帧队列和关闭;优先级:P0;预计:60 分钟。 +- [x] 3.3 完善 `SoundDeviceAudioTransport` 播放路径;前置条件:AudioSegment 播放格式确定;验收标准:可播放 PCM 或交给 `afplay` 播放临时文件;测试要点:播放成功/失败返回结构化结果;优先级:P0;预计:60 分钟。 +- [x] 3.4 增加设备和 Transport 错误恢复测试;前置条件:3.1 至 3.3 完成;验收标准:无设备、缺依赖、播放失败均不抛未捕获异常;测试要点:unittest 覆盖;优先级:P0;预计:45 分钟。 +- [x] 3.5 验证并提交“本机音频 Transport”模块;前置条件:3.1 至 3.4 完成;验收标准:compileall、transport tests、device-check、OpenSpec strict 通过后 commit;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。 ## 4. Wake/VAD/STT 与模型检查 diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 2476312..c39ed03 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -14,7 +14,7 @@ from .models import AudioFrame, ProviderError from .pipeline import VoicePipeline from .speech_models import check_speech_models, model_status_errors from .stt import MetadataSttProvider -from .transport import MemoryAudioTransport +from .transport import MemoryAudioTransport, sounddevice_device_report from .tts import SineTtsProvider from .vad import EnergyVadProvider, VadRecorder from .wakeword import KeywordWakeWordProvider @@ -30,6 +30,7 @@ def main(argv: list[str] | None = None) -> int: subparsers.add_parser("security-check", help="Scan tracked files for leaked API keys") model_check = subparsers.add_parser("model-check", help="Validate local speech model files") model_check.add_argument("--models-dir", default=None, help="Speech models directory. Defaults to .env or models") + subparsers.add_parser("device-check", help="Validate local microphone and speaker availability") smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint") smoke.add_argument("--message", default="用一句中文回复:小杰在线。") smoke.add_argument("--no-stream", action="store_true") @@ -77,6 +78,11 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(data, ensure_ascii=False, sort_keys=True)) return 1 if errors else 0 + if args.command == "device-check": + report = sounddevice_device_report() + print(json.dumps(report, ensure_ascii=False, sort_keys=True)) + return 0 if report["ok"] else 1 + if args.command == "acceptance": result = run_acceptance() print(json.dumps(result, ensure_ascii=False, sort_keys=True)) diff --git a/src/owner_voice_pet/transport.py b/src/owner_voice_pet/transport.py index 906fb3d..c111b46 100644 --- a/src/owner_voice_pet/transport.py +++ b/src/owner_voice_pet/transport.py @@ -1,6 +1,11 @@ from __future__ import annotations import json +import queue +import shutil +import subprocess +import tempfile +import time from collections import deque from pathlib import Path from typing import Any @@ -154,9 +159,18 @@ class FileReplayTransport(MemoryAudioTransport): class SoundDeviceAudioTransport: - def __init__(self) -> None: - self._sd: Any | None = None + def __init__(self, sounddevice_module: Any | None = None, output_device: str | None = None) -> None: + self._sd: Any | None = sounddevice_module self._load_error: Exception | None = None + self._stream: Any | None = None + self._queue: queue.Queue[AudioFrame] = queue.Queue(maxsize=200) + self._frame_id = 0 + self._start_time = time.monotonic() + self._sample_rate = 16000 + self._channels = 1 + self._output_device = output_device + if self._sd is not None: + return try: import sounddevice as sd # type: ignore[import-not-found] @@ -175,16 +189,38 @@ class SoundDeviceAudioTransport: "sounddevice-transport", "transport", ) - raise ProviderError( - ErrorCode.AUDIO_FORMAT_UNSUPPORTED, - "live sounddevice streaming is reserved for the interactive runtime path", - False, - "sounddevice-transport", - "transport", - ) + self.stop() + self._sample_rate = sample_rate + self._channels = channels + self._frame_id = 0 + self._start_time = time.monotonic() + try: + self._stream = self._sd.RawInputStream( + samplerate=sample_rate, + channels=channels, + dtype="int16", + device=_coerce_device_id(device_id), + callback=self._on_input, + ) + self._stream.start() + except Exception as exc: + self._stream = None + raise ProviderError( + ErrorCode.AUDIO_INPUT_DEVICE_MISSING, + f"cannot open sounddevice input stream: {exc}", + False, + "sounddevice-transport", + "transport", + ) from exc def read_frames(self, timeout_ms: int) -> list[AudioFrame]: - return [] + if self._stream is None: + return [] + timeout_s = max(0, timeout_ms) / 1000 + try: + return [self._queue.get(timeout=timeout_s)] + except queue.Empty: + return [] def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult: if self._sd is None: @@ -199,22 +235,159 @@ class SoundDeviceAudioTransport: "transport", ), ) - return PlaybackResult( - False, - 0, - ProviderError( - ErrorCode.AUDIO_FORMAT_UNSUPPORTED, - "live sounddevice playback is reserved for the interactive runtime path", + if not segment.pcm: + return PlaybackResult( False, - "sounddevice-transport", - "transport", - ), - ) + 0, + ProviderError( + ErrorCode.AUDIO_STREAM_UNDERRUN, + "cannot play empty audio segment", + True, + "sounddevice-transport", + "transport", + ), + ) + if segment.metadata.get("format") in {"aiff", "wav"}: + return _play_file_bytes_with_afplay(segment) + try: + with self._sd.RawOutputStream( + samplerate=segment.sample_rate, + channels=segment.channels, + dtype="int16", + device=_coerce_device_id(self._output_device), + ) as stream: + stream.write(segment.pcm) + except Exception as exc: + return PlaybackResult( + False, + 0, + ProviderError( + ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING, + f"cannot play through sounddevice output stream: {exc}", + False, + "sounddevice-transport", + "transport", + ), + ) + return PlaybackResult(True, segment.duration_ms) def stop(self) -> None: + if self._stream is None: + return None + try: + self._stream.stop() + except Exception: + pass + try: + self._stream.close() + except Exception: + pass + self._stream = None + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break return None def health(self) -> TransportHealth: if self._sd is None: return TransportHealth(False, False, "sounddevice unavailable") - return TransportHealth(True, True, "sounddevice import available") + try: + devices = list(self._sd.query_devices()) + except Exception as exc: + return TransportHealth(False, False, f"sounddevice query failed: {exc}") + input_available = any(int(device.get("max_input_channels", 0)) > 0 for device in devices) + output_available = any(int(device.get("max_output_channels", 0)) > 0 for device in devices) + return TransportHealth(input_available, output_available, f"sounddevice devices={len(devices)}") + + def device_report(self) -> dict[str, Any]: + health = self.health() + devices: list[dict[str, Any]] = [] + if self._sd is not None: + try: + for index, device in enumerate(self._sd.query_devices()): + devices.append( + { + "index": index, + "name": str(device.get("name", "")), + "max_input_channels": int(device.get("max_input_channels", 0)), + "max_output_channels": int(device.get("max_output_channels", 0)), + "default_samplerate": float(device.get("default_samplerate", 0.0)), + } + ) + except Exception: + devices = [] + return { + "ok": health.input_available and health.output_available, + "input_available": health.input_available, + "output_available": health.output_available, + "message": health.message, + "devices": devices, + } + + def _on_input(self, indata: Any, frames: int, _time_info: Any, status: Any) -> None: + timestamp_ms = int((time.monotonic() - self._start_time) * 1000) + duration_ms = int(frames / self._sample_rate * 1000) if self._sample_rate else 0 + metadata: dict[str, Any] = {"duration_ms": duration_ms} + if status: + metadata["sounddevice_status"] = str(status) + frame = AudioFrame( + pcm=bytes(indata), + sample_rate=self._sample_rate, + channels=self._channels, + timestamp_ms=timestamp_ms, + frame_id=self._frame_id, + metadata=metadata, + ) + self._frame_id += 1 + if self._queue.full(): + try: + self._queue.get_nowait() + except queue.Empty: + pass + self._queue.put_nowait(frame) + + +def sounddevice_device_report() -> dict[str, Any]: + return SoundDeviceAudioTransport().device_report() + + +def _coerce_device_id(device_id: str | None) -> int | str | None: + if device_id is None or device_id == "": + return None + return int(device_id) if str(device_id).isdigit() else device_id + + +def _play_file_bytes_with_afplay(segment: AudioSegment) -> PlaybackResult: + suffix = "." + str(segment.metadata.get("format", "aiff")).lstrip(".") + if not shutil.which("afplay"): + return PlaybackResult( + False, + 0, + ProviderError( + ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING, + "afplay is not available for file-backed audio playback", + False, + "afplay", + "transport", + ), + ) + with tempfile.NamedTemporaryFile(suffix=suffix) as handle: + handle.write(segment.pcm) + handle.flush() + try: + subprocess.run(["afplay", handle.name], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + return PlaybackResult( + False, + 0, + ProviderError( + ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING, + f"afplay failed: {exc}", + False, + "afplay", + "transport", + ), + ) + return PlaybackResult(True, segment.duration_ms) diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index 8c45364..7bfa808 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -71,6 +71,12 @@ class CliAcceptanceTests(unittest.TestCase): self.assertFalse(data["ok"]) self.assertTrue(data["missing_files"]) + def test_device_check_reports_sounddevice_status(self) -> None: + with patch("owner_voice_pet.cli.sounddevice_device_report", return_value={"ok": True, "devices": []}): + code, data = self.call("device-check") + self.assertEqual(code, 0) + self.assertTrue(data["ok"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py index 7fe4e91..e0672cb 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -65,6 +65,82 @@ class TransportTests(unittest.TestCase): self.assertIsInstance(health.input_available, bool) self.assertIsInstance(health.output_available, bool) + def test_sounddevice_transport_reads_from_raw_input_stream(self) -> None: + fake = FakeSoundDevice() + transport = SoundDeviceAudioTransport(sounddevice_module=fake) + transport.start_input(sample_rate=16000, channels=1) + frames = transport.read_frames(10) + transport.stop() + + self.assertEqual(len(frames), 1) + self.assertEqual(frames[0].pcm, b"\x01\x00\x02\x00") + self.assertEqual(frames[0].sample_rate, 16000) + + def test_sounddevice_transport_plays_pcm_to_raw_output_stream(self) -> None: + fake = FakeSoundDevice() + transport = SoundDeviceAudioTransport(sounddevice_module=fake) + result = transport.play_pcm(AudioSegment(b"\x00\x00\x01\x00", 16000, 1, 0, 20)) + + self.assertTrue(result.played) + self.assertEqual(fake.output_writes, [b"\x00\x00\x01\x00"]) + + def test_sounddevice_device_report_uses_query_devices(self) -> None: + fake = FakeSoundDevice() + report = SoundDeviceAudioTransport(sounddevice_module=fake).device_report() + + self.assertTrue(report["ok"]) + self.assertEqual(len(report["devices"]), 2) + + +class FakeInputStream: + def __init__(self, callback, **kwargs) -> None: + self.callback = callback + self.kwargs = kwargs + self.started = False + self.closed = False + + def start(self) -> None: + self.started = True + self.callback(b"\x01\x00\x02\x00", 2, None, "") + + def stop(self) -> None: + self.started = False + + def close(self) -> None: + self.closed = True + + +class FakeOutputStream: + def __init__(self, owner, **kwargs) -> None: + self.owner = owner + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, *args) -> None: + return None + + def write(self, data: bytes) -> None: + self.owner.output_writes.append(bytes(data)) + + +class FakeSoundDevice: + def __init__(self) -> None: + self.output_writes: list[bytes] = [] + + def RawInputStream(self, **kwargs): + return FakeInputStream(**kwargs) + + def RawOutputStream(self, **kwargs): + return FakeOutputStream(self, **kwargs) + + def query_devices(self): + return [ + {"name": "Mic", "max_input_channels": 1, "max_output_channels": 0, "default_samplerate": 16000}, + {"name": "Speaker", "max_input_channels": 0, "max_output_channels": 2, "default_samplerate": 48000}, + ] + if __name__ == "__main__": unittest.main()