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
6 changes: 3 additions & 3 deletions 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.9:** Eine Trinity-Instanz in einer Windows-VM kann Eve-STT und Eve-TTS jetzt von einem privaten Ubuntu-NVIDIA-Host beziehen. Windows bleibt Control Plane mit Sessions, Memory, Agenten und UI; Ubuntu uebernimmt nur GPU-Inferenz. Dafuer gibt es die Profile `eve-windows-remote` und `eve-linux-gpu-server`, getrennte Tokens, Diagnosepruefungen und Installationsskripte.
> - **v0.17.8:** ClassicUI, iPhone und iPad zeigen nur noch einen zentralen Lautsprecherknopf. Aktiviert ein Client seine Ausgabe, wird er Bridge-weit zum einzigen Sprecher und alle anderen verbundenen Clients zeigen den durchgestrichenen Lautsprecher. Ein erneuter Klick auf dem aktiven Geraet schaltet Trinity ueberall stumm; auch Eves direkter Desktop- und Companion-Audiopfad folgt dieser Auswahl.
> - **v0.17.7:** Die Even G2 erhaelt einen kompakten, zwischengespeicherten Umgebungskontext: die neueste n-tv-Schlagzeile, Wetter und Temperatur der naechsten Stunde sowie optional den naechsten Kalendertermin. iPhone/iPad koennen Standort und Kalender einzeln und widerrufbar an die private Bridge melden; ohne Freigabe gilt der in der G2 gewaehlte Ersatzort.
> - Die vollstaendige Historie steht in **[RELEASES.md](RELEASES.md)** und in den detaillierten **[Release Notes](docs/release_notes/)**.

> [!IMPORTANT]
Expand Down
6 changes: 6 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Einzelnotizen liegen unter [docs/release_notes](docs/release_notes/).

## Aktuelle Highlights

- **v0.17.9:** Windows kann als Trinity-Control-Plane in einer VM laufen, waehrend ein privater Ubuntu-Host mit NVIDIA-GPU Parakeet-STT, Qwen3-TTS/Eve und das konfigurierte OpenAI-kompatible LLM bereitstellt. Die neuen Profile `eve-windows-remote` und `eve-linux-gpu-server`, getrennte Voice-/Core-Tokens, Remote-Diagnosen und Installationsskripte vermeiden unnoetiges GPU-Passthrough. Native Mac- und Windows-Eve-Profile sowie Legacy STT/TTS bleiben unveraendert waehlbar.
- **v0.17.8:** ClassicUI, iPhone und iPad verwenden einen einzigen Bridge-weiten Lautsprecherknopf. Das ausgewaehlte Geraet ist exklusiv; alle anderen Clients werden samt direkter Eve-Wiedergabe sofort stumm und zeigen den durchgestrichenen Lautsprecher. Ein zweiter Klick auf dem aktiven Geraet setzt die Ausgabe explizit auf `Stumm`, statt sie unbemerkt an einen anderen Client weiterzureichen.
- **v0.17.7:** Even G2 zeigt eine neue n-tv-Schlagzeile kurz als Laufhinweis sowie Wetter und Temperatur der naechsten Stunde im kompakten Statusfeld. Optional meldet der Companion den aktuellen Standort und den naechsten Kalendertermin an die private Bridge; beides bleibt nur im Arbeitsspeicher und wird beim Abschalten entfernt. Ohne Freigabe verwendet Trinity den in G2 konfigurierten Ersatzort.
- **v0.17.6:** Eine neue Bridge-weite Ein-Geraet-Auswahl legt eindeutig fest, wo Trinity spricht. ClassicUI, iPhone und iPad koennen mit `Ich spreche hier` die Ausgabe uebernehmen; die Auswahl wird in der Konfiguration persistiert und ueber `/speaker` sowie den normalen Ereignisstatus synchronisiert. Desktop-TTS bleibt still, wenn ein Companion-Client ausgewaehlt ist. Even G2 fragt das konkrete Ziel vor einem Auftrag ab und zeigt dessen Geraetenamen im HUD.
- **v0.17.5:** Even G2 verwirft im Zurufmodus lokal alle Transkripte ohne Wakeword, bevor Befehle, Checklisten oder Antworten angestossen werden. Der Buero-/Konversationsmodus bleibt bewusst direkt. G2 nutzt nach der Konfigurationsmigration das praezise STT-Profil; Checklistenbegriffe sind nicht mehr als Whisper-Hotwords hinterlegt. Companion-Ausgabe meldet deaktiviertes `Hoeren` sichtbar und versucht eine kurzfristig noch nicht verfuegbare Eve-Verbindung einmal erneut.
- **v0.17.4:** Even G2 kann in Vorlesungen als reines Mikrofon arbeiten. Das G2-Profil routet die Antwort wahlweise an den iPhone-/iPad-Companion mit aktivem `Hoeren`, an Trinity Desktop oder nur als Text an HUD und Chat. Die Quellkennung bleibt durch Bridge, STT-Feed und Antwort erhalten; Desktop-Ausgabe wird an die bereits laufende Eve-Realtime-Engine uebergeben und faellt nur ohne Eve kontrolliert auf die bisherige Systemstimme zurueck. `Vorlesung` reagiert ausschliesslich nach dem Wakeword, `Buero` direkt; der fruehere Chat-Betriebsmodus wird kompatibel nach Buero migriert.
- **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.
Expand Down
219 changes: 219 additions & 0 deletions core/ambient_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Short, cached glance information for low-distraction companion displays."""

from __future__ import annotations

import json
import threading
import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from email.utils import parsedate_to_datetime
from urllib.parse import urlencode
from urllib.request import Request, urlopen


NTV_RSS_URL = "https://www.n-tv.de/rss"
OPEN_METEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"


class AmbientContextService:
"""Combine public glance data with ephemeral device context.

