Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd7c747d9 | ||
|
|
d0694cb637 | ||
|
|
8f313eb68b | ||
|
|
954ad9ade8 | ||
|
|
75fa574a85 | ||
|
|
a3d7933949 | ||
|
|
626281dead | ||
|
|
49ea172617 | ||
|
|
a3650bb0e4 | ||
|
|
514ba44e51 | ||
|
|
617dddf3d8 |
@@ -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 20405
|
versionCode 20408
|
||||||
versionName "0.2.4.5"
|
versionName "0.2.4.8"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
// 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 = 600L
|
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.4.5",
|
"version": "0.2.4.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ 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).
|
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
|
||||||
const bargeInEnabledRef = useRef<boolean>(false);
|
const bargeInEnabledRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -1690,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();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1791,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'))));
|
||||||
}
|
}
|
||||||
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
|
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||||
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
|
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||||
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
|
// 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 — Ende, zurueck aufs Wake-Word');
|
const elapsed = Date.now() - passiveListenStartRef.current;
|
||||||
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
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);
|
||||||
@@ -1808,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ import wakeWordService, {
|
|||||||
saveWakeThreshold,
|
saveWakeThreshold,
|
||||||
loadBgWakeEnabled,
|
loadBgWakeEnabled,
|
||||||
saveBgWakeEnabled,
|
saveBgWakeEnabled,
|
||||||
|
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';
|
||||||
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||||
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
||||||
const [bgWake, setBgWake] = useState<boolean>(false);
|
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);
|
||||||
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||||
loadBgWakeEnabled().then(setBgWake).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 => {
|
||||||
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</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.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
||||||
|
|||||||
@@ -171,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
|||||||
} catch {}
|
} 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);
|
||||||
|
|||||||
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
|||||||
} catch {}
|
} 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
|
||||||
@@ -159,6 +180,9 @@ class WakeWordService {
|
|||||||
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
||||||
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
||||||
private bgWakeEnabled: boolean = false;
|
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
|
||||||
@@ -241,15 +265,19 @@ class WakeWordService {
|
|||||||
try {
|
try {
|
||||||
const threshold = await loadWakeThreshold();
|
const threshold = await loadWakeThreshold();
|
||||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
|
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;
|
||||||
@@ -348,6 +376,18 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] Hintergrund-Wake = %s', 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
|
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
|
||||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||||
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
||||||
@@ -358,8 +398,10 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] App im Vordergrund — Cooldown 1s 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
|
||||||
|
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
|
||||||
|
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
|
||||||
if (this.inBackground && !this.bgWakeEnabled) {
|
if (this.inBackground && !this.bgWakeEnabled) {
|
||||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
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(()=>{});
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
||||||
@@ -405,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
|
||||||
@@ -418,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.
|
||||||
|
|||||||
+53
-2
@@ -132,6 +132,34 @@ WEB_SEARCH_TOOL = {
|
|||||||
|
|
||||||
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
||||||
META_TOOLS = [
|
META_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ear_control",
|
||||||
|
"description": (
|
||||||
|
"Schaltet dein Ohr (den Wake-Word-Listener) an oder aus. Nutze das, "
|
||||||
|
"wenn Stefan will dass du aufhoerst zuzuhoeren — EGAL wie er es "
|
||||||
|
"formuliert: 'leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute "
|
||||||
|
"Nacht', 'mach mal Pause vom Zuhoeren', 'ich geh ins Bett, du kannst "
|
||||||
|
"aus' → action='off'. Wenn er dich wieder aktivieren will ('Ohr an', "
|
||||||
|
"'wach auf', 'hoer wieder zu') → action='on'. Reiner Steuerbefehl: "
|
||||||
|
"die App stoppt/startet den Listener, du bestaetigst nur kurz (wird "
|
||||||
|
"nicht vorgelesen). Nach 'off' geht Wieder-An per 'Ohr an' (Text/"
|
||||||
|
"Aufnahme-Knopf) oder App-Button."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["off", "on"],
|
||||||
|
"description": "off = Ohr aus (nicht mehr zuhoeren), on = Ohr wieder an",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1455,7 +1483,12 @@ _WAKE_OFF_RE = re.compile(
|
|||||||
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\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+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
|
||||||
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
|
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
|
||||||
rf"|geh\s+schlafen",
|
# Schlaf-Idiome NUR als klare Imperative an ARIA ('geh/leg dich schlafen',
|
||||||
|
# 'leg dich aufs Ohr/hin'). Mehrdeutige Gruesse ('gute Nacht', 'schlaf gut')
|
||||||
|
# bewusst NICHT — das sind Verabschiedungen, kein Ohr-Aus-Befehl; die faengt
|
||||||
|
# ARIA per ear_control aus dem Kontext ab, wenn's wirklich gemeint ist.
|
||||||
|
rf"|(?:geh|leg\s+dich)\s+schlafen"
|
||||||
|
rf"|leg\s+dich\s+(?:aufs?\s+ohr|hin)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2132,6 +2165,7 @@ class Agent:
|
|||||||
# Konversation bleiben gesprochen.
|
# Konversation bleiben gesprochen.
|
||||||
self._claude_turn_speak = True
|
self._claude_turn_speak = True
|
||||||
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
||||||
|
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||||
try:
|
try:
|
||||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||||
result = self.proxy.chat_full(messages, tools=tools,
|
result = self.proxy.chat_full(messages, tools=tools,
|
||||||
@@ -2245,8 +2279,16 @@ class Agent:
|
|||||||
converse = False
|
converse = False
|
||||||
elif _wants_continue:
|
elif _wants_continue:
|
||||||
converse = True
|
converse = True
|
||||||
|
# ear_control-Tool aufgerufen → Ohr schalten. answered_by=wake-off/wake-on
|
||||||
|
# nutzt die bestehende Plumbing (main.py → wake_off/wake_on → Bridge → App).
|
||||||
|
# Reiner Steuerbefehl: still + kein Weiterlauschen.
|
||||||
|
answered_by = "claude"
|
||||||
|
if self._ear_control == "off":
|
||||||
|
answered_by, speak, converse = "wake-off", False, False
|
||||||
|
elif self._ear_control == "on":
|
||||||
|
answered_by, speak, converse = "wake-on", False, False
|
||||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
return (final_reply, answered_by, speak, converse, awaiting_reply)
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -2841,6 +2883,15 @@ class Agent:
|
|||||||
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
||||||
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
|
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
|
||||||
)
|
)
|
||||||
|
if name == "ear_control":
|
||||||
|
action = (arguments.get("action") or "").strip().lower()
|
||||||
|
if action not in ("off", "on"):
|
||||||
|
return "FEHLER: action muss 'off' oder 'on' sein."
|
||||||
|
self._ear_control = action
|
||||||
|
logger.info("[ear_control] action=%s — App schaltet den Listener", action)
|
||||||
|
return ("Ohr wird ausgeschaltet — Wieder-An per 'Ohr an' (Text/"
|
||||||
|
"Aufnahme-Knopf) oder App-Button." if action == "off"
|
||||||
|
else "Ohr wird wieder eingeschaltet.")
|
||||||
if name == "memory_search":
|
if name == "memory_search":
|
||||||
query = (arguments.get("query") or "").strip()
|
query = (arguments.get("query") or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
|
|||||||
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
|
|||||||
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||||
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||||
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
||||||
|
"",
|
||||||
|
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
|
||||||
|
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
|
||||||
|
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
|
||||||
|
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
|
||||||
|
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
|
||||||
|
"bestaetigst nur kurz.",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2299,9 +2299,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendDiagAttachments() {
|
function sendDiagAttachments() {
|
||||||
// Alle pending Dateien an RVS senden
|
// Alle pending Dateien an RVS senden — projectId des aktuellen Focus
|
||||||
|
// mitschicken, sonst landet die Datei-Bubble im Hauptchat statt im Projekt.
|
||||||
for (const f of diagPendingFiles) {
|
for (const f of diagPendingFiles) {
|
||||||
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 });
|
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64, projectId: focusedContextId });
|
||||||
}
|
}
|
||||||
if (diagPendingFiles.length > 0) {
|
if (diagPendingFiles.length > 0) {
|
||||||
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
|
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
|
||||||
|
|||||||
@@ -2568,7 +2568,7 @@ wss.on("connection", (ws) => {
|
|||||||
// Datei von Diagnostic an Bridge via RVS senden
|
// Datei von Diagnostic an Bridge via RVS senden
|
||||||
sendToRVS_raw({
|
sendToRVS_raw({
|
||||||
type: "file",
|
type: "file",
|
||||||
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
|
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64, projectId: msg.projectId || "" },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "t
|
|||||||
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
||||||
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
||||||
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
||||||
|
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
|
||||||
|
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
|
||||||
|
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
|
||||||
|
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
|
||||||
|
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
|
||||||
|
|
||||||
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
||||||
|
|
||||||
@@ -281,6 +286,7 @@ class StreamSession:
|
|||||||
last_growth_at: float = 0.0
|
last_growth_at: float = 0.0
|
||||||
last_transcribe_at: float = 0.0
|
last_transcribe_at: float = 0.0
|
||||||
last_voice_at: float = 0.0
|
last_voice_at: float = 0.0
|
||||||
|
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||||
noise_floor: float = 0.0
|
noise_floor: float = 0.0
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
endpoint_sent: bool = False
|
endpoint_sent: bool = False
|
||||||
@@ -506,6 +512,26 @@ class SessionManager:
|
|||||||
"audioRequestId": sess.audio_request_id,
|
"audioRequestId": sess.audio_request_id,
|
||||||
"text": "",
|
"text": "",
|
||||||
})
|
})
|
||||||
|
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
|
||||||
|
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
|
||||||
|
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
|
||||||
|
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
|
||||||
|
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
|
||||||
|
# Turns haben Sprache im Fenster → laufen weiter.
|
||||||
|
if (self._buffer_ms(sess) >= sess.endpoint_ms
|
||||||
|
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
|
||||||
|
sess.last_speech_check_at = now
|
||||||
|
try:
|
||||||
|
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
|
||||||
|
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
|
||||||
|
segs = _speech_segments(tail)
|
||||||
|
if segs is not None and len(segs) == 0:
|
||||||
|
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
|
||||||
|
sess.request_id[:8], sess.endpoint_ms)
|
||||||
|
await self._finalize(sess, "endpoint")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
|
||||||
else:
|
else:
|
||||||
self._update_noise_floor(sess, rms)
|
self._update_noise_floor(sess, rms)
|
||||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||||
@@ -700,6 +726,30 @@ 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":
|
elif mtype == "voice_id_status_request":
|
||||||
req_id = payload.get("requestId", "")
|
req_id = payload.get("requestId", "")
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user