[本机音频 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
+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)