From 8ecdde6e0b9f2c529c61d3549c58582bf7d15a17 Mon Sep 17 00:00:00 2001 From: Mathias Engel <27214100+ProfEngel@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:45:48 +0200 Subject: [PATCH 1/3] voice: make remote Eve client resilient --- core/transcriber.py | 61 ++++++++++- core/voice/local_realtime_client.py | 121 ++++++++++++++++++++-- tests/test_runtime_resilience.py | 75 ++++++++++++++ tests/voice/test_local_realtime_client.py | 93 +++++++++++++++++ trinity_launcher.py | 50 ++++++++- 5 files changed, 389 insertions(+), 11 deletions(-) diff --git a/core/transcriber.py b/core/transcriber.py index 0997dfe..1cce14f 100644 --- a/core/transcriber.py +++ b/core/transcriber.py @@ -291,6 +291,59 @@ def _stop_audio_input(self): except Exception as exc: print(f"⚠️ Audioeingang konnte nicht sauber gestoppt werden: {exc}") + def _dynamic_voice_microphone(self): + return ( + sys.platform == "win32" + and os.environ.get("TRINITY_VOICE_DYNAMIC_MIC") == "1" + ) + + def _voice_microphone_claimed(self): + claim_path = os.path.join( + PROJECT_DIR, "TrinityRuntime", "voice", "desktop_eve_audio.claim" + ) + return self._dynamic_voice_microphone() and os.path.isfile(claim_path) + + def _sync_voice_microphone_ownership(self): + """Hand the Windows microphone between Legacy and remote Eve at runtime.""" + + if not self._dynamic_voice_microphone() or getattr(self, "uses_native_speech", False): + return + runtime_dir = os.path.join(PROJECT_DIR, "TrinityRuntime", "voice") + released_path = os.path.join(runtime_dir, "desktop_legacy_audio.released") + if self._voice_microphone_claimed(): + if getattr(self, "_voice_microphone_released", False): + return + self._stop_audio_input() + try: + os.makedirs(runtime_dir, exist_ok=True) + with open(released_path, "w", encoding="utf-8") as handle: + handle.write(str(os.getpid())) + self._voice_microphone_released = True + except OSError as exc: + print(f"⚠️ Mikrofonübergabe an Eve konnte nicht bestätigt werden: {exc}") + return + + was_released = getattr(self, "_voice_microphone_released", False) + try: + os.unlink(released_path) + except FileNotFoundError: + pass + except OSError as exc: + print(f"⚠️ Veralteter Audio-Übergabemarker blieb bestehen: {exc}") + if not was_released: + return + if ( + getattr(self, "mode", "office") != "chat" + and getattr(self, "speech_input_enabled", False) + and (self.audio_stream is None or not self.audio_stream.active) + ): + try: + self._start_audio_input() + except Exception as exc: + print(f"⚠️ Legacy-Audio konnte das Mikrofon nicht zurücknehmen: {exc}") + return + self._voice_microphone_released = False + def load_config(self): """Lädt STT-spezifische Settings aus der config.json.""" try: @@ -327,7 +380,10 @@ def load_config(self): ) and os.environ.get("TRINITY_SERVER") != "1" and self.microphone_enabled self.speech_input_enabled = ( self.speech_input_enabled - and os.environ.get("TRINITY_VOICE_OWNS_MIC") != "1" + and ( + os.environ.get("TRINITY_VOICE_OWNS_MIC") != "1" + or self._dynamic_voice_microphone() + ) ) self._config_mtime = os.path.getmtime(self.config_path) except: @@ -895,7 +951,7 @@ def start(self): blocks_per_chunk = int(self.chunk_duration / 0.5) - if self.mode != "chat" and self.speech_input_enabled: + if self.mode != "chat" and self.speech_input_enabled and not self._voice_microphone_claimed(): try: self._start_audio_input() print(f"Trinity hört jetzt zu... (Model: {self.model_name}, Thresh: {self.silence_threshold})") @@ -917,6 +973,7 @@ def start(self): try: while self.is_running: self.reload_config_if_changed() + self._sync_voice_microphone_ownership() self._process_external_stt_feed() # 1. Prüfe auf stille Text-Eingaben diff --git a/core/voice/local_realtime_client.py b/core/voice/local_realtime_client.py index 9e03678..c8d6478 100644 --- a/core/voice/local_realtime_client.py +++ b/core/voice/local_realtime_client.py @@ -47,7 +47,9 @@ def __init__( self.port = config.profile.internal_port self.endpoint = endpoint.strip() self.access_token = access_token.strip() + self._remote_retry = config.profile.runtime_role == "client" self._stop = threading.Event() + self._started = threading.Event() self._ready = threading.Event() self._thread: threading.Thread | None = None self._connection = None @@ -61,19 +63,27 @@ def __init__( self._last_cancel_at = 0.0 self._speech_queue_path = config.home / "TrinityRuntime" / "voice" / "desktop_speech_queue.jsonl" self._ready_path = config.home / "TrinityRuntime" / "voice" / "desktop_eve_audio.ready" + self._mic_claim_path = config.home / "TrinityRuntime" / "voice" / "desktop_eve_audio.claim" + self._legacy_released_path = config.home / "TrinityRuntime" / "voice" / "desktop_legacy_audio.released" self._trinity_config_path = config.home / "core" / "config.json" self._speaker_check_at = 0.0 self._desktop_output_enabled = True self._speech_queue_offset = 0 def start(self, timeout: float = 20.0) -> None: + if self._remote_retry: + # A forced Windows shutdown can leave marker files behind. They must + # never block the Legacy microphone while Ubuntu is still booting. + self._remove_ready_marker() + self._release_microphone_claim() self._thread = threading.Thread( target=self._run, name="trinity-local-eve-client", daemon=True, ) self._thread.start() - if not self._ready.wait(timeout): + wait_event = self._started if self._remote_retry else self._ready + if not wait_event.wait(timeout): raise TimeoutError("Lokaler Eve-Audioclient wurde nicht rechtzeitig bereit.") if self._error: raise RuntimeError(f"Lokaler Eve-Audioclient konnte nicht starten: {self._error}") @@ -81,6 +91,7 @@ def start(self, timeout: float = 20.0) -> None: def stop(self) -> None: self._stop.set() self._remove_ready_marker() + self._release_microphone_claim() connection = self._connection if connection is not None: try: @@ -139,7 +150,51 @@ def _session_update(self) -> dict[str, Any]: } def _run(self) -> None: + self._started.set() + if not self._remote_retry: + self._run_connection() + return + + retry_delay = 1.0 + while not self._stop.is_set(): + if not self._desktop_speaker_selected(): + # The Bridge persists one globally selected speaker. A remote + # Companion must be able to claim the single GPU pipeline, so + # Windows must not keep an idle WebSocket reservation while a + # different device (or no device) owns speech output. + self._ready.clear() + self._remove_ready_marker() + self._release_microphone_claim() + self._connection = None + retry_delay = 1.0 + self._stop.wait(0.35) + continue + try: + self._run_connection() + if self._stop.is_set(): + break + if not self._desktop_speaker_selected(): + retry_delay = 1.0 + continue + raise RuntimeError("Realtime-Verbindung wurde unerwartet geschlossen.") + except BaseException as exc: + self._ready.clear() + self._remove_ready_marker() + self._release_microphone_claim() + self._connection = None + if self._stop.is_set(): + break + LOGGER.warning( + "Ubuntu Eve ist noch nicht verfügbar (%s). Neuer Versuch in %.0f Sekunden.", + type(exc).__name__, + retry_delay, + ) + self._stop.wait(retry_delay) + retry_delay = min(retry_delay * 2.0, 15.0) + + def _run_connection(self) -> None: sender: threading.Thread | None = None + connection_done = threading.Event() try: import sounddevice as sd from websockets.sync.client import connect @@ -150,14 +205,19 @@ def _run(self) -> None: self._speech_queue_path.parent.mkdir(parents=True, exist_ok=True) self._speech_queue_path.touch(exist_ok=True) self._speech_queue_offset = self._speech_queue_path.stat().st_size + self._discard_pending_input() + self._clear_output() connection.send(json.dumps(self._session_update(), ensure_ascii=False)) sender = threading.Thread( target=self._send_loop, - args=(connection,), + args=(connection, connection_done), name="trinity-local-eve-sender", daemon=True, ) sender.start() + if self._remote_retry: + self._claim_microphone() + self._wait_for_legacy_microphone_release() # Input and output devices commonly use different native sample # rates on macOS (for example 44.1 kHz and 48 kHz). Separate # PortAudio streams avoid the CoreAudio deadlock caused by a @@ -179,6 +239,11 @@ def _run(self) -> None: self._write_ready_marker() print("Eve Desktop-Audio bereit: Unterbrechen durch Sprechen ist aktiv.") while not self._stop.is_set(): + if self._remote_retry and not self._desktop_speaker_selected(): + LOGGER.info( + "Eve-Desktop gibt die Realtime-Pipeline an den aktiven Companion frei." + ) + break self._consume_speech_queue() try: raw = connection.recv(timeout=0.1) @@ -187,18 +252,24 @@ def _run(self) -> None: if raw is None: break self._handle_event(raw) - if not self._stop.is_set(): + if not self._stop.is_set() and self._desktop_speaker_selected(): raise RuntimeError("Realtime-Verbindung wurde unerwartet geschlossen.") except BaseException as exc: + if self._remote_retry: + raise self._error = exc self._ready.set() if not self._stop.is_set(): LOGGER.exception("Lokaler Eve-Audioclient beendet") finally: - self._stop.set() + connection_done.set() + self._ready.clear() self._remove_ready_marker() + self._release_microphone_claim() if sender: sender.join(timeout=2) + if not self._remote_retry: + self._stop.set() def _connection_uri(self) -> str: raw = self.endpoint or f"ws://{self.host}:{self.port}/v1/realtime" @@ -222,6 +293,41 @@ def _remove_ready_marker(self) -> None: except OSError: LOGGER.debug("Eve-Bereitschaftsmarker konnte nicht entfernt werden", exc_info=True) + def _claim_microphone(self) -> None: + try: + self._mic_claim_path.parent.mkdir(parents=True, exist_ok=True) + self._legacy_released_path.unlink(missing_ok=True) + self._mic_claim_path.write_text(str(os.getpid()), encoding="utf-8") + except OSError as exc: + raise RuntimeError("Eve konnte den Desktop-Audioeingang nicht anfordern.") from exc + + def _wait_for_legacy_microphone_release(self, timeout: float = 15.0) -> None: + if os.environ.get("TRINITY_VOICE_DYNAMIC_MIC") != "1": + return + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and not self._stop.is_set(): + if self._legacy_released_path.is_file(): + return + time.sleep(0.1) + if not self._stop.is_set(): + raise TimeoutError("Legacy-Audio hat das Mikrofon nicht rechtzeitig freigegeben.") + + def _release_microphone_claim(self) -> None: + for path in (self._mic_claim_path, self._legacy_released_path): + try: + path.unlink(missing_ok=True) + except OSError: + LOGGER.debug("Audio-Übergabemarker konnte nicht entfernt werden", exc_info=True) + + def _discard_pending_input(self) -> None: + """Never replay microphone packets collected for an older connection.""" + + while True: + try: + self._send_queue.get_nowait() + except Empty: + return + def _consume_speech_queue(self) -> None: try: with self._speech_queue_path.open("r", encoding="utf-8") as handle: @@ -257,8 +363,8 @@ def _consume_speech_queue(self) -> None: }, }) - def _send_loop(self, connection) -> None: - while not self._stop.is_set(): + def _send_loop(self, connection, connection_done: threading.Event) -> None: + while not self._stop.is_set() and not connection_done.is_set(): try: event = self._send_queue.get(timeout=0.1) except Empty: @@ -266,7 +372,8 @@ def _send_loop(self, connection) -> None: try: connection.send(json.dumps(event, ensure_ascii=False)) except Exception: - self._stop.set() + if not self._remote_retry: + self._stop.set() return def _output_callback(self, outdata, frames, _time_info, status) -> None: diff --git a/tests/test_runtime_resilience.py b/tests/test_runtime_resilience.py index 13a908e..fcbcc96 100644 --- a/tests/test_runtime_resilience.py +++ b/tests/test_runtime_resilience.py @@ -100,6 +100,81 @@ def test_windows_speech_can_be_enabled_explicitly(tmp_path, monkeypatch): assert ear.speech_input_enabled is True +def test_windows_microphone_moves_between_legacy_and_remote_eve(tmp_path, monkeypatch): + runtime_voice = tmp_path / "TrinityRuntime" / "voice" + runtime_voice.mkdir(parents=True) + claim_path = runtime_voice / "desktop_eve_audio.claim" + released_path = runtime_voice / "desktop_legacy_audio.released" + claim_path.write_text("voice", encoding="utf-8") + monkeypatch.setattr(transcriber, "PROJECT_DIR", str(tmp_path)) + monkeypatch.setattr(transcriber.sys, "platform", "win32") + monkeypatch.setenv("TRINITY_VOICE_DYNAMIC_MIC", "1") + + stream = type("Stream", (), {"active": True})() + ear = object.__new__(transcriber.TrinityEar) + ear.audio_stream = stream + ear.mode = "office" + ear.speech_input_enabled = True + ear.uses_native_speech = False + transitions = [] + + def stop_audio(): + transitions.append("legacy-stopped") + stream.active = False + + def start_audio(): + transitions.append("legacy-started") + stream.active = True + + ear._stop_audio_input = stop_audio + ear._start_audio_input = start_audio + + ear._sync_voice_microphone_ownership() + ear._sync_voice_microphone_ownership() + + assert transitions == ["legacy-stopped"] + assert released_path.is_file() + + claim_path.unlink() + ear._sync_voice_microphone_ownership() + + assert transitions == ["legacy-stopped", "legacy-started"] + assert stream.active is True + assert not released_path.exists() + + +def test_launcher_clears_stale_dynamic_voice_markers(tmp_path): + runtime_voice = tmp_path / "TrinityRuntime" / "voice" + runtime_voice.mkdir(parents=True) + marker_names = ( + "desktop_eve_audio.ready", + "desktop_eve_audio.claim", + "desktop_legacy_audio.released", + ) + for name in marker_names: + (runtime_voice / name).write_text("123", encoding="utf-8") + + trinity_launcher._clear_dynamic_voice_markers(tmp_path) + + assert all(not (runtime_voice / name).exists() for name in marker_names) + + +def test_launcher_terminates_windows_venv_process_tree(monkeypatch): + process = type("Process", (), {"pid": 1234, "poll": lambda self: None})() + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(trinity_launcher.sys, "platform", "win32") + monkeypatch.setattr(trinity_launcher.subprocess, "run", fake_run) + + trinity_launcher._terminate_process_tree(process) + + assert calls[0][0] == ["taskkill", "/PID", "1234", "/T", "/F"] + + def test_desktop_response_is_queued_for_running_eve_client(tmp_path, monkeypatch): runtime_voice = tmp_path / "TrinityRuntime" / "voice" runtime_voice.mkdir(parents=True) diff --git a/tests/voice/test_local_realtime_client.py b/tests/voice/test_local_realtime_client.py index 38adbc7..a6dfa05 100644 --- a/tests/voice/test_local_realtime_client.py +++ b/tests/voice/test_local_realtime_client.py @@ -1,3 +1,6 @@ +import json +import threading + from core.voice.config import default_voice_config, load_voice_config from core.voice.local_realtime_client import LocalRealtimeAudioClient @@ -22,3 +25,93 @@ def test_remote_connection_uri_adds_token_and_preserves_query(tmp_path): assert client._connection_uri() == ( "wss://voice.example.test/v1/realtime?client=windows&access_token=voice+secret" ) + + +def test_remote_client_stays_alive_while_ubuntu_is_unavailable(tmp_path, monkeypatch): + raw = default_voice_config() + raw.update( + { + "profile": "eve-windows-remote", + "access_token": "voice-secret", + "remote_voice_url": "ws://ubuntu.invalid:8766/v1/realtime", + "backend_token": "core-secret", + } + ) + config = load_voice_config(tmp_path, {"voice": raw}) + client = LocalRealtimeAudioClient(config, endpoint=config.remote_voice_url) + client._mic_claim_path.parent.mkdir(parents=True) + client._mic_claim_path.write_text("stale", encoding="utf-8") + client._legacy_released_path.write_text("stale", encoding="utf-8") + attempted = threading.Event() + + def unavailable(): + attempted.set() + raise ConnectionError("host is still booting") + + monkeypatch.setattr(client, "_run_connection", unavailable) + client.start(timeout=1) + assert attempted.wait(1) + assert client.is_alive + assert client.failure is None + assert not client._mic_claim_path.exists() + assert not client._legacy_released_path.exists() + + client.stop() + assert not client.is_alive + + +def test_reconnect_discards_audio_from_previous_connection(tmp_path): + raw = default_voice_config() + raw.update( + { + "profile": "eve-windows-remote", + "access_token": "voice-secret", + "remote_voice_url": "ws://ubuntu.invalid:8766/v1/realtime", + "backend_token": "core-secret", + } + ) + config = load_voice_config(tmp_path, {"voice": raw}) + client = LocalRealtimeAudioClient(config, endpoint=config.remote_voice_url) + client._queue_event({"type": "input_audio_buffer.append", "audio": "stale"}) + + client._discard_pending_input() + + assert client._send_queue.empty() + + +def test_remote_client_only_claims_pipeline_for_desktop_speaker(tmp_path, monkeypatch): + raw = default_voice_config() + raw.update( + { + "profile": "eve-windows-remote", + "access_token": "voice-secret", + "remote_voice_url": "ws://ubuntu.invalid:8766/v1/realtime", + "backend_token": "core-secret", + } + ) + config = load_voice_config(tmp_path, {"voice": raw}) + config_path = tmp_path / "core" / "config.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"system": {"speech_output": {"kind": "companion"}}}), + encoding="utf-8", + ) + client = LocalRealtimeAudioClient(config, endpoint=config.remote_voice_url) + attempted = threading.Event() + + def connect_once(): + attempted.set() + client._stop.set() + + monkeypatch.setattr(client, "_run_connection", connect_once) + client.start(timeout=1) + + assert not attempted.wait(0.7) + + config_path.write_text( + json.dumps({"system": {"speech_output": {"kind": "desktop"}}}), + encoding="utf-8", + ) + + assert attempted.wait(1.5) + client.stop() diff --git a/trinity_launcher.py b/trinity_launcher.py index d09a0f3..3292fa9 100644 --- a/trinity_launcher.py +++ b/trinity_launcher.py @@ -105,6 +105,45 @@ def _terminate(process): process.terminate() +def _terminate_process_tree(process): + """Stop a spawned runtime including the Windows venv launcher child.""" + + if process is None or process.poll() is not None: + return + if sys.platform == "win32": + try: + result = subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return + except OSError: + pass + process.terminate() + + +def _clear_dynamic_voice_markers(base_dir): + """Release a stale Eve microphone claim after the voice child exits.""" + + runtime_dir = os.path.join(base_dir, "TrinityRuntime", "voice") + for name in ( + "desktop_eve_audio.ready", + "desktop_eve_audio.claim", + "desktop_legacy_audio.released", + ): + try: + os.unlink(os.path.join(runtime_dir, name)) + except FileNotFoundError: + pass + except OSError: + # Startup also clears these markers. A transient antivirus/file-lock + # race must not prevent the launcher from activating Legacy audio. + pass + + def _spawn_ear_process( *, show_terminal, @@ -380,6 +419,10 @@ def launch_trinity(): voice_fallback = bool(voice_config.get("fallback_to_legacy", True)) if voice_enabled: voice_profile = str(voice_config.get("profile") or "eve-trinity") + dynamic_remote_voice = voice_profile == "eve-windows-remote" + if dynamic_remote_voice: + child_env["TRINITY_VOICE_DYNAMIC_MIC"] = "1" + child_env.pop("TRINITY_VOICE_OWNS_MIC", None) voice_command = [ sys.executable, "-u", @@ -398,7 +441,8 @@ def launch_trinity(): creationflags=0, env=child_env, ) - child_env["TRINITY_VOICE_OWNS_MIC"] = "1" + if not dynamic_remote_voice: + child_env["TRINITY_VOICE_OWNS_MIC"] = "1" _log_message(launcher_log, f"Eve Voice wird mit Profil {voice_profile} gestartet.") ear_process = _spawn_ear_process( @@ -468,12 +512,14 @@ def launch_trinity(): f"Eve Voice wurde mit Code {return_code} beendet.", ) voice_process = None + if dynamic_remote_voice: + _clear_dynamic_voice_markers(base_dir) if voice_fallback: _log_message( launcher_log, "Wechsle automatisch auf die bisherige STT/TTS-Laufzeit zurück.", ) - _terminate(ear_process) + _terminate_process_tree(ear_process) if ear_process is not None: try: ear_process.wait(timeout=5) From e336eef25535dd2551b8726c352f24ec1c5f4137 Mon Sep 17 00:00:00 2001 From: Mathias Engel <27214100+ProfEngel@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:45:58 +0200 Subject: [PATCH 2/3] voice: decouple remote GPU server startup --- core/voice/command_builder.py | 2 + core/voice/resilient_s2s.py | 66 ++++++++++++++++++++++++++ core/voice/runtime.py | 10 +++- tests/voice/test_resilient_s2s.py | 36 +++++++++++++++ tests/voice/test_voice_command.py | 4 ++ tests/voice/test_voice_runtime.py | 77 +++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 core/voice/resilient_s2s.py create mode 100644 tests/voice/test_resilient_s2s.py create mode 100644 tests/voice/test_voice_runtime.py diff --git a/core/voice/command_builder.py b/core/voice/command_builder.py index af56b11..e04aa26 100644 --- a/core/voice/command_builder.py +++ b/core/voice/command_builder.py @@ -13,6 +13,8 @@ def _entrypoint(config: VoiceConfig) -> list[str]: configured = config.speech_to_speech_executable.strip() if configured: return shlex.split(configured) + if config.profile.conversation_backend == "remote": + return [sys.executable, str(config.home / "core" / "voice" / "resilient_s2s.py")] return [sys.executable, "-m", "speech_to_speech.s2s_pipeline"] diff --git a/core/voice/resilient_s2s.py b/core/voice/resilient_s2s.py new file mode 100644 index 0000000..2bcb6c2 --- /dev/null +++ b/core/voice/resilient_s2s.py @@ -0,0 +1,66 @@ +"""Start speech-to-speech without requiring the remote Trinity Core at boot.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable +from typing import Any + + +LOGGER = logging.getLogger(__name__) + + +def tolerate_remote_warmup(handler_class: type[Any]) -> Callable[..., Any]: + """Skip the optional remote-Core warm-up entirely. + + Normal conversation requests still use the configured authenticated Core. + The remote endpoint must not consume the GPU server's startup budget or + determine whether STT/TTS and the WebSocket gateway can become ready. + """ + + original = handler_class.warmup + + def resilient_warmup(_self) -> None: + LOGGER.info( + "Remote Trinity Core warm-up skipped; Eve starts independently " + "and connects when the Windows VM is available." + ) + + handler_class.warmup = resilient_warmup + return original + + +def prefer_shared_gpu_ggml_quantization( + model_class: type[Any], + quant: str, +) -> Callable[..., Any]: + """Select an explicitly configured GGUF for a shared-GPU server.""" + + original = model_class.from_pretrained + + def shared_gpu_loader(_class, *args, **kwargs): + if kwargs.get("backend") == "ggml": + kwargs.setdefault("quant", quant) + return original(*args, **kwargs) + + model_class.from_pretrained = classmethod(shared_gpu_loader) + return original + + +def main() -> None: + from faster_qwen3_tts import FasterQwen3TTS + from speech_to_speech.LLM.chat_completions_language_model import ( + ChatCompletionsApiModelHandler, + ) + from speech_to_speech.s2s_pipeline import main as upstream_main + + tolerate_remote_warmup(ChatCompletionsApiModelHandler) + quant = os.environ.get("TRINITY_QWENTTS_QUANT", "").strip() + if quant: + prefer_shared_gpu_ggml_quantization(FasterQwen3TTS, quant) + upstream_main() + + +if __name__ == "__main__": + main() diff --git a/core/voice/runtime.py b/core/voice/runtime.py index c739f37..81ee9b0 100644 --- a/core/voice/runtime.py +++ b/core/voice/runtime.py @@ -88,9 +88,17 @@ def start(self) -> None: command = build_speech_to_speech_command(self.config) env = os.environ.copy() env["TOKENIZERS_PARALLELISM"] = "false" + if profile.device == "cuda": + env.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") self.process = subprocess.Popen(command, env=env) if profile.mode == "realtime": - _wait_for_port("127.0.0.1", profile.internal_port, self.process) + startup_timeout = 600.0 if profile.conversation_backend == "remote" else 120.0 + _wait_for_port( + "127.0.0.1", + profile.internal_port, + self.process, + timeout=startup_timeout, + ) self.proxy = AuthenticatedWebSocketProxy( profile.bind_host, profile.public_port, diff --git a/tests/voice/test_resilient_s2s.py b/tests/voice/test_resilient_s2s.py new file mode 100644 index 0000000..06ab4d4 --- /dev/null +++ b/tests/voice/test_resilient_s2s.py @@ -0,0 +1,36 @@ +from voice.resilient_s2s import ( + prefer_shared_gpu_ggml_quantization, + tolerate_remote_warmup, +) + + +def test_remote_core_warmup_is_skipped(): + class UnavailableCore: + calls = 0 + + def warmup(self): + self.calls += 1 + raise ConnectionError("Windows is still booting") + + tolerate_remote_warmup(UnavailableCore) + handler = UnavailableCore() + + handler.warmup() + + assert handler.calls == 0 + + +def test_shared_gpu_ggml_uses_q8_quantization(): + observed = {} + + class FakeModel: + @classmethod + def from_pretrained(cls, *args, **kwargs): + observed.update(kwargs) + return cls() + + prefer_shared_gpu_ggml_quantization(FakeModel, "Q8_0") + + FakeModel.from_pretrained("model", backend="ggml") + + assert observed["quant"] == "Q8_0" diff --git a/tests/voice/test_voice_command.py b/tests/voice/test_voice_command.py index 75bd6ca..cb250d4 100644 --- a/tests/voice/test_voice_command.py +++ b/tests/voice/test_voice_command.py @@ -63,6 +63,10 @@ def test_ubuntu_server_uses_remote_windows_trinity_core(tmp_path): command = build_speech_to_speech_command(config) + assert command[:2] == [ + sys.executable, + str(tmp_path / "core" / "voice" / "resilient_s2s.py"), + ] assert command[command.index("--responses_api_base_url") + 1] == "http://100.64.0.20:18767/v1" assert command[command.index("--responses_api_api_key") + 1] == "core-secret" assert command[command.index("--model_name") + 1] == "trinity-core" diff --git a/tests/voice/test_voice_runtime.py b/tests/voice/test_voice_runtime.py new file mode 100644 index 0000000..d4b5d18 --- /dev/null +++ b/tests/voice/test_voice_runtime.py @@ -0,0 +1,77 @@ +import pytest + +from voice import runtime +from voice.config import default_voice_config, load_voice_config + + +@pytest.mark.parametrize( + ("configured_allocator", "expected_allocator"), + [(None, "expandable_segments:True"), ("deployment-specific", "deployment-specific")], +) +def test_remote_gpu_server_gets_long_cold_start_window( + tmp_path, + monkeypatch, + configured_allocator, + expected_allocator, +): + reference = tmp_path / "Eve.mp3" + reference.write_bytes(b"voice") + raw = default_voice_config() + raw.update( + { + "engine": "eve", + "profile": "eve-linux-gpu-server", + "access_token": "voice-secret", + "reference_audio": str(reference), + "remote_core_base_url": "http://windows.test:18767/v1", + "remote_core_api_key": "core-secret", + } + ) + config = load_voice_config(tmp_path, {"voice": raw}) + observed = {} + + class FakeProcess: + returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + return self.returncode + + class FakeProxy: + def __init__(self, *_args): + pass + + def start(self): + pass + + def stop(self): + pass + + if configured_allocator is None: + monkeypatch.delenv("PYTORCH_CUDA_ALLOC_CONF", raising=False) + else: + monkeypatch.setenv("PYTORCH_CUDA_ALLOC_CONF", configured_allocator) + monkeypatch.setattr(runtime, "build_speech_to_speech_command", lambda _config: ["voice"]) + def fake_popen(*_args, **kwargs): + observed["environment"] = kwargs["env"] + return FakeProcess() + + monkeypatch.setattr(runtime.subprocess, "Popen", fake_popen) + monkeypatch.setattr(runtime, "AuthenticatedWebSocketProxy", FakeProxy) + monkeypatch.setattr( + runtime, + "_wait_for_port", + lambda _host, _port, _process, timeout: observed.setdefault("timeout", timeout), + ) + + voice_runtime = runtime.VoiceRuntime(config) + voice_runtime.start() + voice_runtime.stop() + + assert observed["timeout"] == 600.0 + assert observed["environment"]["PYTORCH_CUDA_ALLOC_CONF"] == expected_allocator From 97492a5c7c72257d1177254acb7ca28efd8c033a Mon Sep 17 00:00:00 2001 From: Mathias Engel <27214100+ProfEngel@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:46:14 +0200 Subject: [PATCH 3/3] docs: document independent Ubuntu voice startup --- docs/VOICE_UBUNTU_HOST.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/VOICE_UBUNTU_HOST.md b/docs/VOICE_UBUNTU_HOST.md index 40afcb2..dda84fb 100644 --- a/docs/VOICE_UBUNTU_HOST.md +++ b/docs/VOICE_UBUNTU_HOST.md @@ -8,7 +8,7 @@ for sessions, memory, agents, tools, approvals and the user interface. flowchart LR C["Windows, iPhone, iPad or G2 audio client"] <-->|"PCM and realtime events :8766"| U["Ubuntu Eve Voice"] U -->|"transcribed text :18767"| W["Windows Trinity Core"] - W -->|"OpenAI-compatible API"| L["Ubuntu LLM :1234"] + W -->|"OpenAI-compatible API"| L["Ubuntu LLM on a private port"] W -->|"answer text"| U U -->|"Eve audio"| C ``` @@ -59,6 +59,10 @@ Validate and start it: The Voice Gateway listens on port `8766`. Allow access only from the private LAN or Tailscale interface. +On a shared GPU, an administrator may select an installed Qwen3-TTS GGUF with +`TRINITY_QWENTTS_QUANT`. The value is deployment-specific and is not forced by +Trinity. Keep it in the host's protected environment file, not in Git. + ## 3. Configure Windows Follow [VOICE_WINDOWS.md](VOICE_WINDOWS.md) and use profile @@ -75,6 +79,11 @@ must match `VOICE_TOKEN`. 5. Disabling Ubuntu leaves the Windows UI usable; selecting Legacy restores the previous Windows STT/TTS path. +Ubuntu Eve and Windows Trinity must not require one another during startup. +Ubuntu opens its listener without warming up the Windows Core, while the +Windows remote client retries until Ubuntu becomes available and keeps Legacy +audio as the runtime fallback. + GPU passthrough is intentionally not used. It would usually remove the GPU from the Ubuntu host and adds VM/driver fragility without improving this networked speech pipeline.