Compare commits

...
22 Commits
Author SHA1 Message Date
duffyduck 626281dead release: bump version to 0.2.4.7 2026-08-16 21:39:35 +02:00
duffyduckandClaude Opus 4.8 49ea172617 feat(wake): Voxtral-Bestätigungsstufe gegen Musik-Fehltrigger
openWakeWord triggert oft auf Musik (Pet Shop Boys "West End Girls" & Co.). Neu:
nach dem Trigger prüft Voxtral den Vor-Trigger-Audio ("war das wirklich das
Wake-Wort oder nur Musik?") — erst bei Bestätigung Gong + Mikro, sonst still
verworfen + re-arm. Kostet ~0,5-1s vor dem Gong.

- Native (OpenWakeWordModule.kt): 2s Roh-PCM-Ringpuffer; bei Erkennung wird der
  1,5s-Vor-Trigger-Schnipsel base64 (s16le 16kHz) ans WakeWordDetected-Event
  gehängt (preTriggerPcm).
- Bridge (voxtral): neuer One-Shot-Handler stt_transcribe_blob → Silero + Voxtral
  → stt_transcribe_result. Musik/Rauschen → leerer Text.
- App: audio.transcribeBlob() schickt den Schnipsel, wakeword.confirmWake() prüft
  ob das distinktive Keyword (>=4 Zeichen) im Transkript steht. Gate sitzt in
  onWakeDetected VOR Gong/Dialog. Setting "Wake-Wort per Voxtral bestätigen"
  (default aus, opt-in).

FAIL-OPEN durchgängig: kein preTriggerPcm (altes APK) / Timeout / Fehler / VAD
weg → Wake läuft normal durch. Nie schlechter als heute. NATIVER TEIL UNGETESTET
(kein Gerät hier) — braucht Build + Test auf dem Handy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:37:15 +02:00
duffyduck a3650bb0e4 release: bump version to 0.2.4.6 2026-08-16 21:17:07 +02:00
duffyduckandClaude Opus 4.8 514ba44e51 fix(app): Umgebungsmusik killt Weiterreden-Fenster nicht mehr (re-listen)
Wechselwirkung entdeckt: im Passiv-Lausch-Fenster (Weiterreden nach ARIAs
Antwort) nimmt das Mikro Umgebungsmusik auf → Silero verwirft sie korrekt als
vad_no_speech → leerer Endpoint. Die App wertete JEDEN leeren Endpoint als
"nichts gesagt" → sofort zurueck aufs Wake-Word. Bei laufender Musik in einer
Sprechpause endete so das Gespraech, obwohl die Stille-Toleranz noch lief.

Fix: bei leerem Endpoint im Passiv-Fenster wird — solange die Stille-Toleranz ab
Fenster-Oeffnung noch laeuft (Budget) und < 15 Runden — nochmal gelauscht statt
zu beenden. Musik wird verworfen, das Fenster bleibt bis zur Toleranz offen, du
kannst innerhalb reden. Erst wenn die Toleranz wirklich um ist → aufs Wake-Word.
Sprichst du (Text-Endpoint), geht es sofort normal weiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:14:37 +02:00
duffyduckandClaude Opus 4.8 617dddf3d8 fix(app): Konversationsmodus auch im Hintergrund, wenn Background-Wake an
Diagnose aus dem App-State-Trail (Bridge-Log): nach jeder gesprochenen Antwort
rief die App endConversation(bg || !converse) — der bg-Term (AppState != active)
erzwang "zurueck aufs Wake-Word", sobald die App im Hintergrund war. Das war vor
dem Background-Wake-Feature Absicht (kein Multi-Turn im Hintergrund). Jetzt, mit
aktivem Background-Wake, killt genau das den Konversationsmodus im Hintergrund —
im Vordergrund lief er weiter (daher "kommt beim Vorholen wieder"). converse kam
korrekt als true vom Brain (keine Marker, Default true); die App warf es im
Hintergrund weg.

Fix: bg erzwingt "armed" nur noch, wenn Background-Wake AUS ist
(isBgWakeEnabled()). Bei aktivem Background-Wake bleibt der Konversationsmodus
auch im Hintergrund offen — der User hat das Zuhoeren ja bewusst eingeschaltet.
Vordergrund-Verhalten unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:06:40 +02:00
duffyduck 88faf57253 release: bump version to 0.2.4.5 2026-08-16 20:40:47 +02:00
duffyduckandClaude Opus 4.8 41714f7cf1 feat(voice): Wake-Word per Befehl wieder anschalten (Gegenstück zu wake_off)
"Wake-Word an" / "mach das Ohr an" / "hör wieder zu" / "wach auf" → App startet
den Listener wieder. Geht per Text-Nachricht ODER manuellem Aufnahme-Button —
beide laufen unabhängig vom Wake-Word-Listener, also funktioniert das Wieder-An
auch wenn das Ohr gerade taub ist (nur nicht per "Computer", das hört ja nicht).

Symmetrisch zu wake_off: Detektor _user_wants_wake_on → wake_on durch ChatOut →
Bridge → App löst wakeWordService.start() + setWakeWordActive(true) aus. An/Aus
kollidieren nicht (12 Fälle getestet). Damit: Ohr per Befehl ein UND aus, plus
weiterhin der Ohr-Button.

Fix nebenbei: gerades " in den Reply-Strings (hätte den Brain-Start gecrasht) →
einfache Anführungszeichen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:33:16 +02:00
duffyduckandClaude Opus 4.8 93401d42d1 feat(voice): Wake-Word per Sprachbefehl ausschalten
"Computer, Wake-Word aus" / "mach das Ohr aus" / "hör auf zuzuhören" / "geh
schlafen" → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte
Ruhe). Wieder-An nur über den Ohr-Button (bewusst, weil dann taub — Voice-
Wieder-An ist physikalisch unmöglich, wenn nichts mehr hört).

