""" Mic sensor - audio input as a pipeline sensor. Self-contained implementation that handles audio input directly, with graceful degradation if sounddevice is unavailable. """ import atexit import time from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from typing import Any try: import numpy as np import sounddevice as sd _HAS_AUDIO = True except Exception: np = None # type: ignore sd = None # type: ignore _HAS_AUDIO = False from engine.events import MicLevelEvent from engine.sensors import Sensor, SensorRegistry, SensorValue @dataclass class AudioConfig: """Configuration for audio input.""" threshold_db: float = 50.0 sample_rate: float = 44100.0 block_size: int = 1024 class MicSensor(Sensor): """Microphone sensor for pipeline integration. Self-contained implementation with graceful degradation. No external dependencies required - works with or without sounddevice. """ def __init__(self, threshold_db: float = 50.0, name: str = "mic"): self.name = name self.unit = "dB" self._config = AudioConfig(threshold_db=threshold_db) self._db: float = -99.0 self._stream: Any = None self._subscribers: list[Callable[[MicLevelEvent], None]] = [] @property def available(self) -> bool: """Check if audio input is available.""" return _HAS_AUDIO and self._stream is not None def start(self) -> bool: """Start the microphone stream.""" if not _HAS_AUDIO or sd is None: return False try: self._stream = sd.InputStream( samplerate=self._config.sample_rate, blocksize=self._config.block_size, channels=1, callback=self._audio_callback, ) self._stream.start() atexit.register(self.stop) return True except Exception: return False def stop(self) -> None: """Stop the microphone stream.""" if self._stream: try: self._stream.stop() self._stream.close() except Exception: pass self._stream = None def _audio_callback(self, indata, frames, time_info, status) -> None: """Process audio data from sounddevice.""" if not _HAS_AUDIO or np is None: return rms = np.sqrt(np.mean(indata**2)) if rms > 0: db = 20 * np.log10(rms) else: db = -99.0 self._db = db excess = max(0.0, db - self._config.threshold_db) event = MicLevelEvent( db_level=db, excess_above_threshold=excess, timestamp=datetime.now() ) self._emit(event) def _emit(self, event: MicLevelEvent) -> None: """Emit event to all subscribers.""" for callback in self._subscribers: try: callback(event) except Exception: pass def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None: """Subscribe to mic level events.""" if callback not in self._subscribers: self._subscribers.append(callback) def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None: """Unsubscribe from mic level events.""" if callback in self._subscribers: self._subscribers.remove(callback) def read(self) -> SensorValue | None: """Read current mic level as sensor value.""" if not self.available: return None excess = max(0.0, self._db - self._config.threshold_db) return SensorValue( sensor_name=self.name, value=excess, timestamp=time.time(), unit=self.unit, ) def register_mic_sensor() -> None: """Register the mic sensor with the global registry.""" sensor = MicSensor() SensorRegistry.register(sensor) # Auto-register when imported register_mic_sensor()