Compare commits

...
Author SHA1 Message Date
duffyduck cc91497923 release: bump version to 0.2.5.1 2026-09-30 21:18:09 +02:00
duffyduckandClaude Opus 4.8 10e7faa92b fix(app): verlorene erste Sprachaufnahme — Watchdog, Reihenfolge, Startzustand
Drei App-seitige Ursachen fuer 'erst beim zweiten Aufnehmen' (tsc --noEmit: 0 Fehler):

1) No-Speech-Watchdog: Der Aufrufer uebergibt die Sprechpausen-Toleranz
   (sttEndpointMs, Default 2400ms, per Setting bis 1000ms). Die misst ab dem
   ENDE des Sprechens, der Watchdog lief aber ab dem START der Aufnahme. Bis ein
   Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s (600ms
   Mindestpuffer + 700ms Transkriptions-Takt + Netzweg). Schon kurzes Ueberlegen
   nach dem Gong brach die Aufnahme ab. Jetzt +2500ms Zuschlag.

2) stt_stream_start ging ERST NACH PcmStreamRecorder.start() raus — die ersten
   Chunks (200ms-Takt) trafen auf eine Bridge ohne Sitzung und wurden verworfen
   (Wortanfang weg; bei kurzen Kommandos blieb nichts uebrig). Jetzt davor.

3) recordingState wurde erst nach mehreren awaits auf 'recording' gesetzt. In
   diesem Fenster war isStreamingRecording() true, der Zustand aber 'idle': ein
   Abbruch dort liess den Start weiterlaufen, der Recorder blieb haengen und die
   NAECHSTE Aufnahme wurde abgelehnt. Zustand wird jetzt sofort gesetzt, plus
   Session-Guard nach den awaits.

Zusaetzlich: ohne RVS-Verbindung wird gar nicht erst aufgenommen (send()
verwirft still, kein Puffer) — nach App-Resume fiel sonst die erste Aufnahme
komplett aus. Nachtraeglich gepuffertes Audio waere sinnlos, die STT-Sitzung
laeuft in Echtzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 13:02:55 +02:00
duffyduckandClaude Opus 4.8 d030cd7bc6 fix(voice): verlorene Sprachnachrichten — Allow-List, Kaltstart, Dedup, Voice-Router
Vier unabhaengige Ursachen fuer 'erst beim zweiten Aufnehmen' bzw. 'wird gar
nicht verarbeitet' (server-/box-seitig, ohne App-Update wirksam):

1) rvs/server.js: stt_lease, stt_lease_request, stt_transcribe_blob,
   stt_transcribe_result und chat_ack fehlten in ALLOWED_TYPES und wurden STILL
   verworfen (dieselbe Falle wie bei worker_*/host_*). Folgen: das Lease lief
   immer in den 250ms-Timeout -> jede Aufnahme startete verspaetet und OHNE
   targetInstance nahmen alle STT-Worker dieselbe Session an (doppelte
   stt_endpoint); chat_ack kam nie an -> getippte Nachrichten galten in der App
   nach 3 Versuchen als fehlgeschlagen, obwohl sie verarbeitet waren.

2) xtts/whisper/bridge.py: Beim Kaltstart wurde die Session erst NACH dem
   Modell-Load angelegt — alle Chunks der ersten Aufnahme liefen ins Leere und
   der Take war weg (die zweite klappte, weil das Modell dann warm war). Session
   wird jetzt sofort angelegt, der Endpointer wartet solange (runner.model None).

3) bridge/aria_bridge.py: Text-Dedup-Fenster 5s -> 1.5s. Es soll nur den
   Zwilling zweier Worker abfangen; mit 5s fielen echte Wiederholungen weg —
   also genau das Nachsprechen nach einem verschluckten Take, und kurze
   Kommandos ('lauter', 'nächstes').