Deterministischer Detektor _user_wants_wake_off im Brain (kein LLM-Call nötig,
still). Signal fließt als wake_off durch ChatOut → Bridge → App-Payload; die App
löst denselben Pfad wie toggleWakeWord-off aus (cancelRecording +
wakeWordService.stop() + setWakeWordActive(false)). Detektor gegen 13 Positiv-
+ 9 Negativ-Fällen verifiziert (fängt nicht 'Licht aus' / 'Konversation Ende').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:27:48 +02:00
duffyduckandClaude Opus 4.8 c4e658446d feat(voxtral): Silero VAD — echte Sprach-Erkennung gegen generische Phantome + Musik
Der Muster-Filter fängt nur spezifische Artefakte; generische Silence-Phantome
("Ich bin ein guter Mann", "Die Serie wurde in 13 Episoden gedreht") kann ein
Text-Regex prinzipiell nicht vorhersehen. Lösung an der Wurzel: Silero VAD
(neuronal, ~2MB) entscheidet am AUDIO, ob überhaupt echte Sprache drin ist.

Im _finalize vor dem Transkribieren: kein Speech-Segment → no-speech, gar nicht
erst transkribieren → kein Phantom. Trennt zuverlässig Sprache von Stille /
Rauschen / MUSIK (löst damit auch den Musik-False-Positive). Bei Speech wird auf
die Sprach-Spanne getrimmt (Stille-Ränder weg → weniger Edge-Halluzination).

