Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion core/voice/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 18 additions & 1 deletion core/voice/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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"),
Expand All @@ -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)'}")
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion core/voice/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions core/voice/transport/auth_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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

Expand Down
11 changes: 8 additions & 3 deletions docs/VOICE_COMPANION_IOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/VOICE_MACOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/VOICE_WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/release_notes/v0.17.3.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/voice/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions tests/voice/test_voice_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading