[音频 Transport]:完成本机音频抽象与回放测试通道,包含内存 Transport、文件回放、可选 sounddevice 适配和环形缓冲
This commit is contained in:
@@ -15,12 +15,16 @@ from .models import (
|
||||
VadResult,
|
||||
WakeEvent,
|
||||
)
|
||||
from .transport import AudioRingBuffer, FileReplayTransport, MemoryAudioTransport
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"AudioFrame",
|
||||
"AudioSegment",
|
||||
"AudioRingBuffer",
|
||||
"ErrorCode",
|
||||
"FileReplayTransport",
|
||||
"MemoryAudioTransport",
|
||||
"Message",
|
||||
"PipelineState",
|
||||
"PlaybackResult",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import (
|
||||
AudioFrame,
|
||||
AudioSegment,
|
||||
ErrorCode,
|
||||
PlaybackResult,
|
||||
ProviderError,
|
||||
TransportHealth,
|
||||
)
|
||||
|
||||
|
||||
class AudioRingBuffer:
|
||||
def __init__(self, max_duration_ms: int = 3000) -> None:
|
||||
if max_duration_ms <= 0:
|
||||
raise ValueError("max_duration_ms must be positive")
|
||||
self.max_duration_ms = max_duration_ms
|
||||
self._frames: deque[AudioFrame] = deque()
|
||||
|
||||
def append(self, frame: AudioFrame) -> None:
|
||||
if self._frames and frame.timestamp_ms < self._frames[-1].timestamp_ms:
|
||||
raise ValueError("audio frame timestamps must be non-decreasing")
|
||||
self._frames.append(frame)
|
||||
self._trim()
|
||||
|
||||
def extend(self, frames: list[AudioFrame]) -> None:
|
||||
for frame in frames:
|
||||
self.append(frame)
|
||||
|
||||
def frames(self) -> list[AudioFrame]:
|
||||
return list(self._frames)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._frames.clear()
|
||||
|
||||
def _trim(self) -> None:
|
||||
if not self._frames:
|
||||
return
|
||||
latest = self._frames[-1].timestamp_ms
|
||||
cutoff = latest - self.max_duration_ms
|
||||
while self._frames and self._frames[0].timestamp_ms < cutoff:
|
||||
self._frames.popleft()
|
||||
|
||||
|
||||
class MemoryAudioTransport:
|
||||
def __init__(
|
||||
self,
|
||||
frames: list[AudioFrame] | None = None,
|
||||
input_available: bool = True,
|
||||
output_available: bool = True,
|
||||
) -> None:
|
||||
self._frames: deque[AudioFrame] = deque(frames or [])
|
||||
self.played_segments: list[AudioSegment] = []
|
||||
self.started = False
|
||||
self._input_available = input_available
|
||||
self._output_available = output_available
|
||||
|
||||
def start_input(
|
||||
self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1
|
||||
) -> None:
|
||||
if not self._input_available:
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_INPUT_DEVICE_MISSING,
|
||||
"memory input transport is disabled",
|
||||
False,
|
||||
"memory-transport",
|
||||
"transport",
|
||||
)
|
||||
self.started = True
|
||||
|
||||
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
|
||||
if not self.started:
|
||||
return []
|
||||
if not self._frames:
|
||||
return []
|
||||
return [self._frames.popleft()]
|
||||
|
||||
def inject(self, frame: AudioFrame) -> None:
|
||||
self._frames.append(frame)
|
||||
|
||||
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
|
||||
if not self._output_available:
|
||||
error = ProviderError(
|
||||
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
|
||||
"memory output transport is disabled",
|
||||
False,
|
||||
"memory-transport",
|
||||
"transport",
|
||||
)
|
||||
return PlaybackResult(False, 0, error)
|
||||
if interrupt:
|
||||
self.played_segments.clear()
|
||||
self.played_segments.append(segment)
|
||||
return PlaybackResult(True, segment.duration_ms)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.started = False
|
||||
|
||||
def health(self) -> TransportHealth:
|
||||
return TransportHealth(
|
||||
input_available=self._input_available,
|
||||
output_available=self._output_available,
|
||||
message="memory transport",
|
||||
)
|
||||
|
||||
|
||||
class FileReplayTransport(MemoryAudioTransport):
|
||||
@classmethod
|
||||
def from_jsonl(cls, path: str | Path) -> "FileReplayTransport":
|
||||
frames: list[AudioFrame] = []
|
||||
with Path(path).open("r", encoding="utf-8") as handle:
|
||||
for line_no, line in enumerate(handle, start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
item: dict[str, Any] = json.loads(line)
|
||||
try:
|
||||
frames.append(
|
||||
AudioFrame(
|
||||
pcm=bytes.fromhex(item.get("pcm_hex", "")),
|
||||
sample_rate=int(item.get("sample_rate", 16000)),
|
||||
channels=int(item.get("channels", 1)),
|
||||
timestamp_ms=int(item["timestamp_ms"]),
|
||||
frame_id=int(item.get("frame_id", line_no - 1)),
|
||||
metadata=dict(item.get("metadata", {})),
|
||||
)
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"missing required fixture field on line {line_no}: {exc}") from exc
|
||||
return cls(frames)
|
||||
|
||||
@staticmethod
|
||||
def write_jsonl(path: str | Path, frames: list[AudioFrame]) -> None:
|
||||
with Path(path).open("w", encoding="utf-8") as handle:
|
||||
for frame in frames:
|
||||
handle.write(
|
||||
json.dumps(
|
||||
{
|
||||
"pcm_hex": frame.pcm.hex(),
|
||||
"sample_rate": frame.sample_rate,
|
||||
"channels": frame.channels,
|
||||
"timestamp_ms": frame.timestamp_ms,
|
||||
"frame_id": frame.frame_id,
|
||||
"metadata": dict(frame.metadata),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
class SoundDeviceAudioTransport:
|
||||
def __init__(self) -> None:
|
||||
self._sd: Any | None = None
|
||||
self._load_error: Exception | None = None
|
||||
try:
|
||||
import sounddevice as sd # type: ignore[import-not-found]
|
||||
|
||||
self._sd = sd
|
||||
except Exception as exc: # pragma: no cover - depends on optional system package
|
||||
self._load_error = exc
|
||||
|
||||
def start_input(
|
||||
self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1
|
||||
) -> None:
|
||||
if self._sd is None:
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_INPUT_DEVICE_MISSING,
|
||||
"sounddevice is not installed or cannot be imported",
|
||||
False,
|
||||
"sounddevice-transport",
|
||||
"transport",
|
||||
)
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_FORMAT_UNSUPPORTED,
|
||||
"live sounddevice streaming is reserved for the interactive runtime path",
|
||||
False,
|
||||
"sounddevice-transport",
|
||||
"transport",
|
||||
)
|
||||
|
||||
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
|
||||
return []
|
||||
|
||||
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
|
||||
if self._sd is None:
|
||||
return PlaybackResult(
|
||||
False,
|
||||
0,
|
||||
ProviderError(
|
||||
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
|
||||
"sounddevice is not installed or cannot be imported",
|
||||
False,
|
||||
"sounddevice-transport",
|
||||
"transport",
|
||||
),
|
||||
)
|
||||
return PlaybackResult(
|
||||
False,
|
||||
0,
|
||||
ProviderError(
|
||||
ErrorCode.AUDIO_FORMAT_UNSUPPORTED,
|
||||
"live sounddevice playback is reserved for the interactive runtime path",
|
||||
False,
|
||||
"sounddevice-transport",
|
||||
"transport",
|
||||
),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
def health(self) -> TransportHealth:
|
||||
if self._sd is None:
|
||||
return TransportHealth(False, False, "sounddevice unavailable")
|
||||
return TransportHealth(True, True, "sounddevice import available")
|
||||
Reference in New Issue
Block a user