fix(audio): Placeholder-Race per audioRequestId + Mikro-Offen-Toast erst nach Start
Bug: Bei zwei Sprachnachrichten kurz hintereinander wurde der STT-Text der zweiten in die Bubble der ersten geschrieben. Ursache: findIndex matchte ueber Substring "Spracheingabe wird verarbeitet" → bei zwei offenen Placeholders nahm er immer die ERSTE, egal welches STT-Result gerade kam. Fix: jede Aufnahme bekommt eine eindeutige audioRequestId, App pusht sie in die Placeholder-Bubble + ans audio-Event. Bridge gibt sie unveraendert ans STT-Result zurueck. App matcht primaer per ID, fallback auf Substring (Kompatibilitaet zu alten Bridge-Versionen). Bonus: Toast "Wake-Word erkannt" entfernt, dafuer "🎤 Mikro offen — sprich jetzt" erst wenn audioService.startRecording wirklich erfolgreich war. So weiss der User exakt ab wann er reden darf — vorher war der Toast schon ~400ms vorher da. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -197,7 +197,9 @@ class WakeWordService {
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
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 {}
|
||||
}
|
||||
|
||||
+20
-9
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user