[claude] Mumble voice bridge — Alexander ↔ Timmy co-play audio (#858) #1324

Merged
claude merged 1 commits from claude/issue-858 into main 2026-03-24 02:19:20 +00:00
7 changed files with 1050 additions and 0 deletions

View File

@@ -122,6 +122,33 @@ services:
retries: 3 retries: 3
start_period: 30s 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 — vendored agent runtime sidecar ────────────────────────────
openfang: openfang:
build: build:
@@ -158,6 +185,8 @@ volumes:
device: "${PWD}/data" device: "${PWD}/data"
openfang-data: openfang-data:
driver: local driver: local
mumble-data:
driver: local
# ── Internal network ──────────────────────────────────────────────────────── # ── Internal network ────────────────────────────────────────────────────────
networks: networks:

View File

@@ -49,6 +49,7 @@ pyttsx3 = { version = ">=2.90", optional = true }
openai-whisper = { version = ">=20231117", optional = true } openai-whisper = { version = ">=20231117", optional = true }
piper-tts = { version = ">=1.2.0", optional = true } piper-tts = { version = ">=1.2.0", optional = true }
sounddevice = { version = ">=0.4.6", optional = true } sounddevice = { version = ">=0.4.6", optional = true }
pymumble-py3 = { version = ">=1.0", optional = true }
sentence-transformers = { version = ">=2.0.0", optional = true } sentence-transformers = { version = ">=2.0.0", optional = true }
numpy = { version = ">=1.24.0", optional = true } numpy = { version = ">=1.24.0", optional = true }
requests = { version = ">=2.31.0", optional = true } requests = { version = ">=2.31.0", optional = true }
@@ -69,6 +70,7 @@ telegram = ["python-telegram-bot"]
discord = ["discord.py"] discord = ["discord.py"]
bigbrain = ["airllm"] bigbrain = ["airllm"]
voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"] voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
mumble = ["pymumble-py3"]
celery = ["celery"] celery = ["celery"]
embeddings = ["sentence-transformers", "numpy"] embeddings = ["sentence-transformers", "numpy"]
git = ["GitPython"] git = ["GitPython"]

View File

@@ -90,6 +90,27 @@ class Settings(BaseSettings):
# Discord bot token — set via DISCORD_TOKEN env var or the /discord/setup endpoint # Discord bot token — set via DISCORD_TOKEN env var or the /discord/setup endpoint
discord_token: str = "" 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.01.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 ────────────────────────────────────────── # ── Discord action confirmation ──────────────────────────────────────────
# When True, dangerous tools (shell, write_file, python) require user # When True, dangerous tools (shell, write_file, python) require user
# confirmation via Discord button before executing. # confirmation via Discord button before executing.

View File

@@ -7,6 +7,7 @@ External platform bridges. All are optional dependencies.
- `telegram_bot/` — Telegram bot bridge - `telegram_bot/` — Telegram bot bridge
- `shortcuts/` — iOS Siri Shortcuts API metadata - `shortcuts/` — iOS Siri Shortcuts API metadata
- `voice/` — Local NLU intent detection (regex-based, no cloud) - `voice/` — Local NLU intent detection (regex-based, no cloud)
- `mumble/` — Mumble voice bridge (bidirectional audio: Timmy TTS ↔ Alexander mic)
## Testing ## Testing
```bash ```bash

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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