4) bridge/aria_bridge.py Voice-Router: Komma als Prefix-Trenner entfernt (nur
   noch ':' und '-'), Fuzzy-Schwelle 0.6 -> 0.82, Sticky wird nicht mehr bei
   jeder Nachricht verlaengert. Vorher matchten normale Saetze wie 'In Ordnung,
   mach mal ...' als Projekt-Prefix: die Nachricht landete still in einem
   Projekt und war im Hauptchat unsichtbar — verifiziert mit Beispielsaetzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:05:06 +02:00
duffyduckandClaude Opus 4.8 36641e6519 fix(proxy): 'spawn E2BIG' — System-Prompt ueber Datei statt Kommandozeile
Gefunden ueber die Brain-Logs: mehrere Nachrichten blieben unbeantwortet, weil
POST /chat mit 502 endete. Ursache:
  Proxy HTTP 500: {"error":{"message":"spawn E2BIG"}}
execve() begrenzt argv+envp ZUSAMMEN auf ARG_MAX — im Proxy-Container nur
131072 Bytes (getconf ARG_MAX). Der System-Prompt wurde als einzelnes Argument
uebergeben und war im Fehlerfall 76881 Zeichen gross (gepinnte Erinnerungen +
Skills + Projektkontext) -> Start scheitert, Turn verloren, App bekommt 502.
11 solcher Faelle im aktuellen Log.

