Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
626281dead | ||
|
|
49ea172617 | ||
|
|
a3650bb0e4 | ||
|
|
514ba44e51 | ||
|
|
617dddf3d8 | ||
|
|
88faf57253 | ||
|
|
41714f7cf1 | ||
|
|
93401d42d1 | ||
|
|
c4e658446d | ||
|
|
3693157210 | ||
|
|
64670cdd12 | ||
|
|
c82616ebbd | ||
|
|
b226e1da11 | ||
|
|
0b7ed241b4 | ||
|
|
a2b7e3a48d | ||
|
|
7b72149671 | ||
|
|
e9439dbccb | ||
|
|
219de091d2 | ||
|
|
5992a7e441 | ||
|
|
07ccf05429 | ||
|
|
4ab0e68245 | ||
|
|
9636a702d3 | ||
|
|
5f09e2bca3 | ||
|
|
ebe0e8065f | ||
|
|
9d2c07d8d1 | ||
|
|
9aae5af6a9 | ||
|
|
a8ff73f93d | ||
|
|
0e9adeee5c | ||
|
|
6addb2f8fe | ||
|
|
9bdfb7193e | ||
|
|
9e78d75149 | ||
|
|
517c993ac8 | ||
|
|
c1bd13687d | ||
|
|
d9bb7239c6 | ||
|
|
0265aabb5e | ||
|
|
17bc50b847 | ||
|
|
1f2be4299d | ||
|
|
0ca8a82013 | ||
|
|
7bc3f827d0 | ||
|
|
e7da9cf9c4 | ||
|
|
353fd98d3f |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20305
|
versionCode 20407
|
||||||
versionName "0.2.3.5"
|
versionName "0.2.4.7"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
|
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
|
||||||
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
||||||
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
||||||
private const val STARTUP_SUPPRESSION_MS = 1500L
|
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()
|
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 val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||||
private var consecutiveAboveThreshold: Int = 0
|
private var consecutiveAboveThreshold: Int = 0
|
||||||
private var lastDetectionMs: Long = 0L
|
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
|
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
||||||
private var recordingStartedMs: Long = 0L
|
private var recordingStartedMs: Long = 0L
|
||||||
|
|
||||||
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
embBuffer.clear()
|
embBuffer.clear()
|
||||||
consecutiveAboveThreshold = 0
|
consecutiveAboveThreshold = 0
|
||||||
lastDetectionMs = 0L
|
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() {
|
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)")
|
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val preTriggerB64 = snapshotPreTrigger()
|
||||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||||
putString("model", modelName)
|
putString("model", modelName)
|
||||||
|
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reactApplicationContext
|
reactApplicationContext
|
||||||
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
read += n
|
read += n
|
||||||
}
|
}
|
||||||
if (!running.get()) break
|
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 {
|
try {
|
||||||
processChunk(buf)
|
processChunk(buf)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.3.5",
|
"version": "0.2.4.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
|
|||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||||
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
||||||
import projectFocus from '../services/projectFocus';
|
import projectFocus from '../services/projectFocus';
|
||||||
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
|
|||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
import MessageText from '../components/MessageText';
|
import MessageText from '../components/MessageText';
|
||||||
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } from '../services/audio';
|
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
@@ -384,6 +384,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
||||||
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
||||||
const converseRef = useRef<boolean>(true);
|
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);
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
@@ -662,6 +672,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const voice = await AsyncStorage.getItem('aria_xtts_voice');
|
const voice = await AsyncStorage.getItem('aria_xtts_voice');
|
||||||
localXttsVoiceRef.current = voice || '';
|
localXttsVoiceRef.current = voice || '';
|
||||||
ttsSpeedRef.current = await loadTtsSpeed();
|
ttsSpeedRef.current = await loadTtsSpeed();
|
||||||
|
bargeInEnabledRef.current = await loadBargeInEnabled();
|
||||||
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
||||||
setGpsEnabled(gps === 'true');
|
setGpsEnabled(gps === 'true');
|
||||||
const hints = await AsyncStorage.getItem('aria_show_hints');
|
const hints = await AsyncStorage.getItem('aria_show_hints');
|
||||||
@@ -1398,13 +1409,61 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||||
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
||||||
// stoppen — onPlaybackFinished liest converseRef. Default true.
|
// stoppen — onPlaybackFinished liest converseRef. Default true.
|
||||||
converseRef.current = (message.payload as any).converse !== false;
|
// Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
|
||||||
|
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
|
||||||
|
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
|
||||||
|
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
|
||||||
|
converseRef.current = (message.payload as any).converse === true;
|
||||||
|
const _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;
|
const _isSilent = (message.payload as any).speak === false;
|
||||||
if (_isSilent && wakeWordService.isConversing()) {
|
if (_wakeOn) {
|
||||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
|
||||||
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
|
||||||
// kein 30s-Fenster (skipPassive=true).
|
// weil der Befehl NICHT ueber "Computer" kam.
|
||||||
wakeWordService.endConversation(true).catch(() => {});
|
(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.
|
||||||
|
if (converseRef.current) {
|
||||||
|
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
|
||||||
|
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
|
||||||
|
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
|
||||||
|
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
|
||||||
|
if (wakeWordService.isConversing()) {
|
||||||
|
wakeWordService.endConversation(false).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
|
||||||
|
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
|
||||||
|
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
|
||||||
|
ariaStopRecording('silent-command').catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1639,8 +1698,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
||||||
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
||||||
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
|
// 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';
|
const bg = AppState.currentState !== 'active';
|
||||||
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
|
const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
|
||||||
|
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
|
||||||
});
|
});
|
||||||
return () => unsubPlayback();
|
return () => unsubPlayback();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1659,7 +1722,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
const wasInterrupted = interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const windowMs = await loadConvWindowMs();
|
// EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
|
||||||
|
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
|
||||||
|
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
|
||||||
|
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
|
||||||
|
const sttEndpointMs = await loadSttEndpointMs();
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -1677,8 +1744,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: wasInterrupted,
|
interrupted: wasInterrupted,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
|
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
|
||||||
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
|
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
|
||||||
hardCapMs: await loadMaxRecordingMs(),
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
@@ -1736,12 +1803,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
!(m.audioRequestId === ev.audioRequestId
|
!(m.audioRequestId === ev.audioRequestId
|
||||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||||
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||||
// Sonst endConversation wie bisher.
|
// 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') {
|
if (wakeWordService.getState() === 'listening') {
|
||||||
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
|
const elapsed = Date.now() - passiveListenStartRef.current;
|
||||||
startPassiveStreamingRecording();
|
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 {
|
} else {
|
||||||
wakeWordService.endConversation();
|
wakeWordService.endConversation();
|
||||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||||
@@ -1753,8 +1832,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
||||||
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
||||||
// Whisper-Bridge filtert fremde Stimmen weg.
|
// 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');
|
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();
|
startPassiveStreamingRecording();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1771,7 +1856,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const windowMs = await loadConvWindowMs();
|
const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -1789,8 +1874,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
|
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
|
||||||
hardCapMs: await loadMaxRecordingMs(),
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
@@ -1810,7 +1895,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Prozess nicht killt wenn die App im Hintergrund ist.
|
// Prozess nicht killt wenn die App im Hintergrund ist.
|
||||||
const unsubTtsStart = audioService.onPlaybackStarted(() => {
|
const unsubTtsStart = audioService.onPlaybackStarted(() => {
|
||||||
acquireBackgroundAudio('tts').catch(() => {});
|
acquireBackgroundAudio('tts').catch(() => {});
|
||||||
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
|
// Barge-Listening (Mikro waehrend TTS) NUR im Barge-in-Modus. Default aus =
|
||||||
|
// Halb-Duplex: ARIA spricht ungestoert zu Ende, dann erst geht das Mikro auf.
|
||||||
|
if (bargeInEnabledRef.current && wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
|
||||||
wakeWordService.startBargeListening().catch(() => {});
|
wakeWordService.startBargeListening().catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1849,16 +1936,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const passiveMs = await loadPassiveListenMs();
|
// Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
|
||||||
|
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
|
||||||
|
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
|
||||||
|
const sttEndpointMs = await loadSttEndpointMs();
|
||||||
const { ok } = await audioService.startStreamingRecording({
|
const { ok } = await audioService.startStreamingRecording({
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
// Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
|
||||||
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -2231,15 +2322,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
advanceQueue(pid);
|
advanceQueue(pid);
|
||||||
}, [advanceQueue]);
|
}, [advanceQueue]);
|
||||||
|
|
||||||
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
|
// Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
|
||||||
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
|
// TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
|
||||||
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
|
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||||
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
|
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||||
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
|
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||||
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
|
|
||||||
const interruptAriaIfBusy = useCallback(() => {
|
const interruptAriaIfBusy = useCallback(() => {
|
||||||
if (audioService.isPlayingAudio()) {
|
if (audioService.isPlayingAudio()) {
|
||||||
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
|
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
||||||
|
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2288,11 +2380,50 @@ const ChatScreen: React.FC = () => {
|
|||||||
return true;
|
return true;
|
||||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||||
|
|
||||||
|
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||||
|
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||||
|
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
|
||||||
|
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
|
||||||
|
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
|
||||||
|
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
|
||||||
|
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
|
||||||
|
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
|
||||||
|
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
|
||||||
|
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
|
||||||
|
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
|
||||||
|
converseRef.current = false;
|
||||||
|
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
|
||||||
|
// KEINE neue passive Aufnahme).
|
||||||
|
if (wakeWordService.getState() === 'listening') {
|
||||||
|
await wakeWordService.exitPassiveListening('manual').catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
|
||||||
|
if (audioService.isStreamingRecording()) {
|
||||||
|
await audioService.cancelStreamingRecording(reason).catch(() => {});
|
||||||
|
}
|
||||||
|
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
|
||||||
|
if (wakeWordService.isConversing()) {
|
||||||
|
await wakeWordService.endConversation(true).catch(() => {});
|
||||||
|
} else if (!wakeWordService.isActive()) {
|
||||||
|
setWakeWordActive(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
|
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
|
||||||
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
|
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
|
||||||
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
||||||
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
||||||
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
||||||
|
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
|
||||||
|
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
|
||||||
|
converseRef.current = false;
|
||||||
|
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
|
||||||
|
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
|
||||||
|
if (audioService.isPlayingAudio()) {
|
||||||
|
audioService.haltAllPlayback('user stop');
|
||||||
|
rvs.send('cancel_request' as any, { hard: true, source: 'voice-stop' });
|
||||||
|
}
|
||||||
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
|
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
|
||||||
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
|
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
|
||||||
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
|
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
|
||||||
|
|||||||
@@ -63,10 +63,6 @@ import {
|
|||||||
VAD_SILENCE_MIN_SEC,
|
VAD_SILENCE_MIN_SEC,
|
||||||
VAD_SILENCE_MAX_SEC,
|
VAD_SILENCE_MAX_SEC,
|
||||||
VAD_SILENCE_STORAGE_KEY,
|
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_DEFAULT_MS,
|
||||||
STT_ENDPOINT_MIN_MS,
|
STT_ENDPOINT_MIN_MS,
|
||||||
STT_ENDPOINT_MAX_MS,
|
STT_ENDPOINT_MAX_MS,
|
||||||
@@ -75,6 +71,8 @@ import {
|
|||||||
MAX_RECORDING_MIN_SEC,
|
MAX_RECORDING_MIN_SEC,
|
||||||
MAX_RECORDING_MAX_SEC,
|
MAX_RECORDING_MAX_SEC,
|
||||||
MAX_RECORDING_STORAGE_KEY,
|
MAX_RECORDING_STORAGE_KEY,
|
||||||
|
loadBargeInEnabled,
|
||||||
|
saveBargeInEnabled,
|
||||||
VAD_SILENCE_DB_DEFAULT,
|
VAD_SILENCE_DB_DEFAULT,
|
||||||
VAD_SILENCE_DB_MIN,
|
VAD_SILENCE_DB_MIN,
|
||||||
VAD_SILENCE_DB_MAX,
|
VAD_SILENCE_DB_MAX,
|
||||||
@@ -114,9 +112,10 @@ import wakeWordService, {
|
|||||||
WAKE_THRESHOLD_MAX,
|
WAKE_THRESHOLD_MAX,
|
||||||
loadWakeThreshold,
|
loadWakeThreshold,
|
||||||
saveWakeThreshold,
|
saveWakeThreshold,
|
||||||
PASSIVE_LISTEN_DEFAULT_MS,
|
loadBgWakeEnabled,
|
||||||
loadPassiveListenMs,
|
saveBgWakeEnabled,
|
||||||
savePassiveListenMs,
|
loadWakeConfirmEnabled,
|
||||||
|
saveWakeConfirmEnabled,
|
||||||
} from '../services/wakeword';
|
} from '../services/wakeword';
|
||||||
import ModeSelector from '../components/ModeSelector';
|
import ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
@@ -202,8 +201,9 @@ const SettingsScreen: React.FC = () => {
|
|||||||
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
|
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
|
||||||
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
|
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
|
||||||
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
|
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);
|
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);
|
||||||
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
||||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||||
@@ -216,7 +216,10 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||||
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
|
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
|
||||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
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 [editingPath, setEditingPath] = useState(false);
|
||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||||
@@ -305,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 => {
|
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
const n = parseInt(saved, 10);
|
const n = parseInt(saved, 10);
|
||||||
@@ -329,6 +324,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
loadBargeInEnabled().then(setBargeIn).catch(() => {});
|
||||||
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
|
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
|
||||||
if (saved != null && saved !== '') {
|
if (saved != null && saved !== '') {
|
||||||
const n = parseFloat(saved);
|
const n = parseFloat(saved);
|
||||||
@@ -348,7 +344,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
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(() => {});
|
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
@@ -1666,7 +1663,24 @@ const SettingsScreen: React.FC = () => {
|
|||||||
{currentSection === 'voice_input' && (<>
|
{currentSection === 'voice_input' && (<>
|
||||||
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
|
<View style={styles.toggleRow}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das
|
||||||
|
Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du
|
||||||
|
kannst sie waehrend des Sprechens per Wake-Wort unterbrechen.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={bargeIn}
|
||||||
|
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
|
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
|
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
|
||||||
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
|
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
|
||||||
@@ -1699,39 +1713,6 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
|
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
|
||||||
@@ -1923,38 +1904,58 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
|
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
|
||||||
<Text style={styles.toggleHint}>
|
<View style={styles.toggleInfo}>
|
||||||
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
|
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
|
||||||
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
|
<Text style={styles.toggleHint}>
|
||||||
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
|
AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist —
|
||||||
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
|
im Hintergrund sind die meisten „Trigger" Fehlalarme (TV, Husten).
|
||||||
</Text>
|
AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
|
||||||
<View style={styles.prerollRow}>
|
„{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" — mehr Fehlauslöser möglich.
|
||||||
<TouchableOpacity
|
</Text>
|
||||||
style={styles.prerollButton}
|
</View>
|
||||||
onPress={() => {
|
<Switch
|
||||||
const next = Math.max(10, passiveSec - 5);
|
value={bgWake}
|
||||||
setPassiveSec(next);
|
onValueChange={(val) => {
|
||||||
savePassiveListenMs(next * 1000);
|
setBgWake(val);
|
||||||
|
saveBgWakeEnabled(val).catch(() => {});
|
||||||
|
wakeWordService.setBgWakeEnabled(val);
|
||||||
}}
|
}}
|
||||||
disabled={passiveSec <= 10}
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
>
|
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
|
||||||
<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>
|
|
||||||
</View>
|
</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,5–1 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>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
|||||||
@@ -143,14 +143,6 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
|
|||||||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
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
|
// 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
|
// 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
|
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
|
||||||
@@ -160,6 +152,58 @@ export const STT_ENDPOINT_MIN_MS = 1000;
|
|||||||
export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
|
export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
|
||||||
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
|
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
|
||||||
|
|
||||||
|
// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)?
|
||||||
|
// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro —
|
||||||
|
// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen.
|
||||||
|
export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled';
|
||||||
|
|
||||||
|
export async function loadBargeInEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
|
||||||
|
} 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> {
|
export async function loadSttEndpointMs(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||||
@@ -189,18 +233,6 @@ export async function loadTtsSpeed(): Promise<number> {
|
|||||||
return TTS_SPEED_DEFAULT;
|
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> {
|
async function loadVadSilenceMs(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,34 +30,22 @@ type PassiveListenCallback = () => void;
|
|||||||
|
|
||||||
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
|
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
|
||||||
|
|
||||||
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
|
/** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
|
||||||
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
|
* die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
|
||||||
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
|
* sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) → ChatScreen ruft
|
||||||
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
|
* dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden —
|
||||||
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
|
* 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
|
||||||
export async function loadPassiveListenMs(): Promise<number> {
|
* Antworten mitten im Satz kappte, ist damit raus. */
|
||||||
try {
|
const PASSIVE_BACKSTOP_MS = 10 * 60_000;
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||||
|
|
||||||
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
|
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
|
||||||
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
|
// weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
|
||||||
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
|
// "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
|
||||||
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
|
// deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
|
||||||
export const WAKE_THRESHOLD_DEFAULT = 0.6;
|
export const WAKE_THRESHOLD_DEFAULT = 0.45;
|
||||||
export const WAKE_THRESHOLD_MIN = 0.3;
|
export const WAKE_THRESHOLD_MIN = 0.3;
|
||||||
export const WAKE_THRESHOLD_MAX = 0.9;
|
export const WAKE_THRESHOLD_MAX = 0.9;
|
||||||
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
|
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
|
||||||
@@ -77,6 +65,49 @@ export async function saveWakeThreshold(v: number): Promise<void> {
|
|||||||
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
|
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
|
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||||
@@ -103,7 +134,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
|||||||
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
|
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
|
||||||
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
|
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
|
||||||
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
|
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
|
||||||
const DEFAULT_PATIENCE = 2;
|
// patience=1 statt 2: nur EIN Frame ueber Threshold noetig → deutlich schneller.
|
||||||
|
// Speaker-ID filtert Fehlausloeser, also ist das vertretbar.
|
||||||
|
const DEFAULT_PATIENCE = 1;
|
||||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
interface OpenWakeWordModule {
|
interface OpenWakeWordModule {
|
||||||
@@ -143,6 +176,13 @@ class WakeWordService {
|
|||||||
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
||||||
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
||||||
private inBackground: boolean = false;
|
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
|
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
||||||
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
||||||
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
||||||
@@ -150,8 +190,9 @@ class WakeWordService {
|
|||||||
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
|
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
|
||||||
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
|
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
|
||||||
private detectionInProgress: boolean = false;
|
private detectionInProgress: boolean = false;
|
||||||
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
|
/** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
|
||||||
* beendet den listening-State und geht zurueck zu armed. */
|
* das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
|
||||||
|
* trotzdem, zurueck zu armed. */
|
||||||
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
|
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
|
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
|
||||||
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||||
@@ -223,15 +264,20 @@ class WakeWordService {
|
|||||||
this.initInProgress = (async () => {
|
this.initInProgress = (async () => {
|
||||||
try {
|
try {
|
||||||
const threshold = await loadWakeThreshold();
|
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);
|
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||||
// Subscribe nur einmal
|
// Subscribe nur einmal
|
||||||
if (!this.eventSub) {
|
if (!this.eventSub) {
|
||||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
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');
|
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||||
this.onWakeDetected().catch(err =>
|
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
|
||||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
// 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;
|
this.nativeReady = true;
|
||||||
@@ -310,7 +356,7 @@ class WakeWordService {
|
|||||||
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
||||||
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
||||||
* die openWakeWord faelschlich als Trigger interpretiert. */
|
* die openWakeWord faelschlich als Trigger interpretiert. */
|
||||||
setResumeCooldown(ms: number = 1500): void {
|
setResumeCooldown(ms: number = 500): void {
|
||||||
this.cooldownUntilMs = Date.now() + ms;
|
this.cooldownUntilMs = Date.now() + ms;
|
||||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||||
}
|
}
|
||||||
@@ -320,23 +366,45 @@ class WakeWordService {
|
|||||||
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
||||||
setBackground(): void {
|
setBackground(): void {
|
||||||
this.inBackground = true;
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
|
/** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
|
||||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
|
setBgWakeEnabled(enabled: boolean): void {
|
||||||
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
|
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
|
||||||
|
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||||
|
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
||||||
|
* App-Oeffnen wurden verschluckt). */
|
||||||
setForeground(): void {
|
setForeground(): void {
|
||||||
this.inBackground = false;
|
this.inBackground = false;
|
||||||
this.cooldownUntilMs = Date.now() + 3000;
|
this.cooldownUntilMs = Date.now() + 1000;
|
||||||
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
|
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
|
||||||
private async onWakeDetected(): Promise<void> {
|
* preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
|
||||||
if (this.inBackground) {
|
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
|
||||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
|
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
|
||||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
|
if (this.inBackground && !this.bgWakeEnabled) {
|
||||||
|
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
||||||
@@ -379,6 +447,22 @@ class WakeWordService {
|
|||||||
// Kein erneutes setState — wir bleiben in 'conversing'.
|
// Kein erneutes setState — wir bleiben in 'conversing'.
|
||||||
return;
|
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');
|
this.setState('conversing');
|
||||||
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||||
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
||||||
@@ -392,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
|
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
||||||
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
||||||
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
||||||
@@ -486,13 +598,12 @@ class WakeWordService {
|
|||||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
|
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
|
||||||
|
|
||||||
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
|
// Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
|
||||||
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
|
// noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
|
||||||
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
|
// der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
|
||||||
// Anrede weitersprechen.
|
// Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
|
||||||
const passiveMs = await loadPassiveListenMs();
|
if (!skipPassive && this.nativeReady) {
|
||||||
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
|
this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
|
||||||
this.enterPassiveListening(passiveMs);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,10 +645,10 @@ class WakeWordService {
|
|||||||
this.cancelPassiveListenTimer();
|
this.cancelPassiveListenTimer();
|
||||||
this.setState('listening');
|
this.setState('listening');
|
||||||
const seconds = Math.round(durationMs / 1000);
|
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',
|
import('./logger').then(m => m.reportAppDebug('wake.passive',
|
||||||
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
`entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||||
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
|
ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
|
||||||
this.passiveListenTimer = setTimeout(() => {
|
this.passiveListenTimer = setTimeout(() => {
|
||||||
this.passiveListenTimer = null;
|
this.passiveListenTimer = null;
|
||||||
this.exitPassiveListening('timeout').catch(() => {});
|
this.exitPassiveListening('timeout').catch(() => {});
|
||||||
|
|||||||
+210
-6
@@ -1347,6 +1347,149 @@ def _extract_await_marker(text: str) -> tuple:
|
|||||||
return text, False
|
return text, False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
|
||||||
|
#
|
||||||
|
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
|
||||||
|
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
|
||||||
|
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
|
||||||
|
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
|
||||||
|
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
|
||||||
|
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
|
||||||
|
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
|
||||||
|
#
|
||||||
|
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
|
||||||
|
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
|
||||||
|
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
|
||||||
|
# (converse=true) — kein erneutes "Computer" noetig.
|
||||||
|
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
|
||||||
|
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
|
||||||
|
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
|
||||||
|
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_flow_markers(text: str) -> tuple:
|
||||||
|
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
|
||||||
|
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
|
||||||
|
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
|
||||||
|
Regeln: [[STUMM]] alleine = Einzelbefehl → auch converse=false (Mikro zu),
|
||||||
|
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
|
||||||
|
if not text:
|
||||||
|
return text, None, None
|
||||||
|
speak_ov = None
|
||||||
|
conv_ov = None
|
||||||
|
if _SILENT_MARKER_RE.search(text):
|
||||||
|
speak_ov = False
|
||||||
|
text = _SILENT_MARKER_RE.sub("", text)
|
||||||
|
if _END_MARKER_RE.search(text):
|
||||||
|
conv_ov = False
|
||||||
|
text = _END_MARKER_RE.sub("", text)
|
||||||
|
if _CONT_MARKER_RE.search(text):
|
||||||
|
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
|
||||||
|
if conv_ov is None:
|
||||||
|
conv_ov = True
|
||||||
|
text = _CONT_MARKER_RE.sub("", text)
|
||||||
|
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
|
||||||
|
if speak_ov is False and conv_ov is None:
|
||||||
|
conv_ov = False
|
||||||
|
return text.strip(), speak_ov, conv_ov
|
||||||
|
|
||||||
|
|
||||||
|
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
|
||||||
|
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
|
||||||
|
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
|
||||||
|
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
|
||||||
|
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
|
||||||
|
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
|
||||||
|
# o.ae. nicht faelschlich matcht.
|
||||||
|
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
|
||||||
|
_CONV_END_VERB = r"(?:ende|beend\w*|aus|stop\w*|schluss)"
|
||||||
|
_END_CONVERSATION_RE = re.compile(
|
||||||
|
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
|
||||||
|
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_wants_conversation_end(text: str) -> bool:
|
||||||
|
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
|
||||||
|
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||||
|
|
||||||
|
|
||||||
|
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
|
||||||
|
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
|
||||||
|
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
|
||||||
|
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
|
||||||
|
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
|
||||||
|
# "weiter reden/sprechen" ohne Nomen.
|
||||||
|
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
|
||||||
|
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
|
||||||
|
_CONTINUE_CONVERSATION_RE = re.compile(
|
||||||
|
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
|
||||||
|
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
|
||||||
|
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_wants_conversation_continue(text: str) -> bool:
|
||||||
|
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
|
||||||
|
erzwingen — auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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:
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
norm = _strip_leading_hint_blocks(text).lower()
|
norm = _strip_leading_hint_blocks(text).lower()
|
||||||
norm = _fold_umlauts(norm)
|
norm = _fold_umlauts(norm)
|
||||||
@@ -1755,6 +1898,14 @@ class Agent:
|
|||||||
if not user_message:
|
if not user_message:
|
||||||
raise ValueError("Leere Nachricht")
|
raise ValueError("Leere Nachricht")
|
||||||
|
|
||||||
|
# Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
|
||||||
|
# angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
|
||||||
|
# _wants_end → converse=false ("Konversation Ende")
|
||||||
|
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
|
||||||
|
# End hat Vorrang bei Widerspruch.
|
||||||
|
_wants_end = _user_wants_conversation_end(user_message)
|
||||||
|
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
|
||||||
|
|
||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
@@ -1762,6 +1913,27 @@ class Agent:
|
|||||||
active_project_id = (project_id or "").strip()
|
active_project_id = (project_id or "").strip()
|
||||||
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
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.
|
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||||
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||||
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||||
@@ -1782,6 +1954,10 @@ class Agent:
|
|||||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||||
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||||
converse = bool(getattr(self, "_fast_path_converse", False))
|
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||||
return fast_reply, "fast-path", speak, converse, False
|
return fast_reply, "fast-path", speak, converse, False
|
||||||
|
|
||||||
@@ -1801,6 +1977,10 @@ class Agent:
|
|||||||
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||||
speak = getattr(self, "_local_turn_speak", True)
|
speak = getattr(self, "_local_turn_speak", True)
|
||||||
converse = getattr(self, "_local_turn_converse", True)
|
converse = getattr(self, "_local_turn_converse", True)
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
||||||
return local_reply, "local", speak, converse, False
|
return local_reply, "local", speak, converse, False
|
||||||
|
|
||||||
@@ -1818,6 +1998,15 @@ class Agent:
|
|||||||
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
||||||
cold = []
|
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
|
# 4. Aktive Skills holen + Tool-Liste bauen
|
||||||
all_skills = skills_mod.list_skills(active_only=False)
|
all_skills = skills_mod.list_skills(active_only=False)
|
||||||
active_skills = [s for s in all_skills if s.get("active", True)]
|
active_skills = [s for s in all_skills if s.get("active", True)]
|
||||||
@@ -1840,7 +2029,8 @@ class Agent:
|
|||||||
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
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"
|
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,
|
triggers=all_triggers,
|
||||||
condition_vars=condition_vars,
|
condition_vars=condition_vars,
|
||||||
condition_funcs=condition_funcs,
|
condition_funcs=condition_funcs,
|
||||||
@@ -2033,16 +2223,30 @@ class Agent:
|
|||||||
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
|
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
|
||||||
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
|
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
|
||||||
final_reply, awaiting_reply = _extract_await_marker(final_reply)
|
final_reply, awaiting_reply = _extract_await_marker(final_reply)
|
||||||
|
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
|
||||||
|
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
|
||||||
|
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
|
||||||
|
|
||||||
# 7. Assistant-Turn (final reply) in die Conversation
|
# 7. Assistant-Turn (final reply) in die Conversation
|
||||||
self.conversation.add("assistant", final_reply,
|
self.conversation.add("assistant", final_reply,
|
||||||
project_id=active_project_id)
|
project_id=active_project_id)
|
||||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
|
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
|
||||||
|
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
|
||||||
|
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
|
||||||
|
speak = bool(getattr(self, "_claude_turn_speak", True))
|
||||||
|
converse = bool(getattr(self, "_claude_turn_converse", True))
|
||||||
|
if _speak_ov is not None:
|
||||||
|
speak = _speak_ov
|
||||||
|
if _conv_ov is not None:
|
||||||
|
converse = _conv_ov
|
||||||
|
# Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
|
||||||
|
# beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||||
return (final_reply, "claude",
|
return (final_reply, "claude", speak, converse, awaiting_reply)
|
||||||
bool(getattr(self, "_claude_turn_speak", True)),
|
|
||||||
bool(getattr(self, "_claude_turn_converse", True)),
|
|
||||||
awaiting_reply)
|
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
|
|||||||
pinned: bool = False
|
pinned: bool = False
|
||||||
category: str = ""
|
category: str = ""
|
||||||
source: str = "manual"
|
source: str = "manual"
|
||||||
|
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||||
tags: List[str] = Field(default_factory=list)
|
tags: List[str] = Field(default_factory=list)
|
||||||
conversation_id: Optional[str] = None
|
conversation_id: Optional[str] = None
|
||||||
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
||||||
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
|
|||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
pinned: Optional[bool] = None
|
pinned: Optional[bool] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
scope: Optional[str] = None # system | personal
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
|
|||||||
pinned: bool
|
pinned: bool
|
||||||
category: str
|
category: str
|
||||||
source: str
|
source: str
|
||||||
|
scope: str = "personal"
|
||||||
tags: List[str]
|
tags: List[str]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
|
|||||||
pinned=body.pinned,
|
pinned=body.pinned,
|
||||||
category=body.category,
|
category=body.category,
|
||||||
source=body.source,
|
source=body.source,
|
||||||
|
scope=body.scope,
|
||||||
tags=body.tags,
|
tags=body.tags,
|
||||||
conversation_id=body.conversation_id,
|
conversation_id=body.conversation_id,
|
||||||
attachments=body.attachments or [],
|
attachments=body.attachments or [],
|
||||||
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
|
|||||||
existing.pinned = body.pinned
|
existing.pinned = body.pinned
|
||||||
if body.category is not None:
|
if body.category is not None:
|
||||||
existing.category = body.category
|
existing.category = body.category
|
||||||
|
if body.scope is not None:
|
||||||
|
existing.scope = body.scope
|
||||||
if body.tags is not None:
|
if body.tags is not None:
|
||||||
existing.tags = body.tags
|
existing.tags = body.tags
|
||||||
|
|
||||||
@@ -537,12 +543,23 @@ def memory_import_files():
|
|||||||
# Wiederherstellen einer schlanken ARIA nach Wipe.
|
# Wiederherstellen einer schlanken ARIA nach Wipe.
|
||||||
|
|
||||||
@app.get("/memory/export-bootstrap")
|
@app.get("/memory/export-bootstrap")
|
||||||
def memory_export_bootstrap():
|
def memory_export_bootstrap(scope: str = "system"):
|
||||||
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download."""
|
"""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()
|
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 {
|
return {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
|
"scope": scope,
|
||||||
"exported_at": __import__("datetime").datetime.now(
|
"exported_at": __import__("datetime").datetime.now(
|
||||||
__import__("datetime").timezone.utc
|
__import__("datetime").timezone.utc
|
||||||
).isoformat(),
|
).isoformat(),
|
||||||
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
|
|||||||
"pinned": True,
|
"pinned": True,
|
||||||
"category": p.category,
|
"category": p.category,
|
||||||
"source": p.source,
|
"source": p.source,
|
||||||
|
"scope": p.scope,
|
||||||
"tags": p.tags,
|
"tags": p.tags,
|
||||||
}
|
}
|
||||||
for p in pinned
|
for p in pinned
|
||||||
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
|
|||||||
|
|
||||||
class BootstrapBundle(BaseModel):
|
class BootstrapBundle(BaseModel):
|
||||||
version: int = 1
|
version: int = 1
|
||||||
|
scope: Optional[str] = None # system | personal | all (aus dem Export)
|
||||||
memories: List[dict]
|
memories: List[dict]
|
||||||
|
|
||||||
|
|
||||||
@app.post("/memory/import-bootstrap")
|
@app.post("/memory/import-bootstrap")
|
||||||
def memory_import_bootstrap(body: BootstrapBundle):
|
def memory_import_bootstrap(body: BootstrapBundle):
|
||||||
"""Loescht alle pinned Memories und importiert die im Bundle.
|
"""Importiert ein Bootstrap-Bundle scope-sicher.
|
||||||
Cold Memory (unpinned) bleibt unangetastet.
|
|
||||||
|
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
|
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
|
||||||
Caller soll erst exportieren und dann importieren.
|
Caller soll erst exportieren und dann importieren.
|
||||||
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
|||||||
|
|
||||||
s = store()
|
s = store()
|
||||||
e = embedder()
|
e = embedder()
|
||||||
|
|
||||||
# Alle aktuell pinned Punkte loeschen
|
|
||||||
from qdrant_client.http import models as qm
|
from qdrant_client.http import models as qm
|
||||||
from memory.vector_store import COLLECTION
|
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(
|
s.client.delete(
|
||||||
collection_name=COLLECTION,
|
collection_name=COLLECTION,
|
||||||
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
|
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
|
||||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
|
||||||
])),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Neue Punkte einspeisen
|
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
|
||||||
created = 0
|
created = 0
|
||||||
for m in body.memories:
|
for m in body.memories:
|
||||||
content = (m.get("content") or "").strip()
|
content = (m.get("content") or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
|
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
|
||||||
point = MemoryPoint(
|
point = MemoryPoint(
|
||||||
id="",
|
id="",
|
||||||
type=m.get("type", "fact"),
|
type=m.get("type", "fact"),
|
||||||
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
|||||||
pinned=True,
|
pinned=True,
|
||||||
category=m.get("category", ""),
|
category=m.get("category", ""),
|
||||||
source=m.get("source", "bootstrap-import"),
|
source=m.get("source", "bootstrap-import"),
|
||||||
|
scope=mscope,
|
||||||
tags=list(m.get("tags", [])),
|
tags=list(m.get("tags", [])),
|
||||||
)
|
)
|
||||||
vec = e.embed(content)
|
vec = e.embed(content)
|
||||||
s.upsert(point, vec)
|
s.upsert(point, vec)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
return {"created": created, "deleted_previous_pinned": True}
|
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
|
||||||
|
|
||||||
|
|
||||||
# ─── Conversation-Loop ──────────────────────────────────────────────
|
# ─── Conversation-Loop ──────────────────────────────────────────────
|
||||||
@@ -644,6 +676,11 @@ class ChatOut(BaseModel):
|
|||||||
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
|
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
|
||||||
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
|
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
|
||||||
awaiting_reply: bool = False
|
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
|
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||||
# UI landet.
|
# UI landet.
|
||||||
@@ -753,6 +790,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
|||||||
speak=speak,
|
speak=speak,
|
||||||
converse=converse,
|
converse=converse,
|
||||||
awaiting_reply=awaiting_reply,
|
awaiting_reply=awaiting_reply,
|
||||||
|
wake_off=(answered_by == "wake-off"),
|
||||||
|
wake_on=(answered_by == "wake-on"),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_project_pending[pid] = [
|
_project_pending[pid] = [
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
|
|||||||
content — eigentlicher Text (wird embedded)
|
content — eigentlicher Text (wird embedded)
|
||||||
pinned — bool, True = Hot Memory (immer in Prompt)
|
pinned — bool, True = Hot Memory (immer in Prompt)
|
||||||
source — import | conversation | manual
|
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
|
tags — Liste von Strings
|
||||||
created_at, updated_at — ISO-Strings
|
created_at, updated_at — ISO-Strings
|
||||||
conversation_id — optional, nur fuer type=conversation
|
conversation_id — optional, nur fuer type=conversation
|
||||||
@@ -55,6 +59,7 @@ class MemoryPoint:
|
|||||||
pinned: bool = False
|
pinned: bool = False
|
||||||
category: str = ""
|
category: str = ""
|
||||||
source: str = "manual"
|
source: str = "manual"
|
||||||
|
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||||
tags: List[str] = field(default_factory=list)
|
tags: List[str] = field(default_factory=list)
|
||||||
created_at: str = ""
|
created_at: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
@@ -74,6 +79,7 @@ class MemoryPoint:
|
|||||||
"pinned": self.pinned,
|
"pinned": self.pinned,
|
||||||
"category": self.category,
|
"category": self.category,
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
|
"scope": self.scope,
|
||||||
"tags": self.tags,
|
"tags": self.tags,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"updated_at": self.updated_at,
|
"updated_at": self.updated_at,
|
||||||
@@ -94,6 +100,7 @@ class MemoryPoint:
|
|||||||
pinned=payload.get("pinned", False),
|
pinned=payload.get("pinned", False),
|
||||||
category=payload.get("category", ""),
|
category=payload.get("category", ""),
|
||||||
source=payload.get("source", "manual"),
|
source=payload.get("source", "manual"),
|
||||||
|
scope=payload.get("scope", "personal"),
|
||||||
tags=payload.get("tags", []),
|
tags=payload.get("tags", []),
|
||||||
created_at=payload.get("created_at", ""),
|
created_at=payload.get("created_at", ""),
|
||||||
updated_at=payload.get("updated_at", ""),
|
updated_at=payload.get("updated_at", ""),
|
||||||
@@ -120,14 +127,23 @@ class VectorStore:
|
|||||||
collection_name=COLLECTION,
|
collection_name=COLLECTION,
|
||||||
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
|
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
|
||||||
)
|
)
|
||||||
# Indexe fuer typische Filter-Felder
|
# Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
|
||||||
for field_name in ("type", "pinned", "category", "source", "migration_key"):
|
# 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(
|
self.client.create_payload_index(
|
||||||
collection_name=COLLECTION,
|
collection_name=COLLECTION,
|
||||||
field_name=field_name,
|
field_name=field_name,
|
||||||
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
|
field_schema=schema,
|
||||||
else qm.PayloadSchemaType.BOOL,
|
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
# Index existiert bereits — kein Problem.
|
||||||
|
pass
|
||||||
|
|
||||||
# ─── Schreib-Operationen ─────────────────────────────────────────
|
# ─── Schreib-Operationen ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,6 +180,38 @@ class VectorStore:
|
|||||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
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]:
|
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
|
||||||
return self._scroll(
|
return self._scroll(
|
||||||
filter=qm.Filter(must=[
|
filter=qm.Filter(must=[
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
|
|||||||
type_="preference", title=f"User: {btitle}",
|
type_="preference", title=f"User: {btitle}",
|
||||||
content=btext, category="allgemein",
|
content=btext, category="allgemein",
|
||||||
migration_key=f"{source_file}/general-{idx}",
|
migration_key=f"{source_file}/general-{idx}",
|
||||||
|
scope="personal",
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
|
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,
|
type_="preference", title=title,
|
||||||
content=content, category=cat_key,
|
content=content, category=cat_key,
|
||||||
migration_key=f"{source_file}/{cat_key}",
|
migration_key=f"{source_file}/{cat_key}",
|
||||||
|
scope="personal",
|
||||||
))
|
))
|
||||||
return points
|
return points
|
||||||
|
|
||||||
@@ -283,7 +285,11 @@ def _mk(
|
|||||||
migration_key: str,
|
migration_key: str,
|
||||||
pinned: bool = True,
|
pinned: bool = True,
|
||||||
category: str = "",
|
category: str = "",
|
||||||
|
scope: str = "system",
|
||||||
) -> MemoryPoint:
|
) -> 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(
|
p = MemoryPoint(
|
||||||
id="",
|
id="",
|
||||||
type=type_,
|
type=type_,
|
||||||
@@ -292,6 +298,7 @@ def _mk(
|
|||||||
pinned=pinned,
|
pinned=pinned,
|
||||||
category=category,
|
category=category,
|
||||||
source="import",
|
source="import",
|
||||||
|
scope=scope,
|
||||||
tags=[],
|
tags=[],
|
||||||
)
|
)
|
||||||
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
|
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
|
||||||
|
|||||||
+76
-1
@@ -162,6 +162,46 @@ def build_time_section() -> str:
|
|||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def build_voice_flow_section() -> str:
|
||||||
|
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
|
||||||
|
Frage, Kette vs. Ende) und deklariert sie per Marker — wie [[AWAIT]]. Die
|
||||||
|
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
|
||||||
|
return "\n".join([
|
||||||
|
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
|
||||||
|
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
|
||||||
|
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
|
||||||
|
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
|
||||||
|
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
|
||||||
|
"",
|
||||||
|
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
|
||||||
|
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
|
||||||
|
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
|
||||||
|
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
|
||||||
|
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
|
||||||
|
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
|
||||||
|
"Wake-Word.",
|
||||||
|
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
|
||||||
|
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
|
||||||
|
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
|
||||||
|
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
|
||||||
|
"oder das Gespraech klar weitergeht.",
|
||||||
|
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
|
||||||
|
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
|
||||||
|
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
|
||||||
|
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
|
||||||
|
"`[[ENDE]]` an.",
|
||||||
|
"",
|
||||||
|
"Regeln:",
|
||||||
|
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
|
||||||
|
"ohne Marker (wird vorgelesen).",
|
||||||
|
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
|
||||||
|
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
|
||||||
|
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||||
|
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||||
|
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
TYPE_HEADINGS = {
|
TYPE_HEADINGS = {
|
||||||
"identity": "## Wer du bist",
|
"identity": "## Wer du bist",
|
||||||
"rule": "## Sicherheitsregeln & Prinzipien",
|
"rule": "## Sicherheitsregeln & Prinzipien",
|
||||||
@@ -260,6 +300,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
|||||||
return "\n".join(lines)
|
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:
|
def build_skills_section(skills: List[dict]) -> str:
|
||||||
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
|
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
|
||||||
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
|
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
|
||||||
@@ -450,6 +520,7 @@ def build_flux_section(flux_config: dict) -> str:
|
|||||||
def build_system_prompt(
|
def build_system_prompt(
|
||||||
pinned: List[MemoryPoint],
|
pinned: List[MemoryPoint],
|
||||||
cold: List[MemoryPoint] | None = None,
|
cold: List[MemoryPoint] | None = None,
|
||||||
|
memory_index: List[MemoryPoint] | None = None,
|
||||||
skills: List[dict] | None = None,
|
skills: List[dict] | None = None,
|
||||||
triggers: List[dict] | None = None,
|
triggers: List[dict] | None = None,
|
||||||
condition_vars: List[dict] | None = None,
|
condition_vars: List[dict] | None = None,
|
||||||
@@ -463,7 +534,8 @@ def build_system_prompt(
|
|||||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
|
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||||
|
"", build_voice_flow_section()]
|
||||||
if skills:
|
if skills:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_skills_section(skills))
|
parts.append(build_skills_section(skills))
|
||||||
@@ -482,6 +554,9 @@ def build_system_prompt(
|
|||||||
callback_host=oauth_callback_host,
|
callback_host=oauth_callback_host,
|
||||||
callback_port=oauth_callback_port,
|
callback_port=oauth_callback_port,
|
||||||
callback_tls=oauth_callback_tls))
|
callback_tls=oauth_callback_tls))
|
||||||
|
if memory_index:
|
||||||
|
parts.append("")
|
||||||
|
parts.append(build_memory_index_section(memory_index))
|
||||||
if cold:
|
if cold:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_cold_memory_section(cold))
|
parts.append(build_cold_memory_section(cold))
|
||||||
|
|||||||
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
|
|||||||
"pinned": True,
|
"pinned": True,
|
||||||
"category": rule.get("category", ""),
|
"category": rule.get("category", ""),
|
||||||
"source": "seed",
|
"source": "seed",
|
||||||
|
"scope": "system",
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
|
|||||||
+12
-1
@@ -1622,6 +1622,11 @@ class ARIABridge:
|
|||||||
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
|
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
|
||||||
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
|
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
|
||||||
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
|
"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),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
@@ -2017,6 +2022,10 @@ class ARIABridge:
|
|||||||
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
|
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
|
||||||
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
|
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
|
||||||
awaiting_reply = bool(data.get("awaiting_reply", False))
|
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)
|
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||||
# damit sie in der UI vor der Reply auftauchen
|
# damit sie in der UI vor der Reply auftauchen
|
||||||
@@ -2108,7 +2117,9 @@ class ARIABridge:
|
|||||||
"answeredBy": answered_by,
|
"answeredBy": answered_by,
|
||||||
"speak": speak,
|
"speak": speak,
|
||||||
"converse": converse,
|
"converse": converse,
|
||||||
"awaiting_reply": awaiting_reply})
|
"awaiting_reply": awaiting_reply,
|
||||||
|
"wake_off": wake_off,
|
||||||
|
"wake_on": wake_on})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[brain] _process_core_response Fehler")
|
logger.exception("[brain] _process_core_response Fehler")
|
||||||
await self._emit_activity("idle", "", project_id=project_id)
|
await self._emit_activity("idle", "", project_id=project_id)
|
||||||
|
|||||||
+53
-13
@@ -788,6 +788,18 @@
|
|||||||
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
||||||
Status wird geladen...
|
Status wird geladen...
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||||
|
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
|
||||||
|
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
|
||||||
|
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
|
||||||
|
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
|
||||||
|
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
|
||||||
|
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
|
||||||
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
|
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
|
||||||
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
||||||
@@ -1068,11 +1080,13 @@
|
|||||||
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
|
<div style="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:#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;">
|
<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.
|
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.
|
||||||
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer.
|
<span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
|
||||||
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet.
|
Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
|
||||||
</div>
|
</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)">
|
<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>
|
<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>
|
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
|
||||||
@@ -1396,6 +1410,11 @@
|
|||||||
<input type="checkbox" id="memory-pinned">
|
<input type="checkbox" id="memory-pinned">
|
||||||
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
||||||
</label>
|
</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 -->
|
<!-- 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;">
|
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||||
@@ -1899,6 +1918,11 @@
|
|||||||
if (slider) slider.value = msg.voiceIdThreshold;
|
if (slider) slider.value = msg.voiceIdThreshold;
|
||||||
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
||||||
}
|
}
|
||||||
|
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
|
||||||
|
{
|
||||||
|
const cb = document.getElementById('diag-voice-id-enabled');
|
||||||
|
if (cb) cb.checked = !!msg.voiceIdEnabled;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3600,13 +3624,14 @@
|
|||||||
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
||||||
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
||||||
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||||
|
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
|
||||||
send({
|
send({
|
||||||
action: 'send_voice_config',
|
action: 'send_voice_config',
|
||||||
ttsEnabled, xttsVoice, whisperModel,
|
ttsEnabled, xttsVoice, whisperModel,
|
||||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||||
f5ttsCfgStrength, f5ttsNfeStep,
|
f5ttsCfgStrength, f5ttsNfeStep,
|
||||||
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
||||||
voiceIdThreshold,
|
voiceIdThreshold, voiceIdEnabled,
|
||||||
});
|
});
|
||||||
const statusEl = document.getElementById('voice-status');
|
const statusEl = document.getElementById('voice-status');
|
||||||
if (statusEl && xttsVoice) {
|
if (statusEl && xttsVoice) {
|
||||||
@@ -5786,9 +5811,15 @@
|
|||||||
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
const 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 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>` : '';
|
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;">
|
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="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>` : ''}
|
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||||
@@ -5998,6 +6029,7 @@
|
|||||||
document.getElementById('memory-category').value = m.category || '';
|
document.getElementById('memory-category').value = m.category || '';
|
||||||
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
||||||
document.getElementById('memory-pinned').checked = !!m.pinned;
|
document.getElementById('memory-pinned').checked = !!m.pinned;
|
||||||
|
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
|
||||||
// Anhang-Block sichtbar — Liste rendern
|
// Anhang-Block sichtbar — Liste rendern
|
||||||
if (attBlock) attBlock.style.display = 'block';
|
if (attBlock) attBlock.style.display = 'block';
|
||||||
if (attHint) attHint.style.display = 'none';
|
if (attHint) attHint.style.display = 'none';
|
||||||
@@ -6011,6 +6043,7 @@
|
|||||||
document.getElementById('memory-category').value = '';
|
document.getElementById('memory-category').value = '';
|
||||||
document.getElementById('memory-tags').value = '';
|
document.getElementById('memory-tags').value = '';
|
||||||
document.getElementById('memory-pinned').checked = false;
|
document.getElementById('memory-pinned').checked = false;
|
||||||
|
document.getElementById('memory-scope').value = 'personal';
|
||||||
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
||||||
if (attBlock) attBlock.style.display = 'none';
|
if (attBlock) attBlock.style.display = 'none';
|
||||||
if (attHint) attHint.style.display = 'block';
|
if (attHint) attHint.style.display = 'block';
|
||||||
@@ -6115,6 +6148,7 @@
|
|||||||
const category = document.getElementById('memory-category').value.trim();
|
const category = document.getElementById('memory-category').value.trim();
|
||||||
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
|
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
const pinned = document.getElementById('memory-pinned').checked;
|
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 (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
|
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
@@ -6125,13 +6159,13 @@
|
|||||||
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
|
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title, content, pinned, category, tags }),
|
body: JSON.stringify({ title, content, pinned, category, scope, tags }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
r = await fetch('/api/brain/memory/save', {
|
r = await fetch('/api/brain/memory/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!r.ok) {
|
||||||
@@ -6508,11 +6542,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bootstrap Export / Import ──────────────────────────
|
// ── Bootstrap Export / Import ──────────────────────────
|
||||||
async function exportBootstrap() {
|
async function exportBootstrap(scope) {
|
||||||
|
scope = scope || 'system';
|
||||||
const status = document.getElementById('bootstrap-status');
|
const status = document.getElementById('bootstrap-status');
|
||||||
if (status) status.innerHTML = '⏳ Lade...';
|
if (status) status.innerHTML = '⏳ Lade...';
|
||||||
try {
|
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);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
@@ -6520,10 +6555,11 @@
|
|||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `aria-bootstrap-${ts}.json`;
|
a.download = `aria-bootstrap-${scope}-${ts}.json`;
|
||||||
document.body.appendChild(a); a.click();
|
document.body.appendChild(a); a.click();
|
||||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
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) {
|
} catch (e) {
|
||||||
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
|
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
|
||||||
}
|
}
|
||||||
@@ -6537,7 +6573,11 @@
|
|||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const bundle = JSON.parse(text);
|
const bundle = JSON.parse(text);
|
||||||
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
|
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 = '';
|
event.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
|
|||||||
const t = parseFloat(msg.voiceIdThreshold);
|
const t = parseFloat(msg.voiceIdThreshold);
|
||||||
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
||||||
}
|
}
|
||||||
|
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
|
||||||
|
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
|
||||||
|
// config-Broadcast; aus = gar keine Pruefung.
|
||||||
|
if (msg.voiceIdEnabled !== undefined) {
|
||||||
|
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync("/shared/config", { recursive: true });
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
volumes:
|
volumes:
|
||||||
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
||||||
|
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||||
environment:
|
environment:
|
||||||
- RVS_HOST=${RVS_HOST}
|
- RVS_HOST=${RVS_HOST}
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ COPY requirements.txt .
|
|||||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||||
|
|
||||||
COPY bridge.py .
|
COPY bridge.py speaker_id.py ./
|
||||||
|
|
||||||
CMD ["python3", "bridge.py"]
|
CMD ["python3", "bridge.py"]
|
||||||
|
|||||||
+393
-1
@@ -29,6 +29,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -38,6 +39,8 @@ import numpy as np
|
|||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
@@ -59,6 +62,7 @@ STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "
|
|||||||
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
||||||
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
||||||
STREAM_MIN_AUDIO_MS = 600
|
STREAM_MIN_AUDIO_MS = 600
|
||||||
|
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
|
||||||
STREAM_SESSION_TTL_S = 120
|
STREAM_SESSION_TTL_S = 120
|
||||||
STREAM_ENERGY_WINDOW_MS = 300
|
STREAM_ENERGY_WINDOW_MS = 300
|
||||||
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
||||||
@@ -66,6 +70,108 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
|||||||
STREAM_VOICE_FACTOR = 2.5
|
STREAM_VOICE_FACTOR = 2.5
|
||||||
STREAM_VOICE_RMS_MIN = 0.005
|
STREAM_VOICE_RMS_MIN = 0.005
|
||||||
STREAM_VOICE_RMS_MAX = 0.020
|
STREAM_VOICE_RMS_MAX = 0.020
|
||||||
|
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
|
||||||
|
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
|
||||||
|
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
|
||||||
|
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
|
||||||
|
|
||||||
|
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
|
||||||
|
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
|
||||||
|
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
|
||||||
|
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
|
||||||
|
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
|
||||||
|
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
|
||||||
|
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
|
||||||
|
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
|
||||||
|
_HALLUCINATION_RE = re.compile(
|
||||||
|
r"untertitel"
|
||||||
|
r"|amara\.org"
|
||||||
|
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
|
||||||
|
r"|bis\s+zum\s+n[aä]chsten\s+mal"
|
||||||
|
r"|abonnier"
|
||||||
|
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
|
||||||
|
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
|
||||||
|
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
|
||||||
|
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
|
||||||
|
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
|
||||||
|
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def _collapse_repetitions(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = text
|
||||||
|
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
|
||||||
|
new = _REPEAT_RE.sub(r"\1", out)
|
||||||
|
if new == out:
|
||||||
|
break
|
||||||
|
out = new
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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-
|
||||||
|
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
|
||||||
|
# vorbelegt werden.
|
||||||
|
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_speaker_id_enabled(val: bool) -> None:
|
||||||
|
global SPEAKER_ID_ENABLED
|
||||||
|
SPEAKER_ID_ENABLED = bool(val)
|
||||||
|
|
||||||
|
|
||||||
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
||||||
@@ -123,7 +229,20 @@ class VoxtralRunner:
|
|||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
||||||
# mehrminutige Aufnahmen abgeschnitten.
|
# 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]:]
|
trimmed = outputs[:, inputs.input_ids.shape[1]:]
|
||||||
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
||||||
return (text[0] if text else "").strip()
|
return (text[0] if text else "").strip()
|
||||||
@@ -165,6 +284,18 @@ class StreamSession:
|
|||||||
noise_floor: float = 0.0
|
noise_floor: float = 0.0
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
endpoint_sent: bool = False
|
endpoint_sent: bool = False
|
||||||
|
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||||
|
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||||
|
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
|
||||||
|
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
||||||
|
speech_signaled: bool = False
|
||||||
|
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
|
||||||
|
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
|
||||||
|
voiced_frames: int = 0
|
||||||
|
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||||
|
speaker_checked: bool = False
|
||||||
|
speaker_match: Optional[bool] = None
|
||||||
|
speaker_similarity: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
@@ -259,6 +390,63 @@ class SessionManager:
|
|||||||
else:
|
else:
|
||||||
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
||||||
|
|
||||||
|
async def _check_speaker(self, sess: StreamSession) -> None:
|
||||||
|
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
||||||
|
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
||||||
|
sess.speaker_checked = True
|
||||||
|
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||||
|
if not SPEAKER_ID_ENABLED:
|
||||||
|
sess.speaker_match = True
|
||||||
|
return
|
||||||
|
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||||
|
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
||||||
|
sess.speaker_match = True
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
|
||||||
|
sess.request_id[:8], exc)
|
||||||
|
sess.speaker_match = True
|
||||||
|
return
|
||||||
|
sess.speaker_match = is_match
|
||||||
|
sess.speaker_similarity = sim
|
||||||
|
logger.info("Stream %s: speaker-check sim=%.2f → %s (thr=%.2f)",
|
||||||
|
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
|
||||||
|
speaker_id.DEFAULT_THRESHOLD)
|
||||||
|
if not is_match:
|
||||||
|
await self._finalize_speaker_mismatch(sess, sim)
|
||||||
|
|
||||||
|
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
|
||||||
|
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
|
||||||
|
Session droppen — kein Voxtral-Transcribe, kein Brain-Call."""
|
||||||
|
if sess.endpoint_sent:
|
||||||
|
return
|
||||||
|
sess.endpoint_sent = True
|
||||||
|
duration_s = self._buffer_ms(sess) / 1000.0
|
||||||
|
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
|
||||||
|
sess.request_id[:8], similarity, duration_s)
|
||||||
|
if self._ws is not None:
|
||||||
|
payload = {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "", "reason": "speaker_mismatch",
|
||||||
|
"durationS": duration_s, "sttMs": 0,
|
||||||
|
"voice": sess.voice, "speed": sess.speed,
|
||||||
|
"interrupted": sess.interrupted,
|
||||||
|
"speakerSimilarity": float(similarity),
|
||||||
|
}
|
||||||
|
if sess.location:
|
||||||
|
payload["location"] = sess.location
|
||||||
|
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": "speaker_mismatch",
|
||||||
|
})
|
||||||
|
self.drop(sess.request_id)
|
||||||
|
|
||||||
async def run_endpointer(self) -> None:
|
async def run_endpointer(self) -> None:
|
||||||
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
|
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
|
||||||
STREAM_TRANSCRIBE_INTERVAL_MS)
|
STREAM_TRANSCRIBE_INTERVAL_MS)
|
||||||
@@ -286,6 +474,12 @@ class SessionManager:
|
|||||||
return
|
return
|
||||||
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
|
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
|
||||||
return
|
return
|
||||||
|
# Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
|
||||||
|
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
|
||||||
|
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
|
||||||
|
await self._check_speaker(sess)
|
||||||
|
if sess.speaker_match is False:
|
||||||
|
return
|
||||||
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
|
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
|
||||||
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
|
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
|
||||||
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
|
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
|
||||||
@@ -295,17 +489,111 @@ class SessionManager:
|
|||||||
rms = self._tail_rms(sess)
|
rms = self._tail_rms(sess)
|
||||||
if rms >= self._voice_threshold(sess):
|
if rms >= self._voice_threshold(sess):
|
||||||
sess.last_voice_at = now
|
sess.last_voice_at = now
|
||||||
|
sess.voiced_frames += 1
|
||||||
|
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
|
||||||
|
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
|
||||||
|
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
|
||||||
|
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
|
||||||
|
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
|
||||||
|
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
|
||||||
|
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
|
||||||
|
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
|
||||||
|
if (not sess.speech_signaled and self._ws is not None
|
||||||
|
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
|
||||||
|
sess.speech_signaled = True
|
||||||
|
await _send(self._ws, "stt_partial", {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "",
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
self._update_noise_floor(sess, rms)
|
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?
|
# 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:
|
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
|
||||||
await self._finalize(sess, "endpoint")
|
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:
|
async def _finalize(self, sess: StreamSession, reason: str) -> None:
|
||||||
if sess.endpoint_sent:
|
if sess.endpoint_sent:
|
||||||
return
|
return
|
||||||
sess.endpoint_sent = True
|
sess.endpoint_sent = True
|
||||||
|
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
||||||
|
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
||||||
|
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
|
||||||
|
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
|
||||||
|
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
|
||||||
|
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
|
||||||
|
#
|
||||||
|
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
|
||||||
|
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
|
||||||
|
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
|
||||||
|
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
|
||||||
|
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||||
|
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||||
|
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||||
|
if sess.voiced_frames < _min_voiced:
|
||||||
|
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||||
|
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||||
|
if self._ws is not None:
|
||||||
|
nospeech = {"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "", "reason": f"no_speech:{reason}",
|
||||||
|
"durationS": 0.0, "sttMs": 0}
|
||||||
|
await _send(self._ws, "stt_endpoint", nospeech)
|
||||||
|
await _send(self._ws, "stt_stream_done", {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "", "reason": f"no_speech:{reason}"})
|
||||||
|
self.drop(sess.request_id)
|
||||||
|
return
|
||||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
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()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
|
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
|
||||||
@@ -314,8 +602,43 @@ class SessionManager:
|
|||||||
final_text = sess.last_partial
|
final_text = sess.last_partial
|
||||||
stt_ms = int((time.time() - t0) * 1000)
|
stt_ms = int((time.time() - t0) * 1000)
|
||||||
duration_s = audio.size / 16000.0
|
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",
|
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
|
||||||
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
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:
|
if self._ws is not None:
|
||||||
payload = {
|
payload = {
|
||||||
"requestId": sess.request_id,
|
"requestId": sess.request_id,
|
||||||
@@ -377,6 +700,75 @@ async def run_loop(sessions: SessionManager) -> None:
|
|||||||
sessions.feed_chunk(payload)
|
sessions.feed_chunk(payload)
|
||||||
elif mtype == "stt_stream_end":
|
elif mtype == "stt_stream_end":
|
||||||
sessions.end_session(payload.get("requestId", ""))
|
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:
|
||||||
|
status = speaker_id.status()
|
||||||
|
await _send(ws, "voice_id_status_response",
|
||||||
|
{"requestId": req_id, "ok": True, **status})
|
||||||
|
except Exception as exc:
|
||||||
|
await _send(ws, "voice_id_status_response",
|
||||||
|
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
|
||||||
|
elif mtype == "voice_id_enroll_request":
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
samples = payload.get("samples") or []
|
||||||
|
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, speaker_id.enroll_from_samples, samples)
|
||||||
|
await _send(ws, "voice_id_enroll_response", {
|
||||||
|
"requestId": req_id, "ok": True,
|
||||||
|
"sample_count": result.get("sample_count", 0),
|
||||||
|
"rejected": result.get("rejected", []),
|
||||||
|
"updated_at": result.get("updated_at"),
|
||||||
|
"embedding_dim": result.get("embedding_dim"),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("voice_id_enroll failed: %s", exc)
|
||||||
|
await _send(ws, "voice_id_enroll_response",
|
||||||
|
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
|
||||||
|
elif mtype == "voice_id_delete_request":
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
removed = speaker_id.delete_fingerprint()
|
||||||
|
await _send(ws, "voice_id_delete_response",
|
||||||
|
{"requestId": req_id, "ok": True, "removed": removed})
|
||||||
|
elif mtype == "config":
|
||||||
|
if "voiceIdThreshold" in payload:
|
||||||
|
try:
|
||||||
|
t = float(payload.get("voiceIdThreshold", 0.5))
|
||||||
|
if 0.0 <= t <= 1.0:
|
||||||
|
speaker_id.DEFAULT_THRESHOLD = t
|
||||||
|
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if "voiceIdEnabled" in payload:
|
||||||
|
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||||
|
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||||
|
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
transformers>=4.54
|
transformers>=4.54
|
||||||
mistral-common[audio]>=1.8.1
|
mistral-common[audio]>=1.8.1
|
||||||
accelerate>=0.30
|
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
|
soundfile>=0.12
|
||||||
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
|
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
|
||||||
numpy>=1.24
|
numpy>=1.24
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""
|
||||||
|
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
|
||||||
|
|
||||||
|
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
|
||||||
|
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
|
||||||
|
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
|
||||||
|
|
||||||
|
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
|
||||||
|
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
|
||||||
|
|
||||||
|
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
|
||||||
|
verify() → (True, 0.0) — Fail-open, damit Speaker-ID-Gating den
|
||||||
|
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
|
||||||
|
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
|
||||||
|
|
||||||
|
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
|
||||||
|
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
|
||||||
|
# Diagnostic-Setting feintunen.
|
||||||
|
DEFAULT_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
|
||||||
|
MIN_SAMPLE_BYTES = 32000
|
||||||
|
|
||||||
|
_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_loaded():
|
||||||
|
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
|
||||||
|
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
|
||||||
|
danach <1s warm. Wirft bei Fehler — Caller muss catchen + fail-open."""
|
||||||
|
global _model
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
import torch
|
||||||
|
from speechbrain.inference.speaker import EncoderClassifier
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
|
||||||
|
_model = EncoderClassifier.from_hparams(
|
||||||
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||||
|
savedir="/root/.cache/huggingface/speechbrain-ecapa",
|
||||||
|
run_opts={"device": device},
|
||||||
|
)
|
||||||
|
logger.info("[speaker-id] model ready (device=%s)", device)
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||||
|
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||||
|
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||||
|
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||||
|
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
tmp = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||||
|
tf.write(audio_bytes)
|
||||||
|
tmp = tf.name
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||||
|
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0 or not proc.stdout:
|
||||||
|
raise ValueError(
|
||||||
|
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||||
|
return proc.stdout
|
||||||
|
finally:
|
||||||
|
if tmp:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||||
|
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||||
|
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||||
|
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||||
|
if (len(audio_bytes) >= 44
|
||||||
|
and audio_bytes[:4] == b"RIFF"
|
||||||
|
and audio_bytes[8:12] == b"WAVE"):
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
|
||||||
|
sr = wav.getframerate()
|
||||||
|
ch = wav.getnchannels()
|
||||||
|
sw = wav.getsampwidth()
|
||||||
|
if sr != 16000:
|
||||||
|
raise ValueError(f"WAV-Samplerate {sr} != 16000")
|
||||||
|
if ch != 1:
|
||||||
|
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
|
||||||
|
if sw != 2:
|
||||||
|
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||||
|
return wav.readframes(wav.getnframes())
|
||||||
|
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||||
|
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||||
|
return _decode_compressed_to_pcm(audio_bytes)
|
||||||
|
return audio_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_bytes_to_tensor(audio_bytes: bytes):
|
||||||
|
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
|
||||||
|
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
|
||||||
|
import torch
|
||||||
|
raw = _normalize_audio_bytes(audio_bytes)
|
||||||
|
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
return torch.from_numpy(arr).unsqueeze(0)
|
||||||
|
|
||||||
|
|
||||||
|
def embed(audio_bytes: bytes) -> np.ndarray:
|
||||||
|
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
|
||||||
|
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
|
||||||
|
import torch
|
||||||
|
model = _ensure_loaded()
|
||||||
|
wav = _audio_bytes_to_tensor(audio_bytes)
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = model.encode_batch(wav)
|
||||||
|
return emb.squeeze().cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
|
||||||
|
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
|
||||||
|
na = np.linalg.norm(a)
|
||||||
|
nb = np.linalg.norm(b)
|
||||||
|
if na < 1e-9 or nb < 1e-9:
|
||||||
|
return 0.0
|
||||||
|
return float(np.dot(a, b) / (na * nb))
|
||||||
|
|
||||||
|
|
||||||
|
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
|
||||||
|
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
|
||||||
|
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError("Keine Embeddings zum Speichern")
|
||||||
|
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stacked = np.stack(embeddings)
|
||||||
|
mean = stacked.mean(axis=0)
|
||||||
|
mean = mean / max(np.linalg.norm(mean), 1e-9)
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"embedding": mean.tolist(),
|
||||||
|
"embedding_dim": int(mean.shape[0]),
|
||||||
|
"sample_count": len(embeddings),
|
||||||
|
"sample_durations_s": [float(s) for s in sample_durations_s],
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
|
||||||
|
len(embeddings), mean.shape[0], sum(sample_durations_s))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_fingerprint() -> Optional[dict]:
|
||||||
|
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
|
||||||
|
if not FINGERPRINT_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_fingerprint() -> bool:
|
||||||
|
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
|
||||||
|
if FINGERPRINT_FILE.exists():
|
||||||
|
FINGERPRINT_FILE.unlink()
|
||||||
|
logger.info("[speaker-id] fingerprint geloescht")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
|
||||||
|
"""Returns (is_match, similarity).
|
||||||
|
|
||||||
|
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) — der wird
|
||||||
|
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
|
||||||
|
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
|
||||||
|
|
||||||
|
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
|
||||||
|
crasht, returnt (True, 0.0) — kein Filtering. Sonst wuerde ein kaputter
|
||||||
|
Speaker-ID-Service die ganze Aufnahme blockieren."""
|
||||||
|
if threshold is None:
|
||||||
|
threshold = DEFAULT_THRESHOLD
|
||||||
|
fp = load_fingerprint()
|
||||||
|
if fp is None:
|
||||||
|
return True, 0.0
|
||||||
|
if len(audio_bytes) < MIN_SAMPLE_BYTES:
|
||||||
|
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
|
||||||
|
return True, 0.0
|
||||||
|
try:
|
||||||
|
saved_emb = np.array(fp["embedding"], dtype=np.float32)
|
||||||
|
new_emb = embed(audio_bytes)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
|
||||||
|
return True, 0.0
|
||||||
|
sim = cosine_similarity(new_emb, saved_emb)
|
||||||
|
return sim >= threshold, sim
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict:
|
||||||
|
"""Status-Snapshot fuer die App / Diagnostic."""
|
||||||
|
fp = load_fingerprint()
|
||||||
|
return {
|
||||||
|
"enrolled": fp is not None,
|
||||||
|
"sample_count": fp.get("sample_count", 0) if fp else 0,
|
||||||
|
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
|
||||||
|
"updated_at": fp.get("updated_at") if fp else None,
|
||||||
|
"embedding_dim": fp.get("embedding_dim") if fp else None,
|
||||||
|
"default_threshold": DEFAULT_THRESHOLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||||
|
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
|
||||||
|
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
|
||||||
|
if not samples_b64:
|
||||||
|
raise ValueError("Keine Samples uebergeben")
|
||||||
|
embeddings: list[np.ndarray] = []
|
||||||
|
durations: list[float] = []
|
||||||
|
rejected: list[dict] = []
|
||||||
|
for idx, s in enumerate(samples_b64):
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(s)
|
||||||
|
except Exception as exc:
|
||||||
|
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||||
|
continue
|
||||||
|
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||||
|
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||||
|
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||||
|
try:
|
||||||
|
pcm = _normalize_audio_bytes(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||||
|
continue
|
||||||
|
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||||
|
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
emb = embed(pcm)
|
||||||
|
embeddings.append(emb)
|
||||||
|
durations.append(len(pcm) / 2 / 16000.0)
|
||||||
|
except Exception as exc:
|
||||||
|
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError(
|
||||||
|
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
|
||||||
|
f"Details: {rejected[:3]}"
|
||||||
|
)
|
||||||
|
fingerprint = save_fingerprint(embeddings, durations)
|
||||||
|
fingerprint["rejected"] = rejected
|
||||||
|
return fingerprint
|
||||||
@@ -86,6 +86,16 @@ STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
|
|||||||
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
|
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
|
||||||
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
||||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
||||||
|
|
||||||
|
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
|
||||||
|
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
|
||||||
|
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
|
||||||
|
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_speaker_id_enabled(val: bool) -> None:
|
||||||
|
global SPEAKER_ID_ENABLED
|
||||||
|
SPEAKER_ID_ENABLED = bool(val)
|
||||||
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
||||||
@@ -467,6 +477,11 @@ class SessionManager:
|
|||||||
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||||
Session sofort beendet mit synthetischem stt_endpoint."""
|
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||||
sess.speaker_checked = True
|
sess.speaker_checked = True
|
||||||
|
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||||
|
if not SPEAKER_ID_ENABLED:
|
||||||
|
sess.speaker_match = True
|
||||||
|
sess.speaker_similarity = 0.0
|
||||||
|
return
|
||||||
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
|
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
|
||||||
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||||
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
||||||
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
if "voiceIdEnabled" in payload:
|
||||||
|
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||||
|
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||||
|
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||||
if "whisperDebugLog" in payload:
|
if "whisperDebugLog" in payload:
|
||||||
global _DEBUG_LOG_TO_BRIDGE
|
global _DEBUG_LOG_TO_BRIDGE
|
||||||
old = _DEBUG_LOG_TO_BRIDGE
|
old = _DEBUG_LOG_TO_BRIDGE
|
||||||
|
|||||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
|||||||
return _model
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||||
|
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||||
|
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||||
|
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||||
|
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
tmp = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||||
|
tf.write(audio_bytes)
|
||||||
|
tmp = tf.name
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||||
|
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0 or not proc.stdout:
|
||||||
|
raise ValueError(
|
||||||
|
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||||
|
return proc.stdout
|
||||||
|
finally:
|
||||||
|
if tmp:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||||
Ergebnis: rohes PCM."""
|
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||||
if (len(audio_bytes) >= 44
|
if (len(audio_bytes) >= 44
|
||||||
and audio_bytes[:4] == b"RIFF"
|
and audio_bytes[:4] == b"RIFF"
|
||||||
and audio_bytes[8:12] == b"WAVE"):
|
and audio_bytes[8:12] == b"WAVE"):
|
||||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
|||||||
if sw != 2:
|
if sw != 2:
|
||||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||||
return wav.readframes(wav.getnframes())
|
return wav.readframes(wav.getnframes())
|
||||||
|
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||||
|
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||||
|
return _decode_compressed_to_pcm(audio_bytes)
|
||||||
return audio_bytes
|
return audio_bytes
|
||||||
|
|
||||||
|
|
||||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||||
continue
|
continue
|
||||||
if len(raw) < MIN_SAMPLE_BYTES:
|
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||||
|
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||||
|
try:
|
||||||
|
pcm = _normalize_audio_bytes(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||||
|
continue
|
||||||
|
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||||
|
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
emb = embed(raw)
|
emb = embed(pcm)
|
||||||
embeddings.append(emb)
|
embeddings.append(emb)
|
||||||
durations.append(len(raw) / 2 / 16000.0)
|
durations.append(len(pcm) / 2 / 16000.0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||||
if not embeddings:
|
if not embeddings:
|
||||||
|
|||||||
Reference in New Issue
Block a user