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