This commit was merged in pull request #1324.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
5
src/integrations/mumble/__init__.py
Normal file
5
src/integrations/mumble/__init__.py
Normal 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"]
|
||||
464
src/integrations/mumble/bridge.py
Normal file
464
src/integrations/mumble/bridge.py
Normal 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()
|
||||
528
tests/integrations/test_mumble_bridge.py
Normal file
528
tests/integrations/test_mumble_bridge.py
Normal 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
|
||||
Reference in New Issue
Block a user