Compare commits

...
28 Commits
Author SHA1 Message Date
duffyduck 3693157210 release: bump version to 0.2.4.4 2026-08-16 19:57:14 +02:00
duffyduckandClaude Opus 4.8 64670cdd12 fix(voxtral): Repetition-Loop bremsen ('vergiss das'-Schleife)
Stefans Repro: eine echte Nachricht endete mit "...vergiss das, das ist nur..."
und Voxtral hat den Satz ~15x geloopt, bis zur Brain durch. Klassische
Repetition-Halluzination bei Stille/Rauschen am Ende.

Zwei Ebenen: (1) Generation bekommt no_repeat_ngram_size=4 + repetition_penalty
=1.15 → erste echte Nennung bleibt, exakte 4-Gramm-Wiederholung verboten, Loop
bricht an der Quelle ab. (2) Post-Detektor _collapse_repetitions kassiert einen
durchgerutschten Loop auf eine Kopie ein. Gegen den echten Loop + legitime
Doppelungen ('ja ja ja', 'sehr sehr') verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:42:22 +02:00
duffyduckandClaude Opus 4.8 c82616ebbd fix(voxtral): No-Speech-Timeout — Stille-Fenster schließt selbst
Stefans Repro: "die Stille-Ende wird nie erreicht, stop ich selbst ist es weg,
und geht automatisch auf lausche Computer". Ursache: der Endpoint feuert nur
wenn schon Stimme da war (last_voice_at>0). Bei totaler Stille bleibt
last_voice_at==0 → Endpoint feuert NIE → Fenster offen bis Hardcap/manuellem
Stop (→ stream_end → Phantom).

