Some checks failed
- Replace gitleaks-action (requires paid license) with direct CLI invocation -- same engine, no license needed - Fix ruff I001 import sorting in voice.py and test_llm.py - Remove unused imports: _chat_request (test_llm), Path (test_music) - Remove unused assignment: original_spawn (test_voice) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
798 lines
25 KiB
Python
798 lines
25 KiB
Python
"""Tests for the voice STT/TTS plugin."""
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import io
|
|
import sys
|
|
import time
|
|
import wave
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
# -- Load plugin module directly ---------------------------------------------
|
|
|
|
_spec = importlib.util.spec_from_file_location("voice", "plugins/voice.py")
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
sys.modules["voice"] = _mod
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
|
|
# -- Fakes -------------------------------------------------------------------
|
|
|
|
|
|
class _FakeState:
|
|
def __init__(self):
|
|
self._store: dict[str, dict[str, str]] = {}
|
|
|
|
def get(self, ns: str, key: str) -> str | None:
|
|
return self._store.get(ns, {}).get(key)
|
|
|
|
def set(self, ns: str, key: str, value: str) -> None:
|
|
self._store.setdefault(ns, {})[key] = value
|
|
|
|
def delete(self, ns: str, key: str) -> None:
|
|
self._store.get(ns, {}).pop(key, None)
|
|
|
|
def keys(self, ns: str) -> list[str]:
|
|
return list(self._store.get(ns, {}).keys())
|
|
|
|
|
|
class _FakeBot:
|
|
"""Minimal bot for voice plugin testing."""
|
|
|
|
def __init__(self, *, mumble: bool = True):
|
|
self.sent: list[tuple[str, str]] = []
|
|
self.replied: list[str] = []
|
|
self.actions: list[tuple[str, str]] = []
|
|
self.state = _FakeState()
|
|
self.config: dict = {}
|
|
self._pstate: dict = {}
|
|
self._tasks: set[asyncio.Task] = set()
|
|
self.nick = "derp"
|
|
self._sound_listeners: list = []
|
|
if mumble:
|
|
self.stream_audio = AsyncMock()
|
|
|
|
async def send(self, target: str, text: str) -> None:
|
|
self.sent.append((target, text))
|
|
|
|
async def reply(self, message, text: str) -> None:
|
|
self.replied.append(text)
|
|
|
|
async def action(self, target: str, text: str) -> None:
|
|
self.actions.append((target, text))
|
|
|
|
def _spawn(self, coro, *, name=None):
|
|
task = asyncio.ensure_future(coro)
|
|
self._tasks.add(task)
|
|
task.add_done_callback(self._tasks.discard)
|
|
return task
|
|
|
|
|
|
class _Msg:
|
|
"""Minimal message object."""
|
|
|
|
def __init__(self, text="!listen", nick="Alice", target="0",
|
|
is_channel=True):
|
|
self.text = text
|
|
self.nick = nick
|
|
self.target = target
|
|
self.is_channel = is_channel
|
|
self.prefix = nick
|
|
self.command = "PRIVMSG"
|
|
self.params = [target, text]
|
|
self.tags = {}
|
|
self.raw = {}
|
|
|
|
|
|
class _FakeSoundChunk:
|
|
"""Minimal sound chunk with PCM data."""
|
|
|
|
def __init__(self, pcm: bytes = b"\x00\x00" * 960):
|
|
self.pcm = pcm
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMumbleGuard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMumbleGuard:
|
|
def test_is_mumble_true(self):
|
|
bot = _FakeBot(mumble=True)
|
|
assert _mod._is_mumble(bot) is True
|
|
|
|
def test_is_mumble_false(self):
|
|
bot = _FakeBot(mumble=False)
|
|
assert _mod._is_mumble(bot) is False
|
|
|
|
def test_listen_non_mumble(self):
|
|
bot = _FakeBot(mumble=False)
|
|
msg = _Msg(text="!listen on")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
assert any("Mumble-only" in r for r in bot.replied)
|
|
|
|
def test_say_non_mumble(self):
|
|
bot = _FakeBot(mumble=False)
|
|
msg = _Msg(text="!say hello")
|
|
asyncio.run(_mod.cmd_say(bot, msg))
|
|
assert any("Mumble-only" in r for r in bot.replied)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestListenCommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListenCommand:
|
|
def test_listen_status(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!listen")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
assert any("off" in r.lower() for r in bot.replied)
|
|
|
|
def test_listen_on(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!listen on")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
ps = _mod._ps(bot)
|
|
assert ps["listen"] is True
|
|
assert any("Listening" in r for r in bot.replied)
|
|
|
|
def test_listen_off(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
ps["buffers"]["Alice"] = bytearray(b"\x00" * 100)
|
|
ps["last_ts"]["Alice"] = time.monotonic()
|
|
msg = _Msg(text="!listen off")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
assert ps["listen"] is False
|
|
assert ps["buffers"] == {}
|
|
assert ps["last_ts"] == {}
|
|
assert any("Stopped" in r for r in bot.replied)
|
|
|
|
def test_listen_invalid(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!listen maybe")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
assert any("Usage" in r for r in bot.replied)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestSayCommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSayCommand:
|
|
def test_say_no_text(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!say")
|
|
asyncio.run(_mod.cmd_say(bot, msg))
|
|
assert any("Usage" in r for r in bot.replied)
|
|
|
|
def test_say_too_long(self):
|
|
bot = _FakeBot()
|
|
text = "x" * 501
|
|
msg = _Msg(text=f"!say {text}")
|
|
asyncio.run(_mod.cmd_say(bot, msg))
|
|
assert any("too long" in r.lower() for r in bot.replied)
|
|
|
|
def test_say_spawns_task(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!say hello world")
|
|
|
|
spawned = []
|
|
|
|
def track_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = track_spawn
|
|
asyncio.run(_mod.cmd_say(bot, msg))
|
|
assert "voice-tts" in spawned
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestAudioBuffering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAudioBuffering:
|
|
def test_accumulates_pcm(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert "Alice" in ps["buffers"]
|
|
assert len(ps["buffers"]["Alice"]) == 960
|
|
|
|
def test_ignores_own_nick(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
user = {"name": "derp"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert "derp" not in ps["buffers"]
|
|
|
|
def test_respects_listen_false(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = False
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert ps["buffers"] == {}
|
|
|
|
def test_caps_at_max_bytes(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
user = {"name": "Alice"}
|
|
# Fill beyond max
|
|
big_chunk = _FakeSoundChunk(b"\x00\x01" * (_mod._MAX_BYTES // 2 + 100))
|
|
_mod._on_voice(bot, user, big_chunk)
|
|
assert len(ps["buffers"]["Alice"]) <= _mod._MAX_BYTES
|
|
|
|
def test_empty_pcm_ignored(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"")
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert "Alice" not in ps["buffers"]
|
|
|
|
def test_none_user_ignored(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, "not_a_dict", chunk)
|
|
assert ps["buffers"] == {}
|
|
|
|
def test_updates_timestamp(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert "Alice" in ps["last_ts"]
|
|
ts1 = ps["last_ts"]["Alice"]
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert ps["last_ts"]["Alice"] >= ts1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestFlushLogic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFlushLogic:
|
|
def test_silence_gap_triggers_flush(self):
|
|
"""Buffer is flushed and transcribed after silence gap."""
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
ps["silence_gap"] = 0.1 # very short for testing
|
|
|
|
# Pre-populate buffer with enough PCM (> _MIN_BYTES)
|
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0 # already silent
|
|
|
|
async def _check():
|
|
with patch.object(_mod, "_transcribe", return_value="hello"):
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.sleep(1.0)
|
|
ps["listen"] = False # stop the monitor
|
|
await asyncio.sleep(0.2)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
assert any("hello" in a[1] for a in bot.actions)
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_min_duration_filter(self):
|
|
"""Short utterances (< _MIN_BYTES) are discarded."""
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
ps["silence_gap"] = 0.1
|
|
|
|
# Buffer too small
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(b"\x00\x01" * 10)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
|
|
|
async def _check():
|
|
with patch.object(_mod, "_transcribe", return_value="x") as mock_t:
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.sleep(0.5)
|
|
ps["listen"] = False
|
|
await asyncio.sleep(0.2)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
mock_t.assert_not_called()
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_buffer_cleared_after_flush(self):
|
|
"""Buffer and timestamp are removed after flushing."""
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
ps["silence_gap"] = 0.1
|
|
|
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
|
|
|
async def _check():
|
|
with patch.object(_mod, "_transcribe", return_value="test"):
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.sleep(0.5)
|
|
ps["listen"] = False
|
|
await asyncio.sleep(0.2)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
assert "Alice" not in ps["buffers"]
|
|
assert "Alice" not in ps["last_ts"]
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestPcmToWav
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPcmToWav:
|
|
def test_valid_wav(self):
|
|
pcm = b"\x00\x00" * 48000 # 1 second of silence
|
|
wav_data = _mod._pcm_to_wav(pcm)
|
|
# Should start with RIFF header
|
|
assert wav_data[:4] == b"RIFF"
|
|
# Parse it back
|
|
buf = io.BytesIO(wav_data)
|
|
with wave.open(buf, "rb") as wf:
|
|
assert wf.getnchannels() == 1
|
|
assert wf.getsampwidth() == 2
|
|
assert wf.getframerate() == 48000
|
|
assert wf.getnframes() == 48000
|
|
|
|
def test_empty_pcm(self):
|
|
wav_data = _mod._pcm_to_wav(b"")
|
|
buf = io.BytesIO(wav_data)
|
|
with wave.open(buf, "rb") as wf:
|
|
assert wf.getnframes() == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestTranscribe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranscribe:
|
|
def test_parse_json_response(self):
|
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
|
pcm = b"\x00\x00" * 4800 # 0.1s
|
|
resp = MagicMock()
|
|
resp.read.return_value = b'{"text": "hello world"}'
|
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
|
text = _mod._transcribe(ps, pcm)
|
|
assert text == "hello world"
|
|
|
|
def test_empty_text(self):
|
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
|
pcm = b"\x00\x00" * 4800
|
|
resp = MagicMock()
|
|
resp.read.return_value = b'{"text": ""}'
|
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
|
text = _mod._transcribe(ps, pcm)
|
|
assert text == ""
|
|
|
|
def test_missing_text_key(self):
|
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
|
pcm = b"\x00\x00" * 4800
|
|
resp = MagicMock()
|
|
resp.read.return_value = b'{"result": "something"}'
|
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
|
text = _mod._transcribe(ps, pcm)
|
|
assert text == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestPerBotState
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPerBotState:
|
|
def test_ps_initializes(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
assert ps["listen"] is False
|
|
assert ps["buffers"] == {}
|
|
assert ps["last_ts"] == {}
|
|
|
|
def test_ps_stable_reference(self):
|
|
bot = _FakeBot()
|
|
ps1 = _mod._ps(bot)
|
|
ps2 = _mod._ps(bot)
|
|
assert ps1 is ps2
|
|
|
|
def test_ps_isolated_per_bot(self):
|
|
bot1 = _FakeBot()
|
|
bot2 = _FakeBot()
|
|
_mod._ps(bot1)["listen"] = True
|
|
assert _mod._ps(bot2)["listen"] is False
|
|
|
|
def test_ps_config_override(self):
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"silence_gap": 3.0}}
|
|
ps = _mod._ps(bot)
|
|
assert ps["silence_gap"] == 3.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestEnsureListener
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEnsureListener:
|
|
def test_registers_callback(self):
|
|
bot = _FakeBot()
|
|
_mod._ps(bot) # init state
|
|
_mod._ensure_listener(bot)
|
|
assert len(bot._sound_listeners) == 1
|
|
ps = _mod._ps(bot)
|
|
assert ps["_listener_registered"] is True
|
|
|
|
def test_idempotent(self):
|
|
bot = _FakeBot()
|
|
_mod._ps(bot)
|
|
_mod._ensure_listener(bot)
|
|
_mod._ensure_listener(bot)
|
|
assert len(bot._sound_listeners) == 1
|
|
|
|
def test_no_listener_without_attr(self):
|
|
bot = _FakeBot()
|
|
del bot._sound_listeners
|
|
_mod._ps(bot)
|
|
_mod._ensure_listener(bot)
|
|
# Should not raise, just skip
|
|
|
|
def test_callback_calls_on_voice(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
_mod._ensure_listener(bot)
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
bot._sound_listeners[0](user, chunk)
|
|
assert "Alice" in ps["buffers"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestOnConnected
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOnConnected:
|
|
def test_reregisters_when_listening(self):
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
ps["listen"] = True
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
spawned.append(name)
|
|
coro.close()
|
|
return task
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert ps["_listener_registered"] is True
|
|
assert "voice-flush-monitor" in spawned
|
|
|
|
def test_noop_when_not_listening(self):
|
|
bot = _FakeBot()
|
|
_mod._ps(bot) # init but listen=False
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
coro.close()
|
|
return MagicMock()
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert "voice-flush-monitor" not in spawned
|
|
|
|
def test_noop_non_mumble(self):
|
|
bot = _FakeBot(mumble=False)
|
|
asyncio.run(_mod.on_connected(bot))
|
|
# Should not raise or register anything
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestTriggerMode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTriggerMode:
|
|
def test_trigger_config(self):
|
|
"""_ps() reads trigger from config."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
assert ps["trigger"] == "claude"
|
|
|
|
def test_trigger_default_empty(self):
|
|
"""trigger defaults to empty string (disabled)."""
|
|
bot = _FakeBot()
|
|
ps = _mod._ps(bot)
|
|
assert ps["trigger"] == ""
|
|
|
|
def test_trigger_buffers_without_listen(self):
|
|
"""_on_voice buffers when trigger is set, even with listen=False."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
assert ps["listen"] is False
|
|
user = {"name": "Alice"}
|
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
|
_mod._on_voice(bot, user, chunk)
|
|
assert "Alice" in ps["buffers"]
|
|
assert len(ps["buffers"]["Alice"]) == 960
|
|
|
|
def test_trigger_detected_spawns_tts(self):
|
|
"""Flush monitor detects trigger word and spawns TTS."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
ps["silence_gap"] = 0.1
|
|
|
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
|
|
|
spawned = []
|
|
|
|
async def _check():
|
|
tts_hit = asyncio.Event()
|
|
|
|
def track_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
if name == "voice-tts":
|
|
tts_hit.set()
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = track_spawn
|
|
|
|
with patch.object(_mod, "_transcribe",
|
|
return_value="claude hello world"):
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
|
ps["trigger"] = ""
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
assert "voice-tts" in spawned
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_trigger_strips_word(self):
|
|
"""Trigger word is stripped; only remainder goes to TTS."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
ps["silence_gap"] = 0.1
|
|
|
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
|
|
|
tts_texts = []
|
|
|
|
async def _check():
|
|
tts_hit = asyncio.Event()
|
|
|
|
async def _noop():
|
|
pass
|
|
|
|
def capturing_tts(bot_, text):
|
|
tts_texts.append(text)
|
|
return _noop()
|
|
|
|
def track_spawn(coro, *, name=None):
|
|
if name == "voice-tts":
|
|
tts_hit.set()
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = track_spawn
|
|
original_tts = _mod._tts_play
|
|
_mod._tts_play = capturing_tts
|
|
|
|
try:
|
|
with patch.object(_mod, "_transcribe",
|
|
return_value="Claude hello world"):
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
|
ps["trigger"] = ""
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
finally:
|
|
_mod._tts_play = original_tts
|
|
assert tts_texts == ["hello world"]
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_no_trigger_discards(self):
|
|
"""Non-triggered speech is silently discarded when only trigger active."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
ps["silence_gap"] = 0.1
|
|
|
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
|
with ps["lock"]:
|
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
|
|
|
async def _check():
|
|
transcribed = asyncio.Event()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def mock_transcribe(ps_, pcm_):
|
|
loop.call_soon_threadsafe(transcribed.set)
|
|
return "hello world"
|
|
|
|
with patch.object(_mod, "_transcribe",
|
|
side_effect=mock_transcribe):
|
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
|
await asyncio.wait_for(transcribed.wait(), timeout=5)
|
|
# Give the monitor a moment to process the result
|
|
await asyncio.sleep(0.2)
|
|
ps["trigger"] = ""
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
assert bot.actions == []
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_on_connected_starts_with_trigger(self):
|
|
"""Listener and flush task start on connect when trigger is set."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
ps = _mod._ps(bot)
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
spawned.append(name)
|
|
coro.close()
|
|
return task
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert ps["_listener_registered"] is True
|
|
assert "voice-flush-monitor" in spawned
|
|
|
|
def test_listen_status_shows_trigger(self):
|
|
"""!listen status includes trigger info when set."""
|
|
bot = _FakeBot()
|
|
bot.config = {"voice": {"trigger": "claude"}}
|
|
_mod._ps(bot)
|
|
msg = _Msg(text="!listen")
|
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
|
assert any("Trigger: claude" in r for r in bot.replied)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGreeting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGreeting:
|
|
def test_greet_on_first_connect(self):
|
|
"""TTS greeting fires on first connect when configured."""
|
|
bot = _FakeBot()
|
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
|
bot._is_audio_ready = lambda: True
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert "voice-greet" in spawned
|
|
|
|
def test_greet_only_once(self):
|
|
"""Greeting fires only on first connect, not on reconnect."""
|
|
bot = _FakeBot()
|
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
|
bot._is_audio_ready = lambda: True
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert spawned.count("voice-greet") == 1
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert spawned.count("voice-greet") == 1
|
|
|
|
def test_no_greet_without_config(self):
|
|
"""No greeting when mumble.greet is not set."""
|
|
bot = _FakeBot()
|
|
bot.config = {}
|
|
|
|
spawned = []
|
|
|
|
def fake_spawn(coro, *, name=None):
|
|
spawned.append(name)
|
|
coro.close()
|
|
task = MagicMock()
|
|
task.done.return_value = False
|
|
return task
|
|
|
|
bot._spawn = fake_spawn
|
|
asyncio.run(_mod.on_connected(bot))
|
|
assert "voice-greet" not in spawned
|
|
|
|
def test_no_greet_non_mumble(self):
|
|
"""Greeting skipped for non-Mumble bots."""
|
|
bot = _FakeBot(mumble=False)
|
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
|
asyncio.run(_mod.on_connected(bot))
|
|
# Should not raise or try to greet
|