- Add EventBus class with pub/sub messaging (thread-safe) - Add emitter Protocol classes (EventEmitter, Startable, Stoppable) - Add event emission to NtfyPoller (NtfyMessageEvent) - Add event emission to MicMonitor (MicLevelEvent) - Update StreamController to publish stream start/end events - Add comprehensive tests for eventbus and emitters modules
97 lines
2.7 KiB
Python
97 lines
2.7 KiB
Python
"""
|
|
Microphone input monitor — standalone, no internal dependencies.
|
|
Gracefully degrades if sounddevice/numpy are unavailable.
|
|
"""
|
|
|
|
import atexit
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
|
|
try:
|
|
import numpy as _np
|
|
import sounddevice as _sd
|
|
|
|
_HAS_MIC = True
|
|
except Exception:
|
|
_HAS_MIC = False
|
|
|
|
|
|
from engine.events import MicLevelEvent
|
|
|
|
|
|
class MicMonitor:
|
|
"""Background mic stream that exposes current RMS dB level."""
|
|
|
|
def __init__(self, threshold_db=50):
|
|
self.threshold_db = threshold_db
|
|
self._db = -99.0
|
|
self._stream = None
|
|
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
|
|
|
@property
|
|
def available(self):
|
|
"""True if sounddevice is importable."""
|
|
return _HAS_MIC
|
|
|
|
@property
|
|
def db(self):
|
|
"""Current RMS dB level."""
|
|
return self._db
|
|
|
|
@property
|
|
def excess(self):
|
|
"""dB above threshold (clamped to 0)."""
|
|
return max(0.0, self._db - self.threshold_db)
|
|
|
|
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
|
"""Register a callback to be called when mic level changes."""
|
|
self._subscribers.append(callback)
|
|
|
|
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
|
"""Remove a registered callback."""
|
|
if callback in self._subscribers:
|
|
self._subscribers.remove(callback)
|
|
|
|
def _emit(self, event: MicLevelEvent) -> None:
|
|
"""Emit an event to all subscribers."""
|
|
for cb in self._subscribers:
|
|
try:
|
|
cb(event)
|
|
except Exception:
|
|
pass
|
|
|
|
def start(self):
|
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
|
if not _HAS_MIC:
|
|
return None
|
|
|
|
def _cb(indata, frames, t, status):
|
|
rms = float(_np.sqrt(_np.mean(indata**2)))
|
|
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
|
if self._subscribers:
|
|
event = MicLevelEvent(
|
|
db_level=self._db,
|
|
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
|
timestamp=datetime.now(),
|
|
)
|
|
self._emit(event)
|
|
|
|
try:
|
|
self._stream = _sd.InputStream(
|
|
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
|
)
|
|
self._stream.start()
|
|
atexit.register(self.stop)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def stop(self):
|
|
"""Stop the mic stream if running."""
|
|
if self._stream:
|
|
try:
|
|
self._stream.stop()
|
|
except Exception:
|
|
pass
|
|
self._stream = None
|