Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3650bb0e4 | ||
|
|
514ba44e51 | ||
|
|
617dddf3d8 | ||
|
|
88faf57253 | ||
|
|
41714f7cf1 | ||
|
|
93401d42d1 | ||
|
|
c4e658446d |
@@ -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 20404
|
versionCode 20406
|
||||||
versionName "0.2.4.4"
|
versionName "0.2.4.6"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.4.4",
|
"version": "0.2.4.6",
|
||||||
"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);
|
||||||
|
|
||||||
@@ -1406,8 +1414,39 @@ const ChatScreen: React.FC = () => {
|
|||||||
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
|
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
|
||||||
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
|
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
|
||||||
converseRef.current = (message.payload as any).converse === true;
|
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) {
|
if (_wakeOn) {
|
||||||
|
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
|
||||||
|
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
|
||||||
|
// weil der Befehl NICHT ueber "Computer" kam.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const started = await wakeWordService.start();
|
||||||
|
setWakeWordActive(started);
|
||||||
|
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else if (_wakeOff) {
|
||||||
|
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
|
||||||
|
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
|
||||||
|
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (audioService.isStreamingRecording()) {
|
||||||
|
await audioService.cancelStreamingRecording('wake-off-voice');
|
||||||
|
} else {
|
||||||
|
await audioService.stopRecording();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try { await wakeWordService.stop(); } catch {}
|
||||||
|
setWakeWordActive(false);
|
||||||
|
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
|
||||||
|
})();
|
||||||
|
} else if (_isSilent) {
|
||||||
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
|
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
|
||||||
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
|
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
|
||||||
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
|
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
|
||||||
@@ -1659,8 +1698,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
// 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();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1760,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);
|
||||||
@@ -1777,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -348,6 +348,12 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
console.log('[WakeWord] Hintergrund-Wake = %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
|
||||||
|
|||||||
@@ -1442,6 +1442,54 @@ def _user_wants_conversation_continue(text: str) -> bool:
|
|||||||
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
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)
|
||||||
@@ -1865,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.
|
||||||
|
|||||||
@@ -676,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.
|
||||||
@@ -785,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] = [
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
@@ -112,6 +112,55 @@ def _collapse_repetitions(text: str) -> str:
|
|||||||
out = new
|
out = new
|
||||||
return out.strip()
|
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"-
|
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
|
||||||
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
||||||
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
||||||
@@ -474,6 +523,21 @@ class SessionManager:
|
|||||||
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
|
||||||
@@ -508,6 +572,28 @@ class SessionManager:
|
|||||||
self.drop(sess.request_id)
|
self.drop(sess.request_id)
|
||||||
return
|
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()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user