Fix: --system-prompt-file <tmpfile> statt --system-prompt <text> (die CLI
unterstuetzt das). Damit faellt die Groessengrenze praktisch weg. Die Temp-Datei
wird bei close/error/catch wieder entfernt; schlaegt das Schreiben fehl, wird
wie bisher das Argument genutzt (kleine Prompts laufen weiter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 10:58:45 +02:00
duffyduckandClaude Opus 4.8 9b11c16479 fix(android-agent): Sprachausgabe — ARIAs echte Stimme statt (fehlender) Geraete-TTS
Der Agent meldete 'gesprochen', es kam aber kein Ton: Fire TV Sticks haben in
der Regel keine (deutsche) TTS-Engine, und wir haben weder setLanguage noch den
Rueckgabewert von speak() geprueft -> stiller Fehlschlag.

- speak/notify fordern jetzt ARIAs ECHTE Stimme ueber den bestehenden Serverpfad
  an: tts_request -> Bridge -> F5/XTTS -> audio_pcm, das der Agent abspielt.
  Keine Server-Aenderung noetig (beide Typen stehen in der RVS-Allow-List).
  VoiceSession merkt sich die angeforderte messageId und spielt genau dieses
  Audio ab — die Turn-Ownership bleibt sonst unveraendert (Handy redet nicht mit).
- Geraete-TTS nur noch auf ausdrueckliche Anforderung (engine='device') und nun
  ehrlich: fehlende Engine, fehlende deutsche Sprachdaten und ein abgelehntes
  speak() werden als Fehler gemeldet statt als Erfolg.
Android-Version 0.0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:47:22 +02:00
duffyduckandClaude Opus 4.8 8f9e3e5c79 feat: Jellyfin-Integration — ARIA steuert die Wiedergabe ueber die API
Statt sich durch die Fire-TV-Oberflaeche zu klicken (fehleranfaellig: ARIA ist am
Titel vorbeigescrollt und auf Home gelandet) spricht ARIA den Jellyfin-Server
direkt an: suchen -> Sitzung des Abspielgeraets finden -> abspielen.

- Diagnostic (Einstellungen > Integrationen): Jellyfin-Abschnitt mit MEHREREN
  Servern (+-Button; eigener, vom Kumpel ...), einer davon Standard. Pro Server
  Name/URL/API-Key + Test-Knopf (prueft /System/Info, meldet Name und Version).
  Gespeichert in /shared/config/jellyfin.json mit Datei-Rechten 0600 (API-Keys).
- Brain: jellyfin_search / jellyfin_devices / jellyfin_play / jellyfin_control.
  Serverwahl per Name oder Standard. 'resume' ist echt implementiert (liest
  PlaybackPositionTicks und setzt startPositionTicks) statt nur zu existieren.
  Fehlertexte sind handlungsleitend: 401 -> API-Key pruefen; kein Geraet -> die
  Jellyfin-App muss offen sein, sonst erst host_app_launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:23:30 +02:00
13 changed files with 714 additions and 44 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20500
versionName "0.2.5.0"
versionCode 20501
versionName "0.2.5.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.0",
"version": "0.2.5.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+50 -15
View File
@@ -1141,7 +1141,7 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> {
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
@@ -1155,12 +1155,28 @@ class AudioService {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
if (rvs.getState() !== 'connected') {
console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)',
rvs.getState());
return { requestId: '', ok: false, error: 'not-connected' };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
this.setState('recording');
this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
@@ -1202,20 +1218,17 @@ class AudioService {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
// Sitzung zu starten, die niemand mehr erwartet.
if (this.streamRequestId !== requestId) {
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
return { requestId: '', ok: false, error: 'superseded' };
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
// gar nichts uebrig.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
@@ -1230,18 +1243,40 @@ class AudioService {
targetInstance: this.streamTargetInstance,
});
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
// der zweite Versuch klappte, weil man da sofort losredet.
const NO_SPEECH_GRACE_MS = 2500;
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
requestId.slice(0, 12), effectiveMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}, effectiveMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
+245
View File
@@ -1501,6 +1501,88 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_search",
"description": (
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
"zuverlaessig und schnell. Server werden im Diagnostic unter "
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_devices",
"description": (
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
"und frage dann erneut."
),
"parameters": {
"type": "object",
"properties": {
"server": {"type": "string", "description": "Server-Name (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_play",
"description": (
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
"3. jellyfin_play. Laeuft die Jellyfin-App auf dem Zielgeraet noch nicht, "
"vorher host_app_launch (query='Jellyfin') aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
"server": {"type": "string", "description": "Server-Name (optional)."},
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
},
"required": ["item_id", "device"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_control",
"description": (
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
"unpause, stop, nextTrack, previousTrack."
),
"parameters": {
"type": "object",
"properties": {
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
"server": {"type": "string", "description": "Server-Name (optional)."},
},
"required": ["device", "command"],
},
},
},
{
"type": "function",
"function": {
@@ -2749,6 +2831,165 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
# ── Jellyfin (Medien) ─────────────────────────────────────
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
try:
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
servers = (json.load(f) or {}).get("servers") or []
except Exception:
return None
servers = [s for s in servers if s.get("url")]
if not servers:
return None
w = (wanted or "").strip().lower()
if w:
for s in servers:
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
return s
for s in servers:
if s.get("isDefault"):
return s
return servers[0]
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
params: Optional[dict] = None, timeout: float = 15.0):
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
url = server["url"].rstrip("/") + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method, headers={
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
return (json.loads(raw) if raw.strip() else {}), ""
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
"unter Integrationen pruefen.")
return None, f"Jellyfin-Fehler HTTP {exc.code}"
except Exception as exc:
return None, f"Jellyfin nicht erreichbar: {exc}"
def _jellyfin_find_session(self, server: dict, device: str):
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext)."""
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return None, err
d = (device or "").strip().lower()
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
for s in cands:
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
if d and d in blob:
return s, ""
if not d and len(cands) == 1:
return cands[0], ""
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
f"(query='Jellyfin') aufrufen.")
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
server = self._jellyfin_server(arguments.get("server") or "")
if not server:
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
"API-Key anlegen.")
label = server.get("name") or server.get("url")
try:
if name == "jellyfin_search":
q = (arguments.get("query") or "").strip()
if not q:
return "FEHLER: query ist Pflicht."
data, err = self._jellyfin_call(server, "/Items", params={
"searchTerm": q, "Recursive": "true",
"IncludeItemTypes": "Movie,Series,Episode",
"Limit": int(arguments.get("limit") or 10),
"Fields": "ProductionYear,Overview",
})
if err:
return f"FEHLER: {err}"
items = (data or {}).get("Items") or []
if not items:
return f"Keine Treffer fuer '{q}' auf {label}."
lines = []
for it in items:
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
f"[{it.get('Type')}] itemId={it.get('Id')}")
return f"Treffer auf {label}:\n" + "\n".join(lines)
if name == "jellyfin_devices":
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return f"FEHLER: {err}"
out = []
for s in (sessions or []):
if not s.get("SupportsRemoteControl"):
continue
now = (s.get("NowPlayingItem") or {}).get("Name")
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
+ (f" — laeuft gerade: {now}" if now else ""))
if not out:
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
if name == "jellyfin_play":
item_id = (arguments.get("item_id") or "").strip()
device = (arguments.get("device") or "").strip()
if not item_id or not device:
return "FEHLER: item_id und device sind Pflicht."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
note = ""
if arguments.get("resume") is not False:
# Fortsetzen: gespeicherte Position des Titels mitgeben.
info, _e = self._jellyfin_call(server, "/Items",
params={"ids": item_id, "Fields": "UserData"})
items = (info or {}).get("Items") or []
ticks = 0
if items:
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
if ticks > 0:
play_params["startPositionTicks"] = ticks
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
params=play_params)
if err:
return f"FEHLER: {err}"
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' gestartet"
f"{note} (Server {label}).")
if name == "jellyfin_control":
device = (arguments.get("device") or "").strip()
cmd = (arguments.get("command") or "").strip()
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
"playpause", "seek"}
if cmd.lower() not in allowed:
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
if err:
return f"FEHLER: {err}"
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
@@ -3737,6 +3978,10 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
+23 -7
View File
@@ -3407,11 +3407,18 @@ class ARIABridge:
# weiter auseinander (man hoert den Wechsel erst).
_norm_txt = " ".join(text.lower().split())
_now_t = asyncio.get_event_loop().time()
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
# Idempotenz ueber audioRequestId weiter oben.
_DUPE_WINDOW_SEC = 1.5
if (_norm_txt
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
text[:60])
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
_DUPE_WINDOW_SEC, text[:60])
return
self._last_endpoint_norm = _norm_txt
self._last_endpoint_at = _now_t
@@ -3777,8 +3784,12 @@ class ARIABridge:
r")\s*[.!?]?\s*$",
re.IGNORECASE,
)
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
_VOICE_META_PROJECT_PREFIX = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
@@ -3833,7 +3844,10 @@ class ARIABridge:
s = SequenceMatcher(None, q, pname).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
# Aehnlichkeit, und die Nachricht verschwand still in einem
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
if best and best_score >= 0.82:
pid = best["id"]
self._voice_sticky_project_id = pid
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
@@ -3845,8 +3859,10 @@ class ARIABridge:
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
# 3) Kein Meta / Prefix → Sticky oder Default
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
# Sticky refreshen
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
# Zeit aus und der Hauptchat uebernimmt wieder.
return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen
self._voice_sticky_project_id = ""
+142
View File
@@ -961,6 +961,33 @@
</div>
</div>
<!-- Jellyfin (Medien) -->
<div class="settings-section">
<h2>Jellyfin (Filme &amp; Serien)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
Standard, wenn ARIA keinen nennt.
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
nicht des Browsers).
</div>
<div class="card" style="max-width:780px;">
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="color:#555570;font-style:italic;">Lade …</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
</div>
<div style="color:#666680;font-size:10px;margin-top:8px;">
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -4537,6 +4564,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
@@ -4743,6 +4771,120 @@
send({ action: 'host_forget', hostId });
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
async function loadJellyfinConfig() {
try {
const r = await fetch('/api/jellyfin-config');
const d = await r.json();
jellyfinServers = d.servers || [];
} catch (e) {
jellyfinServers = [];
}
renderJellyfin();
}
function addJellyfinServer() {
jellyfinServers.push({
id: 'jf_' + Date.now().toString(36),
name: '', url: '', apiKey: '',
isDefault: jellyfinServers.length === 0,
});
renderJellyfin();
}
function removeJellyfinServer(i) {
const s = jellyfinServers[i];
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
jellyfinServers.splice(i, 1);
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
jellyfinServers[0].isDefault = true;
}
renderJellyfin();
}
function setJellyfinDefault(i) {
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
renderJellyfin();
}
function updateJellyfinField(i, field, value) {
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
}
function renderJellyfin() {
const box = document.getElementById('jellyfin-list');
if (!box) return;
if (!jellyfinServers.length) {
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
return;
}
box.innerHTML = jellyfinServers.map((s, i) =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'</div>' +
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'</div>'
).join('');
}
async function testJellyfinServer(i) {
const s = jellyfinServers[i];
const out = document.getElementById('jf-test-' + i);
if (!s || !out) return;
out.textContent = 'prüfe …';
try {
const r = await fetch('/api/jellyfin-test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
});
const d = await r.json();
out.innerHTML = d.ok
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
} catch (e) {
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
async function saveJellyfinConfig() {
const el = document.getElementById('jellyfin-status');
el.textContent = 'speichere …';
try {
const r = await fetch('/api/jellyfin-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: jellyfinServers }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
jellyfinServers = d.config.servers || [];
renderJellyfin();
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
setTimeout(() => { el.textContent = ''; }, 4000);
} catch (e) {
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
let apkTarget = null; // {satId, ip, name}
let apkRequestId = null;
+114
View File
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
return cur;
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
function readJellyfinConfig() {
try {
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
const servers = Array.isArray(p.servers) ? p.servers : [];
return {
servers: servers.map(s => ({
id: String(s.id || ""),
name: String(s.name || ""),
url: String(s.url || ""),
apiKey: String(s.apiKey || ""),
isDefault: !!s.isDefault,
})).filter(s => s.id && s.url),
};
} catch {
return { servers: [] };
}
}
function writeJellyfinConfig(cfg) {
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
.map(s => ({
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
name: String(s.name || "").trim(),
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
apiKey: String(s.apiKey || "").trim(),
isDefault: !!s.isDefault,
}))
.filter(s => s.url);
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
let seen = false;
for (const s of servers) {
if (s.isDefault && seen) s.isDefault = false;
if (s.isDefault) seen = true;
}
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
return { servers };
}
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
function testJellyfin(url, apiKey) {
return new Promise((resolve) => {
let u;
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
const mod = u.protocol === "https:" ? require("https") : require("http");
const req = mod.request(u, {
method: "GET",
timeout: 8000,
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
}, (res) => {
let body = "";
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
res.on("end", () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
}
if (res.statusCode !== 200) {
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
}
try {
const j = JSON.parse(body);
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
});
});
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
@@ -2026,6 +2107,39 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readJellyfinConfig()));
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeJellyfinConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", async () => {
try {
const { url, apiKey } = JSON.parse(body);
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -331,7 +331,7 @@ class RvsClient(
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen" }
?: run { done += "gesprochen (ARIAs Stimme)" }
}
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
@@ -342,21 +342,33 @@ class RvsClient(
private fun doSpeak(p: JSONObject): JSONObject {
val text = p.optString("text")
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
val r = speakOut(p, text)
return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) },
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
/**
* Sprachausgabe. Reihenfolge:
* 1. mitgeliefertes Audio abspielen,
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
* Sticks haben oft gar keine (deutsche) TTS-Engine,
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
* (engine='device') oder wenn kein Server-Audio moeglich ist.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
*/
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
val audio = p.optString("audio_base64")
val focus = p.optString("focus").ifBlank { "duck" }
return if (audio.isNotBlank())
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
else Speaker.speak(appCtx, text, focus)
if (audio.isNotBlank())
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
}
private fun doVolume(p: JSONObject): JSONObject {
@@ -65,7 +65,14 @@ object Speaker {
var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
}
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
if (!ttsReady) throw IllegalStateException(
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
throw IllegalStateException(
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
AudioFocusHelper.request(ctx, focus, "speak")
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(id: String?) {}
@@ -73,7 +80,11 @@ object Speaker {
@Deprecated("Deprecated in Java")
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
})
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
if (rc != TextToSpeech.SUCCESS) {
AudioFocusHelper.abandon(ctx)
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
}
Unit
}
@@ -61,6 +61,10 @@ class VoiceSession(
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = ""
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ────────────────────────────────────────
@@ -170,6 +174,32 @@ class VoiceSession(
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
return mid
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
@@ -206,10 +236,15 @@ class VoiceSession(
true
}
"audio_pcm" -> {
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
val mid = p.optString("messageId")
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p)
if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true
}
else -> false
+43 -1
View File
@@ -18,6 +18,9 @@
*/
import { spawn } from "child_process";
import { EventEmitter } from "events";
import fs from "fs";
import os from "os";
import path from "path";
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
export class ClaudeSubprocess extends EventEmitter {
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
timeoutId = null;
isKilled = false;
_stdinClosed = false;
_systemPromptFile = null;
_systemPromptDir = null;
/**
* Start the Claude CLI subprocess with the given prompt
*/
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
// Handle spawn errors (e.g., claude not found)
this.process.on("error", (err) => {
this.clearTimeout();
this._cleanupSystemPrompt();
if (err.message.includes("ENOENT")) {
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
}
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
this.process.on("close", (code) => {
console.error(`[Subprocess] Process closed with code: ${code}`);
this.clearTimeout();
this._cleanupSystemPrompt();
// Process any remaining buffer
if (this.buffer.trim()) {
this.processBuffer();
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
}
catch (err) {
this.clearTimeout();
this._cleanupSystemPrompt();
reject(err);
}
});
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
"--model",
options.model, // Model alias (opus/sonnet/haiku)
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
];
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
// praktisch keine Groessengrenze mehr.
this._systemPromptFile = null;
const sysPrompt = options.systemPrompt || "";
if (sysPrompt) {
try {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
const file = path.join(dir, "system-prompt.txt");
fs.writeFileSync(file, sysPrompt, "utf-8");
this._systemPromptFile = file;
this._systemPromptDir = dir;
args.push("--system-prompt-file", file);
}
catch (err) {
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
args.push("--system-prompt", sysPrompt);
}
}
if (options.sessionId) {
args.push("--session-id", options.sessionId);
}
return args;
}
/** Temporaere System-Prompt-Datei wieder entfernen. */
_cleanupSystemPrompt() {
try {
if (this._systemPromptFile)
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
}
catch (_) { }
this._systemPromptFile = null;
this._systemPromptDir = null;
}
/**
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
+9
View File
@@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
// der RVS sie still an der Allow-List und die Historie bleibt leer.
"agent_history", "agent_history_query", "agent_history_list",
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
"chat_ack",
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
+15 -6
View File
@@ -584,6 +584,12 @@ class SessionManager:
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
# Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT
# transkribieren/finalisieren — sonst kaeme leerer Text heraus und die
# Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter.
if self.runner.model is None:
return
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
@@ -962,19 +968,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
# WICHTIG: Session SOFORT anlegen — auch wenn das Modell
# noch laedt. Sonst laufen alle stt_audio_chunk der ersten
# Aufnahme ins Leere (feed_chunk findet keine Session) und
# der Take ist verloren; erst die zweite Aufnahme klappt,
# weil das Modell dann warm ist. Der Buffer fuellt sich
# jetzt waehrend des Ladens, das Transkribieren wartet.
sessions.start_session(payload)
if needs_load:
async def _load_then_start(p, target):
async def _load_only(target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
asyncio.create_task(_load_only(target_model))
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)