This commit is contained in:
Javis committed 2026-10-10 13:20:35 +02:00
1 parent 4c16f87a3e
commit 2493c5c532
27 files changed
+8008 -568

No files matched your search

+123
View File
@@ -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()
+120
View File
@@ -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()))
+335
View File
@@ -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()
File diff suppressed because it is too large. Load diff