diff --git a/README.md b/README.md index 2859c7e..2638fbd 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ ![Trinity Assistant Banner](assets/banner.png) > [!NOTE] > **Aktuelle Highlights:** +> - **v0.17.3:** Eve stellt standardmaessig zwei parallele Realtime-Pipelines bereit. Dadurch kann die lokale Mac-/Windows-Stimme aktiv bleiben, waehrend ein iPhone oder iPad eine eigene Sprachsession nutzt. Der geschuetzte Voice-Port akzeptiert neben einem bewusst getrennten Voice-Token auch den vorhandenen Companion-Bridge-Token; bestehende Konfigurationen bleiben gueltig. > - **v0.17.2:** `Soul.md` und `User.md` sind gegen leere Einstellungen abgesichert. Native UI, WebUI und Companion-Bridge lehnen leere Prompttexte ab, schreiben Änderungen atomar und legen vor echten Änderungen eine private Recovery-Kopie an. Mac- und Windows-Updates übernehmen nur nichtleere Promptdateien. Eve bestätigt eine Unterbrechung nun kurz und behält den akustischen Wiedergabe-Fingerabdruck, damit sie nach einer Unterbrechung spätere Antworten wieder vollständig spricht. > - **v0.17.1:** Der lokale Eve-Desktoppfad öffnet Mikrofon und Lautsprecher jetzt als getrennte Audiostreams. Das verhindert insbesondere auf Macs mit unterschiedlichen Ein-/Ausgabe-Sampleraten einen blockierenden CoreAudio-Duplexstart. Erkannte echte Sprache leert Eves Ausgabe sofort und sendet zusätzlich ein lokales `response.cancel`; die serverseitige VAD bleibt als zweite Sicherung aktiv. -> - **v0.17.0:** Eve Voice ist produktiv auf Mac, Windows und iPhone/iPad angebunden. Lokale Desktop-Gespräche laufen jetzt full-duplex über den Realtime-Pfad: Neue Sprache stoppt die laufende Antwort sofort und leert bereits gepufferte Audioausgabe. Bestehende lokale Eve-Profile werden automatisch migriert; `Legacy` bleibt unverändert als manueller und automatischer Fallback erhalten. Die Companion-App nutzt Apples Voice-Chat-Echounterdrückung und kann Eve ebenfalls per Sprache, Stopptaste oder Wischgeste unterbrechen. > - Die vollstaendige Historie steht in **[RELEASES.md](RELEASES.md)** und in den detaillierten **[Release Notes](docs/release_notes/)**. > [!IMPORTANT] diff --git a/RELEASES.md b/RELEASES.md index 2b0614d..81bb1ec 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -6,6 +6,7 @@ Einzelnotizen liegen unter [docs/release_notes](docs/release_notes/). ## Aktuelle Highlights +- **v0.17.3:** Eve Voice reserviert standardmaessig zwei parallele Realtime-Pipelines: eine fuer die lokale Desktop-Unterhaltung und eine fuer einen iPhone-/iPad-Companion. Der authentifizierte Voice-Proxy akzeptiert sowohl einen bewusst getrennten Voice-Token als auch den bereits konfigurierten Companion-Bridge-Token. Damit bleibt die bestehende Token-Trennung moeglich, waehrend normale Companion-Profile ohne doppelte Tokenpflege funktionieren. Die Companion-App v0.17.7 wartet zudem auf die echte Serverbestaetigung und beendet Wiederholungsversuche bei Authentifizierungs- oder Kapazitaetsfehlern kontrolliert. - **v0.17.2:** Private Persona-Dateien werden beim Speichern nicht mehr durch leere UI-Inhalte ersetzt. Native Einstellungen, WebUI und Companion-Bridge nutzen denselben validierten, atomaren Schreibpfad und sichern die vorherige nichtleere Fassung privat unter `TrinityRuntime/recovery/prompts`. Mac- und Windows-Installer sichern und restaurieren ausschließlich nichtleere `Soul.md` und `User.md`; neutrale Vorlagen greifen nur, wenn keine verwertbare private Datei existiert. Eves lokale Unterbrechung wartet auf vier aufeinanderfolgende Sprachblöcke, vergleicht zusätzlich das Frequenzspektrum und behält den jüngsten Wiedergabe-Fingerabdruck nach `response.cancel`. Dadurch bleibt echtes Barge-in schnell, ohne nach einer ersten Unterbrechung jede weitere Antwort selbst abzuschneiden. - **v0.17.1:** Lokale Eve-Unterhaltung auf Mac und Windows nutzt getrennte Mikrofon- und Lautsprecherstreams statt eines gekoppelten PortAudio-Duplexstreams. Das vermeidet blockierende Starts bei unterschiedlichen nativen Geräteraten und hält Ein- und Ausgabefehler voneinander isoliert. Sobald die Echo-Prüfung echte neue Sprache erkennt, leert Trinity die lokale Wiedergabe und sendet `response.cancel`, bevor die serverseitige VAD-Unterbrechung eintrifft. Legacy und Serverprofile bleiben unverändert verfügbar. - **v0.17.0:** Die optionale Eve Voice Runtime ist als produktiver, weiterhin vollständig rückrollbarer Sprachpfad für Apple-Silicon-Macs, CUDA-fähige Windows-Systeme und die iPhone-/iPad-Companion-App ausgebaut. Lokale Desktop-Profile nutzen Trinitys eigenen full-duplex Realtime-Audioclient statt des bisherigen halbduplexen Upstream-Streamers. Server-VAD bricht LLM und TTS bei neuer Sprache ab; der Client verwirft gleichzeitig noch gepufferte Eve-Ausgabe. Ein Echo-Korrelationsfilter schützt den offenen Desktop-Lautsprecherpfad, während iOS/iPadOS `voiceChat`-AEC nutzt. Alte lokale `mode: local`-Profile werden beim Laden kontrolliert migriert, ohne Modell-, Stimm- oder LLM-Einstellungen zu verändern. `Legacy` bleibt als manueller Pfad und automatischer Fehler-Fallback erhalten. Mac- und Windows-Serverprofile, getrennte Bridge-/Voice-Token, Tailscale-Port 8766 und GPU-/VM-Voraussetzungen sind neu dokumentiert. diff --git a/core/voice/command_builder.py b/core/voice/command_builder.py index e3208b6..419be6f 100644 --- a/core/voice/command_builder.py +++ b/core/voice/command_builder.py @@ -63,6 +63,6 @@ def build_speech_to_speech_command(config: VoiceConfig) -> list[str]: command.extend([ "--ws_host", "127.0.0.1", "--ws_port", str(profile.internal_port), - "--num_pipelines", "1", + "--num_pipelines", str(profile.num_pipelines), ]) return command diff --git a/core/voice/config.py b/core/voice/config.py index a39405d..852d801 100644 --- a/core/voice/config.py +++ b/core/voice/config.py @@ -27,6 +27,7 @@ "public_port": 8766, "internal_port": 18766, "local_audio": True, + "num_pipelines": 2, "stt_model": "mlx-community/parakeet-tdt-0.6b-v3", "tts_model": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-6bit", "tts_backend": "ggml", @@ -39,6 +40,7 @@ "public_port": 8766, "internal_port": 18766, "local_audio": False, + "num_pipelines": 2, "stt_model": "mlx-community/parakeet-tdt-0.6b-v3", "tts_model": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-6bit", "tts_backend": "ggml", @@ -51,6 +53,7 @@ "public_port": 8766, "internal_port": 18766, "local_audio": False, + "num_pipelines": 2, "stt_model": "nvidia/parakeet-tdt-0.6b-v3", "tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "tts_backend": "torch", @@ -63,6 +66,7 @@ "public_port": 8766, "internal_port": 18766, "local_audio": True, + "num_pipelines": 2, "stt_model": "nvidia/parakeet-tdt-0.6b-v3", "tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "tts_backend": "torch", @@ -87,6 +91,7 @@ "public_port": 8766, "internal_port": 18766, "local_audio": True, + "num_pipelines": 2, "stt_model": "mlx-community/parakeet-tdt-0.6b-v3", "tts_model": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-6bit", "tts_backend": "ggml", @@ -118,6 +123,7 @@ class VoiceProfile: public_port: int internal_port: int local_audio: bool + num_pipelines: int stt_model: str tts_model: str tts_backend: str @@ -131,6 +137,7 @@ class VoiceConfig: fallback_to_legacy: bool = True language_policy: str = "de_only" access_token: str = "" + companion_access_token: str = "" backend_host: str = "127.0.0.1" backend_port: int = 18767 backend_token: str = field(default_factory=lambda: secrets.token_urlsafe(24)) @@ -174,6 +181,7 @@ def profile(self) -> VoiceProfile: public_port=int(raw.get("public_port") or 8766), internal_port=int(raw.get("internal_port") or 18766), local_audio=bool(raw.get("local_audio", False)), + num_pipelines=max(1, int(raw.get("num_pipelines") or 1)), stt_model=str(raw.get("stt_model") or self.stt_model), tts_model=str(raw.get("tts_model") or self.tts_model), tts_backend=str(raw.get("tts_backend") or "ggml"), @@ -190,7 +198,11 @@ def validate(self) -> list[str]: errors.append(f"Unbekannter Voice-Modus: {profile.mode}") if profile.conversation_backend not in {"trinity", "direct"}: errors.append(f"Unbekanntes Conversation-Backend: {profile.conversation_backend}") - if profile.mode == "realtime" and not _loopback(profile.bind_host) and not self.access_token: + if ( + profile.mode == "realtime" + and not _loopback(profile.bind_host) + and not (self.access_token or self.companion_access_token) + ): errors.append("Ein extern gebundener Voice-Server braucht voice.access_token.") if self.enabled and not self.reference_audio.is_file(): errors.append(f"Eve-Referenzaudio fehlt: {self.reference_audio or '(nicht konfiguriert)'}") @@ -292,6 +304,11 @@ def load_voice_config(home: str | Path, config: dict[str, Any], profile_name: st or raw.get("access_token") or "" ), + companion_access_token=str( + (config.get("companion") or {}).get("token") + if isinstance(config.get("companion"), dict) + else "" + ), backend_host=str(raw.get("backend_host") or "127.0.0.1"), backend_port=int(raw.get("backend_port") or 18767), stt_model=str(raw.get("stt_model") or "mlx-community/parakeet-tdt-0.6b-v3"), diff --git a/core/voice/runtime.py b/core/voice/runtime.py index 472ea3d..c4774a4 100644 --- a/core/voice/runtime.py +++ b/core/voice/runtime.py @@ -83,7 +83,7 @@ def start(self) -> None: profile.bind_host, profile.public_port, profile.internal_port, - self.config.access_token, + [self.config.access_token, self.config.companion_access_token], ) self.proxy.start() if profile.local_audio: diff --git a/core/voice/transport/auth_proxy.py b/core/voice/transport/auth_proxy.py index b8e9929..4203a27 100644 --- a/core/voice/transport/auth_proxy.py +++ b/core/voice/transport/auth_proxy.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import hmac import threading +from collections.abc import Iterable from urllib.parse import parse_qs, urlsplit @@ -21,11 +23,12 @@ def _token_from_request(path: str, headers) -> str: class AuthenticatedWebSocketProxy: """Expose a public socket while keeping speech-to-speech loopback-only.""" - def __init__(self, host: str, port: int, upstream_port: int, token: str): + def __init__(self, host: str, port: int, upstream_port: int, tokens: str | Iterable[str]): self.host = host self.port = int(port) self.upstream_port = int(upstream_port) - self.token = token + raw_tokens = [tokens] if isinstance(tokens, str) else list(tokens) + self.tokens = tuple(dict.fromkeys(token for token in raw_tokens if token)) self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None self._server = None @@ -36,7 +39,8 @@ async def _handler(self, client) -> None: request = getattr(client, "request", None) path = getattr(request, "path", "/") headers = getattr(request, "headers", {}) - if self.token and _token_from_request(path, headers) != self.token: + supplied_token = _token_from_request(path, headers) + if self.tokens and not any(hmac.compare_digest(supplied_token, token) for token in self.tokens): await client.close(code=4401, reason="Unauthorized") return diff --git a/docs/VOICE_COMPANION_IOS.md b/docs/VOICE_COMPANION_IOS.md index aa7ef0b..0b3b480 100644 --- a/docs/VOICE_COMPANION_IOS.md +++ b/docs/VOICE_COMPANION_IOS.md @@ -7,15 +7,20 @@ Parakeet, Qwen and the LLM stay on the configured Mac or Windows server. 1. Start `eve-mac-server` or `eve-windows-server` on the Trinity machine. 2. Use the machine's Tailscale address and voice port `8766` in the matching Companion connection profile. -3. Enter the same Voice token. Tokens are stored in the device Keychain. +3. Enter the Companion Bridge token, or a deliberately separate Voice token. + Tokens are stored in the device Keychain. 4. Select **Realtime Eve vom Server** in Voice settings and start listening. 5. Switch to **Legacy Companion STT/TTS** immediately if realtime is unavailable. -The Bridge token for port `8765` and the Voice token for port `8766` are -separate settings. They may deliberately have different values. The default +The protected Voice port accepts the existing Bridge token and, when configured, +a separate Voice token. This keeps simple setups consistent while still +allowing strict token separation. The default Voice URL is derived from the selected Bridge profile by replacing the port with `8766`; it can be overridden per Work/Private/Test connection profile. +Realtime profiles provide two concurrent session slots by default so local +desktop Eve and one Companion conversation can remain active together. + Input is PCM signed 16-bit mono at 16 kHz unless server negotiation reports a different format. Output honors event metadata rather than assuming that input and output sample rates match. The client reconnects with bounded exponential diff --git a/docs/VOICE_MACOS.md b/docs/VOICE_MACOS.md index 1c966b8..a28b188 100644 --- a/docs/VOICE_MACOS.md +++ b/docs/VOICE_MACOS.md @@ -30,10 +30,11 @@ if CoreAudio still waits indefinitely. `voice doctor` validates models and configuration; the final local smoke test additionally validates the physical microphone and speaker path. -For a Companion server, choose `eve-mac-server`, set a separate long Voice -token, bind to `0.0.0.0` only inside a trusted Tailscale/private network, then +For a Companion server, choose `eve-mac-server`, optionally set a separate long +Voice token, bind to `0.0.0.0` only inside a trusted Tailscale/private network, then restart Trinity. The Companion uses `ws://TAILSCALE-IP:8766/v1/realtime` and -that Voice token. The normal Bridge remains on port `8765` with its own token. +either that Voice token or the existing Bridge token. The normal Bridge remains +on port `8765`. ```bash venv/bin/trinity voice benchmark --profile eve-mac-local --rounds 3 diff --git a/docs/VOICE_WINDOWS.md b/docs/VOICE_WINDOWS.md index 200b0ad..931c954 100644 --- a/docs/VOICE_WINDOWS.md +++ b/docs/VOICE_WINDOWS.md @@ -26,7 +26,8 @@ cancels the active response and flushes buffered Eve audio. Keep the bind host at `127.0.0.1` for local validation. For Tailscale clients, select the server profile, configure a separate Voice token, and if needed create the private firewall rule with `-OpenFirewall`. In the Companion enter -`ws://TAILSCALE-IP:8766/v1/realtime` and the same Voice token. Never forward +`ws://TAILSCALE-IP:8766/v1/realtime` and either that Voice token or the existing +Companion Bridge token. Never forward port 8766 from a public router. If CUDA, the selected checkpoint or synthesis performance is unsuitable, choose diff --git a/docs/release_notes/v0.17.3.md b/docs/release_notes/v0.17.3.md new file mode 100644 index 0000000..0d7ea5a --- /dev/null +++ b/docs/release_notes/v0.17.3.md @@ -0,0 +1,19 @@ +# Trinity v0.17.3 + +## Eve Voice multi-client fix + +- Realtime profiles now start with two concurrent pipelines by default. +- Local desktop audio and one iPhone or iPad voice conversation can therefore + stay active at the same time. +- The authenticated Voice proxy accepts either the dedicated Voice token or + the existing Companion Bridge token. +- Existing dedicated-token setups remain compatible and unchanged. +- Companion v0.17.7 waits for an actual server session before activating its + microphone, preventing reconnect flicker on rejected connections. + +## Verification + +- Voice configuration, command builder and service tests pass. +- The live Mac profile starts with `--num_pipelines 2`. +- A second authenticated WebSocket receives `session.created` while local Eve + remains active. diff --git a/pyproject.toml b/pyproject.toml index 9bbf3dd..6834a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trinity-assistant" -version = "0.17.2" +version = "0.17.3" description = "Local-first academic personal concierge for macOS, Windows 11, and Linux servers" readme = "README.md" requires-python = ">=3.10,<3.15" diff --git a/tests/voice/test_voice_command.py b/tests/voice/test_voice_command.py index af7c9c6..7503eb7 100644 --- a/tests/voice/test_voice_command.py +++ b/tests/voice/test_voice_command.py @@ -35,6 +35,7 @@ def test_realtime_upstream_is_forced_to_loopback(tmp_path): assert command[command.index("--ws_host") + 1] == "127.0.0.1" assert command[command.index("--ws_port") + 1] == str(config.profile.internal_port) + assert command[command.index("--num_pipelines") + 1] == str(config.profile.num_pipelines) def test_windows_profile_uses_cuda_compatible_models(tmp_path): diff --git a/tests/voice/test_voice_config.py b/tests/voice/test_voice_config.py index c42ee76..567d58f 100644 --- a/tests/voice/test_voice_config.py +++ b/tests/voice/test_voice_config.py @@ -25,6 +25,28 @@ def test_external_realtime_bind_requires_token(tmp_path): assert any("access_token" in error for error in config.validate()) +def test_companion_token_also_secures_external_realtime_bind(tmp_path): + voice = default_voice_config() + voice["engine"] = "eve" + voice["profile"] = "eve-mac-server" + voice["reference_audio"] = str(tmp_path / "Eve.mp3") + Path(voice["reference_audio"]).write_bytes(b"voice") + + config = load_voice_config( + tmp_path, + {"voice": voice, "companion": {"token": "shared-companion-token"}}, + ) + + assert config.companion_access_token == "shared-companion-token" + assert not any("access_token" in error for error in config.validate()) + + +def test_local_profile_keeps_a_slot_for_a_companion(tmp_path): + config = load_voice_config(tmp_path, {}, profile_name="eve-mac-local") + + assert config.profile.num_pipelines == 2 + + def test_profile_overrides_are_merged_without_mutating_defaults(tmp_path): original = deepcopy(default_voice_config()) config = load_voice_config(