diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 0f5a1fb..65bdc30 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -1141,7 +1141,7 @@ class AudioService { * Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit * automatisch in dem Kontext den Stefan gerade sieht. */ projectId?: string; - }): Promise<{ requestId: string; ok: boolean }> { + }): Promise<{ requestId: string; ok: boolean; error?: string }> { if (this.recordingState !== 'idle') { console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState); return { requestId: '', ok: false }; @@ -1155,12 +1155,28 @@ class AudioService { console.warn('[Audio] Keine Mikrofon-Berechtigung'); return { requestId: '', ok: false }; } + // Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn + // der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel + // nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch — + // start, Chunks und end waren weg, die Bubble verschwand, und erst der + // zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich + // gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit. + if (rvs.getState() !== 'connected') { + console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)', + rvs.getState()); + return { requestId: '', ok: false, error: 'not-connected' }; + } // Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert) this.stopPlayback(); const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`; this.streamRequestId = requestId; + // Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist + // isStreamingRecording() bereits true, recordingState aber noch 'idle' — + // ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der + // Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde. + this.setState('recording'); this.streamAudioRequestId = opts.audioRequestId || ''; // Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit // start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout → @@ -1202,20 +1218,17 @@ class AudioService { console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err); } - const started = await PcmStreamRecorder.start(); - if (!started) { - throw new Error('PcmStreamRecorder.start returned false'); + // Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine + // Sitzung zu starten, die niemand mehr erwartet. + if (this.streamRequestId !== requestId) { + console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab'); + return { requestId: '', ok: false, error: 'superseded' }; } - // AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad. - // Vorher merken ob Musik lief (fuer Resume am Dialog-Ende). - await this._captureMediaActive(); - this._cancelDeferredFocusRelease(); - AudioFocus?.requestExclusive().catch(() => {}); - - this.setState('recording'); - - // stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen. + // stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten + // Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt, + // und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt + // gar nichts uebrig. rvs.send('stt_stream_start' as any, { requestId, audioRequestId: opts.audioRequestId || '', @@ -1230,18 +1243,40 @@ class AudioService { targetInstance: this.streamTargetInstance, }); + const started = await PcmStreamRecorder.start(); + if (!started) { + throw new Error('PcmStreamRecorder.start returned false'); + } + + // AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad. + // Vorher merken ob Musik lief (fuer Resume am Dialog-Ende). + await this._captureMediaActive(); + this._cancelDeferredFocusRelease(); + AudioFocus?.requestExclusive().catch(() => {}); + + // No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer. // Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist, // hat der User vermutlich nix gesagt → Session beenden. + // WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz + // (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des + // Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das + // erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s + // (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck). + // Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die + // Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst + // der zweite Versuch klappte, weil man da sofort losredet. + const NO_SPEECH_GRACE_MS = 2500; const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0); if (noSpeechMs > 0) { + const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS; this.streamNoSpeechTimer = setTimeout(() => { if (this.streamRequestId === requestId && !this.streamGotPartial) { console.log('[Audio] Stream %s: no-speech nach %dms → cancel', - requestId.slice(0, 12), noSpeechMs); + requestId.slice(0, 12), effectiveMs); this.cancelStreamingRecording('no-speech').catch(() => {}); } - }, noSpeechMs); + }, effectiveMs); } // Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).