diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 226e6ce..51badf0 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -55,6 +55,10 @@ interface ChatMessage { messageId?: string; /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ audioPath?: string; + /** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck- + * gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen, + * auch wenn mehrere Aufnahmen parallel offen sind. */ + audioRequestId?: string; } // --- Konstanten --- @@ -292,46 +296,42 @@ const ChatScreen: React.FC = () => { // den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste). if (sender === 'stt') { const sttText = (message.payload.text as string) || ''; - // Debug-Toast: visualisiert dass das STT-Event in der App angekommen ist. - // Wenn dieser Toast NICHT erscheint, kommt das Event nicht durch (Bridge - // oder RVS broadcastet es nicht), und der Bug liegt server-side. - ToastAndroid.show(`STT empfangen: "${sttText.slice(0, 40)}"`, ToastAndroid.SHORT); - if (sttText) { - setMessages(prev => { - const idx = prev.findIndex(m => - m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') - ); - const placeholderCount = prev.filter(m => - m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') - ).length; - console.log('[Chat] STT-Result: idx=%d text="%s" placeholders=%d', - idx, sttText.slice(0, 60), placeholderCount); - // Zweiter Toast: zeigt ob die Placeholder gefunden wurde. - ToastAndroid.show( - idx < 0 - ? `STT: keine Placeholder (${placeholderCount}) \u2192 neue Bubble` - : `STT: Bubble #${idx} ersetzt`, - ToastAndroid.SHORT, - ); - const newText = `\uD83C\uDFA4 ${sttText}`; - if (idx < 0) { - // Defensiv: wenn keine Placeholder im State (z.B. weil sie nie - // hinzugefuegt wurde oder schon durch ein anderes Update verloren - // ging), die Sprachnachricht trotzdem als neue Bubble einfuegen. - // Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht. - return capMessages([...prev, { - id: nextId(), - sender: 'user', - text: newText, - timestamp: message.timestamp, - attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], - }]); + const sttAudioReqId = (message.payload.audioRequestId as string) || ''; + if (!sttText) { + return; + } + setMessages(prev => { + const newText = `\uD83C\uDFA4 ${sttText}`; + // Primaer: matche per audioRequestId (eindeutig pro Aufnahme). + // So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander + // gesendet wurden und ihre STT-Results ueberlappen. + if (sttAudioReqId) { + const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId); + if (idxById >= 0) { + const next = prev.slice(); + next[idxById] = { ...next[idxById], text: newText }; + return next; } + } + // Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring, + // nimmt die ERSTE noch unaufgeloeste Placeholder. + const idx = prev.findIndex(m => + m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') + ); + if (idx >= 0) { const next = prev.slice(); next[idx] = { ...next[idx], text: newText }; return next; - }); - } + } + // Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen + return capMessages([...prev, { + id: nextId(), + sender: 'user', + text: newText, + timestamp: message.timestamp, + attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], + }]); + }); return; } @@ -493,7 +493,12 @@ const ChatScreen: React.FC = () => { // Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus const windowMs = await loadConvWindowMs(); const started = await audioService.startRecording(true, windowMs); - if (!started) { + if (started) { + // Erst JETZT signalisieren dass das Mikro wirklich offen ist — + // vorher war's noch in der Init-Phase. So weiss der User exakt + // ab wann er reden kann. + ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); + } else { // Mikrofon nicht verfuegbar, naechsten Versuch wakeWordService.resume(); } @@ -507,12 +512,14 @@ const ChatScreen: React.FC = () => { // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist. const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); + const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], + audioRequestId, }; setMessages(prev => capMessages([...prev, userMsg])); rvs.send('audio', { @@ -522,6 +529,7 @@ const ChatScreen: React.FC = () => { voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, + audioRequestId, ...(location && { location }), }); // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert. @@ -677,12 +685,14 @@ const ChatScreen: React.FC = () => { // Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv. const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); + const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), + audioRequestId, }; setMessages(prev => capMessages([...prev, userMsg])); @@ -693,6 +703,7 @@ const ChatScreen: React.FC = () => { voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, + audioRequestId, ...(location && { location }), }); }, [getCurrentLocation, interruptAriaIfBusy]); diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 548cadf..3b51de8 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -197,7 +197,9 @@ class WakeWordService { /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); - ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT); + // KEIN Toast hier — der Toast "sprich jetzt" kommt erst wenn das Mikro + // wirklich offen ist (audioService meldet 'recording'-State). So weiss + // der User exakt ab wann er reden darf. if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 6b99ab5..565003e 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1510,10 +1510,12 @@ class ARIABridge: except (TypeError, ValueError): self._next_speed_override = None interrupted = bool(payload.get("interrupted", False)) - logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s", + audio_request_id = payload.get("audioRequestId", "") or "" + logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s", mime_type, duration_ms, len(audio_b64) // 1365, - " [BARGE-IN]" if interrupted else "") - asyncio.create_task(self._process_app_audio(audio_b64, mime_type, interrupted)) + " [BARGE-IN]" if interrupted else "", + f" reqId={audio_request_id[:16]}" if audio_request_id else "") + asyncio.create_task(self._process_app_audio(audio_b64, mime_type, interrupted, audio_request_id)) elif msg_type == "stt_response": # Antwort der whisper-bridge auf unseren stt_request @@ -1569,13 +1571,19 @@ class ARIABridge: _STT_REMOTE_TIMEOUT_READY_S = 45.0 _STT_REMOTE_TIMEOUT_LOADING_S = 300.0 - async def _process_app_audio(self, audio_b64: str, mime_type: str, interrupted: bool = False) -> None: + async def _process_app_audio(self, audio_b64: str, mime_type: str, + interrupted: bool = False, + audio_request_id: str = "") -> None: """App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal. interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat (Barge-In). Wird als Hinweis-Praefix an aria-core mitgegeben damit ARIA die Korrektur/Unterbrechung in den Kontext einordnen kann statt als reine - Folgefrage zu behandeln.""" + Folgefrage zu behandeln. + + audio_request_id: Korrelations-ID die die App im audio-Event mitschickt — wird + unveraendert ans STT-Result zurueckgegeben damit die App die EXAKT richtige + 'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen).""" # Erst Remote versuchen text = await self._stt_remote(audio_b64, mime_type) if text is None: @@ -1601,12 +1609,15 @@ class ARIABridge: # STT-Text an RVS senden (fuer Anzeige in App + Diagnostic) # sender="stt" damit Bridge es ignoriert (kein Loop) try: + stt_payload = { + "text": text, + "sender": "stt", + } + if audio_request_id: + stt_payload["audioRequestId"] = audio_request_id ok = await self._send_to_rvs({ "type": "chat", - "payload": { - "text": text, - "sender": "stt", - }, + "payload": stt_payload, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) if ok: