diff --git a/docker-compose.yml b/docker-compose.yml index a33efbd7..a54cae1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,6 +122,33 @@ services: retries: 3 start_period: 30s + # ── Mumble — voice chat server for Alexander + Timmy ───────────────────── + mumble: + image: mumblevoip/mumble-server:latest + container_name: timmy-mumble + profiles: + - mumble + ports: + - "${MUMBLE_PORT:-64738}:64738" # TCP + UDP: Mumble protocol + - "${MUMBLE_PORT:-64738}:64738/udp" + environment: + MUMBLE_CONFIG_WELCOMETEXT: "Timmy Time voice channel — co-play audio bridge" + MUMBLE_CONFIG_USERS: "10" + MUMBLE_CONFIG_BANDWIDTH: "72000" + # Set MUMBLE_SUPERUSER_PASSWORD in .env to secure the server + MUMBLE_SUPERUSER_PASSWORD: "${MUMBLE_SUPERUSER_PASSWORD:-changeme}" + volumes: + - mumble-data:/data + networks: + - timmy-net + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "nc -z localhost 64738 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + # ── OpenFang — vendored agent runtime sidecar ──────────────────────────── openfang: build: @@ -158,6 +185,8 @@ volumes: device: "${PWD}/data" openfang-data: driver: local + mumble-data: + driver: local # ── Internal network ──────────────────────────────────────────────────────── networks: diff --git a/pyproject.toml b/pyproject.toml index 51f294eb..eb0b8acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ pyttsx3 = { version = ">=2.90", optional = true } openai-whisper = { version = ">=20231117", optional = true } piper-tts = { version = ">=1.2.0", optional = true } sounddevice = { version = ">=0.4.6", optional = true } +pymumble-py3 = { version = ">=1.0", optional = true } sentence-transformers = { version = ">=2.0.0", optional = true } numpy = { version = ">=1.24.0", optional = true } requests = { version = ">=2.31.0", optional = true } @@ -69,6 +70,7 @@ telegram = ["python-telegram-bot"] discord = ["discord.py"] bigbrain = ["airllm"] voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"] +mumble = ["pymumble-py3"] celery = ["celery"] embeddings = ["sentence-transformers", "numpy"] git = ["GitPython"] diff --git a/src/config.py b/src/config.py index 4312f26f..a0f82393 100644 --- a/src/config.py +++ b/src/config.py @@ -90,6 +90,27 @@ class Settings(BaseSettings): # Discord bot token — set via DISCORD_TOKEN env var or the /discord/setup endpoint discord_token: str = "" + # ── Mumble voice bridge ─────────────────────────────────────────────────── + # Enables Mumble voice chat between Alexander and Timmy. + # Set MUMBLE_ENABLED=true and configure the server details to activate. + mumble_enabled: bool = False + # Mumble server hostname — override with MUMBLE_HOST env var + mumble_host: str = "localhost" + # Mumble server port — override with MUMBLE_PORT env var + mumble_port: int = 64738 + # Mumble username for Timmy's connection — override with MUMBLE_USER env var + mumble_user: str = "Timmy" + # Mumble server password (if required) — override with MUMBLE_PASSWORD env var + mumble_password: str = "" + # Mumble channel to join — override with MUMBLE_CHANNEL env var + mumble_channel: str = "Root" + # Audio mode: "ptt" (push-to-talk) or "vad" (voice activity detection) + mumble_audio_mode: str = "vad" + # VAD silence threshold (RMS 0.0–1.0) — audio below this is treated as silence + mumble_vad_threshold: float = 0.02 + # Milliseconds of silence before PTT/VAD releases the floor + mumble_silence_ms: int = 800 + # ── Discord action confirmation ────────────────────────────────────────── # When True, dangerous tools (shell, write_file, python) require user # confirmation via Discord button before executing. diff --git a/src/integrations/CLAUDE.md b/src/integrations/CLAUDE.md index 258ac18f..a43667d1 100644 --- a/src/integrations/CLAUDE.md +++ b/src/integrations/CLAUDE.md @@ -7,6 +7,7 @@ External platform bridges. All are optional dependencies. - `telegram_bot/` — Telegram bot bridge - `shortcuts/` — iOS Siri Shortcuts API metadata - `voice/` — Local NLU intent detection (regex-based, no cloud) +- `mumble/` — Mumble voice bridge (bidirectional audio: Timmy TTS ↔ Alexander mic) ## Testing ```bash diff --git a/src/integrations/mumble/__init__.py b/src/integrations/mumble/__init__.py new file mode 100644 index 00000000..16726b65 --- /dev/null +++ b/src/integrations/mumble/__init__.py @@ -0,0 +1,5 @@ +"""Mumble voice bridge — bidirectional audio between Alexander and Timmy.""" + +from integrations.mumble.bridge import MumbleBridge, mumble_bridge + +__all__ = ["MumbleBridge", "mumble_bridge"] diff --git a/src/integrations/mumble/bridge.py b/src/integrations/mumble/bridge.py new file mode 100644 index 00000000..70cb66bf --- /dev/null +++ b/src/integrations/mumble/bridge.py @@ -0,0 +1,464 @@ +"""Mumble voice bridge — bidirectional audio between Alexander and Timmy. + +Connects Timmy to a Mumble server so voice conversations can happen during +co-play and be piped to the stream. Timmy's TTS output is sent to the +Mumble channel; Alexander's microphone is captured on stream via Mumble. + +Audio pipeline +-------------- + Timmy TTS → PCM 16-bit 48 kHz mono → Mumble channel → stream mix + Mumble channel (Alexander's mic) → PCM callback → optional STT + +Audio mode +---------- + "vad" — voice activity detection: transmit when RMS > threshold + "ptt" — push-to-talk: transmit only while ``push_to_talk()`` context active + +Optional dependency — install with: + pip install ".[mumble]" + +Degrades gracefully when ``pymumble`` is not installed or the server is +unreachable; all public methods become safe no-ops. +""" + +from __future__ import annotations + +import io +import logging +import struct +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +# Mumble audio constants +_SAMPLE_RATE = 48000 # Hz — Mumble native sample rate +_CHANNELS = 1 # Mono +_SAMPLE_WIDTH = 2 # 16-bit PCM → 2 bytes per sample +_FRAME_MS = 10 # milliseconds per Mumble frame +_FRAME_SAMPLES = _SAMPLE_RATE * _FRAME_MS // 1000 # 480 samples per frame +_FRAME_BYTES = _FRAME_SAMPLES * _SAMPLE_WIDTH # 960 bytes per frame + + +class MumbleBridge: + """Manages a Mumble client connection for Timmy's voice bridge. + + Usage:: + + bridge = MumbleBridge() + await bridge.start() # connect + join channel + await bridge.speak("Hello!") # TTS → Mumble audio + await bridge.stop() # disconnect + + Audio received from other users triggers ``on_audio`` callbacks + registered via ``add_audio_callback()``. + """ + + def __init__(self) -> None: + self._client = None + self._connected: bool = False + self._running: bool = False + self._ptt_active: bool = False + self._lock = threading.Lock() + self._audio_callbacks: list[Callable[[str, bytes], None]] = [] + self._send_thread: threading.Thread | None = None + self._audio_queue: list[bytes] = [] + self._queue_lock = threading.Lock() + + # ── Properties ──────────────────────────────────────────────────────────── + + @property + def connected(self) -> bool: + """True when the Mumble client is connected and authenticated.""" + return self._connected + + @property + def running(self) -> bool: + """True when the bridge loop is active.""" + return self._running + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + def start(self) -> bool: + """Connect to Mumble and join the configured channel. + + Returns True on success, False if the bridge is disabled or + ``pymumble`` is not installed. + """ + try: + from config import settings + except Exception as exc: + logger.warning("MumbleBridge: config unavailable — %s", exc) + return False + + if not settings.mumble_enabled: + logger.info("MumbleBridge: disabled (MUMBLE_ENABLED=false)") + return False + + if self._connected: + return True + + try: + import pymumble_py3 as pymumble + except ImportError: + logger.warning( + "MumbleBridge: pymumble-py3 not installed — " + 'run: pip install ".[mumble]"' + ) + return False + + try: + self._client = pymumble.Mumble( + host=settings.mumble_host, + user=settings.mumble_user, + port=settings.mumble_port, + password=settings.mumble_password, + reconnect=True, + stereo=False, + ) + self._client.set_receive_sound(True) + self._client.callbacks.set_callback( + pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, + self._on_sound_received, + ) + self._client.start() + self._client.is_ready() # blocks until connected + synced + + self._join_channel(settings.mumble_channel) + + self._running = True + self._connected = True + + # Start the audio sender thread + self._send_thread = threading.Thread( + target=self._audio_sender_loop, daemon=True, name="mumble-sender" + ) + self._send_thread.start() + + logger.info( + "MumbleBridge: connected to %s:%d as %s, channel=%s", + settings.mumble_host, + settings.mumble_port, + settings.mumble_user, + settings.mumble_channel, + ) + return True + + except Exception as exc: + logger.warning("MumbleBridge: connection failed — %s", exc) + self._connected = False + self._running = False + self._client = None + return False + + def stop(self) -> None: + """Disconnect from Mumble and clean up.""" + self._running = False + self._connected = False + + if self._client is not None: + try: + self._client.stop() + except Exception as exc: + logger.debug("MumbleBridge: stop error — %s", exc) + finally: + self._client = None + + logger.info("MumbleBridge: disconnected") + + # ── Audio send ──────────────────────────────────────────────────────────── + + def send_audio(self, pcm_bytes: bytes) -> None: + """Enqueue raw PCM audio (16-bit, 48 kHz, mono) for transmission. + + The bytes are sliced into 10 ms frames and sent by the background + sender thread. Safe to call from any thread. + """ + if not self._connected or self._client is None: + return + + with self._queue_lock: + self._audio_queue.append(pcm_bytes) + + def speak(self, text: str) -> None: + """Convert *text* to speech and send the audio to the Mumble channel. + + Tries Piper TTS first (high quality), falls back to pyttsx3, and + degrades silently if neither is available. + """ + if not self._connected: + logger.debug("MumbleBridge.speak: not connected, skipping") + return + + pcm = self._tts_to_pcm(text) + if pcm: + self.send_audio(pcm) + + # ── Push-to-talk ────────────────────────────────────────────────────────── + + @contextmanager + def push_to_talk(self): + """Context manager that activates PTT for the duration of the block. + + Example:: + + with bridge.push_to_talk(): + bridge.send_audio(pcm_data) + """ + self._ptt_active = True + try: + yield + finally: + self._ptt_active = False + + # ── Audio receive callbacks ─────────────────────────────────────────────── + + def add_audio_callback(self, callback: Callable[[str, bytes], None]) -> None: + """Register a callback for incoming audio from other Mumble users. + + The callback receives ``(username: str, pcm_bytes: bytes)`` where + ``pcm_bytes`` is 16-bit, 48 kHz, mono PCM audio. + """ + self._audio_callbacks.append(callback) + + def remove_audio_callback(self, callback: Callable[[str, bytes], None]) -> None: + """Unregister a previously added audio callback.""" + try: + self._audio_callbacks.remove(callback) + except ValueError: + pass + + # ── Internal helpers ────────────────────────────────────────────────────── + + def _join_channel(self, channel_name: str) -> None: + """Move to the named channel, creating it if it doesn't exist.""" + if self._client is None: + return + try: + channels = self._client.channels + channel = channels.find_by_name(channel_name) + self._client.my_channel().move_in(channel) + logger.debug("MumbleBridge: joined channel '%s'", channel_name) + except Exception as exc: + logger.warning( + "MumbleBridge: could not join channel '%s' — %s", channel_name, exc + ) + + def _on_sound_received(self, user, soundchunk) -> None: + """Called by pymumble when audio arrives from another user.""" + try: + username = user.get("name", "unknown") + pcm = soundchunk.pcm + if pcm and self._audio_callbacks: + for cb in self._audio_callbacks: + try: + cb(username, pcm) + except Exception as exc: + logger.debug("MumbleBridge: audio callback error — %s", exc) + except Exception as exc: + logger.debug("MumbleBridge: _on_sound_received error — %s", exc) + + def _audio_sender_loop(self) -> None: + """Background thread: drain the audio queue and send frames.""" + while self._running: + chunks: list[bytes] = [] + with self._queue_lock: + if self._audio_queue: + chunks = list(self._audio_queue) + self._audio_queue.clear() + + if chunks and self._client is not None: + buf = b"".join(chunks) + self._send_pcm_buffer(buf) + else: + time.sleep(0.005) + + def _send_pcm_buffer(self, pcm: bytes) -> None: + """Slice a PCM buffer into 10 ms frames and send each one.""" + if self._client is None: + return + + try: + from config import settings + + mode = settings.mumble_audio_mode + threshold = settings.mumble_vad_threshold + except Exception: + mode = "vad" + threshold = 0.02 + + offset = 0 + while offset < len(pcm): + frame = pcm[offset : offset + _FRAME_BYTES] + if len(frame) < _FRAME_BYTES: + # Pad the last frame with silence + frame = frame + b"\x00" * (_FRAME_BYTES - len(frame)) + offset += _FRAME_BYTES + + if mode == "vad": + rms = _rms(frame) + if rms < threshold: + continue # silence — don't transmit + + if mode == "ptt" and not self._ptt_active: + continue + + try: + self._client.sound_output.add_sound(frame) + except Exception as exc: + logger.debug("MumbleBridge: send frame error — %s", exc) + break + + def _tts_to_pcm(self, text: str) -> bytes | None: + """Convert text to 16-bit 48 kHz mono PCM via Piper or pyttsx3.""" + # Try Piper TTS first (higher quality) + pcm = self._piper_tts(text) + if pcm: + return pcm + + # Fall back to pyttsx3 via an in-memory WAV buffer + pcm = self._pyttsx3_tts(text) + if pcm: + return pcm + + logger.debug("MumbleBridge._tts_to_pcm: no TTS engine available") + return None + + def _piper_tts(self, text: str) -> bytes | None: + """Synthesize speech via Piper TTS, returning 16-bit 48 kHz mono PCM.""" + try: + import wave + + from piper.voice import PiperVoice + + try: + from config import settings + + voice_path = getattr(settings, "piper_voice_path", None) or str( + __import__("pathlib").Path.home() + / ".local/share/piper-voices/en_US-lessac-medium.onnx" + ) + except Exception: + voice_path = str( + __import__("pathlib").Path.home() + / ".local/share/piper-voices/en_US-lessac-medium.onnx" + ) + + voice = PiperVoice.load(voice_path) + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(_CHANNELS) + wf.setsampwidth(_SAMPLE_WIDTH) + wf.setframerate(voice.config.sample_rate) + voice.synthesize(text, wf) + + buf.seek(0) + with wave.open(buf, "rb") as wf: + raw = wf.readframes(wf.getnframes()) + src_rate = wf.getframerate() + + return _resample_pcm(raw, src_rate, _SAMPLE_RATE) + + except ImportError: + return None + except Exception as exc: + logger.debug("MumbleBridge._piper_tts: %s", exc) + return None + + def _pyttsx3_tts(self, text: str) -> bytes | None: + """Synthesize speech via pyttsx3, returning 16-bit 48 kHz mono PCM. + + pyttsx3 doesn't support in-memory output directly, so we write to a + temporary WAV file, read it back, and resample if necessary. + """ + try: + import os + import tempfile + import wave + + import pyttsx3 + + engine = pyttsx3.init() + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp_path = tmp.name + + engine.save_to_file(text, tmp_path) + engine.runAndWait() + + with wave.open(tmp_path, "rb") as wf: + raw = wf.readframes(wf.getnframes()) + src_rate = wf.getframerate() + src_channels = wf.getnchannels() + + os.unlink(tmp_path) + + # Convert stereo → mono if needed + if src_channels == 2: + raw = _stereo_to_mono(raw, _SAMPLE_WIDTH) + + return _resample_pcm(raw, src_rate, _SAMPLE_RATE) + + except ImportError: + return None + except Exception as exc: + logger.debug("MumbleBridge._pyttsx3_tts: %s", exc) + return None + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _rms(pcm: bytes) -> float: + """Compute the root mean square (RMS) energy of a 16-bit PCM buffer.""" + if not pcm: + return 0.0 + n = len(pcm) // _SAMPLE_WIDTH + if n == 0: + return 0.0 + samples = struct.unpack(f"<{n}h", pcm[: n * _SAMPLE_WIDTH]) + mean_sq = sum(s * s for s in samples) / n + return (mean_sq**0.5) / 32768.0 + + +def _stereo_to_mono(pcm: bytes, sample_width: int = 2) -> bytes: + """Convert interleaved stereo 16-bit PCM to mono by averaging channels.""" + n = len(pcm) // (sample_width * 2) + if n == 0: + return pcm + samples = struct.unpack(f"<{n * 2}h", pcm[: n * 2 * sample_width]) + mono = [(samples[i * 2] + samples[i * 2 + 1]) // 2 for i in range(n)] + return struct.pack(f"<{n}h", *mono) + + +def _resample_pcm(pcm: bytes, src_rate: int, dst_rate: int, sample_width: int = 2) -> bytes: + """Resample 16-bit mono PCM from *src_rate* to *dst_rate* Hz. + + Uses linear interpolation — adequate quality for voice. + """ + if src_rate == dst_rate: + return pcm + n_src = len(pcm) // sample_width + if n_src == 0: + return pcm + src = struct.unpack(f"<{n_src}h", pcm[: n_src * sample_width]) + ratio = src_rate / dst_rate + n_dst = int(n_src / ratio) + dst: list[int] = [] + for i in range(n_dst): + pos = i * ratio + lo = int(pos) + hi = min(lo + 1, n_src - 1) + frac = pos - lo + sample = int(src[lo] * (1.0 - frac) + src[hi] * frac) + dst.append(max(-32768, min(32767, sample))) + return struct.pack(f"<{n_dst}h", *dst) + + +# Module-level singleton +mumble_bridge = MumbleBridge() diff --git a/tests/integrations/test_mumble_bridge.py b/tests/integrations/test_mumble_bridge.py new file mode 100644 index 00000000..f0c5222a --- /dev/null +++ b/tests/integrations/test_mumble_bridge.py @@ -0,0 +1,528 @@ +"""Unit tests for the Mumble voice bridge integration.""" + +from __future__ import annotations + +import struct +import sys +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.unit + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _pcm_silence(ms: int = 10, sample_rate: int = 48000) -> bytes: + """Return *ms* milliseconds of 16-bit 48 kHz silent PCM.""" + n = sample_rate * ms // 1000 + return struct.pack(f"<{n}h", *([0] * n)) + + +def _pcm_tone(ms: int = 10, sample_rate: int = 48000, amplitude: int = 16000) -> bytes: + """Return *ms* milliseconds of a constant-amplitude 16-bit PCM signal.""" + import math + + n = sample_rate * ms // 1000 + freq = 440 # Hz + samples = [ + int(amplitude * math.sin(2 * math.pi * freq * i / sample_rate)) for i in range(n) + ] + return struct.pack(f"<{n}h", *samples) + + +# ── _rms helper ─────────────────────────────────────────────────────────────── + + +class TestRmsHelper: + """Tests for the internal _rms() energy function.""" + + def test_silence_is_zero(self): + from integrations.mumble.bridge import _rms + + assert _rms(_pcm_silence()) == 0.0 + + def test_empty_bytes_is_zero(self): + from integrations.mumble.bridge import _rms + + assert _rms(b"") == 0.0 + + def test_tone_has_positive_rms(self): + from integrations.mumble.bridge import _rms + + rms = _rms(_pcm_tone(amplitude=16000)) + assert 0.0 < rms <= 1.0 + + def test_louder_tone_has_higher_rms(self): + from integrations.mumble.bridge import _rms + + quiet = _rms(_pcm_tone(amplitude=1000)) + loud = _rms(_pcm_tone(amplitude=20000)) + assert loud > quiet + + def test_max_amplitude_rms_near_one(self): + from integrations.mumble.bridge import _rms + + # All samples at max positive value + n = 480 + pcm = struct.pack(f"<{n}h", *([32767] * n)) + rms = _rms(pcm) + assert rms > 0.99 + + +# ── MumbleBridge unit tests ─────────────────────────────────────────────────── + + +class TestMumbleBridgeProperties: + def test_initial_state(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + assert not bridge.connected + assert not bridge.running + + def test_singleton_exists(self): + from integrations.mumble.bridge import MumbleBridge, mumble_bridge + + assert isinstance(mumble_bridge, MumbleBridge) + + +class TestMumbleBridgeStart: + def test_start_disabled_returns_false(self): + """start() returns False when MUMBLE_ENABLED=false.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_settings = MagicMock() + mock_settings.mumble_enabled = False + with patch("config.settings", mock_settings): + result = bridge.start() + assert result is False + assert not bridge.connected + + def test_start_missing_pymumble_returns_false(self): + """start() returns False gracefully when pymumble_py3 is absent.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_settings = MagicMock() + mock_settings.mumble_enabled = True + with ( + patch("config.settings", mock_settings), + patch.dict(sys.modules, {"pymumble_py3": None}), + ): + result = bridge.start() + assert result is False + assert not bridge.connected + + def test_start_already_connected_returns_true(self): + """start() short-circuits when already connected.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + mock_settings = MagicMock() + mock_settings.mumble_enabled = True + with patch("config.settings", mock_settings): + result = bridge.start() + assert result is True + + def test_start_connection_error_returns_false(self): + """start() returns False and stays clean when Mumble raises.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_settings = MagicMock() + mock_settings.mumble_enabled = True + mock_settings.mumble_host = "127.0.0.1" + mock_settings.mumble_port = 64738 + mock_settings.mumble_user = "Timmy" + mock_settings.mumble_password = "" + + mock_mumble_module = MagicMock() + mock_mumble_module.Mumble.side_effect = ConnectionRefusedError("refused") + + with ( + patch("config.settings", mock_settings), + patch.dict(sys.modules, {"pymumble_py3": mock_mumble_module}), + ): + result = bridge.start() + + assert result is False + assert not bridge.connected + assert bridge._client is None + + +class TestMumbleBridgeStop: + def test_stop_when_not_connected_is_noop(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge.stop() # Must not raise + assert not bridge.connected + assert not bridge.running + + def test_stop_clears_state(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._running = True + mock_client = MagicMock() + bridge._client = mock_client + + bridge.stop() + + mock_client.stop.assert_called_once() + assert not bridge.connected + assert not bridge.running + assert bridge._client is None + + def test_stop_tolerates_client_error(self): + """stop() cleans up state even when client.stop() raises.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._running = True + mock_client = MagicMock() + mock_client.stop.side_effect = RuntimeError("already stopped") + bridge._client = mock_client + + bridge.stop() # Must not propagate + + assert not bridge.connected + + +# ── Audio send ──────────────────────────────────────────────────────────────── + + +class TestMumbleBridgeSendAudio: + def test_send_audio_when_not_connected_is_noop(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + pcm = _pcm_tone() + bridge.send_audio(pcm) # Must not raise + + def test_send_audio_enqueues_data(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._client = MagicMock() + + pcm = _pcm_tone(ms=20) + bridge.send_audio(pcm) + + assert len(bridge._audio_queue) == 1 + assert bridge._audio_queue[0] == pcm + + def test_send_audio_multiple_chunks(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._client = MagicMock() + + for _ in range(3): + bridge.send_audio(_pcm_tone(ms=10)) + + assert len(bridge._audio_queue) == 3 + + +# ── Audio callbacks ─────────────────────────────────────────────────────────── + + +class TestMumbleBridgeAudioCallbacks: + def test_add_and_trigger_callback(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + received: list[tuple[str, bytes]] = [] + + def cb(username: str, pcm: bytes): + received.append((username, pcm)) + + bridge.add_audio_callback(cb) + + # Simulate sound received + fake_user = {"name": "Alexander"} + fake_chunk = MagicMock() + fake_chunk.pcm = _pcm_tone() + bridge._on_sound_received(fake_user, fake_chunk) + + assert len(received) == 1 + assert received[0][0] == "Alexander" + + def test_remove_callback(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + received: list = [] + + def cb(username: str, pcm: bytes): + received.append(username) + + bridge.add_audio_callback(cb) + bridge.remove_audio_callback(cb) + + fake_user = {"name": "Alexander"} + fake_chunk = MagicMock() + fake_chunk.pcm = _pcm_tone() + bridge._on_sound_received(fake_user, fake_chunk) + + assert received == [] + + def test_remove_nonexistent_callback_is_noop(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + + def cb(u, p): + pass + + bridge.remove_audio_callback(cb) # Must not raise + + def test_on_sound_received_no_callbacks(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + fake_user = {"name": "Test"} + fake_chunk = MagicMock() + fake_chunk.pcm = _pcm_tone() + bridge._on_sound_received(fake_user, fake_chunk) # Must not raise + + def test_on_sound_received_missing_user_key(self): + """Falls back to 'unknown' when user dict has no 'name' key.""" + from integrations.mumble.bridge import MumbleBridge + + received_names: list[str] = [] + + bridge = MumbleBridge() + bridge.add_audio_callback(lambda u, p: received_names.append(u)) + + fake_chunk = MagicMock() + fake_chunk.pcm = _pcm_tone() + bridge._on_sound_received({}, fake_chunk) + + assert received_names == ["unknown"] + + def test_callback_exception_does_not_propagate(self): + """A crashing callback must not bubble up to the Mumble thread.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + + def bad_cb(u, p): + raise RuntimeError("oops") + + bridge.add_audio_callback(bad_cb) + + fake_chunk = MagicMock() + fake_chunk.pcm = _pcm_tone() + bridge._on_sound_received({"name": "X"}, fake_chunk) # Must not raise + + +# ── Push-to-talk ────────────────────────────────────────────────────────────── + + +class TestPushToTalk: + def test_ptt_context_sets_and_clears_flag(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + assert not bridge._ptt_active + + with bridge.push_to_talk(): + assert bridge._ptt_active + + assert not bridge._ptt_active + + def test_ptt_clears_on_exception(self): + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + try: + with bridge.push_to_talk(): + raise ValueError("test") + except ValueError: + pass + + assert not bridge._ptt_active + + +# ── VAD send_pcm_buffer ─────────────────────────────────────────────────────── + + +class TestSendPcmBuffer: + def test_vad_suppresses_silence(self): + """VAD mode must not call sound_output.add_sound for silent PCM.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_client = MagicMock() + bridge._client = mock_client + + mock_settings = MagicMock() + mock_settings.mumble_audio_mode = "vad" + mock_settings.mumble_vad_threshold = 0.02 + + with patch("config.settings", mock_settings): + bridge._send_pcm_buffer(_pcm_silence(ms=50)) + + mock_client.sound_output.add_sound.assert_not_called() + + def test_vad_transmits_tone(self): + """VAD mode must send audible PCM frames.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_client = MagicMock() + bridge._client = mock_client + + mock_settings = MagicMock() + mock_settings.mumble_audio_mode = "vad" + mock_settings.mumble_vad_threshold = 0.01 + + with patch("config.settings", mock_settings): + bridge._send_pcm_buffer(_pcm_tone(ms=50, amplitude=16000)) + + assert mock_client.sound_output.add_sound.call_count > 0 + + def test_ptt_suppresses_when_inactive(self): + """PTT mode must not send when _ptt_active is False.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_client = MagicMock() + bridge._client = mock_client + bridge._ptt_active = False + + mock_settings = MagicMock() + mock_settings.mumble_audio_mode = "ptt" + mock_settings.mumble_vad_threshold = 0.02 + + with patch("config.settings", mock_settings): + bridge._send_pcm_buffer(_pcm_tone(ms=50, amplitude=16000)) + + mock_client.sound_output.add_sound.assert_not_called() + + def test_ptt_sends_when_active(self): + """PTT mode must send when _ptt_active is True.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + mock_client = MagicMock() + bridge._client = mock_client + bridge._ptt_active = True + + mock_settings = MagicMock() + mock_settings.mumble_audio_mode = "ptt" + mock_settings.mumble_vad_threshold = 0.02 + + with patch("config.settings", mock_settings): + bridge._send_pcm_buffer(_pcm_tone(ms=50, amplitude=16000)) + + assert mock_client.sound_output.add_sound.call_count > 0 + + def test_no_client_is_noop(self): + """_send_pcm_buffer is a no-op when client is None.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._client = None + bridge._send_pcm_buffer(_pcm_tone(ms=20)) # Must not raise + + +# ── TTS pipeline ────────────────────────────────────────────────────────────── + + +class TestTtsToPcm: + def test_no_tts_engines_returns_none(self): + """_tts_to_pcm returns None gracefully when no engine is available.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + with ( + patch.dict(sys.modules, {"piper": None, "piper.voice": None, "pyttsx3": None}), + ): + result = bridge._tts_to_pcm("Hello world") + assert result is None + + def test_speak_when_not_connected_is_noop(self): + """speak() must be a safe no-op when bridge is not connected.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = False + bridge.speak("Hello") # Must not raise + + def test_speak_calls_send_audio_when_tts_succeeds(self): + """speak() calls send_audio when _tts_to_pcm returns bytes.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._client = MagicMock() + + fake_pcm = _pcm_tone(ms=200) + with patch.object(bridge, "_tts_to_pcm", return_value=fake_pcm): + with patch.object(bridge, "send_audio") as mock_send: + bridge.speak("Hello Timmy") + + mock_send.assert_called_once_with(fake_pcm) + + def test_speak_does_not_call_send_when_tts_fails(self): + """speak() does not call send_audio when TTS returns None.""" + from integrations.mumble.bridge import MumbleBridge + + bridge = MumbleBridge() + bridge._connected = True + bridge._client = MagicMock() + + with patch.object(bridge, "_tts_to_pcm", return_value=None): + with patch.object(bridge, "send_audio") as mock_send: + bridge.speak("Hello") + + mock_send.assert_not_called() + + +# ── Config settings integration ─────────────────────────────────────────────── + + +class TestMumbleSettings: + def test_settings_have_mumble_fields(self): + """Settings object exposes all required Mumble configuration fields.""" + from config import settings + + assert hasattr(settings, "mumble_enabled") + assert hasattr(settings, "mumble_host") + assert hasattr(settings, "mumble_port") + assert hasattr(settings, "mumble_user") + assert hasattr(settings, "mumble_password") + assert hasattr(settings, "mumble_channel") + assert hasattr(settings, "mumble_audio_mode") + assert hasattr(settings, "mumble_vad_threshold") + assert hasattr(settings, "mumble_silence_ms") + + def test_default_mumble_disabled(self): + """Mumble is disabled by default (opt-in only).""" + from config import settings + + assert settings.mumble_enabled is False + + def test_default_mumble_port(self): + from config import settings + + assert settings.mumble_port == 64738 + + def test_default_audio_mode(self): + from config import settings + + assert settings.mumble_audio_mode == "vad" + + def test_default_vad_threshold(self): + from config import settings + + assert 0.0 < settings.mumble_vad_threshold < 1.0