From 1dd47888a8ac912725c74888f9f4928b23bfbacc Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 11 Jul 2026 20:29:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(app):=20STT-Endpoint=20gro=C3=9Fz=C3=BCgige?= =?UTF-8?q?r=20+=20Mikro=20vor=20Re-Arm=20freigeben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 Vorzeitiges Absenden: endpointMs war hart 1500ms — im Auto (Sprechpausen) schnitt das mitten im Satz ab (die 11.8s-Frage wurde bei "…ohne dass ein" gekappt). Jetzt konfigurierbar (aria_stt_endpoint_ms, Default 2400, 1000-4000). #3 Ohr bleibt ausgegraut: beim Re-Arm rief der Wake-Word-Service OpenWakeWord.start(), waehrend die passive Streaming-Aufnahme noch das Mikro hielt → start() schlug fehl → state=off. Neuer micReleaseHook cancelt die Aufnahme VOR start() (endConversation / exitPassiveListening / discardIfFreshlyTriggered). ChatScreen registriert den Hook. Co-Authored-By: Claude Opus 4.8 --- android/src/screens/ChatScreen.tsx | 18 +++++++++++++----- android/src/services/audio.ts | 20 ++++++++++++++++++++ android/src/services/wakeword.ts | 22 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index a54d88d..5a9c162 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -49,7 +49,7 @@ import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; import MessageText from '../components/MessageText'; -import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio'; +import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- @@ -562,6 +562,14 @@ const ChatScreen: React.FC = () => { // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt) useEffect(() => { wakeWordService.loadFromStorage().catch(() => {}); + // Mikro-Release-Hook: vor jedem Re-Arm eine laufende (passive) Streaming- + // Aufnahme canceln, sonst haelt sie das Mikro und OpenWakeWord.start() + // schlaegt fehl → Ohr bleibt ausgegraut (state=off). + wakeWordService.setMicReleaseHook(async () => { + if (audioService.isStreamingRecording()) { + await audioService.cancelStreamingRecording('rearm-mic-free'); + } + }); const unsub = wakeWordService.onStateChange((s) => { setWakeWordState(s); setWakeWordActive(s !== 'off'); @@ -1465,7 +1473,7 @@ const ChatScreen: React.FC = () => { interrupted: wasInterrupted, location: location || null, noSpeechTimeoutMs: windowMs, - endpointMs: 1500, + endpointMs: await loadSttEndpointMs(), hardCapMs: 60000, projectId: focusedProjectIdRef.current, }); @@ -1561,7 +1569,7 @@ const ChatScreen: React.FC = () => { interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen" location: location || null, noSpeechTimeoutMs: windowMs, - endpointMs: 1500, + endpointMs: await loadSttEndpointMs(), hardCapMs: 60000, projectId: focusedProjectIdRef.current, }); @@ -1618,7 +1626,7 @@ const ChatScreen: React.FC = () => { interrupted: false, location: location || null, noSpeechTimeoutMs: Math.min(passiveMs, 30000), - endpointMs: 1500, + endpointMs: await loadSttEndpointMs(), hardCapMs: Math.max(passiveMs + 5000, 35000), projectId: focusedProjectIdRef.current, }); @@ -2028,7 +2036,7 @@ const ChatScreen: React.FC = () => { // Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper // die Session auch app-seitig haben wir +2s Toleranz. noSpeechTimeoutMs: 0, - endpointMs: 1500, + endpointMs: await loadSttEndpointMs(), hardCapMs: 300000, projectId: focusedProjectIdRef.current, }); diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 7199895..6a84bde 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -151,6 +151,26 @@ export const CONV_WINDOW_MIN_SEC = 3.0; export const CONV_WINDOW_MAX_SEC = 20.0; export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec'; +// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten +// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die +// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv; +// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings. +export const STT_ENDPOINT_DEFAULT_MS = 2400; +export const STT_ENDPOINT_MIN_MS = 1000; +export const STT_ENDPOINT_MAX_MS = 4000; +export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms'; + +export async function loadSttEndpointMs(): Promise { + try { + const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); + if (raw != null) { + const n = parseInt(raw, 10); + if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) return n; + } + } catch {} + return STT_ENDPOINT_DEFAULT_MS; +} + // TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die // Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal. export const TTS_SPEED_DEFAULT = 1.0; diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 9248f95..fa5b1a9 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -132,6 +132,12 @@ class WakeWordService { * hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */ private passiveListenCallbacks: PassiveListenCallback[] = []; + /** Hook, der das Mikro freigibt (laufende Streaming-Aufnahme canceln) BEVOR + * wir OpenWakeWord.start() rufen. Ohne das haelt die passive/conversing + * Aufnahme das Mikro noch, start() schlaegt fehl → Ohr bleibt ausgegraut + * (state=off). ChatScreen registriert den Hook mit audioService.cancel…. */ + private micReleaseHook: (() => Promise) | null = null; + private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; private initInProgress: Promise | null = null; @@ -149,6 +155,19 @@ class WakeWordService { } } + /** ChatScreen registriert hier einen Hook, der eine laufende Streaming- + * Aufnahme cancelt (Mikro freigeben) — wird vor jedem Re-Arm gerufen. */ + setMicReleaseHook(fn: (() => Promise) | null): void { + this.micReleaseHook = fn; + } + + private async _freeMic(): Promise { + if (!this.micReleaseHook) return; + try { await this.micReleaseHook(); } catch (e) { + console.warn('[WakeWord] micReleaseHook err:', e); + } + } + /** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */ async configure(keyword: string): Promise { const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword) @@ -454,6 +473,7 @@ class WakeWordService { // als state extra zu fragen, garantiert dass nach diesem Pfad // Native auch wirklich an ist falls es out-of-band gestoppt wurde. try { + await this._freeMic(); // Streaming-Aufnahme canceln → Mikro frei await OpenWakeWord.start(); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge); import('./logger').then(m => m.reportAppDebug('wake.end', @@ -520,6 +540,7 @@ class WakeWordService { // timeout oder manual → Wake-Word reaktivieren, armed-State. if (this.nativeReady && OpenWakeWord) { try { + await this._freeMic(); // passive Streaming-Aufnahme canceln → Mikro frei await OpenWakeWord.start(); console.log('[WakeWord] zurueck zu armed nach passive-listen'); ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); @@ -564,6 +585,7 @@ class WakeWordService { this.lastTriggerAt = 0; if (this.nativeReady && OpenWakeWord) { try { + await this._freeMic(); // ggf. laufende Aufnahme canceln → Mikro frei await OpenWakeWord.start(); ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT); this.setState('armed');