Compare commits

..
5 Commits
Author SHA1 Message Date
Javis 2493c5c532 ji 2026-10-10 13:20:35 +02:00
JavisandCopilot 4c16f87a3e Correct the voice_gate_ratio tuning direction
A higher ratio raises the loudness gate, so it rejects more audio rather than less. The config guidance claimed the opposite; pin the direction with a test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 13:07:32 +02:00
JavisandCopilot 1fa75e58fc End recording when the user stops, not the music
Silero VAD answers "is this voice-like?", which music, TV and podcasts
satisfy just as well as the user does. With Spotify playing through the
device's own speakers the VAD never went quiet, so record_utterance never
saw the end of the command and ran to the 20 s cap on master and slave
alike -- and music alone was enough to defeat the "no speech during the
grace window" early return.

Wrap the VAD in a SpeechEndpointer that tracks the background loudness of
the recent window and only reports speech while a frame is clearly louder
than it. Both wake-word loops feed their idle audio through observe(), so
the gate is already calibrated when a turn starts, and only non-voice
frames update the background so a long sentence cannot raise the gate onto
itself. Onset/release hysteresis keeps trailing quiet syllables.

The master owns the knob (wake_word.voice_gate_ratio, 0 = off) and pushes
it to satellites in the wake-word block.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 13:04:13 +02:00
JavisandCopilot 74bc1be2d6 Start listening for expected follow-ups
Propagate the master's follow-up decision to slave devices and skip wake-word detection for one turn after response playback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 12:51:36 +02:00
JavisandCopilot b921938970 Handle CUDA context loss gracefully
Fall back to CPU for speaker identification and fail speech recognition with an actionable restart message when the NVIDIA driver resets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 12:46:59 +02:00
41 changed files with 8976 additions and 597 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
{
"entries": {
"spotify_pause_access_denied_unknown_speaker": {
"value": "spotify__pausePlayback mit deviceId 'a169794694f72b88e60439a45e2b506a30254979' faellt mit 'Access denied: unknown speaker' durch. Bei Spotify-Befehlern zuerst die Integration laden oder gueltige Geraete-IDs ueber getAvailableDevices ermitteln.",
"category": "spotify",
"created_at": "2026-10-10T10:43:30.486067+00:00",
"updated_at": "2026-10-10T10:43:30.486067+00:00"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"entries": {
"user_name": {
"value": "Der Nutzer heisst Florian.",
"category": "personal",
"created_at": "2026-10-10T10:40:14.388969+00:00",
"updated_at": "2026-10-10T10:40:14.388969+00:00"
}
}
}
+19 -1
View File
@@ -1,4 +1,22 @@
{
"version": 1,
"commands": {}
"commands": {
"abfbc899-8ad1-436b-b386-152c80669dcd": {
"id": "abfbc899-8ad1-436b-b386-152c80669dcd",
"trigger": "küche ein",
"match_mode": "contains",
"actions": [
{
"tool": "homey__control_zone",
"args": {
"capability": "onoff",
"value": true,
"zone": "Küche"
}
}
],
"created_at": "2026-10-10T10:39:38.382223+00:00",
"updated_at": "2026-10-10T10:39:38.382223+00:00"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"devices": {
"a32f0630-04e5-4eda-9ae2-44fa4eeb6068": {
"name": "Tablet K\u00fcche",
"api_key_hash": "89db0c92e376e0160a756f8285d940ba8813d2867cd699badac4deb2b85b813b",
"room": "K\u00fcche",
"ip_address": "10.10.10.225",
"created_at": "2026-10-07T17:40:24+00:00",
"last_seen": "2026-10-10T10:44:31+00:00",
"last_seen_ip": "10.10.10.225"
}
}
}
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "Florian",
"file": "Florian.npy",
"enrolled_at": "2026-10-07T21:32:39",
"clip_count": 13,
"last_seen": "2026-10-10T12:44:06"
}
]
@@ -0,0 +1 @@
[]
+27
View File
@@ -89,6 +89,33 @@ Push-to-talk: `Ctrl+Space` in the window, `Ctrl+Alt+Space` system-wide (Windows)
3. Javis will VAD-split, transcribe, and build a clean dataset.
4. Test with zero-shot cloning (instant) or click **Fine-tune** for higher quality.
Fine-tuning runs in a console window of its own. Meanwhile Javis speaks with the
voice being trained, refreshed with the training progress every 15 minutes. Stop it
any time — Ctrl+C, closing the window, logging off or shutting down saves the
epoch so far, and the next run continues with its next batch. A crash loses at most
the last 10 minutes (`JAVIS_PIPER_PROGRESS_MINUTES`); training restarts by itself.
## Speaker identification
With `speaker_id.enabled`, Javis recognises who is talking (ECAPA embeddings) and manages
speakers in **Intelligence → Voice Profiles**:
- **Enroll** — `+` records a few sentences and creates a profile.
- **Unidentified speakers** — utterances that matched no profile are kept (audio,
transcript, time, source device, closest profile). **▶ Play** listens to one;
**Sort…** adds it to an existing profile or creates a new one. Recordings that sound
alike are offered and pre-selected so you can sort several at once. ✕ / **Clear all**
delete them.
- **Enhance a voice** — 🎤 on a profile card opens a recorder: click **● Record sample**,
speak, review each sample (playback + how well it matches the profile; mismatches are
flagged), then **Add N samples** to blend them in.
- The "unknown speaker" pop-up (`speaker_id.auto_enroll`) can also play back what was said.
Unknown-speaker clips are stored locally only, in `<voice_profiles_dir>/unidentified/`.
They are capped by `speaker_id.max_unidentified` (default 50, oldest dropped first). Turn
the inbox off with `speaker_id.keep_unidentified: false`. Both settings are under
**Settings → Speaker Identification**.
## MCP servers
Edit `mcp_servers.json`. Schema matches VS Code's `mcp.json`, so you can copy/paste server entries.
+8 -1
View File
@@ -40,7 +40,7 @@ training:
batch_size: 32
num_workers: 2
accelerator: "gpu" # gpu | cpu
precision: "16-mixed" # 32 | 16-mixed | bf16-mixed
precision: "bf16-mixed" # bf16-mixed (fastest) | 32 | 16-mixed
language: "de" # espeak-ng voice code used for phonemisation (de, en-us, …)
audio:
@@ -56,6 +56,11 @@ wake_word:
vad_threshold: 0.5 # silero-vad probability threshold
vad_gate: 0.0 # >0: wake only when openWakeWord's own VAD agrees (fewer false wakes from TV/music)
silence_ms: 800 # end-of-utterance silence
voice_gate_ratio: 3.0 # how much louder than the background a frame must be to count as
# the user talking — lets recording end while your own speakers play
# music (Silero rates music as speech). 0 = off. Higher is stricter:
# raise it if music keeps the recorder running, lower it if quiet
# commands get cut off.
mcp:
servers_config: "mcp_servers.json"
@@ -84,6 +89,8 @@ agent:
max_llm_iterations: 5
# Spoken feedback for voice users (empty string = silent).
error_phrase: "Entschuldigung, da ist etwas schiefgelaufen. Bitte versuch es noch einmal."
# Spoken when the GPU was lost (e.g. NVIDIA driver updated while running).
gpu_lost_phrase: "Die Grafikkarte ist nicht mehr erreichbar, vermutlich wurde der Treiber aktualisiert. Bitte starte Javis neu."
approval_phrase: "Soll ich das ausführen? Sag ja oder nein."
memory:
+7 -3
View File
@@ -494,9 +494,12 @@ The overnight scheduler runs `run_pipeline()` via `asyncio.to_thread`; the threa
on the training subprocess for hours. Cancelling the scheduler task (quit) released
the training lock immediately while the thread kept training, and at interpreter exit
Python joins executor threads — so Javis hung on quit until training finished.
**Fix:** cancellation calls `piper_train.request_stop()`, which terminates the child
process (`_run` now polls instead of blocking in `wait()`); the lock is only released
once the worker has actually finished.
**Fix:** cancellation calls `piper_train.request_stop()`: the training process finishes
its step, saves its progress — within the epoch too: the next run continues with the
next batch — and exits (it is killed if that takes longer than
`STOP_GRACE_SECONDS`), and the scheduler waits for that (`_run` polls instead of
blocking in `wait()`). The lock (`training/.training.lock`, held by `run_pipeline`)
names the training processes and is only released once they have exited.
#### VOI-01
**Imported voice archives may carry pickle checkpoints** — *Medium (trust)*
@@ -609,4 +612,5 @@ stt:
hotwords: "Javis, Esszimmer, Homey" # bias Whisper towards these words
wake_word:
vad_gate: 0.5 # wake only when openWakeWord's VAD agrees
voice_gate_ratio: 3.0 # end recording when the *user* stops, not the music
```
+11
View File
@@ -27,9 +27,20 @@ screen (GUI mode) and played back through the device's speakers.
4. **Turns** — After the wake word, the utterance is recorded locally
(Silero VAD or RMS end-pointing), uploaded as WAV to `POST /api/v1/turn`,
and the master's reply text + synthesized speech are returned and played.
Recording ends when *your voice* stops: Silero rates music and TV as
speech, so a loudness gate (`wake_word.voice_gate_ratio`, pushed by the
master) only counts audio that is clearly louder than the background the
wake-word loop measured. Without it, music from this device's own speakers
kept the recorder running until the hard length cap.
When the master marks a reply as expecting a follow-up, the slave starts
listening immediately after playback without requiring another wake word.
If the master's turn fails it returns its spoken error phrase instead, so a
screenless satellite still gets audible feedback; connection problems play
a low falling tone.
5. **Conversation view** — The GUI lists the conversation with the newest
message on top; scroll down for older ones. The fullscreen button in the
conversation card (or `F11`) toggles window fullscreen; the orb and the
conversation stay visible. `Esc` leaves fullscreen.
## Setup
@@ -37,6 +37,7 @@ class TurnResult:
audio_wav: Optional[bytes] # decoded WAV bytes (16-bit PCM)
sample_rate: Optional[int]
error: Optional[str] = None # master-side failure; reply_text is the spoken error phrase
expects_followup: bool = False
def _http_detail(r: httpx.Response) -> str:
@@ -57,6 +58,8 @@ def _wake_word_from(payload: dict) -> WakeWordSettings:
threshold=float(ww.get("threshold", 0.5)),
vad_threshold=float(ww.get("vad_threshold", 0.5)),
silence_ms=int(ww.get("silence_ms", 800)),
# Older masters do not send this; keep the satellite's own default.
voice_gate_ratio=float(ww.get("voice_gate_ratio", 3.0)),
)
@@ -263,6 +266,7 @@ class MasterClient:
audio_wav=base64.b64decode(audio_b64) if audio_b64 else None,
sample_rate=data.get("sample_rate"),
error=data.get("error") or None,
expects_followup=bool(data.get("expects_followup", False)),
)
async def check_health(self, host: str, port: int) -> bool:
+42 -29
View File
@@ -72,8 +72,9 @@ class AudioEngine:
self.wake: Optional[WakeWordDetector] = None
self.vad = None
self._config_task: Optional[asyncio.Task] = None
# Set when last response ended with '?'; next recording will use 3x silence timeout
self._last_response_is_question: bool = False
# Set by the master when the reply expects an answer. The next loop
# skips wake-word detection and starts recording after playback ends.
self._followup_pending: bool = False
# UI callbacks (assigned by the app layer).
self.on_state: Callable[[str], None] = lambda s: None
@@ -103,7 +104,10 @@ class AudioEngine:
)
try:
await self.mic.start()
self.vad = make_vad(threshold=self.cfg.wake_word.vad_threshold)
self.vad = make_vad(
threshold=self.cfg.wake_word.vad_threshold,
voice_gate_ratio=self.cfg.wake_word.voice_gate_ratio,
)
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
except BaseException:
# The caller retries with a fresh engine; an open stream left behind
@@ -203,21 +207,27 @@ class AudioEngine:
await self._wait_unmuted_or_stop()
continue
try:
woke = await self._wait_for_wake()
if not woke:
if self._stop.is_set() or self.muted:
followup = self._followup_pending
if followup:
self._followup_pending = False
log.info("follow-up mode: skipping wake word")
spawn(self._play_chime([880.0, 1320.0], 0.06, 0.18))
else:
woke = await self._wait_for_wake()
if not woke:
if self._stop.is_set() or self.muted:
continue
# The mic yielded nothing for _WAKE_STALE_S: USB device
# unplugged / driver stalled. Reopen instead of looping forever.
stale_exits += 1
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
log.warning("mic silent for too long — reopening device")
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
await self.mic.restart()
stale_exits = 0
continue
# The mic yielded nothing for _WAKE_STALE_S: USB device
# unplugged / driver stalled. Reopen instead of looping forever.
stale_exits += 1
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
log.warning("mic silent for too long — reopening device")
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
await self.mic.restart()
stale_exits = 0
continue
stale_exits = 0
await self._handle_turn()
await self._handle_turn(is_followup=followup)
consecutive_errors = 0
except asyncio.CancelledError:
raise
@@ -254,10 +264,14 @@ class AudioEngine:
self.on_state("muted")
return False
score = self.wake.feed(chunk)
# Everything heard before the wake word is background (music, TV,
# this device's own speakers), so the recorder's voice gate is
# already calibrated when the turn starts.
rms = self.vad.observe(chunk) if hasattr(self.vad, "observe") else (
float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0)
now = time.monotonic()
if now - last_pub > 0.1:
last_pub = now
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
self.last_rms = rms
self.last_score = float(score)
self.on_mic_level(rms, float(score))
@@ -282,16 +296,17 @@ class AudioEngine:
"""Low descending two-tone: "something went wrong"."""
await self._play_chime([440.0, 330.0], 0.12, 0.2)
async def _handle_turn(self) -> None:
async def _handle_turn(self, *, is_followup: bool = False) -> None:
assert self.mic is not None
self.on_state("listening")
# If last response was a question, triple the listening timeout
silence_override = None
if self._last_response_is_question:
if is_followup:
base_silence = self.cfg.wake_word.silence_ms
silence_override = base_silence * 3
log.debug(f"last response was a question; extending silence timeout to {silence_override}ms (was {base_silence}ms)")
self._last_response_is_question = False # reset flag for next turn
log.debug(
f"follow-up recording: extending silence timeout to "
f"{silence_override}ms (was {base_silence}ms)"
)
audio = await record_utterance(
self.cfg.sample_rate, self.cfg.wake_word.silence_ms, self.mic, self.vad,
silence_ms_override=silence_override,
@@ -328,10 +343,6 @@ class AudioEngine:
if result.user_text or result.reply_text:
self.on_turn(result.user_text, result.reply_text)
# Check if response ended with '?' to trigger extended listening on next turn
if result.reply_text.rstrip().endswith("?"):
log.debug("assistant response ends with '?'; will extend listening timeout on next turn")
self._last_response_is_question = True
if result.audio_wav and result.sample_rate:
self.on_state("speaking")
@@ -346,6 +357,7 @@ class AudioEngine:
cancel_event=self._stop)
except Exception as e: # noqa: BLE001
log.warning(f"reply playback failed: {e}")
self._followup_pending = result.expects_followup and not bool(result.error)
self.on_state("idle")
# ---------------- config refresh ----------------
@@ -381,8 +393,8 @@ class AudioEngine:
self.on_room(room)
changed = True
old = self.cfg.wake_word
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms) != (
old.model, old.threshold, old.vad_threshold, old.silence_ms):
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms, ww.voice_gate_ratio) != (
old.model, old.threshold, old.vad_threshold, old.silence_ms, old.voice_gate_ratio):
log.info(f"wake-word settings updated by master: {ww}")
rebuild_model = ww.model != old.model
self.cfg.wake_word = ww
@@ -392,7 +404,8 @@ class AudioEngine:
spawn(self._swap_wake_model(ww.model, ww.threshold))
else:
self.wake.threshold = ww.threshold
self.vad = make_vad(threshold=ww.vad_threshold)
self.vad = make_vad(
threshold=ww.vad_threshold, voice_gate_ratio=ww.voice_gate_ratio)
changed = True
return changed
@@ -57,11 +57,13 @@ async def record_utterance(
silent_run = 0
seen_speech = False
chunks_seen = 0
background = 0.0
async for chunk in mic.chunks(stale_timeout=stale_timeout):
chunks_seen += 1
collected.append(chunk)
result = vad.process(chunk)
background = result.background
if result.is_speech:
seen_speech = True
@@ -70,9 +72,10 @@ async def record_utterance(
silent_run += 1
if seen_speech and silent_run >= silence_chunks_needed:
log.debug(f"speech ended (background rms={background:.4f})")
break
if not seen_speech and chunks_seen >= grace_chunks:
log.debug("no speech during grace window")
log.debug(f"no speech during grace window (background rms={background:.4f})")
return None
if chunks_seen >= max_chunks:
log.info("max utterance length reached")
+124 -6
View File
@@ -5,8 +5,9 @@ diagnostics helper so this module has no dependency on the ``javis`` package.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Protocol
from typing import Deque, Protocol
import numpy as np
@@ -19,6 +20,10 @@ log = get_logger("audio.vad")
class VADResult:
is_speech: bool
probability: float
# Loudness of this frame and the background level it was judged against.
# Both are 0.0 for VADs that do not track loudness.
rms: float = 0.0
background: float = 0.0
class VAD(Protocol):
@@ -49,16 +54,129 @@ class NullVAD:
if chunk.size == 0:
return VADResult(False, 0.0)
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
return VADResult(is_speech=rms >= self.threshold, probability=rms)
return VADResult(is_speech=rms >= self.threshold, probability=rms, rms=rms)
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
# A frame must be at least this loud (RMS, normalized float32) to count as
# someone talking. Without the floor the gate would collapse to ~0 in a silent
# room and every faint noise would read as speech.
_MIN_VOICE_RMS = 0.006
# Speech has short gaps, so a low percentile of the recent window still tracks
# the room even while the gate is open.
_BACKGROUND_PERCENTILE = 20.0
# Once the user is already talking the gate drops to this fraction of the onset
# ratio, so a trailing quiet syllable is not clipped off mid-word.
_RELEASE_SCALE = 0.55
def frame_rms(chunk: np.ndarray) -> float:
"""RMS loudness of one mono float32 frame (0.0 for an empty frame)."""
if chunk is None or len(chunk) == 0:
return 0.0
return float(np.sqrt(np.mean(np.square(np.asarray(chunk, dtype=np.float32)))))
class SpeechEndpointer:
"""Reports only the *near-field* talker as speech.
Silero answers "is this audio voice-like?", which music, TV and podcasts
satisfy just as well as the user does. When the satellite plays music
through its own speakers the VAD therefore never goes quiet, so a recording
never detects the end of the command and runs until the hard length cap.
This wrapper adds the missing distinction: it tracks how loud the
surrounding audio is and only reports speech while a frame is clearly
louder than that background — which is exactly what separates someone
talking into the device from steady playback. Idle audio fed through
:meth:`observe` (the wake-word loop does) means the background is already
known by the time a recording starts.
Implements the :class:`VAD` protocol, so it can be used anywhere a VAD is
expected. With ``onset_ratio <= 1.0`` the gate is disabled and the inner
VAD's verdict is passed through unchanged.
"""
def __init__(self, vad: VAD, *, onset_ratio: float = 3.0,
window_s: float = 1.5, chunk_ms: int = 32) -> None:
self.vad = vad
self.sample_rate = vad.sample_rate
self.onset_ratio = max(0.0, float(onset_ratio))
self.release_ratio = max(1.0, self.onset_ratio * _RELEASE_SCALE)
window = max(4, int(window_s * 1000.0 / max(1, chunk_ms)))
self._levels: Deque[float] = deque(maxlen=window)
self._in_speech = False
@property
def threshold(self) -> float:
return self.vad.threshold
@threshold.setter
def threshold(self, value: float) -> None:
self.vad.threshold = value
@property
def enabled(self) -> bool:
"""False when the loudness gate is off (plain VAD behaviour)."""
return self.onset_ratio > 1.0
@property
def background(self) -> float:
"""Background loudness of the recent window; 0.0 until audio is seen."""
if not self._levels:
return 0.0
levels = sorted(self._levels)
idx = min(len(levels) - 1,
int(len(levels) * _BACKGROUND_PERCENTILE / 100.0))
return levels[idx]
def observe(self, chunk: np.ndarray) -> float:
"""Feed audio heard while *not* recording; returns the frame's RMS.
Anything heard outside a turn is background by definition, so the gate
is calibrated before the user even says the wake word.
"""
rms = frame_rms(chunk)
self._levels.append(rms)
self._in_speech = False
return rms
def reset(self) -> None:
"""Start a new utterance. Deliberately keeps the learned background."""
self._in_speech = False
self.vad.reset()
def process(self, chunk: np.ndarray) -> VADResult:
rms = frame_rms(chunk)
background = self.background
inner = self.vad.process(chunk)
if not self.enabled:
self._levels.append(rms)
return VADResult(inner.is_speech, inner.probability, rms, background)
ratio = self.release_ratio if self._in_speech else self.onset_ratio
gate = max(_MIN_VOICE_RMS, background * ratio)
is_voice = bool(inner.is_speech) and rms >= gate
self._in_speech = is_voice
if not is_voice:
# Only non-voice frames describe the background; letting speech in
# would let a long sentence raise the gate onto itself and cut the
# user off mid-utterance.
self._levels.append(rms)
return VADResult(is_voice, inner.probability, rms, background)
def make_vad(sample_rate: int = 16000, threshold: float = 0.5,
voice_gate_ratio: float = 3.0, chunk_ms: int = 32) -> VAD:
"""Construct the capture VAD, falling back to NullVAD if torch/silero fails.
The VAD is wrapped in a :class:`SpeechEndpointer` so that playback from the
satellite's own speakers is not mistaken for the user talking.
"""
try:
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
base: VAD = SileroVAD(sample_rate=sample_rate, threshold=threshold)
except Exception as e: # noqa: BLE001
log.error(f"SileroVAD unavailable: {e}")
return NullVAD(sample_rate=sample_rate, threshold=threshold)
base = NullVAD(sample_rate=sample_rate, threshold=threshold)
return SpeechEndpointer(base, onset_ratio=voice_gate_ratio, chunk_ms=chunk_ms)
class SileroVAD:
+4
View File
@@ -40,6 +40,10 @@ class WakeWordSettings:
threshold: float = 0.5
vad_threshold: float = 0.5
silence_ms: int = 800
# How much louder than the background a frame must be to count as the user
# talking, so recording ends when the user stops even while this device's
# own speakers keep playing. 0 = off.
voice_gate_ratio: float = 3.0
@dataclass
+78 -18
View File
@@ -132,6 +132,7 @@ QPushButton[variant="danger"] {{ color: {CL_ERROR_FG}; }}
QPushButton[variant="danger"]:hover {{ background: {CL_ERROR_BG}; border-color: {CL_ERROR_BORDER}; }}
QPushButton[variant="icon"] {{ padding: 0; min-height: 0; border-radius: 16px; }}
QPushButton[variant="icon"]:checked {{ background: {CL_ERROR}; border-color: {CL_ERROR}; }}
QPushButton[variant="icon"][small="true"] {{ min-width: 46px; min-height: 46px; }}
QScrollArea {{ background: transparent; border: 0; }}
QScrollBar:vertical {{ background: transparent; width: 8px; margin: 4px 0; }}
@@ -211,9 +212,28 @@ def _draw_tune(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor) -> No
p.drawEllipse(QtCore.QPointF(x + knob * s, y + row * s), 0.09 * s, 0.09 * s)
def _draw_expand(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor, collapse: bool) -> None:
"""Four corner brackets: pointing outwards to enter, inwards to leave fullscreen."""
s, x, y = box.width(), box.x(), box.y()
pen = QtGui.QPen(color, max(1.5, s * 0.075))
pen.setCapStyle(QtCore.Qt.RoundCap)
pen.setJoinStyle(QtCore.Qt.RoundJoin)
p.setPen(pen)
p.setBrush(QtCore.Qt.NoBrush)
leg = 0.26 * s
reach = 0.10 if collapse else 0.36 # corner distance from the centre
d = 1 if collapse else -1 # legs run away from / towards the centre
for sx in (-1, 1):
for sy in (-1, 1):
vx, vy = x + (0.5 + sx * reach) * s, y + (0.5 + sy * reach) * s
p.drawPolyline([QtCore.QPointF(vx + d * sx * leg, vy),
QtCore.QPointF(vx, vy),
QtCore.QPointF(vx, vy + d * sy * leg)])
@lru_cache(maxsize=None)
def _icon(kind: str, color: str, size: int = 28) -> QtGui.QIcon:
"""Render a crisp (2x) icon: 'mic', 'mic_off' or 'tune'."""
"""Render a crisp (2x) icon: 'mic', 'mic_off', 'tune', 'expand' or 'collapse'."""
pm = QtGui.QPixmap(size * 2, size * 2)
pm.setDevicePixelRatio(2.0)
pm.fill(QtCore.Qt.transparent)
@@ -222,6 +242,8 @@ def _icon(kind: str, color: str, size: int = 28) -> QtGui.QIcon:
box = QtCore.QRectF(0, 0, size, size)
if kind == "tune":
_draw_tune(p, box, QtGui.QColor(color))
elif kind in ("expand", "collapse"):
_draw_expand(p, box, QtGui.QColor(color), collapse=(kind == "collapse"))
else:
_draw_mic(p, box, QtGui.QColor(color), slashed=(kind == "mic_off"))
p.end()
@@ -574,15 +596,18 @@ class MainScreen(QtWidgets.QWidget):
Alexa-style UX features:
- Mute toggle button on the orb
- Connection health monitoring with retry countdown
- Transcript history (scrollable conversation log)
- Transcript history (scrollable conversation log, newest message on top)
- Fullscreen button in the conversation card (orb and chat stay visible)
- Visual mute indicator overlay
"""
settingsRequested = QtCore.Signal()
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
fullscreenToggleRequested = QtCore.Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._fullscreen = False
self.setFocusPolicy(QtCore.Qt.ClickFocus) # tapping the background clears focus
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(28, 20, 28, 24)
@@ -654,10 +679,24 @@ class MainScreen(QtWidgets.QWidget):
cv.setContentsMargins(24, 18, 14, 18)
cv.setSpacing(10)
# Header label
# Header row: caption + fullscreen-chat toggle
header_row = QtWidgets.QHBoxLayout()
header_row.setContentsMargins(0, 0, 0, 0)
history_header = QtWidgets.QLabel("Konversation")
history_header.setProperty("role", "caption")
cv.addWidget(history_header)
header_row.addWidget(history_header)
header_row.addStretch(1)
self.expand_btn = QtWidgets.QPushButton()
self.expand_btn.setProperty("variant", "icon")
self.expand_btn.setProperty("small", True) # 48 px: the stylesheet's min-height would shrink it
self.expand_btn.setFixedSize(48, 48)
self.expand_btn.setIconSize(QtCore.QSize(26, 26))
self.expand_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.expand_btn.setFocusPolicy(QtCore.Qt.TabFocus)
self.expand_btn.clicked.connect(self.fullscreenToggleRequested.emit)
self._sync_expand_button()
header_row.addWidget(self.expand_btn)
cv.addLayout(header_row)
# Transcript history (replaces single-turn display).
# NOTE: this layout must belong to *exactly one* parent — we set it on
@@ -708,6 +747,20 @@ class MainScreen(QtWidgets.QWidget):
self.mute_btn.setToolTip(tip)
self.mute_btn.setAccessibleName(tip)
def set_fullscreen(self, fullscreen: bool) -> None:
"""Mirror the window's fullscreen state in the toggle button."""
if fullscreen == self._fullscreen:
return
self._fullscreen = fullscreen
self._sync_expand_button()
def _sync_expand_button(self) -> None:
fs = self._fullscreen
self.expand_btn.setIcon(_icon("collapse" if fs else "expand", CL_TEXT, 26))
tip = "Vollbild beenden" if fs else "Vollbild"
self.expand_btn.setToolTip(tip)
self.expand_btn.setAccessibleName(tip)
def _bubble_max_width(self) -> int:
return max(240, int(self.transcript_scroll.viewport().width() * 0.8))
@@ -767,7 +820,8 @@ class MainScreen(QtWidgets.QWidget):
else:
h.addWidget(bubble)
h.addStretch(1)
self.transcript_layout.addWidget(row)
# Newest first: the latest bubble goes above the older ones.
self.transcript_layout.insertWidget(0, row)
# ── slots (called via Qt signals from the engine bridge) ──────────────
@@ -796,9 +850,8 @@ class MainScreen(QtWidgets.QWidget):
def _ensure_transcript_spacer(self) -> None:
"""Keep exactly one expanding spacer, at the end of the transcript.
Bubbles are appended after the previous spacer, so stale spacers must
be removed from anywhere in the layout (they used to pile up between
turns and open large gaps).
It pushes short transcripts to the top; stray spacers (e.g. left from
the empty state) would open gaps between bubbles.
"""
for i in reversed(range(self.transcript_layout.count())):
item = self.transcript_layout.itemAt(i)
@@ -809,16 +862,16 @@ class MainScreen(QtWidgets.QWidget):
self.transcript_layout.addSpacerItem(spacer)
def _prune_transcript(self, max_bubbles: int = 40) -> None:
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
"""Keep at most *max_bubbles* bubbles in the transcript layout (drops the oldest, at the bottom)."""
rows = [self.transcript_layout.itemAt(i).widget()
for i in range(self.transcript_layout.count())]
rows = [row for row in rows if row is not None]
for row in rows[:max(0, len(rows) - max_bubbles)]:
for row in rows[max_bubbles:]:
self.transcript_layout.removeWidget(row)
row.deleteLater()
def set_turn(self, user_text: str, reply_text: str) -> None:
"""Add a new conversation turn to the transcript history (Alexa-style)."""
"""Add a new conversation turn to the transcript history (newest on top)."""
self._add_bubble(user_text, "user")
# Assistant message bubble (if any)
@@ -826,16 +879,16 @@ class MainScreen(QtWidgets.QWidget):
self._add_bubble(reply_text, "assistant")
self._prune_transcript()
# Spacer to push content to bottom (like Alexa's conversation history)
# Keeps short transcripts hugging the top edge
self._ensure_transcript_spacer()
# Auto-scroll to bottom
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
# Auto-scroll to top
QTimer.singleShot(50, self._scroll_transcript_to_top)
def _scroll_transcript_to_bottom(self) -> None:
"""Auto-scroll transcript to show latest messages."""
def _scroll_transcript_to_top(self) -> None:
"""Auto-scroll transcript to show the latest messages (newest is on top)."""
vbar = self.transcript_scroll.verticalScrollBar()
vbar.setValue(vbar.maximum())
vbar.setValue(vbar.minimum())
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
if ok:
@@ -865,7 +918,7 @@ class MainScreen(QtWidgets.QWidget):
self._add_bubble(f"⚠ {message}", "error")
self._prune_transcript()
self._ensure_transcript_spacer()
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
QTimer.singleShot(50, self._scroll_transcript_to_top)
class SlaveWindow(QtWidgets.QMainWindow):
@@ -907,6 +960,7 @@ class SlaveWindow(QtWidgets.QMainWindow):
self.engineError.connect(self.main_screen.show_error)
self.engineConnection.connect(self.main_screen.set_connection)
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
self.main_screen.fullscreenToggleRequested.connect(self._toggle_fullscreen)
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
@@ -919,6 +973,12 @@ class SlaveWindow(QtWidgets.QMainWindow):
self.showFullScreen()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
def changeEvent(self, event): # noqa: N802
super().changeEvent(event)
if event.type() == QtCore.QEvent.Type.WindowStateChange:
# Covers every path in (button, F11, kiosk, settings) and out (Esc).
self.main_screen.set_fullscreen(self.isFullScreen())
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
+391 -37
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import re
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, Optional
@@ -16,6 +18,7 @@ from ..audio_io.vad import make_vad
from ..audio_io.wake_word import WakeWordDetector
from ..core.config import Config
from ..core.events import EventBus
from ..core.hardware import GpuUnavailableError
from ..core.logger import get_logger
from ..core.state import AgentState
from ..core.tasks import cancel_and_wait, spawn
@@ -47,10 +50,14 @@ from .middleware import (
drain_background_tasks,
)
from .prompts import load_system_prompt
from ..stt.unidentified import UnidentifiedStore
from ..stt.voice_profiler import NullProfiler, SpeakerPrivileges, VoiceProfiler
log = get_logger("agent")
# Returned by Orchestrator._identify_speaker when speaker ID raised.
_ID_FAILED = object()
SENTENCE_BOUNDARY = re.compile(r"(?<=[\.\!\?…])\s+|(?<=[\.\!\?…])$")
# Detects whether the last sentence of a reply is a question.
@@ -346,6 +353,38 @@ class RemoteTurnResult:
reply_text: str = ""
audio: Optional[np.ndarray] = None # float32 mono reply speech
sample_rate: Optional[int] = None
expects_followup: bool = False
@dataclass
class VoiceSample:
"""A live voice sample recorded from the GUI to enhance a profile."""
audio: np.ndarray
sample_rate: int
embedding: Optional[np.ndarray] = None
# Cosine similarity to the target profile (None if it does not exist yet).
score: Optional[float] = None
best_name: Optional[str] = None
best_score: Optional[float] = None
@property
def duration_s(self) -> float:
return len(self.audio) / float(self.sample_rate) if self.sample_rate else 0.0
@dataclass
class _PendingEnrollClip:
"""Unknown-speaker clip buffered while the "who is this?" prompt is open."""
audio: np.ndarray
sample_rate: int
# Task storing the same clip in the unidentified inbox (returns its id).
store_task: Optional["asyncio.Task[Optional[str]]"] = None
def inbox_id(self) -> Optional[str]:
t = self.store_task
if t is None or not t.done() or t.cancelled() or t.exception() is not None:
return None
return t.result()
class Orchestrator:
@@ -357,7 +396,10 @@ class Orchestrator:
self.state: AgentState = AgentState.IDLE
self.mic: MicStream | None = MicStream(config) if config.stt.enabled else None
self.vad = make_vad(threshold=config.wake_word.vad_threshold)
self.vad = make_vad(
threshold=config.wake_word.vad_threshold,
voice_gate_ratio=config.wake_word.voice_gate_ratio,
)
# Voice input requires STT; if STT is disabled, also disable wake word.
self.stt = Transcriber(config) if config.stt.enabled else None
self.wake = None
@@ -433,8 +475,16 @@ class Orchestrator:
self.profiler: VoiceProfiler | NullProfiler = (
VoiceProfiler(config) if config.speaker_id.enabled else NullProfiler()
)
self._enroll_clip_buffer: list[np.ndarray] = []
self.unidentified = UnidentifiedStore(
config.paths.voice_profiles_dir / "unidentified",
config.speaker_id.max_unidentified,
)
self._enroll_clip_buffer: list[_PendingEnrollClip] = []
self._enroll_ask_pending: bool = False
# Cleared while the GUI records voice samples so the wake-word loop
# (which reads the mic outside _turn_lock) stops competing for chunks.
self._mic_free = asyncio.Event()
self._mic_free.set()
self._bg_quick_turn_count: int = 0
self._tool_cache: dict[str, tuple[str, float]] = {}
@@ -552,8 +602,13 @@ class Orchestrator:
# Disable any backend whose load() raised so later synthesis/prewarm
# calls don't encounter RuntimeError("load() has not been called").
if self.tts is not None and getattr(self.tts, "_voice", True) is None:
log.warning("TTS model failed to load — speech output disabled")
self.tts = None
backend, self.tts = self.tts, None
if hasattr(backend, "model_available"):
log.warning("TTS model failed to load — speech output disabled until "
"a model is available")
spawn(self._revive_tts(backend), name="tts-revive")
else:
log.warning("TTS model failed to load — speech output disabled")
await self.bus.publish("mcp_ready", self.mcp.status_snapshot())
ok = await self.llm.health()
await self.bus.publish("llm_health", {"ok": ok, "url": self.config.llm.base_url, "model": self.config.llm.model})
@@ -658,10 +713,16 @@ class Orchestrator:
raise
except Exception as e: # noqa: BLE001
log.exception(f"turn failed: {e}")
await self.bus.publish("error", {"where": "turn", "message": str(e)})
gpu_lost = isinstance(e, GpuUnavailableError)
await self.bus.publish(
"error", {"where": "stt" if gpu_lost else "turn", "message": str(e)}
)
await self.set_state(AgentState.ERROR)
# Voice users can't see the GUI error — say something.
await self._speak_notice(self.config.agent.error_phrase)
await self._speak_notice(
self.config.agent.gpu_lost_phrase if gpu_lost
else self.config.agent.error_phrase
)
await asyncio.sleep(1.0)
# Recover to IDLE so the agent can accept new input after an error.
# Without this recovery, a single failure would leave Javis stuck
@@ -797,7 +858,7 @@ class Orchestrator:
if self.config.speaker_id.enabled:
tr, id_result = await asyncio.gather(
self.stt.transcribe(audio, sample_rate),
asyncio.to_thread(self.profiler.identify, audio, sample_rate),
self._identify_speaker(audio, sample_rate),
)
else:
tr = await self.stt.transcribe(audio, sample_rate)
@@ -815,7 +876,9 @@ class Orchestrator:
_sp_name: Optional[str] = None
_privileges: SpeakerPrivileges = SpeakerPrivileges() # default: full access
if self.config.speaker_id.enabled:
if id_result:
if id_result is _ID_FAILED:
_privileges = self._unknown_privileges() # fail closed, no enroll prompt
elif id_result:
sp_name, sp_conf = id_result
_sp_name = sp_name
speaker_prefix = f"[Speaker: {sp_name}] "
@@ -838,8 +901,12 @@ class Orchestrator:
await self.bus.publish(
"speaker_id", {"name": None, "confidence": 0.0, "known": False}
)
if self.config.speaker_id.auto_enroll:
await self._auto_enroll_speaker(audio)
source = device_name or "satellite"
if room:
source = f"{source} ({room})"
await self._handle_unknown_speaker(
audio, sample_rate, text, source=source,
)
if not _privileges.can_respond:
log.info(
@@ -862,12 +929,17 @@ class Orchestrator:
)
await run_start(self._middleware, turn_ctx)
reply = ""
expects_followup = False
try:
reply = await self._llm_loop(turn_ctx, tts_feed=None)
if reply:
await self.bus.publish("assistant_text", reply)
await run_finish(self._middleware, turn_ctx, text, reply)
await turn_ctx.flush()
expects_followup = (
self.config.wake_word.followup_enabled
and _reply_expects_followup(reply)
)
else:
await run_abort(self._middleware, turn_ctx, "empty_reply")
log.info(f"remote turn complete in {time.monotonic()-t0:.2f}s")
@@ -887,6 +959,7 @@ class Orchestrator:
reply_text=reply,
audio=reply_audio,
sample_rate=reply_sr,
expects_followup=expects_followup,
)
async def synthesize_speech(self, text: str) -> tuple[Optional[np.ndarray], Optional[int]]:
@@ -925,6 +998,10 @@ class Orchestrator:
False when the listen loop exited without a detection (stale timeout,
``_stop`` set, or mic disconnected)."""
assert self.wake is not None
# A GUI voice-sample recording owns the mic — wait until it is done.
mic_free: Optional[asyncio.Event] = getattr(self, "_mic_free", None)
if mic_free is not None and not mic_free.is_set():
await mic_free.wait()
await self.set_state(AgentState.IDLE)
self.wake.reset()
if self.mic is not None:
@@ -932,11 +1009,18 @@ class Orchestrator:
log.info(f"listening for wake word: '{self.config.wake_word.model}'")
last_pub = 0.0
async for chunk in self.mic.chunks(stale_timeout=self._WAKE_STALE_S):
if mic_free is not None and not mic_free.is_set():
return False # handed over to an exclusive recording
score = self.wake.feed(chunk)
# Everything heard before the wake word is background (music, TV,
# this device's own speakers), so the recorder's voice gate is
# already calibrated when the turn starts.
observe = getattr(self.vad, "observe", None)
rms = observe(chunk) if observe is not None else (
float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0)
now = time.monotonic()
if now - last_pub > 0.1: # ~10 Hz UI updates
last_pub = now
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
await self.bus.publish("mic_level", {"rms": rms, "score": float(score)})
if self.wake.triggered(score):
log.info(f"wake! score={score:.2f}")
@@ -1186,6 +1270,14 @@ class Orchestrator:
return True
return None
async def _identify_speaker(self, audio: np.ndarray, sample_rate: int) -> Any:
"""Run speaker ID off-loop; a failure must never abort the turn."""
try:
return await asyncio.to_thread(self.profiler.identify, audio, sample_rate)
except Exception as exc: # noqa: BLE001
log.exception(f"[profiler] speaker identification failed: {exc}")
return _ID_FAILED
def _unknown_privileges(self) -> SpeakerPrivileges:
"""Build a :class:`SpeakerPrivileges` from the unknown-speaker config fields."""
cfg = self.config.speaker_id
@@ -1201,6 +1293,32 @@ class Orchestrator:
denied_tools=denied,
)
async def _revive_tts(self, backend, interval: float = 30.0) -> None:
"""Turn speech output on once the TTS model that failed to load is there.
E.g. a voice still in its first training run: training exports the
voice's model as it goes (see ``javis.voices.piper_train``).
"""
while True:
try:
await asyncio.wait_for(self._stop.wait(), timeout=interval)
return
except asyncio.TimeoutError:
pass
try:
if not backend.model_available():
continue
await asyncio.to_thread(backend.load)
except Exception as exc: # noqa: BLE001 - tried again once the model changes
log.warning(f"TTS model still failed to load: {exc}")
continue
if self._stop.is_set():
return
self.tts = backend
log.info("TTS model loaded — speech output enabled")
spawn(self._prewarm_tts(), name="tts-prewarm")
return
async def _prewarm_tts(self) -> None:
"""Silently synthesize a short phrase to warm up TTS model buffers.
@@ -1258,7 +1376,7 @@ class Orchestrator:
if self.config.speaker_id.enabled:
tr, id_result = await asyncio.gather(
self.stt.transcribe(audio, self.config.audio.sample_rate),
asyncio.to_thread(self.profiler.identify, audio, self.config.audio.sample_rate),
self._identify_speaker(audio, self.config.audio.sample_rate),
)
else:
tr = await self.stt.transcribe(audio, self.config.audio.sample_rate)
@@ -1273,7 +1391,9 @@ class Orchestrator:
_sp_name: Optional[str] = None
_privileges: SpeakerPrivileges = SpeakerPrivileges() # default: full access
if self.config.speaker_id.enabled:
if id_result:
if id_result is _ID_FAILED:
_privileges = self._unknown_privileges() # fail closed, no enroll prompt
elif id_result:
sp_name, sp_conf = id_result
_sp_name = sp_name
speaker_prefix = f"[Speaker: {sp_name}] "
@@ -1310,8 +1430,9 @@ class Orchestrator:
await self.bus.publish(
"speaker_id", {"name": None, "confidence": 0.0, "known": False}
)
if self.config.speaker_id.auto_enroll:
await self._auto_enroll_speaker(audio)
await self._handle_unknown_speaker(
audio, self.config.audio.sample_rate, tr.text or "", source="local",
)
# ── privilege: can_respond check ──────────────────────────────────────
if not _privileges.can_respond:
@@ -1970,8 +2091,7 @@ class Orchestrator:
n = self.config.speaker_id.min_enroll_clips
clips: list[np.ndarray] = []
async with self._turn_lock:
await self.set_state(AgentState.ENROLLING)
async with self._exclusive_mic():
for i in range(n):
await self.bus.publish("enroll_progress", {
"name": name, "clip": i, "total": n, "recording": True,
@@ -1979,7 +2099,6 @@ class Orchestrator:
spawn(self._play_wake_chime())
audio = await record_utterance(self.config, self.mic, self.vad)
if audio is None or len(audio) < 1600:
await self.set_state(AgentState.IDLE)
await self.bus.publish("enroll_progress", {
"name": name, "clip": i, "total": n,
"error": "Kein Audio erkannt — Aufnahme abgebrochen.",
@@ -1993,48 +2112,283 @@ class Orchestrator:
sr = self.config.audio.sample_rate
await asyncio.to_thread(self.profiler.enroll, name, clips, sr)
await self.bus.publish("speaker_enrolled", {"name": name, "clip_count": len(clips)})
await self.set_state(AgentState.IDLE)
log.info(f"[profiler] enrollment complete for '{name}'")
return True
@contextlib.asynccontextmanager
async def _exclusive_mic(self, wait_s: float = 0.0) -> AsyncIterator[MicStream]:
"""Own the microphone for a GUI-initiated recording.
Takes ``_turn_lock`` (waiting up to *wait_s*) and pauses the wake-word
loop via ``_mic_free`` so every chunk reaches the recorder. Raises
``RuntimeError`` with a user-facing message when that is not possible.
"""
mic = self.mic
if mic is None:
raise RuntimeError("No microphone available — voice input is disabled.")
try:
await asyncio.wait_for(self._turn_lock.acquire(), timeout=max(0.01, wait_s))
except asyncio.TimeoutError:
raise RuntimeError("Javis is busy right now — try again in a moment.") from None
self._mic_free.clear()
try:
await self.set_state(AgentState.ENROLLING)
mic.drain()
yield mic
finally:
self._mic_free.set()
try:
await self.set_state(AgentState.IDLE)
finally:
self._turn_lock.release()
def _require_profiler(self) -> None:
if not self.config.speaker_id.enabled:
raise RuntimeError(
"Speaker identification is disabled (Settings → Speaker Identification).")
if not self.profiler.is_ready():
raise RuntimeError("The speaker identification model is not loaded yet.")
async def record_voice_sample(
self, name: Optional[str] = None, *, max_seconds: float = 15.0,
) -> VoiceSample:
"""Record one live utterance for enhancing *name*'s voice profile.
Nothing is stored — pass the result to :meth:`add_voice_samples` once
the user has reviewed it. ``score`` is the cosine similarity to
*name*'s current profile (``None`` if it has none). Raises
``RuntimeError`` with a user-facing message on failure.
"""
self._require_profiler()
sr = self.config.audio.sample_rate
async with self._exclusive_mic(wait_s=3.0) as mic:
# Chime first, then drop whatever the mic picked up of it.
await self._play_wake_chime()
mic.drain()
audio = await record_utterance(
self.config, mic, self.vad,
max_seconds=max_seconds, initial_grace_ms=5000,
)
if audio is None or len(audio) < int(sr * 0.5):
raise RuntimeError("No speech detected — please try again.")
emb = await asyncio.to_thread(self.profiler.embed, audio, sr)
ranked = self.profiler.rank(emb)
score = next((s for n, s in ranked if n == name), None) if name else None
best_name, best_score = ranked[0] if ranked else (None, None)
return VoiceSample(
audio=audio, sample_rate=sr, embedding=emb, score=score,
best_name=best_name, best_score=best_score,
)
async def add_voice_samples(self, name: str, samples: list[VoiceSample]) -> int:
"""Blend recorded *samples* into *name*'s profile (created if missing).
Returns the number of samples used.
"""
name = (name or "").strip()
if not name:
raise ValueError("Profile name is empty.")
def _apply() -> tuple[int, bool]:
embs = []
for s in samples:
emb = s.embedding
if emb is None or not np.any(emb):
emb = self.profiler.embed(s.audio, s.sample_rate)
if np.any(emb):
embs.append(emb)
if not embs:
return 0, False
if self.profiler.has_profile(name):
used = sum(1 for e in embs if self.profiler.refine_embedding(name, e))
return used, False
return (len(embs), True) if self.profiler.enroll_embeddings(name, embs) else (0, False)
used, created = await asyncio.to_thread(_apply)
if not used:
raise RuntimeError("No usable voice samples — is the speaker model loaded?")
log.info(f"[profiler] added {used} live sample(s) to '{name}'")
await self.bus.publish("voice_profiles_changed", {
"name": name, "added": used, "created": created,
})
return used
# ---------------- unidentified speakers ----------------
async def _handle_unknown_speaker(
self, audio: np.ndarray, sample_rate: int, text: str, *, source: str,
) -> None:
"""Keep the clip in the unidentified inbox and/or ask for a name."""
store_task: Optional["asyncio.Task[Optional[str]]"] = None
if self.config.speaker_id.keep_unidentified and (text or "").strip():
store_task = spawn(
self._store_unidentified(audio, sample_rate, text, source),
name="unidentified-store",
)
if self.config.speaker_id.auto_enroll:
await self._auto_enroll_speaker(audio, sample_rate, store_task)
async def _store_unidentified(
self, audio: np.ndarray, sample_rate: int, text: str, source: str,
) -> Optional[str]:
"""Embed *audio* and add it to the inbox. Returns the clip id."""
try:
emb = await asyncio.to_thread(self.profiler.embed, audio, sample_rate)
ranked = self.profiler.rank(emb)
best_name, best_score = ranked[0] if ranked else (None, None)
self.unidentified.max_clips = max(1, int(self.config.speaker_id.max_unidentified))
clip_id = await asyncio.to_thread(
self.unidentified.add, audio, sample_rate,
embedding=emb, text=text, source=source,
best_name=best_name, best_score=best_score,
)
except Exception as exc: # noqa: BLE001
log.warning(f"[unidentified] could not store clip: {exc}")
return None
await self.bus.publish("unidentified_changed", {"added": clip_id})
return clip_id
async def assign_unidentified(self, clip_ids: list[str], name: str) -> dict:
"""Sort inbox clips into *name*'s profile, creating it if needed.
Returns ``{"name", "added", "created"}``. The clips are removed from
the inbox. Raises ``ValueError``/``RuntimeError`` with a user-facing
message on failure.
"""
name = (name or "").strip()
if not name:
raise ValueError("Profile name is empty.")
if not clip_ids:
raise ValueError("No clips selected.")
self._require_profiler()
def _apply() -> tuple[list[str], bool]:
used: list[str] = []
embs: list[np.ndarray] = []
for cid in clip_ids:
emb = self.unidentified.load_embedding(cid)
if emb is None:
try:
audio, sr = self.unidentified.load_audio(cid)
except Exception as exc: # noqa: BLE001
log.warning(f"[unidentified] clip {cid} unavailable: {exc}")
continue
emb = self.profiler.embed(audio, sr)
if np.any(emb):
embs.append(emb)
used.append(cid)
if not embs:
return [], False
if self.profiler.has_profile(name):
for e in embs:
self.profiler.refine_embedding(name, e)
created = False
else:
if not self.profiler.enroll_embeddings(name, embs):
return [], False
created = True
self.unidentified.remove(used)
return used, created
used, created = await asyncio.to_thread(_apply)
if not used:
raise RuntimeError("None of the selected clips could be used.")
# Don't let a still-open "who is this?" prompt enroll them a second time.
done = set(used)
self._enroll_clip_buffer = [
p for p in self._enroll_clip_buffer if p.inbox_id() not in done
]
log.info(
f"[profiler] sorted {len(used)} unidentified clip(s) into "
f"{'new' if created else 'existing'} profile '{name}'"
)
await self.bus.publish("voice_profiles_changed", {
"name": name, "added": len(used), "created": created,
})
await self.bus.publish("unidentified_changed", {"removed": used})
return {"name": name, "added": len(used), "created": created}
async def delete_unidentified(self, clip_ids: Optional[list[str]] = None) -> int:
"""Delete the given inbox clips, or all of them when *clip_ids* is None."""
if clip_ids is None:
removed = await asyncio.to_thread(self.unidentified.clear)
else:
removed = await asyncio.to_thread(self.unidentified.remove, clip_ids)
if removed:
await self.bus.publish("unidentified_changed", {"removed": clip_ids})
return removed
def pending_enroll_clips(self) -> list[tuple[np.ndarray, int]]:
"""Clips buffered for the open "who is this?" prompt (for playback)."""
return [(p.audio, p.sample_rate) for p in self._enroll_clip_buffer]
def auto_enroll_dismiss(self) -> None:
"""The user skipped the name prompt.
Drops the buffered clips (they stay in the unidentified inbox when
``keep_unidentified`` is on) and lets the prompt appear again later.
"""
self._enroll_clip_buffer.clear()
self._enroll_ask_pending = False
async def auto_enroll_confirm(self, key: str, name: str) -> bool:
"""Finalize auto-enrollment using all clips buffered since the dialog opened.
Called by the GUI when the user submits their name. *key* is kept for
API compatibility but is no longer used to look up audio — the full
clip buffer is consumed instead.
clip buffer is consumed instead. Clips that were also stored in the
unidentified inbox are removed from it.
"""
clips = list(self._enroll_clip_buffer)
pending = list(self._enroll_clip_buffer)
self._enroll_clip_buffer.clear()
self._enroll_ask_pending = False
if not clips:
if not pending:
log.warning("[profiler] auto_enroll_confirm: clip buffer is empty")
return False
sr = self.config.audio.sample_rate
existing_names = {p["name"] for p in self.profiler.list_profiles()}
if name in existing_names:
# Merge all buffered clips into the existing profile.
for clip in clips:
await asyncio.to_thread(self.profiler.refine, name, clip, sr)
log.info(
f"[profiler] merged {len(clips)} clip(s) into existing profile '{name}'"
)
else:
def _apply() -> int:
embs = [self.profiler.embed(p.audio, p.sample_rate) for p in pending]
if self.profiler.has_profile(name):
# Merge all buffered clips into the existing profile.
n = sum(1 for e in embs if self.profiler.refine_embedding(name, e))
log.info(f"[profiler] merged {n} clip(s) into existing profile '{name}'")
return n
# Build a new profile from all accumulated clips.
await asyncio.to_thread(self.profiler.enroll, name, clips, sr)
log.info(f"[profiler] enrolled '{name}' from {len(clips)} clip(s)")
await self.bus.publish("speaker_enrolled", {"name": name, "clip_count": len(clips)})
return len(embs) if self.profiler.enroll_embeddings(name, embs) else 0
used = await asyncio.to_thread(_apply)
if not used:
return False
inbox_ids: list[str] = []
for p in pending:
if p.store_task is not None:
try:
cid = await p.store_task
except Exception: # noqa: BLE001
cid = None
if cid:
inbox_ids.append(cid)
if inbox_ids:
await self.delete_unidentified(inbox_ids)
await self.bus.publish("speaker_enrolled", {"name": name, "clip_count": used})
return True
async def _auto_enroll_speaker(self, audio: np.ndarray) -> None:
async def _auto_enroll_speaker(
self,
audio: np.ndarray,
sample_rate: Optional[int] = None,
store_task: Optional["asyncio.Task[Optional[str]]"] = None,
) -> None:
"""Buffer *audio* from an unknown speaker and ask for a name (once).
Subsequent unknown utterances while the dialog is open are silently
appended to the buffer so enrollment ultimately uses multiple clips.
"""
self._enroll_clip_buffer.append(audio)
self._enroll_clip_buffer.append(_PendingEnrollClip(
audio, int(sample_rate or self.config.audio.sample_rate), store_task,
))
if not self._enroll_ask_pending:
self._enroll_ask_pending = True
# Use a stable key so the GUI can match confirm callbacks.
+6
View File
@@ -11,6 +11,9 @@ class WakeWordBlock(BaseModel):
threshold: float = 0.5
vad_threshold: float = 0.5
silence_ms: int = 800
# How much louder than the background a frame must be to count as the user
# talking (0 = off). Older satellites simply ignore this field.
voice_gate_ratio: float = 3.0
class PairRequest(BaseModel):
@@ -35,6 +38,9 @@ class TurnResponse(BaseModel):
reply_text: str = ""
tts_audio_b64: Optional[str] = None # base64 WAV (16-bit PCM mono)
sample_rate: Optional[int] = None
# When true, the slave listens for one answer immediately after playback
# instead of requiring another wake word.
expects_followup: bool = False
# Set when the master could not complete the turn. reply_text then holds
# the spoken error phrase (and tts_audio_b64 its audio) so a screenless
# slave can still tell the user something went wrong.
+8 -1
View File
@@ -16,6 +16,7 @@ import numpy as np
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from ..core.config import Config
from ..core.hardware import GpuUnavailableError
from ..core.logger import get_logger
from .devices import SlaveDevice, SlaveDeviceStore
from .models import ConfigResponse, PairRequest, PairResponse, TurnResponse, WakeWordBlock, SlaveDeviceInfo
@@ -54,6 +55,7 @@ def _wake_word_block(config: Config) -> WakeWordBlock:
threshold=ww.threshold,
vad_threshold=ww.vad_threshold,
silence_ms=ww.silence_ms,
voice_gate_ratio=ww.voice_gate_ratio,
)
@@ -183,7 +185,11 @@ async def turn(
except Exception as exc: # noqa: BLE001
# Return a spoken error instead of a bare 500 so the slave can voice it.
log.exception(f"remote turn from {device.name!r} failed: {exc}")
phrase = config.agent.error_phrase
phrase = (
config.agent.gpu_lost_phrase
if isinstance(exc, GpuUnavailableError)
else config.agent.error_phrase
)
err_audio, err_sr = await orch.synthesize_speech(phrase)
return TurnResponse(
user_text="",
@@ -198,6 +204,7 @@ async def turn(
reply_text=result.reply_text,
tts_audio_b64=_wav_b64(result.audio, result.sample_rate),
sample_rate=result.sample_rate if result.audio is not None else None,
expects_followup=result.expects_followup,
)
+4 -1
View File
@@ -102,6 +102,7 @@ async def record_utterance(
silent_run = 0
seen_speech = False
chunks_seen = 0
background = 0.0
# A vanished input device would otherwise block here forever while the
# caller holds the turn lock.
@@ -109,6 +110,7 @@ async def record_utterance(
chunks_seen += 1
collected.append(chunk)
result = vad.process(chunk)
background = result.background
if result.is_speech:
seen_speech = True
@@ -117,9 +119,10 @@ async def record_utterance(
silent_run += 1
if seen_speech and silent_run >= silence_chunks_needed:
log.debug(f"speech ended (background rms={background:.4f})")
break
if not seen_speech and chunks_seen >= grace_chunks:
log.debug("no speech during grace window")
log.debug(f"no speech during grace window (background rms={background:.4f})")
return None
if chunks_seen >= max_chunks:
log.info("max utterance length reached")
+124 -6
View File
@@ -1,8 +1,9 @@
"""Voice activity detection using silero-vad (with graceful fallback)."""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Protocol
from typing import Deque, Protocol
import numpy as np
@@ -15,6 +16,10 @@ log = get_logger("audio.vad")
class VADResult:
is_speech: bool
probability: float
# Loudness of this frame and the background level it was judged against.
# Both are 0.0 for VADs that do not track loudness.
rms: float = 0.0
background: float = 0.0
class VAD(Protocol):
@@ -49,13 +54,125 @@ class NullVAD:
if chunk.size == 0:
return VADResult(False, 0.0)
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
return VADResult(is_speech=rms >= self.threshold, probability=rms)
return VADResult(is_speech=rms >= self.threshold, probability=rms, rms=rms)
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
# A frame must be at least this loud (RMS, normalized float32) to count as
# someone talking. Without the floor the gate would collapse to ~0 in a silent
# room and every faint noise would read as speech.
_MIN_VOICE_RMS = 0.006
# Speech has short gaps, so a low percentile of the recent window still tracks
# the room even while the gate is open.
_BACKGROUND_PERCENTILE = 20.0
# Once the user is already talking the gate drops to this fraction of the onset
# ratio, so a trailing quiet syllable is not clipped off mid-word.
_RELEASE_SCALE = 0.55
def frame_rms(chunk: np.ndarray) -> float:
"""RMS loudness of one mono float32 frame (0.0 for an empty frame)."""
if chunk is None or len(chunk) == 0:
return 0.0
return float(np.sqrt(np.mean(np.square(np.asarray(chunk, dtype=np.float32)))))
class SpeechEndpointer:
"""Reports only the *near-field* talker as speech.
Silero answers "is this audio voice-like?", which music, TV and podcasts
satisfy just as well as the user does. When the device plays music through
its own speakers the VAD therefore never goes quiet, so a recording never
detects the end of the command and runs until the hard length cap.
This wrapper adds the missing distinction: it tracks how loud the
surrounding audio is and only reports speech while a frame is clearly
louder than that background — which is exactly what separates someone
talking into the device from steady playback. Idle audio fed through
:meth:`observe` (the wake-word loops do) means the background is already
known by the time a recording starts.
Implements the :class:`VAD` protocol, so it can be used anywhere a VAD is
expected. With ``onset_ratio <= 1.0`` the gate is disabled and the inner
VAD's verdict is passed through unchanged.
"""
def __init__(self, vad: VAD, *, onset_ratio: float = 3.0,
window_s: float = 1.5, chunk_ms: int = 32) -> None:
self.vad = vad
self.sample_rate = vad.sample_rate
self.onset_ratio = max(0.0, float(onset_ratio))
self.release_ratio = max(1.0, self.onset_ratio * _RELEASE_SCALE)
window = max(4, int(window_s * 1000.0 / max(1, chunk_ms)))
self._levels: Deque[float] = deque(maxlen=window)
self._in_speech = False
@property
def threshold(self) -> float:
return self.vad.threshold
@threshold.setter
def threshold(self, value: float) -> None:
self.vad.threshold = value
@property
def enabled(self) -> bool:
"""False when the loudness gate is off (plain VAD behaviour)."""
return self.onset_ratio > 1.0
@property
def background(self) -> float:
"""Background loudness of the recent window; 0.0 until audio is seen."""
if not self._levels:
return 0.0
levels = sorted(self._levels)
idx = min(len(levels) - 1,
int(len(levels) * _BACKGROUND_PERCENTILE / 100.0))
return levels[idx]
def observe(self, chunk: np.ndarray) -> float:
"""Feed audio heard while *not* recording; returns the frame's RMS.
Anything heard outside a turn is background by definition, so the gate
is calibrated before the user even says the wake word.
"""
rms = frame_rms(chunk)
self._levels.append(rms)
self._in_speech = False
return rms
def reset(self) -> None:
"""Start a new utterance. Deliberately keeps the learned background."""
self._in_speech = False
self.vad.reset()
def process(self, chunk: np.ndarray) -> VADResult:
rms = frame_rms(chunk)
background = self.background
inner = self.vad.process(chunk)
if not self.enabled:
self._levels.append(rms)
return VADResult(inner.is_speech, inner.probability, rms, background)
ratio = self.release_ratio if self._in_speech else self.onset_ratio
gate = max(_MIN_VOICE_RMS, background * ratio)
is_voice = bool(inner.is_speech) and rms >= gate
self._in_speech = is_voice
if not is_voice:
# Only non-voice frames describe the background; letting speech in
# would let a long sentence raise the gate onto itself and cut the
# user off mid-utterance.
self._levels.append(rms)
return VADResult(is_voice, inner.probability, rms, background)
def make_vad(sample_rate: int = 16000, threshold: float = 0.5,
voice_gate_ratio: float = 3.0, chunk_ms: int = 32) -> VAD:
"""Construct the capture VAD, falling back to NullVAD if torch/silero fails.
The VAD is wrapped in a :class:`SpeechEndpointer` so that playback from the
device's own speakers is not mistaken for the user talking.
"""
try:
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
base: VAD = SileroVAD(sample_rate=sample_rate, threshold=threshold)
except Exception as e: # noqa: BLE001
from ..core.hardware import detect_hardware, format_remediation
log.error(f"SileroVAD unavailable: {e}")
@@ -63,7 +180,8 @@ def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
log.error(format_remediation(detect_hardware(), torch_error=str(e)))
except Exception:
pass
return NullVAD(sample_rate=sample_rate, threshold=threshold)
base = NullVAD(sample_rate=sample_rate, threshold=threshold)
return SpeechEndpointer(base, onset_ratio=voice_gate_ratio, chunk_ms=chunk_ms)
class SileroVAD:
+27 -3
View File
@@ -128,6 +128,13 @@ class WakeWordConfig:
# VAD probability is above this value (cuts false wakes from TV/music).
# 0 = off.
vad_gate: float = 0.0
# How much louder than the background a frame must be to count as the user
# talking. This is what lets a recording end when the *user* stops even
# while the device's own speakers keep playing music (which Silero happily
# classifies as speech). Higher is stricter: raise it when playback keeps
# the recorder running, lower it when quiet commands get cut off.
# 0 = off (plain VAD endpointing).
voice_gate_ratio: float = 3.0
@dataclass
@@ -173,6 +180,12 @@ class AgentConfig:
# Spoken when a voice turn fails, so users away from the screen get
# feedback instead of silence. Empty string disables.
error_phrase: str = "Entschuldigung, da ist etwas schiefgelaufen. Bitte versuch es noch einmal."
# Spoken instead of error_phrase when the GPU's CUDA context died (e.g.
# an NVIDIA driver update while Javis was running) — needs a restart.
gpu_lost_phrase: str = (
"Die Grafikkarte ist nicht mehr erreichbar, vermutlich wurde der Treiber "
"aktualisiert. Bitte starte Javis neu."
)
# Spoken when a tool call is waiting for approval and the window is not
# in the foreground. Empty string disables.
approval_phrase: str = "Soll ich das ausführen? Sag ja oder nein."
@@ -240,6 +253,11 @@ class SpeakerIDConfig:
# auto-refinement. Only high-confidence identifications (score >=
# threshold + refine_margin) update the profile, keeping noise clips out.
refine_margin: float = 0.10
# Keep audio clips of unidentified speakers (WAV + embedding, stored under
# voice_profiles_dir/unidentified) so they can be played back and sorted
# into a profile from the GUI. Capped at max_unidentified (oldest dropped).
keep_unidentified: bool = True
max_unidentified: int = 50
# ── Unknown / guest speaker privileges ───────────────────────────────────
# Applied when the speaker cannot be matched to any known voice profile.
# Controlled via the GUI "Settings → Speaker Identification" section.
@@ -282,7 +300,9 @@ class TrainingConfig:
batch_size: int = 32
num_workers: int = 2
accelerator: str = "gpu" # gpu | cpu
precision: str = "16-mixed" # 32 | 16-mixed | bf16-mixed
# bf16-mixed trains fastest and needs no loss scaling; GPUs without bf16
# fall back to 32 at launch.
precision: str = "bf16-mixed" # 32 | 16-mixed | bf16-mixed
language: str = "de" # espeak-ng voice code (de, en-us, ...)
@@ -417,8 +437,12 @@ def validate(cfg: "Config") -> None:
cfg.speaker_id.threshold, 0.0, 1.0, 0.65, "speaker_id.threshold")
cfg.speaker_id.refine_margin = _clamp(
cfg.speaker_id.refine_margin, 0.0, 0.5, 0.10, "speaker_id.refine_margin")
cfg.speaker_id.max_unidentified = _clamp_int(
cfg.speaker_id.max_unidentified, 1, 500, 50, "speaker_id.max_unidentified")
cfg.wake_word.vad_gate = _clamp(
cfg.wake_word.vad_gate, 0.0, 1.0, 0.0, "wake_word.vad_gate")
cfg.wake_word.voice_gate_ratio = _clamp(
cfg.wake_word.voice_gate_ratio, 0.0, 20.0, 3.0, "wake_word.voice_gate_ratio")
# Silero VAD and openWakeWord are only wired up for 16 kHz input.
if cfg.audio.sample_rate != 16000:
@@ -497,8 +521,8 @@ def validate(cfg: "Config") -> None:
if t.precision not in {"32", "16-mixed", "bf16-mixed"}:
log.warning(
f"config: training.precision='{t.precision}' is unknown "
"(32|16-mixed|bf16-mixed), using '16-mixed'")
t.precision = "16-mixed"
"(32|16-mixed|bf16-mixed), using 'bf16-mixed'")
t.precision = "bf16-mixed"
if cfg.memory.provider not in {"ollama", "hermes"}:
log.warning(
+14
View File
@@ -33,6 +33,20 @@ TORCH_INDEX_ROCM = "https://download.pytorch.org/whl/rocm6.0"
CTRANSLATE2_REQUIRED_CUDA_DLL = "cublas64_12.dll"
class GpuUnavailableError(RuntimeError):
"""The CUDA context died while Javis was running (typically an NVIDIA
driver update or reset). It cannot be recovered in-process — restart Javis."""
def is_cuda_error(exc: BaseException) -> bool:
"""True for CUDA / cuBLAS / cuDNN failures raised by torch or CTranslate2.
Out-of-memory is excluded: it is transient and the context stays usable.
"""
msg = str(exc).lower()
return "out of memory" not in msg and any(k in msg for k in ("cuda", "cublas", "cudnn"))
@dataclass
class HardwareInfo:
os_name: str = "" # 'Windows', 'Linux', 'Darwin'
File diff suppressed because it is too large. Load diff
+42 -8
View File
@@ -12,8 +12,14 @@ from ..core.logger import get_logger
log = get_logger("stt")
from ..core.hardware import GpuUnavailableError, is_cuda_error
from ..core.hardware import add_nvidia_dll_dirs as _add_nvidia_dll_dirs
_GPU_LOST_MSG = (
"GPU (CUDA) lost — the NVIDIA driver was probably updated or reset while "
"Javis was running. Restart Javis to restore speech recognition."
)
# Register NVIDIA pip-package DLL dirs on PATH as early as possible so that
# ctranslate2's internal LoadLibrary calls find cublas64_12.dll etc. before
# the first CUDA operation is attempted.
@@ -71,6 +77,22 @@ class Transcriber:
self._model = None
self._batched = None
self._force_cpu: bool = False # set True after a CUDA runtime DLL failure
self._device: str = ""
# Set once the CUDA context died; every later call fails fast.
self._gpu_lost: bool = False
@staticmethod
def _cuda_alive() -> bool:
"""Probe the CUDA context with a tiny host→device copy."""
try:
import ctranslate2
ctranslate2.StorageView.from_array(np.zeros(1, np.float32)).to_device(
ctranslate2.Device.cuda
)
return True
except Exception as exc: # noqa: BLE001
log.debug(f"STT: CUDA probe failed: {exc}")
return "out of memory" in str(exc).lower()
def _resolve_device_and_compute(self) -> tuple[str, str]:
"""Resolve the best device + compute_type for CTranslate2 / faster-whisper.
@@ -173,6 +195,7 @@ class Transcriber:
cpu_threads=0, # 0 = use all available cores
download_root=str(self.config.paths.models_dir / "whisper"),
)
self._device = device
# BatchedInferencePipeline (faster-whisper >=1.1) runs transcription in
# parallel across the batch dimension — roughly 4x faster on GPU with
# no meaningful WER change. CPU path skips this since batching adds
@@ -196,6 +219,8 @@ class Transcriber:
def _transcribe_sync(self, audio: np.ndarray, sample_rate: int) -> TranscriptionResult:
if self._model is None:
raise RuntimeError("Transcriber not loaded — call load() first")
if self._gpu_lost:
raise GpuUnavailableError(_GPU_LOST_MSG)
if sample_rate != 16000:
# resample_poly is O(N) and ~100x faster than scipy.signal.resample (FFT-based).
from fractions import Fraction
@@ -218,20 +243,30 @@ class Transcriber:
def _run_transcribe() -> tuple:
if self._batched is not None:
return self._batched.transcribe(
segments, info = self._batched.transcribe(
audio,
batch_size=self.config.stt.batch_size,
**transcribe_kwargs,
)
return self._model.transcribe(
audio,
vad_filter=False, # VAD already applied upstream
**transcribe_kwargs,
)
else:
segments, info = self._model.transcribe(
audio,
vad_filter=False, # VAD already applied upstream
**transcribe_kwargs,
)
# Segments are a lazy generator — decode here so errors raised
# mid-decode reach the handler below.
return list(segments), info
try:
segments, info = _run_transcribe()
except RuntimeError as exc:
if self._device == "cuda" and is_cuda_error(exc) and not self._cuda_alive():
# Never free or reload the model here: destroying a CTranslate2
# CUDA model on a dead context aborts the whole process.
self._gpu_lost = True
log.error(f"STT: {_GPU_LOST_MSG} ({exc})")
raise GpuUnavailableError(_GPU_LOST_MSG) from exc
_msg = str(exc)
if any(k in _msg.lower() for k in ("dll", "cublas", "cannot be loaded")):
log.warning(
@@ -245,8 +280,7 @@ class Transcriber:
else:
raise
segments = list(segments)
text = "".join(s.text for s in segments).strip()
text = "".join(s.text for s in segments).strip()
return TranscriptionResult(
text=text,
language=info.language,
+186
View File
@@ -0,0 +1,186 @@
"""Inbox of voice clips from speakers that speaker ID could not match.
Each clip is kept so the user can listen to it in the GUI and sort it into an
existing voice profile or use it to create a new one::
~/.javis/voice_profiles/unidentified/{id}.wav 16-bit PCM audio
~/.javis/voice_profiles/unidentified/{id}.npy ECAPA embedding (optional)
~/.javis/voice_profiles/unidentified/index.json metadata, oldest first
The inbox is capped at ``max_clips``; the oldest clips are dropped first.
All methods are thread-safe (clips are added from worker threads).
"""
from __future__ import annotations
import json
import threading
import time
import uuid
from collections.abc import Iterable
from pathlib import Path
import numpy as np
from ..core.logger import get_logger
log = get_logger("unidentified")
_INDEX_FILE = "index.json"
def _mono(audio: np.ndarray) -> np.ndarray:
a = np.asarray(audio, dtype=np.float32)
if a.ndim > 1:
a = a.mean(axis=1)
return a.reshape(-1)
class UnidentifiedStore:
"""Bounded on-disk store of unidentified speaker clips."""
def __init__(self, directory: Path, max_clips: int = 50) -> None:
self.dir = Path(directory)
self.max_clips = max(1, int(max_clips))
self._lock = threading.RLock()
self._index: list[dict] | None = None # lazily loaded
# ── index I/O ─────────────────────────────────────────────────────────────
def _entries(self) -> list[dict]:
if self._index is None:
idx = self.dir / _INDEX_FILE
data: list = []
if idx.exists():
try:
data = json.loads(idx.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
log.warning(f"[unidentified] index read failed: {exc}")
self._index = [
e for e in data
if isinstance(e, dict) and e.get("id") and e.get("file")
and (self.dir / e["file"]).exists()
]
return self._index
def _save(self) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
idx = self.dir / _INDEX_FILE
tmp = idx.with_suffix(".json.part")
try:
tmp.write_text(
json.dumps(self._entries(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
tmp.replace(idx)
except Exception as exc: # noqa: BLE001
log.error(f"[unidentified] index write failed: {exc}")
def _delete_files(self, entry: dict) -> None:
for key in ("file", "embedding"):
fname = entry.get(key)
if not fname:
continue
try:
(self.dir / fname).unlink(missing_ok=True)
except Exception as exc: # noqa: BLE001
log.warning(f"[unidentified] could not delete {fname}: {exc}")
# ── public API ────────────────────────────────────────────────────────────
def add(
self,
audio: np.ndarray,
sample_rate: int,
*,
embedding: np.ndarray | None = None,
text: str = "",
source: str = "",
best_name: str | None = None,
best_score: float | None = None,
) -> str:
"""Store a clip and return its id. Prunes the oldest beyond the cap."""
import soundfile as sf
mono = np.clip(_mono(audio), -1.0, 1.0)
clip_id = f"{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
with self._lock:
self.dir.mkdir(parents=True, exist_ok=True)
wav_name = f"{clip_id}.wav"
sf.write(str(self.dir / wav_name), mono, int(sample_rate), subtype="PCM_16")
emb_name = None
if embedding is not None and np.any(embedding):
emb_name = f"{clip_id}.npy"
np.save(str(self.dir / emb_name),
np.asarray(embedding, dtype=np.float32).reshape(-1))
entries = self._entries()
entries.append({
"id": clip_id,
"file": wav_name,
"embedding": emb_name,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"duration_s": round(len(mono) / float(sample_rate), 2),
"sample_rate": int(sample_rate),
"text": (text or "").strip(),
"source": source or "",
"best_name": best_name,
"best_score": None if best_score is None else round(float(best_score), 4),
})
while len(entries) > self.max_clips:
self._delete_files(entries.pop(0))
self._save()
log.info(f"[unidentified] stored clip {clip_id} ({len(mono) / sample_rate:.1f}s)")
return clip_id
def entries(self) -> list[dict]:
"""All clip metadata, newest first (copies — safe to keep)."""
with self._lock:
return [dict(e) for e in reversed(self._entries())]
def get(self, clip_id: str) -> dict | None:
with self._lock:
entry = next((e for e in self._entries() if e["id"] == clip_id), None)
return dict(entry) if entry else None
def load_audio(self, clip_id: str) -> tuple[np.ndarray, int]:
"""Return ``(float32 mono audio, sample_rate)``; raises ``KeyError`` if unknown."""
import soundfile as sf
entry = self.get(clip_id)
if entry is None:
raise KeyError(clip_id)
audio, sr = sf.read(str(self.dir / entry["file"]), dtype="float32", always_2d=False)
return _mono(audio), int(sr)
def load_embedding(self, clip_id: str) -> np.ndarray | None:
entry = self.get(clip_id)
if entry is None or not entry.get("embedding"):
return None
try:
return np.load(str(self.dir / entry["embedding"])).astype(np.float32)
except Exception as exc: # noqa: BLE001
log.warning(f"[unidentified] embedding for {clip_id} unreadable: {exc}")
return None
def remove(self, clip_ids: Iterable[str]) -> int:
"""Delete the given clips; returns how many were removed."""
wanted = set(clip_ids)
if not wanted:
return 0
with self._lock:
entries = self._entries()
gone = [e for e in entries if e["id"] in wanted]
if not gone:
return 0
for e in gone:
self._delete_files(e)
self._index = [e for e in entries if e["id"] not in wanted]
self._save()
return len(gone)
def clear(self) -> int:
with self._lock:
return self.remove([e["id"] for e in self._entries()])
def __len__(self) -> int:
with self._lock:
return len(self._entries())
+194 -69
View File
@@ -13,6 +13,7 @@ available so the rest of the agent continues to work unchanged.
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass, field
from fnmatch import fnmatch as _fnmatch
@@ -22,6 +23,7 @@ from typing import Optional, Union
import numpy as np
from ..core.config import Config
from ..core.hardware import is_cuda_error
from ..core.logger import get_logger
log = get_logger("voice_profiler")
@@ -45,6 +47,16 @@ def _l2(v: np.ndarray) -> np.ndarray:
return v / n if n > 0 else v
def _as_vector(embedding) -> Optional[np.ndarray]:
"""Flatten *embedding* to float32; ``None`` for missing or all-zero vectors."""
if embedding is None:
return None
vec = np.asarray(embedding, dtype=np.float32).reshape(-1)
if vec.size == 0 or not np.any(vec):
return None
return vec
# Minimum utterance duration for a reliable embedding. Shorter clips are
# passed through but logged as a warning — ECAPA was trained on ≥ 1 s segments.
_MIN_EMBED_DURATION_S = 0.5
@@ -123,6 +135,28 @@ class NullProfiler:
def identify(self, audio_np: np.ndarray, sr: int) -> Optional[tuple[str, float]]: # noqa: ARG002
return None
def identify_embedding(self, embedding) -> Optional[tuple[str, float]]: # noqa: ARG002
return None
def is_ready(self) -> bool:
return False
def has_profile(self, name: str) -> bool: # noqa: ARG002
return False
def rank(self, embedding) -> list[tuple[str, float]]: # noqa: ARG002
return []
def enroll_embeddings(self, name: str, embeddings: list) -> bool: # noqa: ARG002
log.warning("[profiler] NullProfiler — speechbrain unavailable, enroll skipped")
return False
def refine(self, name: str, clip: np.ndarray, sr: int) -> None: # noqa: ARG002
pass
def refine_embedding(self, name: str, embedding) -> bool: # noqa: ARG002
return False
def list_profiles(self) -> list[dict]:
return []
@@ -153,8 +187,13 @@ class VoiceProfiler:
self.config = config
self._profiles_dir: Path = config.paths.voice_profiles_dir
self._encoder = None # speechbrain EncoderClassifier or None
self._device = "cpu" # device the encoder runs on
self._encoder_lock = threading.Lock() # serialises the CPU fallback
self._profiles: dict[str, np.ndarray] = {} # name → L2-normed embedding
self._index: list[dict] = [] # metadata records
# identify/refine run on worker threads while the GUI enrolls/deletes
# on the loop thread — serialise access to _profiles/_index.
self._lock = threading.RLock()
# ── lifecycle ─────────────────────────────────────────────────────────────
@@ -162,9 +201,18 @@ class VoiceProfiler:
"""Load the ECAPA-TDNN encoder and all enrolled profiles from disk."""
self._profiles_dir.mkdir(parents=True, exist_ok=True)
self._load_encoder()
self._load_index()
with self._lock:
self._load_index()
log.info(f"[profiler] ready — {len(self._profiles)} profile(s) loaded")
def is_ready(self) -> bool:
"""``True`` once the ECAPA encoder is loaded (embeddings are meaningful)."""
return self._encoder is not None
def has_profile(self, name: str) -> bool:
with self._lock:
return name in self._profiles
def _load_encoder(self) -> None:
try:
import os
@@ -192,18 +240,12 @@ class VoiceProfiler:
category=UserWarning,
)
from speechbrain.inference.classifiers import EncoderClassifier
import torch # noqa: F401 — verify torch present
# SpeechBrain's run_opts parser expects "cuda:N", not bare "cuda".
device = "cuda:0" if self._try_cuda() else "cpu"
savedir = str(self.config.paths.models_dir / "ecapa-tdnn")
log.info(f"[profiler] loading ECAPA-TDNN on {device} → {savedir}")
self._encoder = EncoderClassifier.from_hparams(
source=_MODEL_SOURCE,
savedir=savedir,
run_opts={"device": device},
)
self._encoder = self._build_encoder(device)
self._device = device
log.info("[profiler] ECAPA-TDNN loaded")
except Exception as exc:
log.warning(
@@ -212,6 +254,33 @@ class VoiceProfiler:
)
self._encoder = None
def _build_encoder(self, device: str):
from speechbrain.inference.classifiers import EncoderClassifier
savedir = str(self.config.paths.models_dir / "ecapa-tdnn")
log.info(f"[profiler] loading ECAPA-TDNN on {device} → {savedir}")
return EncoderClassifier.from_hparams(
source=_MODEL_SOURCE,
savedir=savedir,
run_opts={"device": device},
)
def _fallback_to_cpu(self, failed, exc: BaseException):
"""Swap a broken CUDA encoder for a CPU one (once, thread-safe).
CUDA errors here usually mean the context died (e.g. the NVIDIA driver
was updated while Javis was running), which never recovers in-process.
"""
with self._encoder_lock:
if self._encoder is failed:
log.warning(
f"[profiler] CUDA failed ({exc}) — switching speaker ID to CPU; "
"restart Javis to use the GPU again"
)
self._encoder = self._build_encoder("cpu")
self._device = "cpu"
return self._encoder
@staticmethod
def _try_cuda() -> bool:
try:
@@ -284,8 +353,16 @@ class VoiceProfiler:
wav = torch.tensor(audio_np.astype(np.float32)).unsqueeze(0) # [1, T]
wav_lens = torch.tensor([1.0])
with torch.no_grad():
emb = self._encoder.encode_batch(wav, wav_lens) # [1, 1, D]
encoder = self._encoder
try:
with torch.no_grad():
emb = encoder.encode_batch(wav, wav_lens) # [1, 1, D]
except Exception as exc:
if not (self._device.startswith("cuda") and is_cuda_error(exc)):
raise
encoder = self._fallback_to_cpu(encoder, exc)
with torch.no_grad():
emb = encoder.encode_batch(wav, wav_lens)
return _l2(emb.squeeze().cpu().numpy())
# ── CRUD ──────────────────────────────────────────────────────────────────
@@ -295,25 +372,58 @@ class VoiceProfiler:
if self._encoder is None:
log.warning("[profiler] enroll skipped — encoder not loaded")
return
vecs = [self.embed(c, sr) for c in clips]
avg = _l2(np.mean(vecs, axis=0))
self.enroll_embeddings(name, [self.embed(c, sr) for c in clips])
# Build a filesystem-safe filename from the profile name.
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in name)
npy_path = self._profiles_dir / f"{safe}.npy"
np.save(str(npy_path), avg)
self._profiles[name] = avg
def enroll_embeddings(self, name: str, embeddings: list[np.ndarray]) -> bool:
"""Create (or replace) *name*'s profile from precomputed embeddings.
# Replace existing entry if the name already exists.
self._index = [e for e in self._index if e["name"] != name]
self._index.append({
"name": name,
"file": f"{safe}.npy",
"enrolled_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"clip_count": len(clips),
})
self._save_index()
log.info(f"[profiler] enrolled '{name}' from {len(clips)} clip(s)")
All-zero vectors (from an unloaded encoder) are ignored. Returns
``False`` when no usable embedding remains.
"""
vecs = [v for v in (_as_vector(e) for e in embeddings) if v is not None]
if not vecs:
log.warning(f"[profiler] enroll '{name}' skipped — no usable embeddings")
return False
avg = _l2(np.mean(vecs, axis=0)).astype(np.float32)
with self._lock:
self._profiles_dir.mkdir(parents=True, exist_ok=True)
old = next((e for e in self._index if e["name"] == name), None)
if old is not None:
fname = old["file"]
else:
# Filesystem-safe filename; never reuse another profile's file
# ("Anna B" and "Anna_B" sanitise to the same stem).
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in name)
used = {e["file"] for e in self._index}
fname, i = f"{safe}.npy", 2
while fname in used:
fname, i = f"{safe}_{i}.npy", i + 1
np.save(str(self._profiles_dir / fname), avg)
self._profiles[name] = avg
# Replace existing entry if the name already exists.
self._index = [e for e in self._index if e["name"] != name]
self._index.append({
"name": name,
"file": fname,
"enrolled_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"clip_count": len(vecs),
})
self._save_index()
log.info(f"[profiler] enrolled '{name}' from {len(vecs)} clip(s)")
return True
def rank(self, embedding) -> list[tuple[str, float]]:
"""Cosine similarity of *embedding* to every profile, best first."""
vec = _as_vector(embedding)
if vec is None:
return []
with self._lock:
items = list(self._profiles.items())
scores = [(n, _cosine(vec, e)) for n, e in items if e.shape == vec.shape]
scores.sort(key=lambda x: -x[1])
return scores
def identify(
self, audio_np: np.ndarray, sr: int
@@ -325,20 +435,19 @@ class VoiceProfiler:
"""
if self._encoder is None or not self._profiles:
return None
query = self.embed(audio_np, sr)
threshold = self.config.speaker_id.threshold
return self.identify_embedding(self.embed(audio_np, sr))
scores: dict[str, float] = {
name: _cosine(query, emb) for name, emb in self._profiles.items()
}
best_name = max(scores, key=scores.__getitem__)
best_score = scores[best_name]
def identify_embedding(self, embedding) -> Optional[tuple[str, float]]:
"""Like :meth:`identify` for an embedding computed by :meth:`embed`."""
ranked = self.rank(embedding)
if not ranked:
return None
threshold = self.config.speaker_id.threshold
best_name, best_score = ranked[0]
# Always log all candidate scores so identification failures are visible
# in normal (INFO-level) logs without needing to enable DEBUG mode.
score_str = ", ".join(
f"{n}={s:.3f}" for n, s in sorted(scores.items(), key=lambda x: -x[1])
)
score_str = ", ".join(f"{n}={s:.3f}" for n, s in ranked)
if best_score >= threshold:
log.info(f"[profiler] identified '{best_name}' [{score_str}] threshold={threshold}")
return best_name, float(best_score)
@@ -349,7 +458,8 @@ class VoiceProfiler:
def list_profiles(self) -> list[dict]:
"""Return a list of profile metadata dicts from the index."""
return list(self._index)
with self._lock:
return list(self._index)
def refine(self, name: str, clip: np.ndarray, sr: int) -> None:
"""Blend a new voice sample into *name*'s existing profile.
@@ -360,36 +470,50 @@ class VoiceProfiler:
"""
if self._encoder is None:
return
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None or name not in self._profiles:
if not self.has_profile(name):
log.warning(f"[profiler] refine: no profile found for '{name}'")
return
new_emb = self.embed(clip, sr)
n = entry.get("clip_count", 1)
current = self._profiles[name]
# Running centroid: (current * n + new) / (n+1), then L2-normalise.
blended = _l2(current * n + new_emb)
npy_path = self._profiles_dir / entry["file"]
np.save(str(npy_path), blended)
self._profiles[name] = blended
entry["clip_count"] = n + 1
entry["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%S")
self._save_index()
self.refine_embedding(name, self.embed(clip, sr))
def refine_embedding(self, name: str, embedding) -> bool:
"""Blend a precomputed embedding into *name*'s centroid (see :meth:`refine`)."""
new_emb = _as_vector(embedding)
if new_emb is None:
return False
with self._lock:
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None or name not in self._profiles:
log.warning(f"[profiler] refine: no profile found for '{name}'")
return False
n = entry.get("clip_count", 1)
current = self._profiles[name]
if current.shape != new_emb.shape:
log.warning(f"[profiler] refine '{name}': embedding shape mismatch")
return False
# Running centroid: (current * n + new) / (n+1), then L2-normalise.
blended = _l2(current * n + new_emb).astype(np.float32)
np.save(str(self._profiles_dir / entry["file"]), blended)
self._profiles[name] = blended
entry["clip_count"] = n + 1
entry["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%S")
self._save_index()
log.debug(f"[profiler] refined '{name}' — centroid now over {n + 1} clip(s)")
return True
def delete_profile(self, name: str) -> bool:
"""Remove *name*'s embedding from disk and memory."""
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None:
return False
npy = self._profiles_dir / entry["file"]
try:
npy.unlink(missing_ok=True)
except Exception as exc:
log.warning(f"[profiler] delete {npy.name} failed: {exc}")
self._index = [e for e in self._index if e["name"] != name]
self._profiles.pop(name, None)
self._save_index()
with self._lock:
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None:
return False
npy = self._profiles_dir / entry["file"]
try:
npy.unlink(missing_ok=True)
except Exception as exc:
log.warning(f"[profiler] delete {npy.name} failed: {exc}")
self._index = [e for e in self._index if e["name"] != name]
self._profiles.pop(name, None)
self._save_index()
log.info(f"[profiler] deleted profile '{name}'")
return True
@@ -404,10 +528,11 @@ class VoiceProfiler:
def set_privileges(self, name: str, privileges: SpeakerPrivileges) -> None:
"""Persist updated *privileges* for *name* to ``index.json``."""
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None:
log.warning(f"[profiler] set_privileges: no profile found for '{name}'")
return
entry["privileges"] = privileges.to_dict()
self._save_index()
with self._lock:
entry = next((e for e in self._index if e["name"] == name), None)
if entry is None:
log.warning(f"[profiler] set_privileges: no profile found for '{name}'")
return
entry["privileges"] = privileges.to_dict()
self._save_index()
log.info(f"[profiler] privileges updated for '{name}'")
+129 -34
View File
@@ -12,6 +12,11 @@ The model path is resolved in this order:
1. ``config.tts.piper_model`` (explicit path in config.yaml)
2. ``{voices_dir}/{tts.default_voice}/piper_model.onnx`` (active voice dir)
The model is reloaded by itself when that changes: when training updates the
voice's model (it does every few minutes, see ``javis.voices.piper_train``) or
another voice is activated. Until the new model is loaded, the previous one
keeps speaking.
Inference is synchronous (ONNX runtime) so it runs in a thread-pool executor
to avoid blocking the asyncio event loop.
@@ -23,6 +28,7 @@ from __future__ import annotations
import asyncio
import re
import threading
import time
from pathlib import Path
from typing import Optional
@@ -59,42 +65,42 @@ def _clean(text: str) -> str:
class PiperBackend(TTSBackend):
"""Piper ONNX TTS backend."""
# Synthesis looks for a changed model at most this often.
CHECK_SECONDS = 2.0
def __init__(self, config: Config) -> None:
self.config = config
self._voice: Optional[object] = None # piper.voice.PiperVoice
self._sample_rate: int = 22050
self._lock = threading.Lock()
self._lock = threading.Lock() # one synthesis at a time; guards the swap
self._reloading = threading.Lock()
self._loaded: tuple | None = None # _files_signature() of the loaded model
self._failed: tuple | None = None # ... of a model that failed to load
self._checked = 0.0
# ── TTSBackend interface ──────────────────────────────────────────────────
def load(self) -> None:
"""Load the ONNX model. Called once at startup."""
try:
from piper.voice import PiperVoice # type: ignore[import]
except ImportError as exc:
raise RuntimeError(
"piper-tts is not installed. Run: pip install piper-tts"
) from exc
model_path = self._resolve_model_path()
config_path = Path(str(model_path) + ".json")
log.info(f"Loading Piper model: {model_path}")
self._voice = PiperVoice.load(
str(model_path),
config_path=str(config_path) if config_path.exists() else None,
use_cuda=False, # ONNX runtime picks CUDA automatically when available
)
self._sample_rate = (
self._voice.config.sample_rate # type: ignore[union-attr]
if hasattr(self._voice, "config")
else 22050
)
signature = _files_signature(model_path)
try:
voice = self._load_voice(model_path)
except BaseException:
self._failed = signature
raise
with self._lock:
self._install(voice, signature)
log.info(
f"Piper model loaded — sample_rate={self._sample_rate} Hz, "
f"language={getattr(getattr(self._voice, 'config', None), 'espeak_voice', '?')}"
f"language={getattr(getattr(voice, 'config', None), 'espeak_voice', '?')}"
)
def model_available(self) -> bool:
"""Whether there is a model for :meth:`load` (other than one it failed to load)."""
model_path = self._model_path()
return model_path is not None and _files_signature(model_path) != self._failed
async def synthesize(
self,
text: str,
@@ -108,13 +114,17 @@ class PiperBackend(TTSBackend):
sample_rate=self._sample_rate,
)
audio = await asyncio.to_thread(self._synthesize_sync, text)
return SynthesisResult(audio=audio, sample_rate=self._sample_rate)
audio, sample_rate = await asyncio.to_thread(self._synthesize_sync, text)
return SynthesisResult(audio=audio, sample_rate=sample_rate)
# ── Internal ──────────────────────────────────────────────────────────────
def _synthesize_sync(self, text: str) -> np.ndarray:
"""Run Piper inference synchronously (called from thread-pool)."""
def _synthesize_sync(self, text: str) -> tuple[np.ndarray, int]:
"""Run Piper inference synchronously (called from thread-pool).
Returns the audio and its sample rate.
"""
self._check_for_new_model()
if self._voice is None:
raise RuntimeError("PiperBackend.load() has not been called.")
@@ -128,31 +138,103 @@ class PiperBackend(TTSBackend):
chunks: list[np.ndarray] = []
with self._lock:
sample_rate = self._sample_rate
for chunk in self._voice.synthesize(text, syn_config):
arr = chunk.audio_float_array
if arr is not None and len(arr) > 0:
chunks.append(np.asarray(arr, dtype=np.float32))
if not chunks:
return np.zeros(0, dtype=np.float32)
return np.concatenate(chunks)
return np.zeros(0, dtype=np.float32), sample_rate
return np.concatenate(chunks), sample_rate
def _resolve_model_path(self) -> Path:
"""Return the ONNX model path from config or voice profile dir."""
@staticmethod
def _load_voice(model_path: Path):
try:
from piper.voice import PiperVoice # type: ignore[import]
except ImportError as exc:
raise RuntimeError(
"piper-tts is not installed. Run: pip install piper-tts"
) from exc
config_path = Path(str(model_path) + ".json")
log.info(f"Loading Piper model: {model_path}")
return PiperVoice.load(
str(model_path),
config_path=str(config_path) if config_path.exists() else None,
use_cuda=False, # ONNX runtime picks CUDA automatically when available
)
def _install(self, voice, signature: tuple | None) -> None:
"""Make *voice* the one that speaks (call with ``_lock`` held)."""
self._voice = voice
self._sample_rate = (
voice.config.sample_rate # type: ignore[union-attr]
if hasattr(voice, "config")
else 22050
)
self._loaded, self._failed = signature, None
def _check_for_new_model(self) -> None:
"""Start loading the model in the background if it changed since it was loaded."""
if self._voice is None or time.monotonic() - self._checked < self.CHECK_SECONDS:
return
self._checked = time.monotonic()
model_path = self._model_path()
if model_path is None:
return
signature = _files_signature(model_path)
if signature is None or signature in (self._loaded, self._failed):
return
if not self._reloading.acquire(blocking=False):
return # being loaded already
try:
threading.Thread(target=self._reload, args=(model_path, signature),
name="piper-reload", daemon=True).start()
except BaseException:
self._reloading.release()
raise
def _reload(self, model_path: Path, signature: tuple) -> None:
try:
voice = self._load_voice(model_path)
for _ in voice.synthesize("Bereit."): # warm up before it speaks
pass
except Exception as exc: # noqa: BLE001 - keep speaking with the loaded model
self._failed = signature
log.warning(f"Could not load the changed Piper model {model_path} ({exc}); "
"keeping the current one.")
else:
with self._lock:
self._install(voice, signature)
log.info(f"Piper model reloaded: {model_path}")
finally:
self._reloading.release()
def _model_path(self, *, warn: bool = False) -> Path | None:
"""The model to use (see the module docstring), or ``None`` if there is none."""
explicit = self.config.tts.piper_model.strip()
if explicit:
p = Path(explicit)
if p.exists():
return p
log.warning(f"piper_model path not found: {p}")
if warn:
log.warning(f"piper_model path not found: {p}")
# Fall back to active voice profile directory.
candidate = (self.config.paths.voices_dir / self.config.tts.default_voice
/ "piper_model.onnx")
return candidate if candidate.exists() else None
def _resolve_model_path(self) -> Path:
"""Return the ONNX model path from config or voice profile dir."""
model_path = self._model_path(warn=True)
if model_path is not None:
return model_path
voice_name = self.config.tts.default_voice
voices_dir = self.config.paths.voices_dir
candidate = voices_dir / voice_name / "piper_model.onnx"
if candidate.exists():
return candidate
from ..voices.piper_train import has_checkpoints
if has_checkpoints(voices_dir / voice_name):
hint = (f"Training checkpoints exist — export them with: python -m "
@@ -163,3 +245,16 @@ class PiperBackend(TTSBackend):
f"Piper ONNX model not found. Expected at {candidate} "
f"or set tts.piper_model in config.yaml. {hint}"
)
def _files_signature(model_path: Path) -> tuple | None:
"""What tells a changed model apart: path, modification time and size of its files."""
try:
st = model_path.stat()
except OSError:
return None
try:
config_mtime = Path(str(model_path) + ".json").stat().st_mtime_ns
except OSError:
config_mtime = None
return str(model_path), st.st_mtime_ns, st.st_size, config_mtime
+60 -59
View File
@@ -30,6 +30,7 @@ import json
import os
import shutil
import sys
from collections.abc import Callable
from dataclasses import dataclass, field, asdict
from fractions import Fraction
from pathlib import Path
@@ -106,6 +107,11 @@ _SEGMENTS_FILE = "ingest_segments.npy"
# Source audio is transcoded once to a flat PCM WAV (mono, TARGET_SR) so that
# VAD and per-clip reads never decode compressed audio again.
_CACHE_RAW = "ingest_audio_cache.i16" # raw int16 LE, mono, TARGET_SR Hz
# Free space that must remain on the cache drive; transcoding refuses to start
# or aborts mid-way below this.
_MIN_FREE_BYTES = 1024 * 1024 * 1024
# Re-check free space every N 10-second blocks (~10 min of audio, ~26 MB cache).
_DISK_CHECK_EVERY_BLOCKS = 60
# Save checkpoint every N processed segments (not iterations — skipped ones
# don't count) to limit how much work is lost on an unexpected stop.
_CHECKPOINT_SAVE_INTERVAL = 20
@@ -328,55 +334,6 @@ def _is_clean_ref_clip(clip: np.ndarray, sr: int) -> bool:
return True
def _load_audio(path: Path, target_sr: int = TARGET_SR) -> np.ndarray:
# soundfile 0.12+ ships libsndfile 1.2.2 which reads MP3 natively.
# OGG Vorbis and FLAC were always supported.
# M4A / AAC / OPUS / WMA are not supported by libsndfile — convert to
# MP3 or WAV first.
import soundfile as sf # type: ignore[import]
info = sf.info(str(path))
sr = info.samplerate
# MP3 files with malformed ID3 tags (e.g. non-syncsafe CTOC frames) cause
# libsndfile to report INT64_MAX for the frame count. Calling sf.read()
# then tries to pre-allocate that many samples and raises a ValueError.
# Fall back to chunked streaming via sf.blocks() which reads until EOF
# without pre-allocating based on the (bogus) total-frame count.
#
# Memory note: we use a small blocksize (10 s) and resample + mono-mix
# each block immediately so that only the TARGET_SR data accumulates
# (roughly 2× smaller than keeping raw source-rate stereo chunks).
if info.frames >= sys.maxsize // 2:
log.warning(f"_load_audio: unknown frame count for {path.name} — using chunked read")
frac = Fraction(target_sr, sr).limit_denominator(1000)
resampled_chunks: list[np.ndarray] = []
for block in sf.blocks(str(path), blocksize=sr * 10, dtype="float32", always_2d=True):
# Mono-mix immediately so we don't carry stereo data any longer than needed.
mono = block.mean(axis=1) if block.ndim > 1 else block.ravel()
chunk = (
scipy.signal.resample_poly(mono, frac.numerator, frac.denominator).astype(np.float32)
if sr != target_sr else mono.astype(np.float32)
)
resampled_chunks.append(chunk)
del block, mono # free source-rate data immediately
if not resampled_chunks:
raise ValueError(f"No audio data read from {path}")
audio: np.ndarray = np.concatenate(resampled_chunks)
del resampled_chunks
return audio # already mono, already at target_sr
else:
audio, _ = sf.read(str(path), dtype="float32", always_2d=True)
if audio.ndim > 1:
audio = audio.mean(axis=1)
if sr != target_sr:
frac = Fraction(target_sr, sr).limit_denominator(1000)
audio = scipy.signal.resample_poly(audio, frac.numerator, frac.denominator).astype(np.float32)
return audio
def _segment(audio: np.ndarray, sr: int) -> list[tuple[int, int]]:
"""Return list of (start_sample, end_sample) speech segments via silero-vad."""
from silero_vad import load_silero_vad, get_speech_timestamps # heavy optional dep
@@ -402,11 +359,21 @@ def _save_wav(path: Path, audio: np.ndarray, sr: int) -> None:
sf.write(str(path), audio, sr, subtype="PCM_16")
def _transcode_to_raw(source: Path, target: Path, target_sr: int) -> None:
class TranscodeCancelled(Exception):
"""Raised from the transcode thread when the caller requested a stop."""
def _transcode_to_raw(
source: Path,
target: Path,
target_sr: int,
should_stop: Callable[[], bool] | None = None,
) -> None:
"""Stream *source* → raw int16 LE binary (mono, *target_sr* Hz).
Peak RAM: one 10-second block (~4 MiB). No file-size limit.
Writes atomically via a .part temp file.
Peak RAM: one 10-second block (~4 MiB). Writes atomically via a .part
temp file, re-checks free disk space while writing and raises
:class:`TranscodeCancelled` as soon as *should_stop* returns True.
"""
import soundfile as sf
@@ -417,11 +384,11 @@ def _transcode_to_raw(source: Path, target: Path, target_sr: int) -> None:
tmp = target.with_suffix(".part")
# Pre-check disk space when duration is known (skip for malformed MP3s with
# bogus INT64_MAX frame counts).
# bogus INT64_MAX frame counts — the in-loop check below still applies).
if info.frames < sys.maxsize // 2:
estimated_bytes = int(info.frames / sr * target_sr) * 2 # int16 = 2 B
free = shutil.disk_usage(str(target.parent)).free
if free < estimated_bytes + 64 * 1024 * 1024:
if free < estimated_bytes + _MIN_FREE_BYTES:
raise RuntimeError(
f"Not enough disk space to cache ‘{source.name}’: "
f"need ~{estimated_bytes / 1_000_000_000:.1f} GB, "
@@ -429,9 +396,18 @@ def _transcode_to_raw(source: Path, target: Path, target_sr: int) -> None:
)
try:
with open(str(tmp), "wb") as out_f:
for block in sf.blocks(str(source), blocksize=sr * 10,
dtype="float32", always_2d=True):
# Read until SoundFile.read() returns 0 frames instead of using
# sf.blocks(): MP3s with malformed ID3 tags report INT64_MAX frames,
# and sf.blocks() ignores the real read count, so after EOF it keeps
# re-yielding its stale buffer forever (filled 800 GB once).
with sf.SoundFile(str(source)) as src, open(str(tmp), "wb") as out_f:
n_blocks = 0
while True:
if should_stop is not None and should_stop():
raise TranscodeCancelled(source.name)
block = src.read(sr * 10, dtype="float32", always_2d=True)
if len(block) == 0:
break
mono = block.mean(axis=1) if block.ndim > 1 else block.ravel()
if sr != target_sr:
chunk = scipy.signal.resample_poly(
@@ -442,6 +418,15 @@ def _transcode_to_raw(source: Path, target: Path, target_sr: int) -> None:
i16 = np.clip(chunk * 32767.0, -32768, 32767).astype(np.int16)
out_f.write(i16.tobytes())
del block, mono, chunk, i16
n_blocks += 1
if n_blocks % _DISK_CHECK_EVERY_BLOCKS == 0:
free = shutil.disk_usage(str(target.parent)).free
if free < _MIN_FREE_BYTES:
raise RuntimeError(
f"Aborted caching ‘{source.name}’: free space on "
f"{target.parent} dropped to {free / 1_000_000_000:.1f} GB "
f"after writing {out_f.tell() / 1_000_000_000:.1f} GB"
)
tmp.replace(target)
except Exception:
try:
@@ -599,6 +584,14 @@ async def ingest_audiobook(
# peak RAM per block, no file-size limit). VAD and clip reads operate on
# this cache via numpy memmap — O(1) seek, ~90 MiB peak during segmentation.
cache_raw = prof.dir / _CACHE_RAW
should_stop = stop_event.is_set if stop_event is not None else None
async def _stopped_while_transcoding() -> IngestStats:
log.info(f"ingest stopped by user while transcoding {source.name}")
await _emit({"phase": "stopped", "during": "transcoding",
"at_index": 0, "total": 0})
return stats
if cp is None:
# Fresh run — remove any stale cache from a previous source.
if cache_raw.exists():
@@ -608,7 +601,11 @@ async def ingest_audiobook(
pass
log.info(f"transcoding {source.name} → raw cache ...")
await _emit({"phase": "loading", "source": source.name})
await asyncio.to_thread(_transcode_to_raw, source, cache_raw, TARGET_SR)
try:
await asyncio.to_thread(_transcode_to_raw, source, cache_raw, TARGET_SR,
should_stop)
except TranscodeCancelled:
return await _stopped_while_transcoding()
_cache_dur = cache_raw.stat().st_size / 2 / TARGET_SR # int16 = 2 bytes
log.info(f"transcoded: {_cache_dur:.1f}s")
await _emit({"phase": "loaded", "audio_s": _cache_dur})
@@ -616,7 +613,11 @@ async def ingest_audiobook(
# Resuming but cache was deleted — retranscode.
log.info(f"raw cache missing — retranscoding {source.name} ...")
await _emit({"phase": "loading", "source": source.name})
await asyncio.to_thread(_transcode_to_raw, source, cache_raw, TARGET_SR)
try:
await asyncio.to_thread(_transcode_to_raw, source, cache_raw, TARGET_SR,
should_stop)
except TranscodeCancelled:
return await _stopped_while_transcoding()
log.info("retranscoded OK")
if cp is not None:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+495
View File
@@ -0,0 +1,495 @@
"""Coordination between Javis and a running piper training process.
Shared by the launcher (:mod:`javis.voices.piper_train`, the overnight
scheduler, the GUI) and the training process (:mod:`javis.voices.piper_compat`
and its data-loader workers), so it imports nothing but the standard library.
* **Clean stops.** The launcher names a stop file in ``JAVIS_PIPER_STOP_FILE``;
once the file exists the trainer finishes its current step, saves its
progress — the epoch so far included — and exits with :data:`EXIT_STOPPED`.
It does the same if the launcher (``JAVIS_PIPER_LAUNCHER``) has gone away,
instead of training on unsupervised.
* **Closing the console window** (or logging off, shutting down) gives the
trainer the few seconds Windows allows to save its progress:
:class:`ConsoleClose`. :func:`python_command` starts it so that none of them
is lost: without the virtual environment's ``python.exe`` launcher, which
would end it at once, and without torch's Intel Fortran console handler.
* **One run per voice.** :class:`TrainingLock` records the processes of the run
holding it, so a crashed or killed run never blocks the next one.
* **Long runs on Windows.** :func:`keep_system_awake` and
:func:`console_selection_cannot_pause`.
"""
from __future__ import annotations
import contextlib
import functools
import json
import os
import platform
import sys
import threading
import time
from pathlib import Path
STOP_FILE_ENV = "JAVIS_PIPER_STOP_FILE"
LAUNCHER_ENV = "JAVIS_PIPER_LAUNCHER" # "<pid>:<start time>" of the supervising process
# Exit codes of ``python -m javis.voices.piper_compat fit`` and of run_pipeline().
EXIT_DIVERGED = 3 # training kept producing NaN/inf: restarting would not help
EXIT_STOPPED = 4 # stopped on request, after saving a checkpoint
EXIT_BUSY = 5 # run_pipeline(): another run of this voice holds the lock
LOCK_FILE = ".training.lock"
_LEGACY_LOCK_SECONDS = 48 * 60 * 60 # locks without processes (older Javis versions)
_UNREADABLE_LOCK_SECONDS = 60 # a lock file that is being written
PARTIAL_SUFFIX = ".partial" # a file being written; renamed into place once complete
# Windows: the target is open in another process for a moment (a snapshot export
# reading last.ckpt, a virus scanner): 32/33 sharing/lock violation, 1224 mapped.
_BUSY_WINERRORS = (32, 33, 1224)
def replace_file(src, dst, *, seconds: float = 60.0) -> None:
"""``os.replace`` that gives other processes up to *seconds* to close *dst*."""
delay, deadline = 0.1, time.monotonic() + seconds
while True:
try:
os.replace(src, dst)
return
except OSError as exc:
if not (isinstance(exc, PermissionError)
or getattr(exc, "winerror", None) in _BUSY_WINERRORS) \
or time.monotonic() >= deadline:
raise
time.sleep(delay)
delay = min(delay * 2, 2.0)
# ── Processes ─────────────────────────────────────────────────────────────────
@functools.cache
def _kernel32():
import ctypes
from ctypes import wintypes
# A private instance: argtypes set on ctypes.windll.kernel32 would leak to other code.
k = ctypes.WinDLL("kernel32", use_last_error=True)
k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
k.OpenProcess.restype = wintypes.HANDLE
k.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
k.GetExitCodeProcess.restype = wintypes.BOOL
k.GetProcessTimes.argtypes = (wintypes.HANDLE,) + (ctypes.POINTER(wintypes.FILETIME),) * 4
k.GetProcessTimes.restype = wintypes.BOOL
k.CloseHandle.argtypes = (wintypes.HANDLE,)
k.CloseHandle.restype = wintypes.BOOL
k.SetThreadExecutionState.argtypes = (wintypes.DWORD,)
k.SetThreadExecutionState.restype = wintypes.DWORD
k.GetStdHandle.argtypes = (wintypes.DWORD,)
k.GetStdHandle.restype = wintypes.HANDLE
k.GetConsoleMode.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
k.GetConsoleMode.restype = wintypes.BOOL
k.SetConsoleMode.argtypes = (wintypes.HANDLE, wintypes.DWORD)
k.SetConsoleMode.restype = wintypes.BOOL
k.SetConsoleCtrlHandler.argtypes = (ctypes.c_void_p, wintypes.BOOL)
k.SetConsoleCtrlHandler.restype = wintypes.BOOL
k.SetProcessShutdownParameters.argtypes = (wintypes.DWORD, wintypes.DWORD)
k.SetProcessShutdownParameters.restype = wintypes.BOOL
k.GetModuleHandleW.argtypes = (wintypes.LPCWSTR,)
k.GetModuleHandleW.restype = wintypes.HMODULE
return k
@functools.cache
def _user32():
"""``(user32, WNDPROC, WNDCLASSW)``: what a hidden window takes."""
import ctypes
from ctypes import wintypes
wndproc = ctypes.WINFUNCTYPE(wintypes.LPARAM, wintypes.HWND, wintypes.UINT,
wintypes.WPARAM, wintypes.LPARAM)
class WNDCLASSW(ctypes.Structure):
_fields_ = [("style", wintypes.UINT), ("lpfnWndProc", wndproc),
("cbClsExtra", ctypes.c_int), ("cbWndExtra", ctypes.c_int),
("hInstance", wintypes.HINSTANCE), ("hIcon", wintypes.HICON),
("hCursor", wintypes.HANDLE), ("hbrBackground", wintypes.HBRUSH),
("lpszMenuName", wintypes.LPCWSTR), ("lpszClassName", wintypes.LPCWSTR)]
u = ctypes.WinDLL("user32", use_last_error=True)
u.RegisterClassW.argtypes = (ctypes.POINTER(WNDCLASSW),)
u.RegisterClassW.restype = wintypes.ATOM
u.UnregisterClassW.argtypes = (wintypes.LPCWSTR, wintypes.HINSTANCE)
u.UnregisterClassW.restype = wintypes.BOOL
u.CreateWindowExW.argtypes = (wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR,
wintypes.DWORD, ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_int, wintypes.HWND, wintypes.HMENU,
wintypes.HINSTANCE, wintypes.LPVOID)
u.CreateWindowExW.restype = wintypes.HWND
u.DefWindowProcW.argtypes = (wintypes.HWND, wintypes.UINT, wintypes.WPARAM,
wintypes.LPARAM)
u.DefWindowProcW.restype = wintypes.LPARAM
u.GetMessageW.argtypes = (ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT,
wintypes.UINT)
u.GetMessageW.restype = wintypes.BOOL
u.DispatchMessageW.argtypes = (ctypes.POINTER(wintypes.MSG),)
u.DispatchMessageW.restype = wintypes.LPARAM
u.PostQuitMessage.argtypes = (ctypes.c_int,)
u.PostQuitMessage.restype = None
return u, wndproc, WNDCLASSW
def python_command() -> tuple[str, dict]:
"""``(executable, extra environment)`` to start another Python of this environment.
On Windows a virtual environment's ``python.exe`` is a launcher running the
real interpreter in a job that dies with it — and the launcher ends as soon
as its console window closes, so the interpreter couldn't save anything.
Like :mod:`multiprocessing`, start the real interpreter directly;
``__PYVENV_LAUNCHER__`` tells it which environment it belongs to.
Nor does it get the console handler of the Intel Fortran runtime in torch's
``libiomp5md.dll``: on a closing window that aborts every process loading
torch ("forrtl: error (200)"), and slowly — Windows tells the processes of a
console one after the other, the newest first: data-loader workers and
exports before the trainer, which had seconds less to save its progress.
"""
if sys.platform != "win32":
return sys.executable, {}
env = {"FOR_DISABLE_CONSOLE_CTRL_HANDLER": "1"} # inherited by data-loader workers
base = getattr(sys, "_base_executable", "") or ""
if base and os.path.isfile(base) and os.path.normcase(os.path.abspath(base)) \
!= os.path.normcase(os.path.abspath(sys.executable)):
return base, {**env, "__PYVENV_LAUNCHER__": sys.executable}
return sys.executable, env
def process_start_time(pid: int) -> int | None:
"""When process *pid* started, as an opaque number; ``None`` if it isn't running.
``0`` means it runs but its start time can't be read.
"""
if pid <= 0:
return None
if sys.platform != "win32":
try:
os.kill(pid, 0) # signal 0 only checks; on Windows os.kill would terminate
except PermissionError:
return 0
except OSError:
return None
return 0
import ctypes
from ctypes import wintypes
k = _kernel32()
handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if not handle:
return 0 if ctypes.get_last_error() == 5 else None # 5: access denied, yet it exists
try:
code = wintypes.DWORD()
if k.GetExitCodeProcess(handle, ctypes.byref(code)) and code.value != 259: # STILL_ACTIVE
return None
created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
if not k.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited),
ctypes.byref(kernel), ctypes.byref(user)):
return 0
return (created.dwHighDateTime << 32) | created.dwLowDateTime
finally:
k.CloseHandle(handle)
def process_alive(pid: int, start_time: int | None = None) -> bool:
"""Whether *pid* is running and, given its *start_time*, is still the same process.
Windows soon hands the ID of an exited process to a new one.
"""
current = process_start_time(pid)
if current is None:
return False
return not start_time or not current or current == start_time
def this_process() -> dict:
"""``{"pid", "start"}`` of the calling process."""
pid = os.getpid()
return {"pid": pid, "start": process_start_time(pid) or 0}
def launcher_from_environment() -> tuple[int, int] | None:
"""``(pid, start time)`` of the launcher named in ``JAVIS_PIPER_LAUNCHER``, if any."""
value = os.environ.get(LAUNCHER_ENV, "")
try:
pid, start = (int(part) for part in value.split(":"))
except ValueError:
return None
return pid, start
# ── One training run per voice ────────────────────────────────────────────────
def lock_path(voice_dir: Path) -> Path:
return Path(voice_dir) / "training" / LOCK_FILE
def _read_lock(path: Path) -> str | None:
"""The lock file's text; ``None`` if there is none. Raises other ``OSError``s."""
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
def _held(path: Path, text: str) -> bool:
try:
age = time.time() - path.stat().st_mtime
except FileNotFoundError:
return False
except OSError:
return True # can't tell: assume it is held
try:
info = json.loads(text)
except ValueError:
info = None
if not isinstance(info, dict):
return age < _UNREADABLE_LOCK_SECONDS
try:
processes = [(int(p.get("pid") or 0), int(p.get("start") or 0))
for p in info.get("processes") or () if isinstance(p, dict)]
except (TypeError, ValueError):
processes = []
if not processes or info.get("host", platform.node()) != platform.node():
try:
started = float(info.get("started_epoch") or 0) or path.stat().st_mtime
except (OSError, TypeError, ValueError):
return True
return time.time() - started < _LEGACY_LOCK_SECONDS
return any(process_alive(pid, start) for pid, start in processes)
def _lock_held(path: Path) -> bool:
try:
text = _read_lock(path)
except OSError:
return True
return text is not None and _held(path, text)
def training_in_progress(voice_dir: Path) -> bool:
"""Whether a training run of this voice is running (in any process)."""
return _lock_held(lock_path(voice_dir))
class TrainingLock:
"""``training/.training.lock``: one training run per voice at a time, across processes.
Lists the launcher and, while one runs, its training process (which keeps
training for a minute or two after its launcher was killed, to save its
progress). A lock whose processes have all exited is stale and replaced.
"""
def __init__(self, voice_dir: Path) -> None:
self.path = lock_path(voice_dir)
self._info: dict | None = None
def acquire(self) -> bool:
"""Take the lock; ``False`` if another run holds it."""
info = {"host": platform.node(), "started_epoch": time.time(),
"processes": [this_process()]}
self.path.parent.mkdir(parents=True, exist_ok=True)
for _ in range(2):
try:
with open(self.path, "x", encoding="utf-8") as f:
json.dump(info, f)
except FileExistsError:
try:
text = _read_lock(self.path)
if text is not None:
if _held(self.path, text):
return False
# Left behind by a run that crashed or was killed — unless
# another launcher replaced it just now.
if _read_lock(self.path) == text:
self.path.unlink()
except FileNotFoundError:
pass
except OSError:
return False
continue
self._info = info
return True
return False
def set_trainer(self, pid: int | None) -> None:
"""Record the training process (or that none runs right now)."""
if self._info is None:
return
trainer = [{"pid": pid, "start": process_start_time(pid) or 0}] if pid else []
self._info["processes"] = self._info["processes"][:1] + trainer
with contextlib.suppress(OSError):
self.path.write_text(json.dumps(self._info), encoding="utf-8")
def release(self) -> None:
if self._info is None:
return
owner, self._info = self._info["processes"][0], None
try:
current = json.loads(self.path.read_text(encoding="utf-8"))
if (current.get("processes") or [{}])[0] == owner:
self.path.unlink()
except (OSError, ValueError, AttributeError):
pass
# ── Windows ───────────────────────────────────────────────────────────────────
@contextlib.contextmanager
def keep_system_awake():
"""Keep Windows from sleeping when idle (the display may still turn off)."""
if sys.platform != "win32":
yield
return
k = _kernel32()
es_continuous, es_system_required = 0x80000000, 0x00000001
previous = k.SetThreadExecutionState(es_continuous | es_system_required)
try:
yield
finally:
k.SetThreadExecutionState(previous if previous & es_continuous else es_continuous)
@contextlib.contextmanager
def console_selection_cannot_pause():
"""Turn off QuickEdit in a classic console window while training runs.
There, clicking into the window starts a selection that suspends every
process writing to it — training would stall until a key is pressed.
(Windows Terminal doesn't do that.)
"""
if sys.platform != "win32":
yield
return
import ctypes
from ctypes import wintypes
k = _kernel32()
handle = k.GetStdHandle(wintypes.DWORD(-10).value) # STD_INPUT_HANDLE
mode = wintypes.DWORD()
quick_edit, extended_flags = 0x0040, 0x0080
if not handle or not k.GetConsoleMode(handle, ctypes.byref(mode)) \
or not mode.value & quick_edit:
yield
return
k.SetConsoleMode(handle, (mode.value | extended_flags) & ~quick_edit)
try:
yield
finally:
k.SetConsoleMode(handle, mode.value | extended_flags)
_CONSOLE_CLOSE_EVENTS = {2: "the console window was closed", 5: "the user is logging off",
6: "the system is shutting down"}
_WM_DESTROY, _WM_QUERYENDSESSION, _WM_ENDSESSION = 0x0002, 0x0011, 0x0016
_ENDSESSION_LOGOFF = 0x80000000
# Ends before the other processes of the session — its data-loader workers
# among them: the top of the range Windows leaves to applications.
_SHUTDOWN_LEVEL = 0x3FF
class ConsoleClose:
"""Lets the training process save its progress before Windows ends it.
When its console window is closed (or the user logs off, the system shuts
down), Windows ends every process attached to it after a few seconds.
:meth:`install` handles those events: it sets :attr:`closing` — the training
loop checks it after every step — and holds the process until :meth:`done`
reports the progress saved, or *timeout* seconds have passed.
Logging off and shutting down reach a console handler only in processes
that never loaded ``user32.dll`` — Python and torch load it. A hidden
window learns of them instead (``WM_ENDSESSION``).
"""
def __init__(self, timeout: float = 30.0) -> None:
self.closing = threading.Event()
self.reason = ""
self.timeout = timeout
self._saved = threading.Event()
self._handler = None
self._window = None # the hidden window's handle
def handle(self, event: int) -> bool:
"""The console control handler; runs in a thread of its own."""
reason = _CONSOLE_CLOSE_EVENTS.get(event)
if reason is None:
return False # Ctrl+C, Ctrl+Break: left to Python's own handler
self._close(reason)
return True
def _close(self, reason: str) -> None:
self.reason = reason
self.closing.set()
self._saved.wait(self.timeout)
def done(self) -> None:
"""The progress is saved: Windows may end the process now."""
self._saved.set()
def install(self) -> None:
if sys.platform != "win32" or self._handler is not None:
return
import ctypes
from ctypes import wintypes
handler = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)(self.handle)
if _kernel32().SetConsoleCtrlHandler(handler, True):
self._handler = handler # must outlive every call Windows may make
_kernel32().SetProcessShutdownParameters(_SHUTDOWN_LEVEL, 0)
self._watch_session_end()
def _watch_session_end(self) -> None:
"""Open the hidden window that learns of the user logging off, the system shutting down."""
ready = threading.Event()
threading.Thread(target=self._session_window, args=(ready,), name="session end",
daemon=True).start()
ready.wait(10)
def _session_window(self, ready: threading.Event) -> None:
import ctypes
from ctypes import wintypes
try:
user32, wndproc, wndclass = _user32()
instance = _kernel32().GetModuleHandleW(None)
def window_procedure(hwnd, message, wparam, lparam):
if message == _WM_QUERYENDSESSION:
return 1 # may end: the progress gets saved at WM_ENDSESSION
if message == _WM_ENDSESSION:
if wparam: # else cancelled
self._close("the user is logging off" if lparam & _ENDSESSION_LOGOFF
else "the system is shutting down")
return 0
if message == _WM_DESTROY:
user32.PostQuitMessage(0)
return 0
return user32.DefWindowProcW(hwnd, message, wparam, lparam)
procedure = wndproc(window_procedure) # referenced while the window exists
name = f"JavisTrainingSession{os.getpid()}-{id(self):x}"
if not user32.RegisterClassW(ctypes.byref(wndclass(
lpfnWndProc=procedure, hInstance=instance, lpszClassName=name))):
return
# Top-level but never shown: message-only windows get no WM_ENDSESSION.
window = user32.CreateWindowExW(0, name, "Javis voice training", 0, 0, 0, 0, 0,
None, None, instance, None)
if not window:
user32.UnregisterClassW(name, instance)
return
self._window = window
finally:
ready.set()
message = wintypes.MSG()
while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0:
user32.DispatchMessageW(ctypes.byref(message))
user32.UnregisterClassW(name, instance)
+26 -79
View File
@@ -18,31 +18,29 @@ Design notes
without a restart.
* **Window guard.** Training will not *start* if fewer than
``min_remaining_minutes`` of the window are left. A run that has already
started is allowed to run to completion — PyTorch-Lightning cannot be
cleanly killed mid-epoch, and an unfinished run is safe: it leaves a
checkpoint that the next run resumes from.
* **Concurrency guard.** A lock file (``training/.training.lock``) prevents
the scheduler and the interactive GUI button from training the same voice
at the same time (they run in separate processes).
started keeps going; quitting Javis stops it cleanly — the training
process saves its progress after its current step (the epoch so far
included) first, so Javis's process can take a little while to exit —
and the next run resumes from where it stopped. While it runs, the
voice's model is updated with its progress (see ``piper_train``).
* **Concurrency guard.** ``run_pipeline`` holds a lock file
(``training/.training.lock``) naming its processes, so the scheduler and
the interactive GUI button (separate processes) never train the same voice
at the same time; a run that crashed or was killed leaves no lasting lock.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from ..core.config import Config
from ..core.logger import get_logger
from .training_control import EXIT_BUSY, EXIT_STOPPED, training_in_progress
log = get_logger("voices.training")
# A lock is considered "live" for this long. Overnight runs take a few
# hours; 48 h comfortably bounds a stuck/crashed run so it self-expires.
_LOCK_STALE_SECONDS = 48 * 60 * 60
# ── Pure helpers (unit-testable) ─────────────────────────────────────────────
@@ -97,62 +95,10 @@ def window_end(now: datetime, start_hour: int, end_hour: int) -> datetime:
return end
# ── Cross-process lock ────────────────────────────────────────────────────────
def _lock_path(voice_dir: Path) -> Path:
return voice_dir / "training" / ".training.lock"
def _lock_alive(voice_dir: Path) -> bool:
"""True if a recent lock file indicates another training is in progress."""
lp = _lock_path(voice_dir)
if not lp.exists():
return False
try:
data = json.loads(lp.read_text(encoding="utf-8"))
ts = float(data.get("started_epoch", 0.0))
except Exception:
try:
ts = lp.stat().st_mtime
except OSError:
return False
if not ts:
return False
return (time.time() - ts) < _LOCK_STALE_SECONDS
def _acquire_lock(voice_dir: Path) -> bool:
"""Atomically claim the training lock. Returns False if already held."""
lp = _lock_path(voice_dir)
lp.parent.mkdir(parents=True, exist_ok=True)
try:
# 'x' = exclusive create; fails if a live lock already exists.
with open(lp, "x", encoding="utf-8") as f:
f.write(json.dumps({"started_epoch": time.time()}))
return True
except FileExistsError:
return False
except OSError:
# On platforms where 'x' is flaky, fall back to the liveness check.
return not _lock_alive(voice_dir)
def _release_lock(voice_dir: Path) -> None:
try:
_lock_path(voice_dir).unlink(missing_ok=True)
except OSError:
pass
def training_in_progress(voice_dir: Path) -> bool:
"""Public helper: is another training of this voice currently running?"""
return _lock_alive(voice_dir)
# ── One run ───────────────────────────────────────────────────────────────────
async def _run_once(config: Config, voice_dir: Path, bus: Optional[object] = None) -> None:
"""Execute a single scheduled training run (threaded) with locking."""
"""Execute a single scheduled training run (threaded)."""
from .piper_train import run_pipeline # imported lazily (heavy deps)
t = config.training
@@ -166,14 +112,10 @@ async def _run_once(config: Config, voice_dir: Path, bus: Optional[object] = Non
f"{t.min_remaining_minutes} min minimum); skipping start.")
return
if _lock_alive(voice_dir):
if training_in_progress(voice_dir):
log.info("Another training of this voice is already running; skipping.")
return
if not _acquire_lock(voice_dir):
log.info("Could not acquire training lock (already held); skipping.")
return
voice = voice_dir.name
n_clips = 0
meta = voice_dir / "training" / "metadata.csv"
@@ -214,25 +156,30 @@ async def _run_once(config: Config, voice_dir: Path, bus: Optional[object] = Non
try:
rc = await asyncio.shield(work)
except asyncio.CancelledError:
# Javis is quitting: stop the child process so the worker thread (and
# interpreter exit) doesn't block until training finishes.
from .piper_train import request_stop
# Javis is quitting: have the training process save a checkpoint and
# stop, so the worker thread (and interpreter exit) isn't kept waiting
# until training finishes.
from .piper_train import STOP_GRACE_SECONDS, request_stop
request_stop()
await asyncio.wait({work}, timeout=60)
await asyncio.wait({work}, timeout=STOP_GRACE_SECONDS + 30)
raise
except Exception as exc: # noqa: BLE001 — training must never kill the loop
log.exception(f"Scheduled training crashed: {exc}")
rc = -1
finally:
if work.done():
_release_lock(voice_dir)
status = "completed" if rc == 0 else f"FAILED (exit {rc})"
if rc == 0:
status = "completed"
elif rc == EXIT_STOPPED:
status = "stopped (progress saved)"
elif rc == EXIT_BUSY:
status = "skipped (this voice is being trained already)"
else:
status = f"FAILED (exit {rc})"
log.info(f"Scheduled training {status}: voice={voice!r}")
if bus is not None:
await _safe_publish(
bus, "task_done",
{"id": "training", "ok": rc == 0,
{"id": "training", "ok": rc in (0, EXIT_STOPPED, EXIT_BUSY),
"msg": f"Voice training {status}"})
+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()
+241
View File
@@ -0,0 +1,241 @@
"""Utterance endpointing while the device plays audio through its own speakers.
Silero rates music/TV as speech, so without a loudness gate a recording never
sees silence and runs until the hard length cap. These tests drive synthetic
frames through the real ``record_utterance`` loop.
"""
from __future__ import annotations
import asyncio
import unittest
import numpy as np
from javis.audio_io.vad import SpeechEndpointer, VADResult, make_vad
CHUNK_MS = 32
SR = 16000
SAMPLES = SR * CHUNK_MS // 1000
MUSIC_RMS = 0.02
VOICE_RMS = 0.15
QUIET_RMS = 0.0005
def frame(rms: float) -> np.ndarray:
"""A constant-amplitude frame whose RMS is exactly ``rms``."""
return np.full(SAMPLES, rms, dtype=np.float32)
class AlwaysSpeech:
"""Stand-in for Silero: calls everything voice-like, just as it does for
music, singing and TV dialogue."""
sample_rate = SR
threshold = 0.5
def __init__(self) -> None:
self.resets = 0
def reset(self) -> None:
self.resets += 1
def process(self, chunk: np.ndarray) -> VADResult:
return VADResult(is_speech=True, probability=1.0)
class FakeMic:
chunk_ms = CHUNK_MS
def __init__(self, frames: list[np.ndarray]) -> None:
self._frames = frames
def drain(self) -> None:
pass
async def chunks(self, stale_timeout=None):
for f in self._frames:
yield f
await asyncio.sleep(0)
def endpointer(ratio: float = 3.0) -> SpeechEndpointer:
return SpeechEndpointer(AlwaysSpeech(), onset_ratio=ratio, chunk_ms=CHUNK_MS)
def record(frames, vad, *, silence_ms=800, grace_ms=1500, max_seconds=20.0):
from javis.audio_io.capture import record_utterance
from javis.core.config import Config
cfg = Config()
cfg.wake_word.silence_ms = silence_ms
return asyncio.run(record_utterance(
cfg, FakeMic(frames), vad, max_seconds=max_seconds,
initial_grace_ms=grace_ms,
))
class SpeechEndpointerTests(unittest.TestCase):
def test_music_alone_is_not_speech_once_background_is_known(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.reset()
self.assertFalse(ep.process(frame(MUSIC_RMS)).is_speech)
def test_voice_over_music_is_speech(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.reset()
self.assertTrue(ep.process(frame(VOICE_RMS)).is_speech)
def test_quiet_room_still_detects_normal_speech(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(QUIET_RMS))
ep.reset()
self.assertTrue(ep.process(frame(0.05)).is_speech)
# ...but not the room tone itself.
ep.reset()
self.assertFalse(ep.process(frame(QUIET_RMS)).is_speech)
def test_long_utterance_does_not_raise_the_gate_onto_itself(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(QUIET_RMS))
ep.reset()
for _ in range(200):
self.assertTrue(ep.process(frame(0.05)).is_speech)
def test_release_is_more_forgiving_than_onset(self) -> None:
"""A trailing quiet syllable must not be clipped off mid-word."""
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.reset()
trailing = frame(MUSIC_RMS * 2.0) # below onset (3x), above release
self.assertFalse(ep.process(trailing).is_speech)
ep.process(frame(VOICE_RMS)) # user starts talking
self.assertTrue(ep.process(trailing).is_speech)
def test_reset_keeps_the_background_but_clears_the_latch(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.process(frame(VOICE_RMS))
before = ep.background
ep.reset()
self.assertAlmostEqual(ep.background, before)
self.assertFalse(ep.process(frame(MUSIC_RMS * 2.0)).is_speech)
def test_a_higher_ratio_is_stricter(self) -> None:
"""Pins the documented tuning direction.
Raising the ratio rejects more audio (so it can clip quiet commands);
lowering it admits more (so playback can keep the recorder running).
"""
def accepts(ratio: float) -> bool:
ep = endpointer(ratio=ratio)
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.reset()
return ep.process(frame(MUSIC_RMS * 2.5)).is_speech
self.assertTrue(accepts(2.0)) # permissive — the frame counts as the user
self.assertFalse(accepts(4.0)) # strict — the same frame is rejected
def test_ratio_zero_disables_the_gate(self) -> None:
ep = endpointer(ratio=0.0)
self.assertFalse(ep.enabled)
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
ep.reset()
self.assertTrue(ep.process(frame(MUSIC_RMS)).is_speech)
def test_reset_resets_the_inner_vad(self) -> None:
inner = AlwaysSpeech()
ep = SpeechEndpointer(inner, chunk_ms=CHUNK_MS)
ep.reset()
self.assertEqual(inner.resets, 1)
def test_threshold_proxies_to_the_inner_vad(self) -> None:
inner = AlwaysSpeech()
ep = SpeechEndpointer(inner, chunk_ms=CHUNK_MS)
ep.threshold = 0.9
self.assertEqual(inner.threshold, 0.9)
self.assertEqual(ep.threshold, 0.9)
def test_empty_frame_is_harmless(self) -> None:
ep = endpointer()
self.assertEqual(ep.observe(np.zeros(0, dtype=np.float32)), 0.0)
self.assertFalse(ep.process(np.zeros(0, dtype=np.float32)).is_speech)
class RecordUtteranceEndpointingTests(unittest.TestCase):
def test_recording_ends_when_the_user_stops_not_the_music(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS)) # wake-word loop heard the music
speech = [frame(VOICE_RMS)] * 30 # ~1.0 s of command
tail = [frame(MUSIC_RMS)] * 200 # music keeps playing
audio = record(speech + tail, ep)
self.assertIsNotNone(audio)
# 30 speech frames + 25 frames (800 ms) of "silence" = ~1.76 s, far
# short of the 20 s cap the old endpointer ran into.
self.assertLess(len(audio) / SR, 3.0)
def test_music_only_returns_none_instead_of_20s_of_audio(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(MUSIC_RMS))
audio = record([frame(MUSIC_RMS)] * 400, ep)
self.assertIsNone(audio)
def test_without_the_gate_music_runs_to_the_cap(self) -> None:
"""The behaviour this change fixes, pinned so it cannot creep back."""
ep = endpointer(ratio=0.0)
audio = record([frame(MUSIC_RMS)] * 400, ep, max_seconds=2.0)
self.assertIsNotNone(audio)
self.assertGreaterEqual(len(audio) / SR, 1.9)
def test_quiet_room_recording_is_unaffected(self) -> None:
ep = endpointer()
for _ in range(50):
ep.observe(frame(QUIET_RMS))
speech = [frame(0.05)] * 30
tail = [frame(QUIET_RMS)] * 200
audio = record(speech + tail, ep)
self.assertIsNotNone(audio)
self.assertLess(len(audio) / SR, 3.0)
class MakeVadWiringTests(unittest.TestCase):
def test_make_vad_returns_an_endpointer(self) -> None:
vad = make_vad(voice_gate_ratio=2.5)
self.assertIsInstance(vad, SpeechEndpointer)
self.assertEqual(vad.onset_ratio, 2.5)
self.assertTrue(hasattr(vad, "observe"))
def test_config_clamps_the_ratio(self) -> None:
from javis.core.config import Config, validate
cfg = Config()
self.assertEqual(cfg.wake_word.voice_gate_ratio, 3.0)
cfg.wake_word.voice_gate_ratio = -1.0
validate(cfg)
self.assertEqual(cfg.wake_word.voice_gate_ratio, 3.0)
def test_master_pushes_the_ratio_to_slaves(self) -> None:
from javis.api.routes import _wake_word_block
from javis.core.config import Config
cfg = Config()
cfg.wake_word.voice_gate_ratio = 4.0
self.assertEqual(_wake_word_block(cfg).voice_gate_ratio, 4.0)
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
"""Surviving a dead CUDA context (e.g. NVIDIA driver updated while running)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from javis.core.config import Config, PathsConfig
from javis.core.hardware import GpuUnavailableError, is_cuda_error
from javis.stt.transcriber import Transcriber
from javis.stt.voice_profiler import VoiceProfiler
try:
import torch
except ImportError: # pragma: no cover
torch = None
SR = 16000
_AUDIO = np.full(SR, 0.1, dtype=np.float32)
class IsCudaErrorTests(unittest.TestCase):
def test_classification(self) -> None:
self.assertTrue(is_cuda_error(RuntimeError("CUDA error: unknown error")))
self.assertTrue(is_cuda_error(RuntimeError("CUDA failed with error unknown error")))
self.assertTrue(is_cuda_error(RuntimeError("cuBLAS failed with status X")))
self.assertFalse(is_cuda_error(RuntimeError("CUDA failed with error out of memory")))
self.assertFalse(is_cuda_error(ValueError("bad input")))
class _DeadModel:
"""Fake WhisperModel whose lazy segment generator hits a dead context."""
def __init__(self, msg: str = "CUDA failed with error unknown error") -> None:
self.msg = msg
self.calls = 0
def transcribe(self, audio, **kw):
self.calls += 1
def segments():
raise RuntimeError(self.msg)
yield # pragma: no cover
return segments(), SimpleNamespace(language="de", duration=1.0)
def _transcriber(model) -> Transcriber:
stt = Transcriber(Config())
stt._model = model
stt._batched = None
stt._device = "cuda"
return stt
class TranscriberGpuLostTests(unittest.TestCase):
def test_dead_context_raises_and_then_fails_fast(self) -> None:
model = _DeadModel()
stt = _transcriber(model)
with patch.object(Transcriber, "_cuda_alive", return_value=False):
with self.assertRaisesRegex(GpuUnavailableError, "Restart Javis"):
stt._transcribe_sync(_AUDIO, SR)
with self.assertRaises(GpuUnavailableError):
stt._transcribe_sync(_AUDIO, SR)
self.assertEqual(model.calls, 1) # second call never touched the GPU
self.assertIs(stt._model, model) # freeing it would abort the process
self.assertFalse(stt._force_cpu)
def test_cuda_error_on_live_context_is_not_gpu_lost(self) -> None:
stt = _transcriber(_DeadModel("CUDA failed with error invalid argument"))
with patch.object(Transcriber, "_cuda_alive", return_value=True):
with self.assertRaises(RuntimeError) as cm:
stt._transcribe_sync(_AUDIO, SR)
self.assertNotIsInstance(cm.exception, GpuUnavailableError)
self.assertFalse(stt._gpu_lost)
def test_cpu_model_errors_skip_the_probe(self) -> None:
stt = _transcriber(_DeadModel())
stt._device = "cpu"
with patch.object(Transcriber, "_cuda_alive") as probe:
with self.assertRaises(RuntimeError) as cm:
stt._transcribe_sync(_AUDIO, SR)
probe.assert_not_called()
self.assertNotIsInstance(cm.exception, GpuUnavailableError)
class _BrokenCudaEncoder:
def encode_batch(self, wav, lens):
raise RuntimeError("CUDA error: unknown error")
class _CpuEncoder:
def __init__(self) -> None:
self.calls = 0
def encode_batch(self, wav, lens):
self.calls += 1
return torch.ones(1, 1, 192)
@unittest.skipIf(torch is None, "torch not installed")
class ProfilerCpuFallbackTests(unittest.TestCase):
def _profiler(self, base: Path) -> VoiceProfiler:
prof = VoiceProfiler(Config(paths=PathsConfig(base_dir=base)))
prof._encoder = _BrokenCudaEncoder()
prof._device = "cuda:0"
return prof
def test_cuda_error_switches_encoder_to_cpu_once(self) -> None:
with tempfile.TemporaryDirectory() as d:
prof = self._profiler(Path(d))
cpu = _CpuEncoder()
with patch.object(VoiceProfiler, "_build_encoder", return_value=cpu) as build:
first = prof.embed(_AUDIO, SR)
prof.embed(_AUDIO, SR)
build.assert_called_once_with("cpu")
self.assertIs(prof._encoder, cpu)
self.assertEqual(prof._device, "cpu")
self.assertEqual(cpu.calls, 2)
self.assertAlmostEqual(float(np.linalg.norm(first)), 1.0, places=5)
def test_errors_on_cpu_are_not_swallowed(self) -> None:
with tempfile.TemporaryDirectory() as d:
prof = self._profiler(Path(d))
prof._device = "cpu"
with patch.object(VoiceProfiler, "_build_encoder") as build:
with self.assertRaises(RuntimeError):
prof.embed(_AUDIO, SR)
build.assert_not_called()
class IdentifySpeakerTests(unittest.IsolatedAsyncioTestCase):
async def test_identify_failure_returns_sentinel(self) -> None:
from javis.agent.orchestrator import _ID_FAILED, Orchestrator
orch = object.__new__(Orchestrator)
def boom(audio, sr):
raise RuntimeError("CUDA error: unknown error")
orch.profiler = SimpleNamespace(identify=boom)
self.assertIs(await orch._identify_speaker(_AUDIO, SR), _ID_FAILED)
orch.profiler = SimpleNamespace(identify=lambda a, sr: ("Florian", 0.8))
self.assertEqual(await orch._identify_speaker(_AUDIO, SR), ("Florian", 0.8))
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -143,6 +143,128 @@ def test_send_turn_retries_connect_error_once():
assert len(calls) == 2
def test_send_turn_reads_master_followup_flag():
def handler(request):
return httpx.Response(
200,
json={
"user_text": "Ja",
"reply_text": "Noch etwas?",
"expects_followup": True,
},
)
async def run():
client = _client_with(handler)
try:
return await client.send_turn(b"RIFF")
finally:
await client.aclose()
result = asyncio.run(run())
assert result.expects_followup is True
def test_slave_voice_gate_from_master_is_applied(monkeypatch):
"""The master owns the loudness gate, so a satellite must honour the value
it pushes (and fall back to its own default for older masters)."""
from javis_slave.api_client import _wake_word_from
from javis_slave.audio_engine import AudioEngine
from javis_slave.config import SlaveConfig, WakeWordSettings
from javis_slave import audio_engine as ae
assert _wake_word_from({"wake_word": {"voice_gate_ratio": 4.5}}).voice_gate_ratio == 4.5
assert _wake_word_from({"wake_word": {}}).voice_gate_ratio == 3.0
built = []
monkeypatch.setattr(ae, "make_vad", lambda **kw: built.append(kw) or object())
eng = AudioEngine(SlaveConfig(), client=None)
eng._apply_master_settings(None, WakeWordSettings(voice_gate_ratio=4.5))
assert built and built[-1]["voice_gate_ratio"] == 4.5
assert eng.cfg.wake_word.voice_gate_ratio == 4.5
def test_slave_endpointer_ends_recording_while_music_plays():
"""Silero calls music speech; without the loudness gate the satellite
recorded until the hard cap instead of when the user stopped talking."""
import numpy as np
from javis_slave.audio_io.capture import record_utterance
from javis_slave.audio_io.vad import SpeechEndpointer, VADResult
sr, chunk_ms = 16000, 32
n = sr * chunk_ms // 1000
class AlwaysSpeech:
sample_rate = sr
threshold = 0.5
def reset(self):
pass
def process(self, chunk):
return VADResult(True, 1.0)
class FakeMic:
chunk_ms = 32
def __init__(self, frames):
self._frames = frames
async def chunks(self, stale_timeout=None):
for f in self._frames:
yield f
await asyncio.sleep(0)
def frame(rms):
return np.full(n, rms, dtype=np.float32)
vad = SpeechEndpointer(AlwaysSpeech(), onset_ratio=3.0, chunk_ms=chunk_ms)
for _ in range(50):
vad.observe(frame(0.02)) # wake loop heard the music
frames = [frame(0.15)] * 30 + [frame(0.02)] * 300
audio = asyncio.run(record_utterance(sr, 800, FakeMic(frames), vad))
assert audio is not None
assert len(audio) / sr < 3.0
def test_slave_followup_skips_wake_word_and_starts_recording():
from javis_slave.audio_engine import AudioEngine
async def run():
eng = AudioEngine(SlaveConfig(), client=None)
eng._followup_pending = True
calls = []
async def config_poll():
await eng._stop.wait()
async def wait_for_wake():
calls.append("wake")
return True
async def handle_turn(*, is_followup=False):
calls.append(("turn", is_followup))
eng._stop.set()
async def play_chime(*args):
calls.append("chime")
eng._config_poll_loop = config_poll
eng._wait_for_wake = wait_for_wake
eng._handle_turn = handle_turn
eng._play_chime = play_chime
await eng.run()
await asyncio.sleep(0)
return calls
calls = asyncio.run(run())
assert "wake" not in calls
assert ("turn", True) in calls
# ── SLV-04 / SLV-06: audio engine ────────────────────────────────────────────
def test_engine_start_failure_releases_mic(monkeypatch):
+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