Fix: No-Speech-Timeout im _tick — wenn nach endpoint_ms (Stille-Toleranz) ab
Start noch KEINE Stimme kam, schließt der Bridge das Fenster selbst als
no-speech (leer, lautlos, zurück aufs Wake-Word). voiced_frames==0 →
_finalize verwirft ohne Transkript, also kein Phantom. Logik gegen totale
Stille / Sprache-dann-still / Dauer-Sprache verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:38:38 +02:00
duffyduck b226e1da11 release: bump version to 0.2.4.3 2026-08-16 11:33:45 +02:00
duffyduckandClaude Opus 4.8 0b7ed241b4 fix(voxtral): Phantom-Filter greift auch bei stream_end
Aus dem ai-box-Log gelernt: die realen Silence-Phantome ('Die Stadt hat eine
Fläche von 1,5 km²') kommen ALLE mit reason=stream_end — Passiv-/Wake-Fenster
enden auch per stream_end, wenn sie auf Stille zumachen. stream_end ist also
NICHT gleich 'manueller Stop mit bewusster Sprache'. Meine vorige Fassung nahm
stream_end aus → Phantome liefen durch.

Jetzt: (1) Pre-Guard greift auch bei stream_end, aber mit Schwelle voiced==0
(kurze bewusste Wörter am Button gehen durch, echte Stille nicht). (2) Phrase-
Filter gilt für ALLE reasons; das borderline-Band (wenig voiced_frames) schützt
echte, klar gesprochene Geo-Fragen. Gegen alle 3 Log-Fälle + reale Eingaben
verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:30:20 +02:00
duffyduck a2b7e3a48d release: bump version to 0.2.4.2 2026-08-16 11:26:49 +02:00
duffyduckandClaude Opus 4.8 7b72149671 fix(voxtral): Halluzinations-Filter gegen Phantom-Text aus Stille
Punkt 3: 2. Netz nach der Transkription. Im borderline-Band (wenig echte
Stimme) werden leere/Artefakt-Transkripte verworfen statt als Phantom ans
Brain zu gehen ('Die Stadt hat eine Fläche von 1,5 km²' aus Fast-Stille).
Bekannte Voxtral-Silence-Artefakte (Untertitel-Credits, Geo-/Städte-Fakten)
per Regex, gegated auf voiced_frames — echte Geo-FRAGEN (normale Energie)
gehen durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 e9439dbccb feat(app): Hintergrund-Wake als Schalter (default aus)
Punkt 2: das Wake-Wort triggerte nur im Vordergrund — eine bewusste JS-Zeile
verwarf jede Hintergrund-Erkennung (native Erkennung + Foreground-Service
liefen längst durch). Jetzt hinter Einstellung 'Auch bei gesperrtem Bildschirm
zuhören' (default aus, mehr Fehltrigger möglich). Greift live via
setBgWakeEnabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 219de091d2 feat(brain): Titel-Index fürs kalte Gedächtnis + 'beendet' erkennen
Punkt 4B: jeder System-Prompt bekommt einen kompakten Titel-Index der bewusst
gespeicherten Nachschlage-Memories (Zugangsdaten, Infra, Projekte) — ARIA
sieht WAS sie hat und holt es via memory_search, statt Stefan nach etwas zu
fragen, das schon da ist (Git-Credentials-Vorfall). Auto-distillierte Fakten
+ Conversation-Logs sind ausgefiltert → billig.

Punkt 1: _CONV_END_VERB erkennt jetzt auch 'beendet' (beend\w*) und 'stoppe'
(stop\w*) — 'Konversation beendet' schloss vorher das Mikro nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 5992a7e441 feat(brain): Gedächtnis nach scope trennen (system/personal)
Neues Feld scope=system|personal auf jedem Memory-Punkt. Bootstrap-Export
getrennt: System-Regeln (generisch, teilbar) vs. Persönliches (Name,
Zugangsdaten, Projekte). Import ist scope-sicher — ein System-Import löscht
NICHT die persönlichen pinned Memories. seed_rules + AGENT.md/TOOLING.md →
system, USER.md-Präferenzen → personal. Backfill für Bestand (57 system /
617 personal). Diagnostic: zwei Export-Buttons, scope-Badge (SYS/PRIV) +
Umschalter pro Memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduck 07ccf05429 release: bump version to 0.2.4.1 2026-08-16 02:02:10 +02:00
duffyduckandClaude Opus 4.8 4ab0e68245 fix(voice): Passiv-Backstop lang genug — schneidet lange Antworten nicht mehr ab
Log-Analyse (alter Build): eine lange gesprochene Antwort wurde vom 30s-Passiv-
Timer mitten im Wort gekappt (exit reason=timeout, dann stt_endpoint reason=
stream_end mit abgeschnittenem Text). Genau das Fenster ist raus — ABER mein
Ersatz-Backstop (15s) hätte sogar früher abgeschnitten.

Der Backstop ist eine reine HANG-Notbremse und darf aktives Reden NIE kappen →
jetzt 10min (länger als der ~5min-Hardcap der Aufnahme). Das echte Ende regelt
immer die Aufnahme selbst (Stille-Toleranz / No-Speech / Hardcap). Lange
zusammenhängende Antworten laufen jetzt durch, bis du ≥ Stille-Toleranz pausierst.

Hinweis: der geloggte Fehler ist der ALTE Build — die Fixes sind noch nicht
ausgerollt. Dieser Commit korrigiert den noch-nicht-deployten Stand.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:59:45 +02:00
duffyduckandClaude Opus 4.8 9636a702d3 refactor(voice): tote Fenster-Settings ausgeräumt (Konv-Fenster/Passiv-Lauschen)
Nachdem die Stille-Toleranz jetzt überall der einzige Wert ist, sind die alten
Settings obsolet — raus damit:

- audio.ts: CONV_WINDOW_* Konstanten + loadConvWindowMs() entfernt.
- wakeword.ts: PASSIVE_LISTEN_* + load/savePassiveListenMs() entfernt; der Passiv-
  Master-Timer nutzt jetzt einen festen internen Backstop (PASSIVE_BACKSTOP_MS,
  15s) statt eines Nutzer-Werts — das echte Ende regelt die Stille-Toleranz.
- SettingsScreen: "Konversations-Fenster"- und "Weiterreden-Fenster"-Slider raus;
  Letzterer durch eine kurze Erklärung ersetzt (verweist auf Stille-Toleranz).
- ChatScreen: tote Imports weg.

Kein Verhaltensänderung ggü. ebe0e80 — nur Aufräumen. tsc grün.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:53:55 +02:00
duffyduck 5f09e2bca3 release: bump version to 0.2.4.0 2026-08-16 01:48:43 +02:00
duffyduckandClaude Opus 4.8 ebe0e8065f feat(voice): 30s-Passiv-Fenster raus — Stille-Toleranz regiert alles
Stefans Modell: es braucht keinen separaten 30s-Wert. Nach ARIAs Antwort geht das
Mikro auf; fängst du nicht innerhalb der Stille-Toleranz (z.B. 5s) an zu reden, ist
Schluss → zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
Ein Befehl ohne "fortführen" = Ende; nach einer normalen Antwort = ein Stille-
Fenster zum Weiterreden.

- Alle Aufnahme-Pfade (wake/barge/passiv): noSpeechTimeoutMs = endpointMs =
  loadSttEndpointMs() statt loadConvWindowMs()/loadPassiveListenMs(). Ein Wert.
- Passiv-Hardcap: loadMaxRecordingMs() statt fix 35s (schnitt langes Reden ab).
- Leeres Endpoint im listening-State: KEIN Re-Arm mehr → exitPassiveListening
  (ein 5s-Fenster, dann Ende). Der 30s-Master-Timer in wakeword.ts wird dadurch
  nie mehr scharf (harmloser Backstop).

Redest du weiter → Antwort → wieder ein Stille-Fenster (Multi-Turn bleibt, nur ohne
30s-Leerlauf). Die Settings "Konversations-Fenster"/"Passiv-Lauschen" sind damit
obsolet (UI-Cleanup später).

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:46:26 +02:00
duffyduckandClaude Opus 4.8 9d2c07d8d1 feat(voice): "Konversation fortführen" hält offen — auch bei Fast-Path-Befehl
Stefans Repro: "spiel Spotify auf Smartphone ab ABER Konversation fortführen" →
die Kette endete trotzdem. Grund: "spiel spotify" trifft den Fast-Path (Regex),
der den Satz-Rest nicht versteht → converse=false aus dem Skill-Manifest; ARIAs
[[WEITER]]-Marker lebt in Claude, der wurde uebersprungen.

Fix: _user_wants_conversation_continue() — Gegenstueck zu _user_wants_conversation_
end(). Erkennt "Konversation/Gespraech fortfuehren/weiterfuehren/offen halten/nicht
beenden", "weiter reden/sprechen" etc. und erzwingt converse=true an ALLEN Returns
(fast-path/local/claude), auch wenn das Manifest false sagt. [[ENDE]]/_wants_end hat
Vorrang bei Widerspruch. Getestet inkl. Negativfaelle (sofort/spotify ab).

Deploy: Brain-Neustart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:35:49 +02:00
duffyduck 9aae5af6a9 release: bump version to 0.2.3.9 2026-08-16 01:06:18 +02:00
duffyduckandClaude Opus 4.8 a8ff73f93d feat(speaker-id): Gating als bewusster Schalter — Default AUS (fail-open)
Stefans Repro "nichts geht mehr" kam NICHT von den Marker/Guard-Aenderungen,
sondern von der Speaker-ID: die Bridge-Logs zeigten fast durchgehend
"stt_endpoint mit leerem Text — ignoriert (reason=speaker_mismatch)" — ein aus
einem kaputten Enroll (AAC-als-PCM) entstandener Muell-Fingerprint hat Stefans
EIGENE Stimme abgelehnt und damit die komplette STT lahmgelegt.

Fix: Speaker-ID-Gating ist jetzt ein expliziter Schalter, Default AUS. Bei aus
laeuft die Pruefung GAR NICHT (fail-open, alle Stimmen durch) — ein schlechter
Enroll kann nie wieder alles abwuergen. Damit ist Stefans Problem schon durch den
Voxtral-Rebuild geloest (kein Loeschen noetig, der Check greift einfach nicht).

- voxtral + whisper bridge: SPEAKER_ID_ENABLED (Default False, ENV VOICE_ID_ENABLED),
  _check_speaker faellt bei aus sofort fail-open zurueck; config-Broadcast
  voiceIdEnabled setzt es zur Laufzeit.
- diagnostic: Schalter "Nur meine Stimme" in der Voice-ID-Sektion (Default aus,
  Hinweis: erst an wenn enrollt), broadcastet + persistiert voiceIdEnabled.

Reihenfolge lt. Stefan: erst Konversation sauber, dann Multi-Person/nur-ich.

Deploy: docker compose up -d --build voxtral-bridge; aria-diagnostic neu starten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:00:58 +02:00
duffyduckandClaude Opus 4.8 0e9adeee5c fix(speaker-id): Enrollment akzeptiert Android-MP4/AAC — kein "zu kurz" mehr
Voice-ID-Enrollment scheiterte immer mit "zu kurz". Ursache: die App nimmt die
Samples mit dem Legacy-Recorder als AAC im MP4-Container auf (16kHz mono), die
Bridge dekodierte das base64 aber als ROHES int16-PCM. 4s AAC sind stark
komprimiert (< 32KB = MIN_SAMPLE_BYTES) → faelschlich als "zu kurz" verworfen,
und selbst darueber waere das Embedding Muell.

Fix (bridge-seitig, kein APK): _normalize_audio_bytes erkennt jetzt den MP4/M4A/
AAC-Container ('ftyp' bei Offset 4) und dekodiert ihn via ffmpeg (im Container) auf
16kHz mono int16 PCM — zusaetzlich zu rohem PCM und WAV. enroll_from_samples
dekodiert erst, prueft DANN die Laenge aufs dekodierte PCM (nicht die komprimierten
Bytes). Input via Temp-Datei, da Androids moov-Atom am Ende seekbaren Input braucht.
Gleich in voxtral + whisper (identische Kopien).

Deploy: docker compose up -d --build voxtral-bridge; danach in Einstellungen →
Voice-ID neu einlernen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:43:14 +02:00
duffyduckandClaude Opus 4.8 6addb2f8fe fix(voxtral): Halluzinations-Guard — kein Phantom-Text aus Stille/Blip
Stefans Repro: nach dem Senden ging sofort ein Passiv-Fenster auf (waehrend ARIA
noch arbeitete), und obwohl er nichts sagte, kam eine "Nachricht von ihm" — Voxtral
halluzinierte aus Fast-Nichts einen Fuellsatz. Der ging als Phantom-Turn ans Brain
und liess das Gespraech entgleisen (Antwort drauf, dann Konversation-Ende).

Ursache war eine Nebenwirkung des "Sprache erkannt"-Pings (9e78d75): der feuerte
beim ERSTEN Voice-Frame — also auch bei einem kurzen Geraeusch-Blip. Damit war der
App-No-Speech-Watchdog geloescht, der akustische Endpoint feuerte, und Voxtral
transkribierte den Blip → Halluzination. (Vorher haette der Watchdog den Blip
verworfen.)

Fix: voiced_frames-Zaehler (Endpointer-Frames mit echter Stimme). Erst ab
STREAM_MIN_VOICED_FRAMES (=2, ~400ms, env-tunebar) gilt es als Sprache:
- Der Speech-Ping feuert erst ab dieser Schwelle (ein Blip loescht den Watchdog
  also nicht mehr).
- _finalize transkribiert unter der Schwelle NICHT, sondern schickt ein leeres
  Endpoint (no-speech) → App re-armt still, kein Phantom-Turn. Manueller Stop
  (stream_end) ist ausgenommen (bewusst gesprochen, kurze Woerter ok).

Deploy: docker compose up -d --build voxtral-bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:35:21 +02:00
duffyduckandClaude Opus 4.8 9bdfb7193e feat(voice): "Konversation Ende" deterministisch — nicht nur ARIAs [[ENDE]]-Marker
Beobachtung Stefan: ARIA haelt die Konversation offen (Default, korrekt), aber
auf "Konversation Ende" hin schloss sie NICHT — sie hat den [[ENDE]]-Marker nicht
gesetzt. Das aufs LLM allein zu verlassen ist zu unzuverlaessig fuer einen festen
Trigger.

Fix: _user_wants_conversation_end() erkennt explizite Beenden-Phrasen im User-Text
(konversation/gespraech/befehlskette + ende/beenden/aus/stop/schluss, beide
Reihenfolgen, ein Satzteil) und erzwingt converse=false an ALLEN drei Returns
(fast-path/local/claude). ARIA beantwortet eine evtl. enthaltene Frage noch normal
(speak bleibt), danach zurueck aufs Wake-Word. "befehls?kette" statt bare "kette",
damit "Lieferkette" u.ae. nicht faelschlich matchen (per Test abgesichert).

Deploy: Brain-Neustart (nicht waehrend ARIA arbeitet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:25:09 +02:00
duffyduckandClaude Opus 4.8 9e78d75149 fix(voxtral): "Sprache erkannt"-Ping — No-Speech-Watchdog schneidet nicht mehr ab
Im Ohr-Modus (Wake/Passiv/Barge) beendete die App die Aufnahme am Konversations-
fenster (~conv window), obwohl Stefan noch redete — nur dort, nicht beim normalen
Button. Ursache: der App-No-Speech-Watchdog cancelt, solange KEIN stt_partial kam
(streamGotPartial=false). Voxtral schickt aber seit dem Cutoff-Fix keine Live-
Partials mehr → streamGotPartial blieb immer false → Timer feuerte mitten im Satz.
Der normale Button nutzt noSpeechTimeoutMs=0 (Watchdog aus) → dort nie aufgefallen.

Fix: Voxtral-Bridge feuert EIN leeres stt_partial beim ersten Voice-Frame (nach
der Speaker-ID-Pruefung, fremde Stimmen signalisieren also nicht). Die App setzt
darauf streamGotPartial=true und loescht den Watchdog — bestehender Handler, keine
App-Aenderung. Der akustische Endpoint (Stille >= endpointMs) bleibt der einzige
Turn-Ende-Ausloeser.

Deploy: docker compose up -d --build voxtral-bridge (keine neue APK noetig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:15:21 +02:00
duffyduck 517c993ac8 release: bump version to 0.2.3.8 2026-08-15 14:06:34 +02:00
duffyduckandClaude Opus 4.8 c1bd13687d feat(voice): ARIA erkennt Gespraechs-Phase selbst (Befehl/Frage, Kette/Ende)
Voice-First: ARIA deklariert die Phase aus dem Text per Inline-Marker — dasselbe
Muster wie [[AWAIT]], die Marker werden vor Anzeige/TTS/History entfernt. Loest:
- Claude-Pfad las Steuerbefehle vor, obwohl das Skill lief (Manifest-speak-Flag
  kennt den Unterschied Befehl/Frage nicht — dasselbe Skill ist mal Aktion, mal
  Auskunft). Jetzt entscheidet ARIA aus dem Kontext.
- Befehlsketten (VNC oeffnen, Menue klicken, ...): Mikro soll offen bleiben und
  stumm gearbeitet werden, bis Stefan die Kette beendet.

Marker (ARIA haengt sie ans Antwort-Ende):
  [[STUMM]]  -> Steuerbefehl, nicht vorlesen. Allein = Einzelbefehl -> danach Stop.
  [[WEITER]] -> Konversation/Kette laeuft weiter -> Mikro offen halten.
  [[ENDE]]   -> Konversation/Kette beenden -> zurueck aufs Wake-Word.

Brain:
- _extract_flow_markers() zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem Claude-Reply
  und ueberschreibt speak/converse (Marker ist autoritativ ueber Skill-Flag).
  [[STUMM]] allein impliziert converse=false; [[ENDE]] schlaegt [[WEITER]].
- prompts.py: neue Sektion build_voice_flow_section() bringt ARIA Marker +
  Befehl/Frage-Klassifikation + Ketten-/Ende-Erkennung bei.

App (ChatScreen): stiller Antwort-Zweig verzweigt jetzt auf converse —
  converse=true (Kette) -> endConversation(false), Mikro bleibt offen fuer den
  naechsten Kettenbefehl; converse=false -> ariaStopRecording (Stop wie gehabt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:00:51 +02:00
duffyduckandClaude Opus 4.8 d9bb7239c6 fix(voice): ARIA schliesst bei Steuerbefehl die Aufnahme selbst (stiller Stop)
Steuerbefehle laufen stumm (speak=false in Fast-Path/Local/Claude-Pfad) — ohne
TTS feuert aber onPlaybackFinished nie, und daran hing bisher das Aufraeumen der
Aufnahme. Folge: nach "spotify play" blieb das Mikro-/Konversations-Fenster offen
(30s Leerlauf), obwohl der Befehl laengst ausgefuehrt war.

Neu: ariaStopRecording() — das programmatische Gegenstueck zum Stop-Button. Nach
einer stillen Antwort (speak=false) schliesst ARIA jede offene Aufnahme selbst und
geht zurueck aufs Wake-Word, egal in welchem Zustand: passives Lauschen sauber
beenden, offene Streaming-Aufnahme (aktiv/Barge) verwerfen, sonst endConversation.
Vorher nur der conversing-Fall abgedeckt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:49:18 +02:00
duffyduck 0265aabb5e release: bump version to 0.2.3.7 2026-08-15 13:27:32 +02:00
duffyduckandClaude Opus 4.8 17bc50b847 fix(voice): manueller Stop unterdrueckt Passiv-Fenster nach der Antwort
Stop finalisierte die Aufnahme, aber die danach kommende onPlaybackFinished oeffnete via converseRef erneut das 30s-Passiv-Fenster. Jetzt setzt handleVoiceButtonStop converse=false → nach manuellem Stop kein Passiv-Lauschen mehr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:16:46 +02:00
duffyduckandClaude Opus 4.8 1f2be4299d fix(voice): Passiv-Lauschen nur bei converse:true (kein 30s-Linger nach Befehl)
converse defaultete true → jeder Befehl mit gesprochener Bestaetigung ('Spiele Spotify') ging ins 30s-Passiv-Fenster. Jetzt nur bei explizitem converse:true vom Brain; einzelne Befehle enden sofort → zurueck aufs Wake-Word, Spotify resumed gleich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:15:38 +02:00
19 changed files with 938 additions and 216 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20306
versionName "0.2.3.6"
versionCode 20404
versionName "0.2.4.4"
// 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.3.6",
"version": "0.2.4.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+81 -23
View File
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -1401,13 +1401,30 @@ const ChatScreen: React.FC = () => {
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false;
// Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
// kein 30s-Fenster (skipPassive=true).
wakeWordService.endConversation(true).catch(() => {});
if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
if (converseRef.current) {
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
if (wakeWordService.isConversing()) {
wakeWordService.endConversation(false).catch(() => {});
}
} else {
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
ariaStopRecording('silent-command').catch(() => {});
}
}
}
@@ -1662,7 +1679,11 @@ const ChatScreen: React.FC = () => {
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
// EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
const sttEndpointMs = await loadSttEndpointMs();
const userMsg: ChatMessage = {
id: nextId(),
@@ -1680,8 +1701,8 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
noSpeechTimeoutMs: sttEndpointMs,
endpointMs: sttEndpointMs,
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
hardCapMs: await loadMaxRecordingMs(),
@@ -1739,12 +1760,12 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
// Sonst endConversation wie bisher.
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
startPassiveStreamingRecording();
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
wakeWordService.exitPassiveListening('timeout').catch(() => {});
} else {
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1774,7 +1795,7 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
const userMsg: ChatMessage = {
id: nextId(),
@@ -1792,8 +1813,8 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current,
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
noSpeechTimeoutMs: sttEndpointMs,
endpointMs: sttEndpointMs,
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
@@ -1854,16 +1875,20 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs();
// Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
const sttEndpointMs = await loadSttEndpointMs();
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: false,
location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: await loadSttEndpointMs(),
hardCapMs: Math.max(passiveMs + 5000, 35000),
noSpeechTimeoutMs: sttEndpointMs,
endpointMs: sttEndpointMs,
// Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
});
if (!ok) {
@@ -2294,11 +2319,44 @@ const ChatScreen: React.FC = () => {
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
converseRef.current = false;
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
// KEINE neue passive Aufnahme).
if (wakeWordService.getState() === 'listening') {
await wakeWordService.exitPassiveListening('manual').catch(() => {});
return;
}
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording(reason).catch(() => {});
}
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
if (wakeWordService.isConversing()) {
await wakeWordService.endConversation(true).catch(() => {});
} else if (!wakeWordService.isActive()) {
setWakeWordActive(false);
}
}, []);
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
converseRef.current = false;
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
if (audioService.isPlayingAudio()) {
+33 -81
View File
@@ -63,10 +63,6 @@ import {
VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC,
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
STT_ENDPOINT_DEFAULT_MS,
STT_ENDPOINT_MIN_MS,
STT_ENDPOINT_MAX_MS,
@@ -116,9 +112,8 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX,
loadWakeThreshold,
saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
loadBgWakeEnabled,
saveBgWakeEnabled,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
@@ -204,7 +199,6 @@ const SettingsScreen: React.FC = () => {
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false);
@@ -220,7 +214,8 @@ const SettingsScreen: React.FC = () => {
const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -309,14 +304,6 @@ const SettingsScreen: React.FC = () => {
}
}
});
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
setConvWindowSec(n);
}
}
});
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseInt(saved, 10);
@@ -353,7 +340,7 @@ const SettingsScreen: React.FC = () => {
});
isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1721,39 +1708,6 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
@@ -1945,38 +1899,36 @@ const SettingsScreen: React.FC = () => {
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(10, passiveSec - 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
<Text style={styles.toggleHint}>
AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist
im Hintergrund sind die meisten Trigger" Fehlalarme (TV, Husten).
AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
„{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" mehr Fehlauslöser möglich.
</Text>
</View>
<Switch
value={bgWake}
onValueChange={(val) => {
setBgWake(val);
saveBgWakeEnabled(val).catch(() => {});
wakeWordService.setBgWakeEnabled(val);
}}
disabled={passiveSec <= 10}
>
<Text style={styles.prerollButtonText}>5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{passiveSec} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(60, passiveSec + 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec >= 60}
>
<Text style={styles.prerollButtonText}>+5</Text>
</TouchableOpacity>
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf du kannst ohne
Wake-Word weiterreden. Fängst du nicht innerhalb der Stille-Toleranz"
(Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
mehr — es zählt überall derselbe Stille-Wert.
</Text>
</View>
</>)}
-20
View File
@@ -143,14 +143,6 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
@@ -208,18 +200,6 @@ export async function loadTtsSpeed(): Promise<number> {
return TTS_SPEED_DEFAULT;
}
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> {
try {
+59 -37
View File
@@ -30,26 +30,14 @@ type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
export async function loadPassiveListenMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
/** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
* die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
* sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) ChatScreen ruft
* dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden
* deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
* Antworten mitten im Satz kappte, ist damit raus. */
const PASSIVE_BACKSTOP_MS = 10 * 60_000;
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
@@ -77,6 +65,28 @@ export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
}
// Hintergrund-Wake: darf das Wake-Wort auch triggern, wenn die App im
// Hintergrund / der Bildschirm gesperrt ist? Default AUS — im Hintergrund
// sind die meisten „Trigger" Fehlalarme (TV, Husten, AudioFocus-Spikes).
// AN = auch bei gesperrtem Bildschirm zuhoeren. Die native Erkennung laeuft
// ohnehin durch (Foreground-Service + Wake-Locks) — dieser Schalter oeffnet
// nur das JS-Gate in onWakeDetected.
export const BG_WAKE_STORAGE_KEY = 'aria_bg_wake_enabled';
export async function loadBgWakeEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(BG_WAKE_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_WAKE_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -145,6 +155,10 @@ class WakeWordService {
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
private inBackground: boolean = false;
/** Wenn true: Wake-Wort triggert auch im Hintergrund / bei gesperrtem
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -152,8 +166,9 @@ class WakeWordService {
* Ausnahme: bargeListening Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
* beendet den listening-State und geht zurueck zu armed. */
/** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
* das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
* trotzdem, zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
@@ -225,7 +240,8 @@ class WakeWordService {
this.initInProgress = (async () => {
try {
const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold);
this.bgWakeEnabled = await loadBgWakeEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
@@ -322,7 +338,14 @@ class WakeWordService {
* was als Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
setBackground(): void {
this.inBackground = true;
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
console.log('[WakeWord] App im Hintergrund — Detections %s',
this.bgWakeEnabled ? 'AKTIV (Hintergrund-Wake an)' : 'gesperrt');
}
/** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
setBgWakeEnabled(enabled: boolean): void {
this.bgWakeEnabled = enabled;
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
@@ -337,9 +360,9 @@ class WakeWordService {
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
if (this.inBackground) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
return;
}
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
@@ -489,13 +512,12 @@ class WakeWordService {
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
// Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs();
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs);
// Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
// noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
// der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
// Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
if (!skipPassive && this.nativeReady) {
this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
return;
}
@@ -537,10 +559,10 @@ class WakeWordService {
this.cancelPassiveListenTimer();
this.setState('listening');
const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
`entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {});
+141 -6
View File
@@ -1347,6 +1347,101 @@ def _extract_await_marker(text: str) -> tuple:
return text, False
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
#
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
#
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
# (converse=true) — kein erneutes "Computer" noetig.
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
def _extract_flow_markers(text: str) -> tuple:
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
Regeln: [[STUMM]] alleine = Einzelbefehl auch converse=false (Mikro zu),
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
if not text:
return text, None, None
speak_ov = None
conv_ov = None
if _SILENT_MARKER_RE.search(text):
speak_ov = False
text = _SILENT_MARKER_RE.sub("", text)
if _END_MARKER_RE.search(text):
conv_ov = False
text = _END_MARKER_RE.sub("", text)
if _CONT_MARKER_RE.search(text):
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
if conv_ov is None:
conv_ov = True
text = _CONT_MARKER_RE.sub("", text)
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
if speak_ov is False and conv_ov is None:
conv_ov = False
return text.strip(), speak_ov, conv_ov
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
# o.ae. nicht faelschlich matcht.
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
_CONV_END_VERB = r"(?:ende|beend\w*|aus|stop\w*|schluss)"
_END_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
re.IGNORECASE,
)
def _user_wants_conversation_end(text: str) -> bool:
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
if not text:
return False
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
# "weiter reden/sprechen" ohne Nomen.
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
_CONTINUE_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
re.IGNORECASE,
)
def _user_wants_conversation_continue(text: str) -> bool:
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
erzwingen auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
if not text:
return False
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1755,6 +1850,14 @@ class Agent:
if not user_message:
raise ValueError("Leere Nachricht")
# Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
# angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
# _wants_end → converse=false ("Konversation Ende")
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
# End hat Vorrang bei Widerspruch.
_wants_end = _user_wants_conversation_end(user_message)
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
# Events vom letzten Turn weglassen
self._pending_events = []
@@ -1782,6 +1885,10 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
@@ -1801,6 +1908,10 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
@@ -1818,6 +1929,15 @@ class Agent:
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
cold = []
# 3b. Titel-Index des kalten Gedaechtnisses — ARIA sieht WAS sie an
# Nachschlage-Wissen hat (Zugangsdaten, Infra, Projekte) und holt es via
# memory_search, statt Stefan danach zu fragen. Nur Titel = billig.
try:
memory_index = self.store.list_index_titles()
except Exception as exc:
logger.warning("Titel-Index laden fehlgeschlagen: %s", exc)
memory_index = []
# 4. Aktive Skills holen + Tool-Liste bauen
all_skills = skills_mod.list_skills(active_only=False)
active_skills = [s for s in all_skills if s.get("active", True)]
@@ -1840,7 +1960,8 @@ class Agent:
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
system_prompt = build_system_prompt(hot, cold, memory_index=memory_index,
skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
condition_funcs=condition_funcs,
@@ -2033,16 +2154,30 @@ class Agent:
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
final_reply, awaiting_reply = _extract_await_marker(final_reply)
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
speak = bool(getattr(self, "_claude_turn_speak", True))
converse = bool(getattr(self, "_claude_turn_converse", True))
if _speak_ov is not None:
speak = _speak_ov
if _conv_ov is not None:
converse = _conv_ov
# Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
# beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
return (final_reply, "claude", speak, converse, awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
+90
View File
@@ -0,0 +1,90 @@
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
(system | personal) zu. Sicher & reversibel Stefan kann pro Eintrag in der
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
Heuristik (datengetrieben aus dem realen Bestand):
- type=preference / fact / conversation / reminder -> personal
- source in (seed, auto-feedback) -> system
- type=identity -> system
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
- sonst -> personal (sicher: nichts leakt)
Aufruf im Brain-Container:
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
"""
import os
import sys
from collections import Counter
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
COLLECTION = "aria_memory"
SYSTEM_CATS = {
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
"pentest", "ausgabe",
}
def compute_scope(pl: dict) -> str:
typ = pl.get("type")
src = pl.get("source")
cat = (pl.get("category") or "").lower()
if typ == "preference":
return "personal"
if typ in ("fact", "conversation", "reminder"):
return "personal"
if src in ("seed", "auto-feedback"):
return "system"
if typ == "identity":
return "system"
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
return "system"
return "personal"
def main():
apply = "--apply" in sys.argv
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
c = QdrantClient(
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
port=int(os.environ.get("QDRANT_PORT", "6333")),
)
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
with_payload=True, with_vectors=False)
per_scope: dict[str, list] = {"system": [], "personal": []}
pinned_examples = Counter()
skipped = 0
for p in pts:
pl = p.payload or {}
if pl.get("scope") in ("system", "personal") and not force:
skipped += 1
continue
scope = compute_scope(pl)
per_scope[scope].append(p.id)
if pl.get("pinned"):
pinned_examples[(scope, pl.get("source"), pl.get("type"),
pl.get("category"))] += 1
print(f"total={len(pts)} skipped(already set)={skipped}")
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
print("pinned split (scope, source, type, category):")
for k, v in sorted(pinned_examples.items()):
print(" ", k, v)
if not apply:
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
return
for scope, ids in per_scope.items():
if not ids:
continue
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
if __name__ == "__main__":
main()
+45 -13
View File
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
pinned: bool = False
category: str = ""
source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
content: Optional[str] = None
pinned: Optional[bool] = None
category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
pinned: bool
category: str
source: str
scope: str = "personal"
tags: List[str]
created_at: str
updated_at: str
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
pinned=body.pinned,
category=body.category,
source=body.source,
scope=body.scope,
tags=body.tags,
conversation_id=body.conversation_id,
attachments=body.attachments or [],
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
existing.pinned = body.pinned
if body.category is not None:
existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None:
existing.tags = body.tags
@@ -537,12 +543,23 @@ def memory_import_files():
# Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap")
def memory_export_bootstrap():
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download."""
def memory_export_bootstrap(scope: str = "system"):
"""Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
scope='system' nur generische Regeln (fuer ein frisches System),
scope='personal' nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
scope='all' alles pinned (Vollbackup).
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
s = store()
pinned = s.list_pinned()
if scope == "all":
pinned = s.list_pinned()
elif scope in ("system", "personal"):
pinned = s.list_pinned_by_scope(scope)
else:
raise HTTPException(400, f"Ungueltiger scope: {scope}")
return {
"version": 1,
"version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).isoformat(),
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
"pinned": True,
"category": p.category,
"source": p.source,
"scope": p.scope,
"tags": p.tags,
}
for p in pinned
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
class BootstrapBundle(BaseModel):
version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict]
@app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle):
"""Loescht alle pinned Memories und importiert die im Bundle.
Cold Memory (unpinned) bleibt unangetastet.
"""Importiert ein Bootstrap-Bundle scope-sicher.
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
gehoert ein System-Import laesst also die persoenlichen pinned Memories
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
(Vollbackup) werden alle pinned ersetzt.
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt der
Caller soll erst exportieren und dann importieren.
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
s = store()
e = embedder()
# Alle aktuell pinned Punkte loeschen
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
bundle_scope = body.scope
if bundle_scope not in ("system", "personal", "all"):
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
# Nur die pinned Punkte des betroffenen scope loeschen.
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
if bundle_scope in ("system", "personal"):
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
s.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])),
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
)
# Neue Punkte einspeisen
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0
for m in body.memories:
content = (m.get("content") or "").strip()
if not content:
continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint(
id="",
type=m.get("type", "fact"),
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
pinned=True,
category=m.get("category", ""),
source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])),
)
vec = e.embed(content)
s.upsert(point, vec)
created += 1
return {"created": created, "deleted_previous_pinned": True}
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ──────────────────────────────────────────────
+52 -4
View File
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
content eigentlicher Text (wird embedded)
pinned bool, True = Hot Memory (immer in Prompt)
source import | conversation | manual
scope system | personal. system = generische Regeln, die JEDER
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
daten, Projekte). Steuert den getrennten Bootstrap-Export.
tags Liste von Strings
created_at, updated_at ISO-Strings
conversation_id optional, nur fuer type=conversation
@@ -55,6 +59,7 @@ class MemoryPoint:
pinned: bool = False
category: str = ""
source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
@@ -74,6 +79,7 @@ class MemoryPoint:
"pinned": self.pinned,
"category": self.category,
"source": self.source,
"scope": self.scope,
"tags": self.tags,
"created_at": self.created_at,
"updated_at": self.updated_at,
@@ -94,6 +100,7 @@ class MemoryPoint:
pinned=payload.get("pinned", False),
category=payload.get("category", ""),
source=payload.get("source", "manual"),
scope=payload.get("scope", "personal"),
tags=payload.get("tags", []),
created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""),
@@ -120,14 +127,23 @@ class VectorStore:
collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
)
# Indexe fuer typische Filter-Felder
for field_name in ("type", "pinned", "category", "source", "migration_key"):
# Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
# einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
self._ensure_indexes()
def _ensure_indexes(self):
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
else qm.PayloadSchemaType.KEYWORD)
try:
self.client.create_payload_index(
collection_name=COLLECTION,
field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
else qm.PayloadSchemaType.BOOL,
field_schema=schema,
)
except Exception:
# Index existiert bereits — kein Problem.
pass
# ─── Schreib-Operationen ─────────────────────────────────────────
@@ -164,6 +180,38 @@ class VectorStore:
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
]))
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
"""Alle pinned Punkte eines scope (system | personal). Fuer den
getrennten Bootstrap-Export."""
return self._scroll(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
]))
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
memory_search statt Stefan nach etwas zu fragen, das schon da ist.
Bewusst NUR die deliberat gespeicherten Punkte:
- nicht pinned (die sind eh schon voll im Prompt),
- kein type=conversation (Chat-Mitschnitte),
- kein source=distilled (die 100e auto-destillierten Gespraechs-
Fakten die traegt das semantische Auto-Retrieval, sie hier
als Titel zu listen wuerde nur Kontext fressen).
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
return self._scroll(
filter=qm.Filter(
must_not=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
]
),
limit=limit,
)
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll(
filter=qm.Filter(must=[
+7
View File
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=f"User: {btitle}",
content=btext, category="allgemein",
migration_key=f"{source_file}/general-{idx}",
scope="personal",
))
else:
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=title,
content=content, category=cat_key,
migration_key=f"{source_file}/{cat_key}",
scope="personal",
))
return points
@@ -283,7 +285,11 @@ def _mk(
migration_key: str,
pinned: bool = True,
category: str = "",
scope: str = "system",
) -> MemoryPoint:
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
p = MemoryPoint(
id="",
type=type_,
@@ -292,6 +298,7 @@ def _mk(
pinned=pinned,
category=category,
source="import",
scope=scope,
tags=[],
)
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
+76 -1
View File
@@ -162,6 +162,46 @@ def build_time_section() -> str:
]
return "\n".join(lines)
def build_voice_flow_section() -> str:
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
Frage, Kette vs. Ende) und deklariert sie per Marker wie [[AWAIT]]. Die
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
return "\n".join([
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
"",
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
"Wake-Word.",
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
"oder das Gespraech klar weitergeht.",
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
"`[[ENDE]]` an.",
"",
"Regeln:",
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
"ohne Marker (wird vorgelesen).",
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
])
TYPE_HEADINGS = {
"identity": "## Wer du bist",
"rule": "## Sicherheitsregeln & Prinzipien",
@@ -260,6 +300,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
return "\n".join(lines)
def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
"""Titel-Index des kalten Gedaechtnisses: ARIA sieht WELCHES Nachschlage-
Wissen sie hat (nur Titel, kein Inhalt = billig), damit sie den Inhalt via
memory_search holt statt Stefan nach etwas zu fragen, das schon da ist.
Nach Kategorie gruppiert; Conversation-Logs + auto-destillierte Fakten sind
bereits ausgefiltert (siehe list_index_titles)."""
if not index_titles:
return ""
grouped: dict[str, List[MemoryPoint]] = {}
for p in index_titles:
key = (p.category or p.type or "sonstiges").strip() or "sonstiges"
grouped.setdefault(key, []).append(p)
lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
"",
]
for cat in sorted(grouped.keys()):
items = grouped[cat]
lines.append(f"### {cat}")
for p in items:
lines.append(f"- {p.title}")
lines.append("")
return "\n".join(lines).strip()
def build_skills_section(skills: List[dict]) -> str:
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
@@ -450,6 +520,7 @@ def build_flux_section(flux_config: dict) -> str:
def build_system_prompt(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
memory_index: List[MemoryPoint] | None = None,
skills: List[dict] | None = None,
triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None,
@@ -463,7 +534,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
"", build_voice_flow_section()]
if skills:
parts.append("")
parts.append(build_skills_section(skills))
@@ -482,6 +554,9 @@ def build_system_prompt(
callback_host=oauth_callback_host,
callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls))
if memory_index:
parts.append("")
parts.append(build_memory_index_section(memory_index))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+1
View File
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
"pinned": True,
"category": rule.get("category", ""),
"source": "seed",
"scope": "system",
"tags": [],
"created_at": now,
"updated_at": now,
+53 -13
View File
@@ -788,6 +788,18 @@
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
Status wird geladen...
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
</label>
</div>
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
@@ -1068,11 +1080,13 @@
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON.
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer.
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet.
Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
<span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
</div>
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">Bootstrap exportieren (JSON)</button>
<button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">System-Regeln exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
@@ -1396,6 +1410,11 @@
<input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label>
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
</select>
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
@@ -1899,6 +1918,11 @@
if (slider) slider.value = msg.voiceIdThreshold;
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
}
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
{
const cb = document.getElementById('diag-voice-id-enabled');
if (cb) cb.checked = !!msg.voiceIdEnabled;
}
return;
}
@@ -3600,13 +3624,14 @@
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
send({
action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold,
voiceIdThreshold, voiceIdEnabled,
});
const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) {
@@ -5786,9 +5811,15 @@
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
const scopeBadge = m.pinned
? (m.scope === 'system'
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
: '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
@@ -5998,6 +6029,7 @@
document.getElementById('memory-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned;
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
// Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none';
@@ -6011,6 +6043,7 @@
document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false;
document.getElementById('memory-scope').value = 'personal';
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block';
@@ -6115,6 +6148,7 @@
const category = document.getElementById('memory-category').value.trim();
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const pinned = document.getElementById('memory-pinned').checked;
const scope = document.getElementById('memory-scope').value || 'personal';
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
@@ -6125,13 +6159,13 @@
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, pinned, category, tags }),
body: JSON.stringify({ title, content, pinned, category, scope, tags }),
});
} else {
r = await fetch('/api/brain/memory/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }),
body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
});
}
if (!r.ok) {
@@ -6508,11 +6542,12 @@
}
// ── Bootstrap Export / Import ──────────────────────────
async function exportBootstrap() {
async function exportBootstrap(scope) {
scope = scope || 'system';
const status = document.getElementById('bootstrap-status');
if (status) status.innerHTML = '⏳ Lade...';
try {
const r = await fetch('/api/brain/memory/export-bootstrap');
const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -6520,10 +6555,11 @@
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a');
a.href = url;
a.download = `aria-bootstrap-${ts}.json`;
a.download = `aria-bootstrap-${scope}-${ts}.json`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`;
const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
} catch (e) {
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
}
@@ -6537,7 +6573,11 @@
const text = await file.text();
const bundle = JSON.parse(text);
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) {
const bScope = bundle.scope || 'all';
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
: 'ALLE aktuell pinned Memories werden überschrieben.';
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
event.target.value = '';
return;
}
+6
View File
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
const t = parseFloat(msg.voiceIdThreshold);
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
}
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
// config-Broadcast; aus = gar keine Pruefung.
if (msg.voiceIdEnabled !== undefined) {
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+176 -1
View File
@@ -29,6 +29,7 @@ import base64
import json
import logging
import os
import re
import tempfile
import time
from dataclasses import dataclass, field
@@ -69,6 +70,59 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
_HALLUCINATION_RE = re.compile(
r"untertitel"
r"|amara\.org"
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
r"|bis\s+zum\s+n[aä]chsten\s+mal"
r"|abonnier"
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
re.IGNORECASE,
)
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
def _collapse_repetitions(text: str) -> str:
if not text:
return text
out = text
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
new = _REPEAT_RE.sub(r"\1", out)
if new == out:
break
out = new
return out.strip()
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
# vorbelegt werden.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
@@ -126,7 +180,20 @@ class VoxtralRunner:
with torch.no_grad():
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
# mehrminutige Aufnahmen abgeschnitten.
outputs = model.generate(**inputs, max_new_tokens=4096)
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
# gern in eine Schleife und wiederholt einen Satz zig-mal
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
# bleibt.
outputs = model.generate(
**inputs,
max_new_tokens=4096,
no_repeat_ngram_size=4,
repetition_penalty=1.15,
)
trimmed = outputs[:, inputs.input_ids.shape[1]:]
text = proc.batch_decode(trimmed, skip_special_tokens=True)
return (text[0] if text else "").strip()
@@ -168,6 +235,14 @@ class StreamSession:
noise_floor: float = 0.0
closed: bool = False
endpoint_sent: bool = False
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
speech_signaled: bool = False
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
voiced_frames: int = 0
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
speaker_checked: bool = False
speaker_match: Optional[bool] = None
@@ -270,6 +345,10 @@ class SessionManager:
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
return
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
sess.speaker_match = True
@@ -361,8 +440,36 @@ class SessionManager:
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
sess.voiced_frames += 1
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
if (not sess.speech_signaled and self._ws is not None
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
sess.speech_signaled = True
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "",
})
else:
self._update_noise_floor(sess, rms)
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
# (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
# last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
# Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
# erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
# schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
# aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
# also KEIN Phantom.
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "no_speech")
return
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "endpoint")
@@ -371,6 +478,35 @@ class SessionManager:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
#
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
if sess.voiced_frames < _min_voiced:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}",
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}"})
self.drop(sess.request_id)
return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
t0 = time.time()
try:
@@ -380,8 +516,43 @@ class SessionManager:
final_text = sess.last_partial
stt_ms = int((time.time() - t0) * 1000)
duration_s = audio.size / 16000.0
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
_collapsed = _collapse_repetitions(final_text)
if _collapsed != final_text:
logger.info("Stream %s: Repetition-Loop kollabiert (%d%d Zeichen)",
sess.request_id[:8], len(final_text), len(_collapsed))
final_text = _collapsed
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
_clean = final_text.strip(" .,!?…-\t\n\r")
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
if _is_phantom:
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
duration_s, final_text[:80])
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}",
"durationS": 0.0, "sttMs": stt_ms}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}"})
self.drop(sess.request_id)
return
if self._ws is not None:
payload = {
"requestId": sess.request_id,
@@ -484,6 +655,10 @@ async def run_loop(sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
+48 -7
View File
@@ -61,10 +61,40 @@ def _ensure_loaded():
return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
Ergebnis: rohes PCM."""
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
if len(raw) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue
try:
emb = embed(raw)
emb = embed(pcm)
embeddings.append(emb)
durations.append(len(raw) / 2 / 16000.0)
durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings:
+19
View File
@@ -86,6 +86,16 @@ STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
@@ -467,6 +477,11 @@ class SessionManager:
Ohne Fingerprint fail-open (match=True). Bei mismatch wird die
Session sofort beendet mit synthetischem stt_endpoint."""
sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
+48 -7
View File
@@ -61,10 +61,40 @@ def _ensure_loaded():
return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
Ergebnis: rohes PCM."""
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
if len(raw) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue
try:
emb = embed(raw)
emb = embed(pcm)
embeddings.append(emb)
durations.append(len(raw) / 2 / 16000.0)
durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings: