[本机音频 Transport]:完成真实设备检查和sounddevice输入输出,包含device-check、RawInputStream和播放错误恢复
This commit is contained in:
@@ -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 与模型检查
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,15 +189,37 @@ class SoundDeviceAudioTransport:
|
||||
"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_FORMAT_UNSUPPORTED,
|
||||
"live sounddevice streaming is reserved for the interactive runtime path",
|
||||
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]:
|
||||
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:
|
||||
@@ -199,22 +235,159 @@ class SoundDeviceAudioTransport:
|
||||
"transport",
|
||||
),
|
||||
)
|
||||
if not segment.pcm:
|
||||
return PlaybackResult(
|
||||
False,
|
||||
0,
|
||||
ProviderError(
|
||||
ErrorCode.AUDIO_FORMAT_UNSUPPORTED,
|
||||
"live sounddevice playback is reserved for the interactive runtime path",
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user