[本机音频 Transport]:完成真实设备检查和sounddevice输入输出,包含device-check、RawInputStream和播放错误恢复

This commit is contained in:
mkbk
2026-06-17 19:37:21 +08:00
parent e877cca00f
commit ac97daa1e7
5 changed files with 288 additions and 27 deletions
@@ -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 与模型检查
+7 -1
View File
@@ -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))
+194 -21
View File
@@ -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)
+6
View File
@@ -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()
+76
View File
@@ -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()