Files
Owner/src/owner_voice_pet/transport.py
T

529 lines
19 KiB
Python

from __future__ import annotations
import json
import queue
import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable
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,
flush_clears_input: bool = True,
) -> None:
self._frames: deque[AudioFrame] = deque(frames or [])
self.played_segments: list[AudioSegment] = []
self.flush_count = 0
self.started = False
self._input_available = input_available
self._output_available = output_available
self._flush_clears_input = flush_clears_input
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 play_pcm_chunks(
self,
segment: AudioSegment,
*,
chunk_ms: int,
after_chunk: Callable[[AudioSegment, int], bool] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> PlaybackResult:
elapsed_ms = 0
for chunk in _raw_pcm_chunks(segment, chunk_ms=chunk_ms):
if should_stop is not None and should_stop():
return PlaybackResult(True, elapsed_ms)
playback = self.play_pcm(chunk)
if playback.error:
return playback
elapsed_ms += chunk.duration_ms
if should_stop is not None and should_stop():
return PlaybackResult(True, elapsed_ms)
if after_chunk is not None and after_chunk(chunk, elapsed_ms):
return PlaybackResult(True, elapsed_ms)
return PlaybackResult(True, elapsed_ms)
def flush_input(self) -> int:
self.flush_count += 1
if not self._flush_clears_input:
return 0
count = len(self._frames)
self._frames.clear()
return count
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, 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]
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",
)
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]:
if self._stream is None:
return []
timeout_s = max(0, timeout_ms) / 1000
try:
frames = [self._queue.get(timeout=timeout_s)]
except queue.Empty:
return []
while True:
try:
frames.append(self._queue.get_nowait())
except queue.Empty:
break
return frames
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",
),
)
if not segment.pcm:
return PlaybackResult(
False,
0,
ProviderError(
ErrorCode.AUDIO_STREAM_UNDERRUN,
"cannot play empty audio segment",
True,
"sounddevice-transport",
"transport",
),
)
if segment.metadata.get("format") in {"aiff", "wav", "mp3", "m4a", "aac"}:
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 play_pcm_chunks(
self,
segment: AudioSegment,
*,
chunk_ms: int,
after_chunk: Callable[[AudioSegment, int], bool] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> PlaybackResult:
if self._sd is None or segment.metadata.get("format") in {"aiff", "wav", "mp3", "m4a", "aac"}:
playback = self.play_pcm(segment)
if playback.error:
return playback
if after_chunk is not None:
after_chunk(segment, segment.duration_ms)
return playback
if not segment.pcm:
return PlaybackResult(
False,
0,
ProviderError(
ErrorCode.AUDIO_STREAM_UNDERRUN,
"cannot play empty audio segment",
True,
"sounddevice-transport",
"transport",
),
)
elapsed_ms = 0
try:
with self._sd.RawOutputStream(
samplerate=segment.sample_rate,
channels=segment.channels,
dtype="int16",
device=_coerce_device_id(self._output_device),
) as stream:
for chunk in _raw_pcm_chunks(segment, chunk_ms=chunk_ms):
if should_stop is not None and should_stop():
return PlaybackResult(True, elapsed_ms)
stream.write(chunk.pcm)
elapsed_ms += chunk.duration_ms
if should_stop is not None and should_stop():
return PlaybackResult(True, elapsed_ms)
if after_chunk is not None and after_chunk(chunk, elapsed_ms):
return PlaybackResult(True, elapsed_ms)
except Exception as exc:
return PlaybackResult(
False,
elapsed_ms,
ProviderError(
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
f"cannot play through sounddevice output stream: {exc}",
False,
"sounddevice-transport",
"transport",
),
)
return PlaybackResult(True, elapsed_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 flush_input(self) -> int:
count = 0
while not self._queue.empty():
try:
self._queue.get_nowait()
count += 1
except queue.Empty:
break
return count
def health(self) -> TransportHealth:
if self._sd is None:
return TransportHealth(False, False, "sounddevice unavailable")
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)
def _raw_pcm_chunks(segment: AudioSegment, *, chunk_ms: int) -> list[AudioSegment]:
if chunk_ms <= 0 or segment.duration_ms <= chunk_ms:
return [segment]
bytes_per_ms = max(1, int(segment.sample_rate * segment.channels * 2 / 1000))
chunk_bytes = max(2 * segment.channels, bytes_per_ms * chunk_ms)
chunk_bytes -= chunk_bytes % (2 * segment.channels)
chunks: list[AudioSegment] = []
offset = 0
start_ms = segment.start_time_ms
while offset < len(segment.pcm):
data = segment.pcm[offset : offset + chunk_bytes]
duration_ms = max(1, int(len(data) / bytes_per_ms))
chunks.append(
AudioSegment(
data,
segment.sample_rate,
segment.channels,
start_ms,
start_ms + duration_ms,
dict(segment.metadata),
)
)
offset += len(data)
start_ms += duration_ms
return chunks