Device coordinates and calendar titles deliberately remain in memory. The
server only caches public news/weather responses and never writes this
context into Trinity's synchronized vault.
"""

def __init__(self, fetch=None, clock=None):
self._fetch = fetch or self._fetch_url
self._clock = clock or time.time
self._lock = threading.Lock()
self._cache = {}
self._device = {}

def report_device(self, payload):
if not isinstance(payload, dict):
raise ValueError("Geraetekontext muss ein Objekt sein.")
now = self._clock()
latitude = _coordinate(payload.get("latitude"), -90, 90)
longitude = _coordinate(payload.get("longitude"), -180, 180)
calendar_title = _clean(payload.get("calendar_title"), 40)
try:
calendar_start = float(payload.get("calendar_start") or 0)
except (TypeError, ValueError):
calendar_start = 0.0
with self._lock:
self._device = {
"latitude": latitude,
"longitude": longitude,
"calendar_title": calendar_title,
"calendar_start": calendar_start,
"updated_at": now,
}
return {"ok": True, "updated_at": now}

def snapshot(self, fallback_place="Filderstadt"):
place = _clean(fallback_place, 80) or "Filderstadt"
now = self._clock()
with self._lock:
device = dict(self._device)

coordinate = None
location_source = "fallback"
if (
device.get("latitude") is not None
and device.get("longitude") is not None
and now - float(device.get("updated_at") or 0) <= 2 * 60 * 60
):
coordinate = (device["latitude"], device["longitude"])
location_source = "device"

with ThreadPoolExecutor(max_workers=2) as pool:
news_future = pool.submit(self._cached, "ntv", 300, self._latest_ntv)
if coordinate:
weather_key = f"weather:{coordinate[0]:.3f}:{coordinate[1]:.3f}"
weather_future = pool.submit(
self._cached,
weather_key,
600,
lambda: self._weather_at(*coordinate, place="Aktueller Standort"),
)
else:
weather_future = pool.submit(
self._cached,
f"weather-place:{place.casefold()}",
600,
lambda: self._weather_for_place(place),
)
try:
headline = news_future.result()
except Exception:
headline = {}
try:
weather = weather_future.result()
except Exception:
weather = {}

calendar = {}
start = float(device.get("calendar_start") or 0)
title = str(device.get("calendar_title") or "")
if title and now - 300 <= start <= now + 24 * 60 * 60:
calendar = {"title": title, "start": start}

return {
"ok": True,
"headline": headline,
"weather": {**weather, "source": location_source} if weather else {},
"calendar": calendar,
"updated_at": now,
}

def _cached(self, key, ttl, loader):
now = self._clock()
with self._lock:
cached = self._cache.get(key)
if cached and cached[0] > now:
return dict(cached[1])
value = loader()
with self._lock:
self._cache[key] = (now + ttl, dict(value))
return value

def _latest_ntv(self):
root = ET.fromstring(self._fetch(NTV_RSS_URL))
candidates = []
for item in root.findall("./channel/item"):
title = _clean(item.findtext("title"), 220)
if not title:
continue
published = item.findtext("pubDate") or ""
try:
timestamp = parsedate_to_datetime(published).timestamp()
except (TypeError, ValueError, OverflowError):
timestamp = 0.0
candidates.append((timestamp, title))
if not candidates:
return {}
published_at, title = max(candidates, key=lambda item: item[0])
return {"source": "n-tv", "title": title, "published_at": published_at}

def _weather_for_place(self, place):
query = urlencode({"name": place, "count": 1, "language": "de", "format": "json"})
payload = json.loads(self._fetch(f"{OPEN_METEO_GEOCODING_URL}?{query}"))
results = payload.get("results") or []
if not results:
return {}
result = results[0]
return self._weather_at(
float(result["latitude"]),
float(result["longitude"]),
place=_clean(result.get("name"), 40) or place,
)

def _weather_at(self, latitude, longitude, place):
query = urlencode(
{
"latitude": latitude,
"longitude": longitude,
"hourly": "temperature_2m,weather_code",
"forecast_hours": 2,
"timezone": "auto",
}
)
payload = json.loads(self._fetch(f"{OPEN_METEO_FORECAST_URL}?{query}"))
hourly = payload.get("hourly") or {}
temperatures = hourly.get("temperature_2m") or []
codes = hourly.get("weather_code") or []
if not temperatures:
return {}
index = 1 if len(temperatures) > 1 else 0
code = int(codes[index] if index < len(codes) else 0)
return {
"place": place,
"temperature": round(float(temperatures[index])),
"code": code,
"symbol": _weather_symbol(code),
}

@staticmethod
def _fetch_url(url):
request = Request(url, headers={"User-Agent": "Trinity-Assistant/0.17"})
with urlopen(request, timeout=3.5) as response:
return response.read()


def _coordinate(value, minimum, maximum):
if value in (None, ""):
return None
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError("Ungueltige Standortkoordinate.") from exc
if not minimum <= number <= maximum:
raise ValueError("Standortkoordinate ausserhalb des gueltigen Bereichs.")
return number


def _clean(value, limit):
return " ".join(str(value or "").split())[:limit]


def _weather_symbol(code):
if code == 0:
return "SUN"
if code in {1, 2}:
return "PART"
if code == 3:
return "CLOUD"
if code in {45, 48}:
return "FOG"
if code in {51, 53, 55, 56, 57}:
return "DRIZ"
if code in {61, 63, 65, 66, 67, 80, 81, 82}:
return "RAIN"
if code in {71, 73, 75, 77, 85, 86}:
return "SNOW"
if code in {95, 96, 99}:
return "STORM"
return "WX"
8 changes: 4 additions & 4 deletions core/bridge_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
MAX_AUDIO_SECONDS = 20
TRINITY_VOCABULARY = (
"Trinity. Deutsche Vorlesung und natuerliche Konversation. "
"Wichtige Begriffe: Trinity, Hilfe, Modus Zuruf, Zurufmodus, "
"Konversationsmodus, Wakeword, Checkliste, Stichwoerter, Arbeitsraum, "
"Kommandos: Trinity, Hilfe, Modus Zuruf, Zurufmodus, "
"Konversationsmodus, Wakeword, Arbeitsraum, "
"Schnellsession, Nash-Gleichgewicht, Gefangenendilemma und Kooperation."
)
TRINITY_HOTWORDS = (
"Trinity Hilfe Zuruf Zurufmodus Konversationsmodus Wakeword Checkliste "
"Stichwoerter Arbeitsraum Schnellsession Nash-Gleichgewicht "
"Trinity Hilfe Zuruf Zurufmodus Konversationsmodus Wakeword "
"Arbeitsraum Schnellsession Nash-Gleichgewicht "
"Gefangenendilemma Kooperation"
)

Expand Down
35 changes: 35 additions & 0 deletions core/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@
"fallback_to_legacy": true,
"language_policy": "de_only",
"access_token": "",
"backend_host": "127.0.0.1",
"backend_port": 18767,
"backend_token": "",
"remote_voice_url": "",
"remote_voice_token": "",
"remote_core_base_url": "",
"remote_core_api_key": "",
"reference_audio": "",
"reference_text": "Herzlich willkommen lieber Schülerinnen und Schüler des Max Planck Gymnasiums. Schön, dass Ihr heute hier seid. Unser Schülervortrag dreht sich um das Thema KI und Wir. Lasst uns gemeinsam entdecken wie künstliche Intelligenz unseren Alltag verändert.",
"stt_model": "mlx-community/parakeet-tdt-0.6b-v3",
Expand Down Expand Up @@ -104,6 +111,34 @@
"stt_model": "nvidia/parakeet-tdt-0.6b-v3",
"tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"tts_backend": "torch"
},
"eve-linux-gpu-server": {
"mode": "realtime",
"device": "cuda",
"runtime_role": "server",
"conversation_backend": "remote",
"bind_host": "0.0.0.0",
"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"
},
"eve-windows-remote": {
"mode": "realtime",
"device": "cpu",
"runtime_role": "client",
"conversation_backend": "trinity",
"bind_host": "127.0.0.1",
"public_port": 8766,
"internal_port": 18766,
"local_audio": true,
"num_pipelines": 1,
"stt_model": "nvidia/parakeet-tdt-0.6b-v3",
"tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"tts_backend": "torch"
}
}
},
Expand Down
12 changes: 12 additions & 0 deletions core/remote_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ def latest_payload(self):
def get_runtime(self):
return self._request("/runtime", method="GET")

def get_speaker(self):
return self._request("/speaker", method="GET")

def set_speaker(self, device_id, label, kind="desktop"):
return self._request(
"/speaker",
{"device_id": device_id, "label": label, "kind": kind},
)

def release_speaker(self):
return self.set_speaker("none", "Stumm", kind="none")

def current_session(self):
return self._request("/session/current", method="GET")

Expand Down
Loading
Loading