[本机音频 Transport]:完成真实设备检查和sounddevice输入输出,包含device-check、RawInputStream和播放错误恢复
This commit is contained in:
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user