From d146ca92c4dbc1e8cf898f25fbbb267b97841dc2 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 25 Apr 2026 00:47:53 +0200 Subject: [PATCH] fix: Aufnahme-Crashes/Double-Tap durch VAD-Multi-Fire + stale closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei zusammenhaengende Bugs: 1. VAD-Timer feuerte im 200ms setInterval WEITER nachdem die Stille- Schwelle erreicht war — listeners wurden pro Aufnahme bis zu 5x getriggert. Parallel laufende stopRecording()-Calls lieferten audio-recorder-player's nativen Layer OOM / Crash. Fix: silenceFired-Latch + Timer-Clear SOFORT beim ersten Feuer (fireSilenceOnce-Helper). Gleiche Logik fuer Max-Dauer + Conv-Window. 2. VoiceButton silence-listener re-registrierte bei jedem isRecording- Flip (deps [isRecording, onRecordingComplete]). Closure-State war stale, und bei schnellen flips gabs register/unregister-Races. Fix: empty deps, state direkt vom audioService via getRecordingState() lesen. onRecordingComplete via Ref (damit der Callback aktuell bleibt ohne re-register). 3. handleTap las den Button-State aus React (isRecording), der bei schnellen Taps stale sein konnte — "erst zweiter Tap geht" Symptom. Fix: audioService.getRecordingState() als Source-of-Truth, plus tapBusy-Ref als Anti-Doppel-Tap-Guard waehrend asyncer start/stop. 'processing'-State wird korrekt ignoriert. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/components/VoiceButton.tsx | 58 +++++++++++++++++--------- android/src/services/audio.ts | 33 +++++++++++---- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/android/src/components/VoiceButton.tsx b/android/src/components/VoiceButton.tsx index 470c0f1..b645889 100644 --- a/android/src/components/VoiceButton.tsx +++ b/android/src/components/VoiceButton.tsx @@ -93,18 +93,24 @@ const VoiceButton: React.FC = ({ } }, [isRecording]); - // VAD Silence Callback — Auto-Stop + // VAD Silence Callback — Auto-Stop. + // WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen + // audioService selber fragen. Empty deps → Listener wird EINMAL registriert. + // audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal + // feuert (silenceFired-Latch). + const onCompleteRef = useRef(onRecordingComplete); + useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]); useEffect(() => { const unsubSilence = audioService.onSilenceDetected(async () => { - if (!isRecording) return; - setIsRecording(false); + if (audioService.getRecordingState() !== 'recording') return; const result = await audioService.stopRecording(); + setIsRecording(false); if (result && result.durationMs > 500) { - onRecordingComplete(result); + onCompleteRef.current(result); } }); return unsubSilence; - }, [isRecording, onRecordingComplete]); + }, []); // Auto-Start fuer Wake Word (extern getriggert) const startAutoRecording = useCallback(async () => { @@ -136,23 +142,35 @@ const VoiceButton: React.FC = ({ } }; - // Tap-to-Talk: Einmal tippen startet mit Auto-Stop + // Tap-to-Talk: Einmal tippen startet mit Auto-Stop. + // Guard gegen Doppel-Tap während asyncer Start/Stop. + const tapBusy = useRef(false); const handleTap = async () => { - if (disabled) return; - if (isRecording) { - // Aufnahme manuell stoppen - setIsRecording(false); - const result = await audioService.stopRecording(); - if (result && result.durationMs > 300) { - onRecordingComplete(result); - } - } else { - // Aufnahme mit Auto-Stop starten - const started = await audioService.startRecording(true); - if (started) { - isLongPress.current = false; - setIsRecording(true); + if (disabled || tapBusy.current) return; + tapBusy.current = true; + try { + // Fragen WIR den Service, nicht den React-State (Closure kann stale sein) + const svcState = audioService.getRecordingState(); + if (svcState === 'recording') { + // Aufnahme manuell stoppen + const result = await audioService.stopRecording(); + setIsRecording(false); + if (result && result.durationMs > 300) { + onRecordingComplete(result); + } + } else if (svcState === 'idle') { + // Aufnahme mit Auto-Stop starten + const started = await audioService.startRecording(true); + if (started) { + isLongPress.current = false; + setIsRecording(true); + } } + // svcState === 'processing': Stopp in progress — nichts tun, User + // muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy + // kurz damit der User's UI-Feedback synchron bleibt. + } finally { + tapBusy.current = false; } }; diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 59bd10d..d21881a 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -196,6 +196,8 @@ class AudioService { private lastSpeechTime: number = 0; private vadTimer: ReturnType | null = null; private maxDurationTimer: ReturnType | null = null; + // Latch damit der Silence-Callback pro Aufnahme genau einmal feuert + private silenceFired: boolean = false; private noSpeechTimer: ReturnType | null = null; constructor() { @@ -305,33 +307,46 @@ class AudioService { // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) AudioFocus?.requestExclusive().catch(() => {}); - // VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar) + // VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar). + // WICHTIG: jeder Trigger (VAD-Stille / Max-Dauer / No-Speech-Window) + // disable SOFORT den VAD-Flag und clear den Timer, BEVOR die Listener + // gefeuert werden. Sonst feuert das setInterval weiter alle 200ms und + // ruft stopRecording parallel auf → audio-recorder-player crasht. this.vadEnabled = autoStop; + this.silenceFired = false; + const fireSilenceOnce = (reason: string) => { + if (this.silenceFired) return; + this.silenceFired = true; + this.vadEnabled = false; + if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; } + if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; } + if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; } + console.log('[Audio] Silence-Fire: %s', reason); + this.silenceListeners.forEach(cb => { + try { cb(); } catch (e) { console.warn('[Audio] silence listener err:', e); } + }); + }; if (autoStop) { const vadSilenceMs = await loadVadSilenceMs(); console.log('[Audio] VAD-Stille:', vadSilenceMs, 'ms'); this.vadTimer = setInterval(() => { const silenceDuration = Date.now() - this.lastSpeechTime; if (silenceDuration >= vadSilenceMs) { - console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`); - this.silenceListeners.forEach(cb => cb()); + fireSilenceOnce(`VAD ${silenceDuration}ms Stille`); } }, 200); // Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen this.maxDurationTimer = setTimeout(() => { - console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`); - this.silenceListeners.forEach(cb => cb()); + fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`); }, MAX_RECORDING_MS); } // Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht - // anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie), - // ChatScreen erkennt das und beendet die Konversation. + // anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie). if (noSpeechTimeoutMs > 0) { this.noSpeechTimer = setTimeout(() => { if (!this.speechDetected && this.recordingState === 'recording') { - console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`); - this.silenceListeners.forEach(cb => cb()); + fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`); } }, noSpeechTimeoutMs); }