[音频 Transport]:完成本机音频抽象与回放测试通道,包含内存 Transport、文件回放、可选 sounddevice 适配和环形缓冲

This commit is contained in:
mkbk
2026-06-17 18:15:14 +08:00
parent 86ad0c86f2
commit 90ba2bf3b0
4 changed files with 299 additions and 5 deletions
+4
View File
@@ -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",
+220
View File
@@ -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")