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