Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions core/transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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})")
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/voice/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down
121 changes: 114 additions & 7 deletions core/voice/local_realtime_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,26 +63,35 @@ 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}")

def stop(self) -> None:
self._stop.set()
self._remove_ready_marker()
self._release_microphone_claim()
connection = self._connection
if connection is not None:
try:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -257,16 +363,17 @@ 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:
continue
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:
Expand Down
66 changes: 66 additions & 0 deletions core/voice/resilient_s2s.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading