feat(voice): Wake-Word per Befehl wieder anschalten (Gegenstück zu wake_off)
"Wake-Word an" / "mach das Ohr an" / "hör wieder zu" / "wach auf" → App startet den Listener wieder. Geht per Text-Nachricht ODER manuellem Aufnahme-Button — beide laufen unabhängig vom Wake-Word-Listener, also funktioniert das Wieder-An auch wenn das Ohr gerade taub ist (nur nicht per "Computer", das hört ja nicht). Symmetrisch zu wake_off: Detektor _user_wants_wake_on → wake_on durch ChatOut → Bridge → App löst wakeWordService.start() + setWakeWordActive(true) aus. An/Aus kollidieren nicht (12 Fälle getestet). Damit: Ohr per Befehl ein UND aus, plus weiterhin der Ohr-Button. Fix nebenbei: gerades " in den Reply-Strings (hätte den Brain-Start gecrasht) → einfache Anführungszeichen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1407,8 +1407,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
// 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 _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 (_wakeOff) {
|
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
|
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
|
||||||
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
|
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
|
||||||
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
|
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
|
||||||
|
|||||||
+33
-1
@@ -1467,6 +1467,29 @@ def _user_wants_wake_off(text: str) -> bool:
|
|||||||
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
|
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)
|
||||||
@@ -1895,13 +1918,22 @@ class Agent:
|
|||||||
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
|
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
|
||||||
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
|
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
|
||||||
if _user_wants_wake_off(user_message):
|
if _user_wants_wake_off(user_message):
|
||||||
reply = "Ohr aus — sag's mir per App-Button, wenn ich wieder lauschen soll. 🔇"
|
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,
|
self.conversation.add("user", user_message, source=source,
|
||||||
project_id=active_project_id)
|
project_id=active_project_id)
|
||||||
self.conversation.add("assistant", reply, project_id=active_project_id)
|
self.conversation.add("assistant", reply, project_id=active_project_id)
|
||||||
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
|
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
|
||||||
return reply, "wake-off", False, False, False
|
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.
|
||||||
|
|||||||
+4
-1
@@ -677,8 +677,10 @@ class ChatOut(BaseModel):
|
|||||||
# 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
|
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
|
||||||
# Wake-Word-Listener komplett (Mikro frei). Wieder-An nur ueber App-Button.
|
# Wake-Word-Listener komplett (Mikro frei).
|
||||||
wake_off: bool = False
|
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.
|
||||||
@@ -789,6 +791,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
|||||||
converse=converse,
|
converse=converse,
|
||||||
awaiting_reply=awaiting_reply,
|
awaiting_reply=awaiting_reply,
|
||||||
wake_off=(answered_by == "wake-off"),
|
wake_off=(answered_by == "wake-off"),
|
||||||
|
wake_on=(answered_by == "wake-on"),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_project_pending[pid] = [
|
_project_pending[pid] = [
|
||||||
|
|||||||
@@ -1623,8 +1623,10 @@ class ARIABridge:
|
|||||||
# 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
|
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
|
||||||
# (Mikro frei). Wieder-An nur ueber den App-Button.
|
# (Mikro frei).
|
||||||
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
|
"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),
|
||||||
})
|
})
|
||||||
@@ -2022,6 +2024,8 @@ class ARIABridge:
|
|||||||
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.
|
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
|
||||||
wake_off = bool(data.get("wake_off", False))
|
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
|
||||||
@@ -2114,7 +2118,8 @@ class ARIABridge:
|
|||||||
"speak": speak,
|
"speak": speak,
|
||||||
"converse": converse,
|
"converse": converse,
|
||||||
"awaiting_reply": awaiting_reply,
|
"awaiting_reply": awaiting_reply,
|
||||||
"wake_off": wake_off})
|
"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)
|
||||||
|
|||||||
Reference in New Issue
Block a user