FAIL-OPEN: Import/Load/Inferenz-Fehler → normal transkribieren (die STT darf nie
komplett sterben — Speaker-ID-Lektion). Alles env-tunebar: SILERO_VAD_ENABLED,
_THRESHOLD (0.5), _MIN_SPEECH_MS (150), _PAD_MS (200). Modell im pip-Paket
gebündelt, kein Runtime-Download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:18:06 +02:00
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
18 changed files with 956 additions and 197 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20400
versionName "0.2.4.0"
versionCode 20407
versionName "0.2.4.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
}
private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return
}
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
}
try {
reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n
}
if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try {
processChunk(buf)
} catch (e: Exception) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.4.0",
"version": "0.2.4.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+71 -10
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 ---
@@ -384,6 +384,14 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
const passiveListenStartRef = useRef<number>(0);
const passiveReListenCountRef = useRef<number>(0);
const passiveToleranceRef = useRef<number>(5000);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
@@ -1406,8 +1414,39 @@ const ChatScreen: React.FC = () => {
// 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 _wakeOff = (message.payload as any).wake_off === true;
const _wakeOn = (message.payload as any).wake_on === true;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent) {
if (_wakeOn) {
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
// weil der Befehl NICHT ueber "Computer" kam.
(async () => {
try {
const started = await wakeWordService.start();
setWakeWordActive(started);
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
} catch (e) {
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
}
})();
} else if (_wakeOff) {
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
(async () => {
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-off-voice');
} else {
await audioService.stopRecording();
}
} catch {}
try { await wakeWordService.stop(); } catch {}
setWakeWordActive(false);
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
})();
} else 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.
@@ -1659,8 +1698,12 @@ const ChatScreen: React.FC = () => {
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
});
return () => unsubPlayback();
}, []);
@@ -1760,12 +1803,24 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// 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.)
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
wakeWordService.exitPassiveListening('timeout').catch(() => {});
const elapsed = Date.now() - passiveListenStartRef.current;
const budget = passiveToleranceRef.current || 5000;
if (elapsed < budget && passiveReListenCountRef.current < 15) {
passiveReListenCountRef.current += 1;
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
ev.reason, elapsed, budget, passiveReListenCountRef.current);
startPassiveStreamingRecording();
} else {
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
wakeWordService.exitPassiveListening('timeout').catch(() => {});
}
} else {
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1777,8 +1832,14 @@ const ChatScreen: React.FC = () => {
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => {
const unsubPassive = wakeWordService.onPassiveListen(async () => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
passiveListenStartRef.current = Date.now();
passiveReListenCountRef.current = 0;
passiveToleranceRef.current = await loadSttEndpointMs();
startPassiveStreamingRecording();
});
+60 -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,10 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX,
loadWakeThreshold,
saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
loadBgWakeEnabled,
saveBgWakeEnabled,
loadWakeConfirmEnabled,
saveWakeConfirmEnabled,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
@@ -204,7 +201,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 +216,10 @@ 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);
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -309,14 +308,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 +344,8 @@ const SettingsScreen: React.FC = () => {
});
isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {});
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1721,39 +1713,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 +1904,58 @@ 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>
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
<Text style={styles.toggleHint}>
Gegen Musik-Fehltrigger: nach {KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,51 s Extra vor dem
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
</Text>
</View>
<Switch
value={wakeConfirm}
onValueChange={(val) => {
setWakeConfirm(val);
saveWakeConfirmEnabled(val).catch(() => {});
wakeWordService.setWakeConfirmEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeConfirm ? '#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>
</>)}
+33 -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
@@ -179,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
} catch {}
}
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(null), timeoutMs);
function finish(val: string | null) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_transcribe_result') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.text === 'string' ? p.text : '');
});
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
} catch {
finish(null);
}
});
}
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -208,18 +233,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 {
+150 -42
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,49 @@ 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 {}
}
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
// Event (neueres APK) — ohne das macht die App normal weiter.
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
export async function loadWakeConfirmEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_CONFIRM_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 +176,13 @@ 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;
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
private wakeConfirmEnabled: 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 +190,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,15 +264,20 @@ class WakeWordService {
this.initInProgress = (async () => {
try {
const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold);
this.bgWakeEnabled = await loadBgWakeEnabled();
this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => {
this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err =>
console.warn('[WakeWord] onWakeDetected crashed:', err));
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
// nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
});
}
this.nativeReady = true;
@@ -322,7 +366,26 @@ 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);
}
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
setWakeConfirmEnabled(enabled: boolean): void {
this.wakeConfirmEnabled = enabled;
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
}
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
isBgWakeEnabled(): boolean {
return this.bgWakeEnabled;
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
@@ -335,11 +398,13 @@ class WakeWordService {
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
}
/** 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(()=>{});
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
* preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
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.
@@ -382,6 +447,22 @@ class WakeWordService {
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
if (this.wakeConfirmEnabled && preTriggerPcm) {
const confirmed = await this.confirmWake(preTriggerPcm);
if (!confirmed) {
this.detectionInProgress = false;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (e) {
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
}
}
return;
}
}
this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
@@ -395,6 +476,34 @@ class WakeWordService {
});
}
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
private async confirmWake(pcm: string): Promise<boolean> {
try {
const audio = await import('./audio');
const text = await audio.transcribeBlob(pcm);
if (text === null) {
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
return true;
}
const norm = text.toLowerCase();
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
.split(/\s+/).filter(w => w.length >= 4);
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
const ok = kwWords.some(w => norm.includes(w));
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
import('./logger').then(m => m.reportAppDebug('wake.confirm',
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')}${ok ? 'ok' : 'reject'}`)).catch(() => {});
return ok;
} catch (e) {
console.warn('[WakeWord] confirmWake err → fail-open:', e);
return true;
}
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
@@ -489,13 +598,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 +645,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(() => {});
+82 -3
View File
@@ -1402,10 +1402,10 @@ def _extract_flow_markers(text: str) -> tuple:
# ([^.!?]{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|beenden|beende|aus|stop|stopp|schluss)"
_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(?:beende|schlie(?:ß|ss)e?)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
re.IGNORECASE,
)
@@ -1442,6 +1442,54 @@ def _user_wants_conversation_continue(text: str) -> bool:
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AUS per Sprachbefehl ──────────────────────────────────────────
# "Wake-Word aus", "mach das Ohr aus", "hoer auf zuzuhoeren", "geh schlafen" …
# → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte Ruhe).
# Wieder-An geht nur ueber den App-Button (bewusst, weil dann taub). Reiner
# Steuerbefehl: still (speak=false), kein Weiterlauschen (converse=false).
_WAKE_NOUN = r"(?:wake[\s-]?word|wakeword|ohr(?:en)?|mikro(?:fon)?|zuh[oö]r\w*|lausch\w*)"
_WAKE_OFF_VERB = (r"(?:aus(?:schalten|stellen)?|abschalten|abstellen|deaktivier\w*|"
r"beenden|beende|stopp?\w*|schlafen|ruhe)")
_WAKE_OFF_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_OFF_VERB}\b"
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
rf"|geh\s+schlafen",
re.IGNORECASE,
)
def _user_wants_wake_off(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Sprache abschalten will."""
if not text:
return False
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AN per Befehl (Text ODER manueller Aufnahme-Button) ────────────
# Gegenstueck zu wake_off. Das "Wieder-An" per Stimme geht NICHT ueber "Computer"
# (das hoert ja nicht mehr), aber ueber eine Text-Nachricht oder den manuellen
# Aufnahme-Button — beide laufen unabhaengig vom Wake-Word-Listener. Die App
# startet den Listener dann wieder (wakeWordService.start()).
_WAKE_ON_VERB = r"(?:an(?:schalten|machen|stellen)?|einschalten|aktivier\w*|starte\w*|reaktivier\w*)"
_WAKE_ON_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_ON_VERB}\b"
rf"|\b(?:aktivier\w*|reaktivier\w*|starte\w*)\b[^.!?]{{0,15}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+(?:mir\s+)?wieder\s+zu"
rf"|(?:wieder|erneut)\s+(?:zu\s*)?(?:h[oö]r|lausch)\w*"
rf"|wach\s+auf|aufwachen",
re.IGNORECASE,
)
def _user_wants_wake_on(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Befehl wieder anschalten will."""
if not text:
return False
return bool(_WAKE_ON_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)
@@ -1865,6 +1913,27 @@ class Agent:
active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Wake-Word AUS per Sprache: reiner Steuerbefehl, KEIN Claude/Fast-Path
# noetig. Still (speak=false) + kein Weiterlauschen (converse=false). Das
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
if _user_wants_wake_off(user_message):
reply = "Ohr aus. Sag 'Wake-Word an' (per Text oder Aufnahme-Knopf) oder tipp den Ohr-Button, wenn ich wieder lauschen soll. 🔇"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
if _user_wants_wake_on(user_message):
reply = "Ohr wieder an — ich lausche auf 'Computer'. 👂"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-on] Befehl erkannt — App startet Listener")
return reply, "wake-on", False, False, False
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
@@ -1929,6 +1998,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)]
@@ -1951,7 +2029,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,
+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()
+52 -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 ──────────────────────────────────────────────
@@ -644,6 +676,11 @@ class ChatOut(BaseModel):
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei).
wake_off: bool = False
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
wake_on: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -753,6 +790,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
wake_on=(answered_by == "wake-on"),
)
finally:
_project_pending[pid] = [
+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
+34
View File
@@ -300,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."""
@@ -490,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,
@@ -523,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,
+12 -1
View File
@@ -1622,6 +1622,11 @@ class ARIABridge:
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
# (Mikro frei).
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener.
"wake_on": bool(payload.get("wake_on", False)) if isinstance(payload, dict) else False,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -2017,6 +2022,10 @@ class ARIABridge:
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
awaiting_reply = bool(data.get("awaiting_reply", False))
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
wake_off = bool(data.get("wake_off", False))
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener wieder.
wake_on = bool(data.get("wake_on", False))
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
@@ -2108,7 +2117,9 @@ class ARIABridge:
"answeredBy": answered_by,
"speak": speak,
"converse": converse,
"awaiting_reply": awaiting_reply})
"awaiting_reply": awaiting_reply,
"wake_off": wake_off,
"wake_on": wake_on})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)
+34 -12
View File
@@ -1080,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>
@@ -1408,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;">
@@ -5804,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>
@@ -6016,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';
@@ -6029,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';
@@ -6133,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; }
@@ -6143,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) {
@@ -6526,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' });
@@ -6538,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>`;
}
@@ -6555,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;
}
+222 -8
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
@@ -74,6 +75,92 @@ STREAM_VOICE_RMS_MAX = 0.020
# (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()
# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ──────────────
# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome
# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen —
# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt
# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment →
# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt
# gleich mit raus).
# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal
# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion).
SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes")
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
_vad_state = {"model": None, "get_ts": None, "failed": False}
def _speech_segments(audio_f32):
"""Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im
16kHz-float32-Audio. Rueckgabe:
[] kein Speech (Stille/Rauschen/Musik) Phantom-Verdacht, verwerfen.
[...] Speech vorhanden.
None VAD nicht verfuegbar fail-open (Aufrufer transkribiert normal)."""
if not SILERO_VAD_ENABLED or _vad_state["failed"]:
return None
if _vad_state["model"] is None:
try:
from silero_vad import load_silero_vad, get_speech_timestamps
_vad_state["model"] = load_silero_vad()
_vad_state["get_ts"] = get_speech_timestamps
logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)",
SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS)
except Exception:
logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)")
_vad_state["failed"] = True
return None
try:
import torch as _torch
segs = _vad_state["get_ts"](
_torch.from_numpy(audio_f32), _vad_state["model"],
sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD,
min_speech_duration_ms=SILERO_MIN_SPEECH_MS,
)
return segs or []
except Exception:
logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open")
return None
# 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-
@@ -142,7 +229,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()
@@ -408,24 +508,57 @@ class SessionManager:
})
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")
async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None:
"""Leeres no-speech-Endpoint senden + Session droppen (kein Transkript).
App re-armt still, zurueck aufs Wake-Word."""
if self._ws is not None:
payload = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label,
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label})
self.drop(sess.request_id)
async def _finalize(self, sess: StreamSession, reason: str) -> None:
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 ("keine Ahnung" o.ae.), 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. Der manuelle Stop (stream_end)
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
# 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, STREAM_MIN_VOICED_FRAMES, reason)
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,
@@ -439,6 +572,28 @@ class SessionManager:
self.drop(sess.request_id)
return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
# Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am
# AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein
# guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt.
# Kein Speech-Segment → no-speech, gar nicht erst transkribieren.
# fail-open: segs=None (VAD nicht verfuegbar) → normal weiter.
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech",
sess.request_id[:8], audio.size / 16000.0, reason)
await self._emit_no_speech(sess, f"vad_no_speech:{reason}")
return
if segs:
# Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral
# halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene
# leise Wort-Anfaenge/-Enden.
pad = int(SILERO_PAD_MS / 1000.0 * 16000)
s0 = max(0, segs[0]["start"] - pad)
s1 = min(int(audio.size), segs[-1]["end"] + pad)
if s1 > s0 and (s1 - s0) < audio.size:
audio = audio[s0:s1]
t0 = time.time()
try:
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
@@ -447,8 +602,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,
@@ -510,6 +700,30 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
# Musik/Rauschen → leerer Text (nicht bestaetigt).
req_id = payload.get("requestId", "")
try:
pcm = base64.b64decode(payload.get("pcm", ""))
audio = pcm_s16le_to_float32(pcm)
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
text = ""
else:
text = (await sessions.runner.transcribe(
audio, payload.get("language", "de"))).strip()
logger.info("stt_transcribe_blob (%.1fs) → %r",
audio.size / 16000.0, text[:60])
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": text})
except Exception as exc:
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try:
+1
View File
@@ -4,6 +4,7 @@ transformers>=4.54
mistral-common[audio]>=1.8.1
accelerate>=0.30
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24