ji
This commit is contained in:
1 parent
4c16f87a3e
commit
2493c5c532
27 files changed
+8008
-568
No files matched your search
@@ -0,0 +1,123 @@
|
||||
"""Audiobook ingest: raw-cache transcoding must terminate and clean up."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
SRC_SR = 44100
|
||||
SRC_SECONDS = 25 # two full 10 s blocks + one partial block
|
||||
TARGET_SR = 22050
|
||||
|
||||
_DiskUsage = namedtuple("_DiskUsage", "total used free")
|
||||
|
||||
|
||||
class TranscodeToRawTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
try:
|
||||
import soundfile as sf
|
||||
except ImportError:
|
||||
self.skipTest("soundfile not installed")
|
||||
from javis.voices import audiobook_ingest
|
||||
self.sf = sf
|
||||
self.ingest = audiobook_ingest
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name)
|
||||
self.source = self.root / "book.wav"
|
||||
t = np.arange(SRC_SR * SRC_SECONDS) / SRC_SR
|
||||
tone = 0.3 * np.sin(2 * np.pi * 220 * t)
|
||||
sf.write(str(self.source), np.stack([tone, tone], axis=1), SRC_SR, subtype="PCM_16")
|
||||
self.target = self.root / "cache" / "ingest_audio_cache.i16"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _bogus_frame_count(self):
|
||||
"""Mimic an MP3 with a malformed ID3 tag: INT64_MAX frames, unseekable."""
|
||||
frames = patch.object(self.sf.SoundFile, "frames", new_callable=PropertyMock,
|
||||
return_value=sys.maxsize)
|
||||
seekable = patch.object(self.sf.SoundFile, "seekable", return_value=False)
|
||||
return frames, seekable
|
||||
|
||||
def _run_with_timeout(self, timeout: float = 60.0, **kw) -> BaseException | None:
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def run():
|
||||
try:
|
||||
self.ingest._transcode_to_raw(self.source, self.target, TARGET_SR, **kw)
|
||||
except BaseException as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
th = threading.Thread(target=run, daemon=True)
|
||||
th.start()
|
||||
th.join(timeout)
|
||||
self.assertFalse(th.is_alive(), "transcode did not stop at EOF (runaway)")
|
||||
return errors[0] if errors else None
|
||||
|
||||
def test_unknown_frame_count_stops_at_eof(self) -> None:
|
||||
frames, seekable = self._bogus_frame_count()
|
||||
with frames, seekable:
|
||||
self.assertGreaterEqual(self.sf.info(str(self.source)).frames, sys.maxsize // 2)
|
||||
err = self._run_with_timeout()
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(self.target.stat().st_size, SRC_SECONDS * TARGET_SR * 2)
|
||||
self.assertFalse(self.target.with_suffix(".part").exists())
|
||||
|
||||
def test_stop_request_aborts_and_removes_partial_file(self) -> None:
|
||||
err = self._run_with_timeout(should_stop=lambda: True)
|
||||
self.assertIsInstance(err, self.ingest.TranscodeCancelled)
|
||||
self.assertFalse(self.target.exists())
|
||||
self.assertFalse(self.target.with_suffix(".part").exists())
|
||||
|
||||
def test_disk_running_full_aborts_and_removes_partial_file(self) -> None:
|
||||
frames, seekable = self._bogus_frame_count()
|
||||
full = patch.object(self.ingest.shutil, "disk_usage",
|
||||
return_value=_DiskUsage(10**12, 10**12, 0))
|
||||
every_block = patch.object(self.ingest, "_DISK_CHECK_EVERY_BLOCKS", 1)
|
||||
with frames, seekable, full, every_block:
|
||||
err = self._run_with_timeout()
|
||||
self.assertIsInstance(err, RuntimeError)
|
||||
self.assertIn("free space", str(err))
|
||||
self.assertFalse(self.target.exists())
|
||||
self.assertFalse(self.target.with_suffix(".part").exists())
|
||||
|
||||
def test_insufficient_space_for_known_length_fails_fast(self) -> None:
|
||||
with patch.object(self.ingest.shutil, "disk_usage",
|
||||
return_value=_DiskUsage(10**12, 10**12, 0)):
|
||||
err = self._run_with_timeout()
|
||||
self.assertIsInstance(err, RuntimeError)
|
||||
self.assertIn("Not enough disk space", str(err))
|
||||
self.assertFalse(self.target.with_suffix(".part").exists())
|
||||
|
||||
def test_stop_button_during_transcode_ends_ingest_as_stopped(self) -> None:
|
||||
events: list[dict] = []
|
||||
|
||||
class Bus:
|
||||
async def publish(self, topic, payload):
|
||||
events.append(payload)
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.root = self.root
|
||||
mgr.create.return_value = SimpleNamespace(dir=self.root / "voice")
|
||||
stop = asyncio.Event()
|
||||
stop.set()
|
||||
with patch("javis.voices.manager.VoiceManager", return_value=mgr):
|
||||
stats = asyncio.run(self.ingest.ingest_audiobook(
|
||||
None, self.source, "voice", bus=Bus(), stop_event=stop, resume=False))
|
||||
self.assertEqual(stats.total_segments, 0)
|
||||
self.assertEqual(events[-1]["phase"], "stopped")
|
||||
self.assertEqual(events[-1]["during"], "transcoding")
|
||||
self.assertFalse((self.root / "voice" / "ingest_audio_cache.i16").exists())
|
||||
self.assertFalse((self.root / "voice" / "ingest_audio_cache.part").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Slave GUI chat: newest-first transcript and fullscreen chat mode (offscreen Qt)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
SLAVE_SRC = Path(__file__).resolve().parents[1] / "javis-slave" / "src"
|
||||
if str(SLAVE_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SLAVE_SRC))
|
||||
|
||||
QtWidgets = pytest.importorskip("PySide6.QtWidgets")
|
||||
QtTest = pytest.importorskip("PySide6.QtTest")
|
||||
|
||||
from javis_slave import main_window # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def screen(qapp):
|
||||
scr = main_window.MainScreen()
|
||||
scr.resize(900, 700)
|
||||
scr.show()
|
||||
yield scr
|
||||
scr.close()
|
||||
|
||||
|
||||
def _texts(scr) -> list[str]:
|
||||
"""Bubble texts from the top of the transcript to the bottom."""
|
||||
layout = scr.transcript_layout
|
||||
out = []
|
||||
for i in range(layout.count()):
|
||||
row = layout.itemAt(i).widget()
|
||||
if row is not None:
|
||||
out.append(row.findChild(QtWidgets.QLabel).text())
|
||||
return out
|
||||
|
||||
|
||||
def test_newest_message_is_on_top(screen):
|
||||
screen.set_turn("frage 1", "antwort 1")
|
||||
screen.set_turn("frage 2", "antwort 2")
|
||||
screen.show_error("kaputt")
|
||||
assert _texts(screen) == [
|
||||
"⚠ kaputt", "antwort 2", "frage 2", "antwort 1", "frage 1"]
|
||||
|
||||
|
||||
def test_prune_drops_oldest_at_the_bottom(screen):
|
||||
for i in range(30):
|
||||
screen.set_turn(f"u{i}", f"a{i}")
|
||||
texts = _texts(screen)
|
||||
assert len(texts) == 40
|
||||
assert texts[0] == "a29"
|
||||
assert "u0" not in texts and "u29" in texts
|
||||
|
||||
|
||||
def test_scrolls_to_top_on_new_message(screen):
|
||||
for i in range(12):
|
||||
screen.set_turn(f"u{i}", "x " * 200)
|
||||
QtTest.QTest.qWait(150) # lets the layout settle (and the first auto-scroll fire)
|
||||
vbar = screen.transcript_scroll.verticalScrollBar()
|
||||
assert vbar.maximum() > 0
|
||||
vbar.setValue(vbar.maximum())
|
||||
screen.set_turn("neu", "neu")
|
||||
QtTest.QTest.qWait(150) # the scroll is deferred by a 50 ms timer
|
||||
assert vbar.value() == vbar.minimum()
|
||||
|
||||
|
||||
def test_fullscreen_button_goes_fullscreen_and_keeps_orb_and_chat_visible(qapp):
|
||||
win = main_window.SlaveWindow()
|
||||
win.show_main()
|
||||
win.show()
|
||||
scr = win.main_screen
|
||||
scr.set_turn("frage", "antwort")
|
||||
try:
|
||||
assert not win.isFullScreen()
|
||||
assert scr.expand_btn.toolTip() == "Vollbild"
|
||||
|
||||
scr.expand_btn.click()
|
||||
QtTest.QTest.qWait(50)
|
||||
assert win.isFullScreen()
|
||||
assert scr.orb.isVisible() and scr.transcript_scroll.isVisible()
|
||||
assert scr.expand_btn.toolTip() == "Vollbild beenden"
|
||||
|
||||
scr.expand_btn.click()
|
||||
QtTest.QTest.qWait(50)
|
||||
assert not win.isFullScreen()
|
||||
assert scr.orb.isVisible() and scr.transcript_scroll.isVisible()
|
||||
assert scr.expand_btn.toolTip() == "Vollbild"
|
||||
finally:
|
||||
win.close()
|
||||
|
||||
|
||||
def test_esc_and_f11_keep_button_in_sync(qapp):
|
||||
win = main_window.SlaveWindow()
|
||||
win.show_main()
|
||||
win.show()
|
||||
try:
|
||||
win._toggle_fullscreen() # F11
|
||||
QtTest.QTest.qWait(50)
|
||||
assert win.isFullScreen() and win.main_screen.expand_btn.toolTip() == "Vollbild beenden"
|
||||
|
||||
win._leave_fullscreen() # Esc
|
||||
QtTest.QTest.qWait(50)
|
||||
assert not win.isFullScreen() and win.main_screen.expand_btn.toolTip() == "Vollbild"
|
||||
finally:
|
||||
win.close()
|
||||
|
||||
def test_expand_icons_render(qapp):
|
||||
for kind in ("expand", "collapse"):
|
||||
img = main_window._icon(kind, "#cccccc", 26).pixmap(26, 26).toImage()
|
||||
assert any(img.pixelColor(x, y).alpha() > 0
|
||||
for x in range(img.width()) for y in range(img.height()))
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Unidentified-speaker inbox, sorting clips into profiles, and live samples."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from javis.core.config import Config, PathsConfig
|
||||
from javis.core.events import EventBus
|
||||
from javis.core.state import AgentState
|
||||
from javis.stt.unidentified import UnidentifiedStore
|
||||
from javis.stt.voice_profiler import NullProfiler, VoiceProfiler
|
||||
|
||||
SR = 16000
|
||||
|
||||
|
||||
def _clip(voice: int, seconds: float = 1.0) -> np.ndarray:
|
||||
"""Audio whose first sample encodes the (fake) speaker id."""
|
||||
a = np.full(int(SR * seconds), 0.2, dtype=np.float32)
|
||||
a[0] = voice / 100.0
|
||||
return a
|
||||
|
||||
|
||||
def _fake_embed(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||
voice = round(float(audio[0]) * 100)
|
||||
v = np.full(192, 0.01, dtype=np.float32)
|
||||
v[voice] = 1.0
|
||||
return v / np.linalg.norm(v)
|
||||
|
||||
|
||||
def _profiler(base: Path) -> VoiceProfiler:
|
||||
prof = VoiceProfiler(Config(paths=PathsConfig(base_dir=base)))
|
||||
prof._encoder = object() # pretend the ECAPA model is loaded
|
||||
prof.embed = _fake_embed # type: ignore[method-assign]
|
||||
return prof
|
||||
|
||||
|
||||
class UnidentifiedStoreTests(unittest.TestCase):
|
||||
def test_add_list_load_remove_and_persist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
store = UnidentifiedStore(Path(d), max_clips=10)
|
||||
emb = _fake_embed(_clip(3), SR)
|
||||
a = store.add(_clip(3), SR, embedding=emb, text=" hallo ", source="local",
|
||||
best_name="Bob", best_score=0.41)
|
||||
b = store.add(_clip(4, 2.0), SR, text="zwei")
|
||||
entries = store.entries()
|
||||
self.assertEqual([e["id"] for e in entries], [b, a]) # newest first
|
||||
self.assertEqual(entries[1]["text"], "hallo")
|
||||
self.assertEqual(entries[1]["best_name"], "Bob")
|
||||
self.assertAlmostEqual(entries[0]["duration_s"], 2.0)
|
||||
|
||||
audio, sr = store.load_audio(a)
|
||||
self.assertEqual(sr, SR)
|
||||
self.assertEqual(len(audio), SR)
|
||||
np.testing.assert_allclose(store.load_embedding(a), emb, rtol=1e-6)
|
||||
self.assertIsNone(store.load_embedding(b))
|
||||
|
||||
# A fresh instance reads the same index from disk.
|
||||
self.assertEqual(len(UnidentifiedStore(Path(d))), 2)
|
||||
|
||||
self.assertEqual(store.remove([a, "nope"]), 1)
|
||||
self.assertFalse((Path(d) / f"{a}.wav").exists())
|
||||
self.assertFalse((Path(d) / f"{a}.npy").exists())
|
||||
self.assertEqual(store.clear(), 1)
|
||||
self.assertEqual(store.entries(), [])
|
||||
with self.assertRaises(KeyError):
|
||||
store.load_audio(a)
|
||||
|
||||
def test_oldest_clips_are_pruned(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
store = UnidentifiedStore(Path(d), max_clips=2)
|
||||
ids = [store.add(_clip(i + 1), SR, text=str(i)) for i in range(3)]
|
||||
self.assertEqual([e["id"] for e in store.entries()], [ids[2], ids[1]])
|
||||
self.assertFalse((Path(d) / f"{ids[0]}.wav").exists())
|
||||
|
||||
|
||||
class ProfilerEmbeddingTests(unittest.TestCase):
|
||||
def test_enroll_rank_identify_refine(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
prof = _profiler(Path(d))
|
||||
self.assertTrue(prof.enroll_embeddings("Alice", [_fake_embed(_clip(1), SR)] * 2))
|
||||
prof.enroll("Bob", [_clip(2)], SR)
|
||||
ranked = prof.rank(_fake_embed(_clip(1), SR))
|
||||
self.assertEqual(ranked[0][0], "Alice")
|
||||
self.assertGreater(ranked[0][1], 0.99)
|
||||
self.assertEqual(prof.identify_embedding(_fake_embed(_clip(2), SR))[0], "Bob")
|
||||
self.assertIsNone(prof.identify_embedding(_fake_embed(_clip(9), SR)))
|
||||
self.assertEqual(prof.rank(np.zeros(192)), [])
|
||||
|
||||
# Running centroid: a Bob sample pulls Alice's centroid towards Bob.
|
||||
before = dict(prof.rank(_fake_embed(_clip(2), SR)))["Alice"]
|
||||
self.assertTrue(prof.refine_embedding("Alice", _fake_embed(_clip(2), SR)))
|
||||
after = dict(prof.rank(_fake_embed(_clip(2), SR)))["Alice"]
|
||||
self.assertGreater(after, before)
|
||||
alice = next(p for p in prof.list_profiles() if p["name"] == "Alice")
|
||||
self.assertEqual(alice["clip_count"], 3)
|
||||
|
||||
self.assertFalse(prof.refine_embedding("Nobody", _fake_embed(_clip(1), SR)))
|
||||
self.assertFalse(prof.refine_embedding("Alice", np.ones(10))) # wrong shape
|
||||
self.assertFalse(prof.enroll_embeddings("Zero", [np.zeros(192)]))
|
||||
|
||||
def test_sanitised_name_collision_gets_its_own_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
prof = _profiler(Path(d))
|
||||
prof.enroll_embeddings("Anna B", [_fake_embed(_clip(1), SR)])
|
||||
prof.enroll_embeddings("Anna_B", [_fake_embed(_clip(2), SR)])
|
||||
files = {p["name"]: p["file"] for p in prof.list_profiles()}
|
||||
self.assertNotEqual(files["Anna B"], files["Anna_B"])
|
||||
prof2 = _profiler(Path(d))
|
||||
prof2._load_index()
|
||||
self.assertEqual(prof2.rank(_fake_embed(_clip(1), SR))[0][0], "Anna B")
|
||||
|
||||
def test_null_profiler_mirrors_api(self) -> None:
|
||||
n = NullProfiler()
|
||||
self.assertFalse(n.is_ready())
|
||||
self.assertFalse(n.has_profile("x"))
|
||||
self.assertEqual(n.rank(np.ones(192)), [])
|
||||
self.assertFalse(n.enroll_embeddings("x", [np.ones(192)]))
|
||||
self.assertFalse(n.refine_embedding("x", np.ones(192)))
|
||||
self.assertIsNone(n.identify_embedding(np.ones(192)))
|
||||
|
||||
|
||||
class _FakeMic:
|
||||
chunk_ms = 32
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.drains = 0
|
||||
|
||||
def drain(self) -> None:
|
||||
self.drains += 1
|
||||
|
||||
|
||||
def _orchestrator(base: Path):
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
|
||||
orch = object.__new__(Orchestrator)
|
||||
orch.config = Config(paths=PathsConfig(base_dir=base))
|
||||
orch.bus = EventBus()
|
||||
orch.profiler = _profiler(base)
|
||||
orch.unidentified = UnidentifiedStore(base / "voice_profiles" / "unidentified")
|
||||
orch._enroll_clip_buffer = []
|
||||
orch._enroll_ask_pending = False
|
||||
orch._turn_lock = asyncio.Lock()
|
||||
orch._mic_free = asyncio.Event()
|
||||
orch._mic_free.set()
|
||||
orch.mic = _FakeMic()
|
||||
orch.vad = None
|
||||
orch.state = AgentState.IDLE
|
||||
events: list[tuple[str, object]] = []
|
||||
|
||||
async def _record(name, payload=None):
|
||||
events.append((name, payload))
|
||||
|
||||
for ev in ("unidentified_changed", "voice_profiles_changed", "speaker_enrolled",
|
||||
"speaker_ask_name"):
|
||||
orch.bus.subscribe(ev, lambda p, _n=ev: _record(_n, p))
|
||||
return orch, events
|
||||
|
||||
|
||||
class AssignUnidentifiedTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unknown_turn_is_stored_then_sorted_into_new_profile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, events = _orchestrator(Path(d))
|
||||
orch.config.speaker_id.auto_enroll = False
|
||||
await orch._handle_unknown_speaker(_clip(5), SR, "wer bin ich", source="Küche")
|
||||
await orch._handle_unknown_speaker(_clip(5), SR, " ", source="local") # no text
|
||||
await asyncio.sleep(0.2)
|
||||
entries = orch.unidentified.entries()
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0]["source"], "Küche")
|
||||
self.assertIn(("unidentified_changed", {"added": entries[0]["id"]}), events)
|
||||
|
||||
res = await orch.assign_unidentified([entries[0]["id"]], " Carla ")
|
||||
self.assertEqual(res, {"name": "Carla", "added": 1, "created": True})
|
||||
self.assertTrue(orch.profiler.has_profile("Carla"))
|
||||
self.assertEqual(orch.unidentified.entries(), [])
|
||||
self.assertEqual(orch.profiler.identify_embedding(_fake_embed(_clip(5), SR))[0],
|
||||
"Carla")
|
||||
names = [e[0] for e in events]
|
||||
self.assertIn("voice_profiles_changed", names)
|
||||
|
||||
async def test_assign_to_existing_profile_refines_it(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, _ = _orchestrator(Path(d))
|
||||
orch.profiler.enroll("Dora", [_clip(6)], SR)
|
||||
ids = [orch.unidentified.add(_clip(6), SR, text="a"),
|
||||
orch.unidentified.add(_clip(6), SR, text="b")] # no stored embedding
|
||||
res = await orch.assign_unidentified(ids, "Dora")
|
||||
self.assertEqual(res["added"], 2)
|
||||
self.assertFalse(res["created"])
|
||||
dora = next(p for p in orch.profiler.list_profiles() if p["name"] == "Dora")
|
||||
self.assertEqual(dora["clip_count"], 3)
|
||||
|
||||
async def test_assign_validates_input(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, _ = _orchestrator(Path(d))
|
||||
with self.assertRaises(ValueError):
|
||||
await orch.assign_unidentified(["x"], " ")
|
||||
with self.assertRaises(RuntimeError):
|
||||
await orch.assign_unidentified(["missing"], "Eve")
|
||||
orch.profiler._encoder = None
|
||||
with self.assertRaises(RuntimeError):
|
||||
await orch.assign_unidentified(["x"], "Eve")
|
||||
|
||||
async def test_name_prompt_confirm_removes_inbox_copies(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, events = _orchestrator(Path(d))
|
||||
await orch._handle_unknown_speaker(_clip(7), SR, "hallo", source="local")
|
||||
await orch._handle_unknown_speaker(_clip(7), SR, "noch mal", source="local")
|
||||
self.assertEqual(len(orch.pending_enroll_clips()), 2)
|
||||
self.assertEqual(sum(1 for e in events if e[0] == "speaker_ask_name"), 1)
|
||||
self.assertTrue(await orch.auto_enroll_confirm("k", "Frank"))
|
||||
self.assertEqual(orch.unidentified.entries(), [])
|
||||
self.assertTrue(orch.profiler.has_profile("Frank"))
|
||||
self.assertIn(("speaker_enrolled", {"name": "Frank", "clip_count": 2}), events)
|
||||
|
||||
async def test_dismiss_keeps_inbox_and_allows_new_prompt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, events = _orchestrator(Path(d))
|
||||
await orch._handle_unknown_speaker(_clip(8), SR, "hallo", source="local")
|
||||
await asyncio.sleep(0.2)
|
||||
orch.auto_enroll_dismiss()
|
||||
self.assertEqual(orch.pending_enroll_clips(), [])
|
||||
self.assertEqual(len(orch.unidentified.entries()), 1)
|
||||
await orch._handle_unknown_speaker(_clip(8), SR, "wieder", source="local")
|
||||
self.assertEqual(sum(1 for e in events if e[0] == "speaker_ask_name"), 2)
|
||||
|
||||
|
||||
class LiveSampleTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_record_and_add_samples(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, events = _orchestrator(Path(d))
|
||||
orch.profiler.enroll("Gina", [_clip(9)], SR)
|
||||
seen = {}
|
||||
|
||||
async def fake_record(config, mic, vad, **kw):
|
||||
# The wake loop must be paused and the turn lock held meanwhile.
|
||||
seen["mic_free"] = orch._mic_free.is_set()
|
||||
seen["locked"] = orch._turn_lock.locked()
|
||||
seen["state"] = orch.state
|
||||
return _clip(9)
|
||||
|
||||
async def no_chime():
|
||||
return None
|
||||
|
||||
orch._play_wake_chime = no_chime
|
||||
with patch("javis.agent.orchestrator.record_utterance", fake_record):
|
||||
sample = await orch.record_voice_sample("Gina")
|
||||
self.assertEqual(seen, {"mic_free": False, "locked": True,
|
||||
"state": AgentState.ENROLLING})
|
||||
self.assertTrue(orch._mic_free.is_set())
|
||||
self.assertFalse(orch._turn_lock.locked())
|
||||
self.assertEqual(orch.state, AgentState.IDLE)
|
||||
self.assertGreater(sample.score, 0.99)
|
||||
self.assertEqual(sample.best_name, "Gina")
|
||||
self.assertAlmostEqual(sample.duration_s, 1.0)
|
||||
|
||||
used = await orch.add_voice_samples("Gina", [sample, sample])
|
||||
self.assertEqual(used, 2)
|
||||
gina = next(p for p in orch.profiler.list_profiles() if p["name"] == "Gina")
|
||||
self.assertEqual(gina["clip_count"], 3)
|
||||
self.assertIn(("voice_profiles_changed",
|
||||
{"name": "Gina", "added": 2, "created": False}), events)
|
||||
|
||||
async def test_record_errors_are_user_facing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, _ = _orchestrator(Path(d))
|
||||
|
||||
async def no_speech(*a, **kw):
|
||||
return None
|
||||
|
||||
async def no_chime():
|
||||
return None
|
||||
|
||||
orch._play_wake_chime = no_chime
|
||||
with patch("javis.agent.orchestrator.record_utterance", no_speech), \
|
||||
self.assertRaisesRegex(RuntimeError, "No speech"):
|
||||
await orch.record_voice_sample("Gina")
|
||||
self.assertTrue(orch._mic_free.is_set())
|
||||
self.assertFalse(orch._turn_lock.locked())
|
||||
|
||||
async with orch._turn_lock: # a conversation turn is running
|
||||
with self.assertRaisesRegex(RuntimeError, "busy"):
|
||||
async with orch._exclusive_mic(wait_s=0.05):
|
||||
pass
|
||||
self.assertTrue(orch._mic_free.is_set())
|
||||
|
||||
orch.mic = None
|
||||
with self.assertRaisesRegex(RuntimeError, "microphone"):
|
||||
await orch.record_voice_sample("Gina")
|
||||
|
||||
async def test_wake_loop_yields_mic_to_exclusive_recording(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch, _ = _orchestrator(Path(d))
|
||||
orch._stop = asyncio.Event()
|
||||
fed: list[int] = []
|
||||
|
||||
class Wake:
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def feed(self, chunk):
|
||||
fed.append(len(chunk))
|
||||
return 0.0
|
||||
|
||||
def triggered(self, score):
|
||||
return False
|
||||
|
||||
class Mic(_FakeMic):
|
||||
async def chunks(self, stale_timeout=None):
|
||||
while True:
|
||||
await asyncio.sleep(0.005)
|
||||
yield np.zeros(512, dtype=np.float32)
|
||||
|
||||
orch.wake = Wake()
|
||||
orch.mic = Mic()
|
||||
orch._mic_free.clear()
|
||||
task = asyncio.ensure_future(Orchestrator._wait_for_wake(orch))
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(fed, []) # waits while the mic is taken
|
||||
orch._mic_free.set()
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertTrue(fed) # resumed wake detection
|
||||
orch._mic_free.clear()
|
||||
self.assertFalse(await asyncio.wait_for(task, 1.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2705
-4
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user