From 9529bb0b168cce0390a9d54b44f3a66801075014 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 31 Oct 2026 20:05:35 +0100 Subject: [PATCH] =?UTF-8?q?feat(app):=20Aufnahme=20verwerfen=20=E2=80=94?= =?UTF-8?q?=20Muelleimer=20neben=20dem=20Aufnahmeknopf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher hiess "Stopp" immer "senden"; wer es sich anders ueberlegt hatte, konnte die Aufnahme nicht zuruecknehmen. - App: waehrend der Aufnahme 🗑 links neben dem roten Knopf. Verwirft ueber ariaStopRecording('user-discard') → cancelStreamingRecording; das leere Endpoint raeumt die Sprechblase ab, zurueck aufs Wake-Word. - Fund dabei: "cancel" schickte der STT-Bridge nur stt_stream_end — die transkribierte trotzdem und der Text ging an ARIA (fiel nur nicht auf, weil cancel bisher bei Stille kam). Jetzt: - Whisper- und Voxtral-Bridge: stream_end mit reason "cancel:*" → discard, kein Transcribe, leerer Text (reason "cancelled"). - aria-bridge merkt sich verworfene requestIds (2 min) und wirft einen trotzdem ankommenden Text weg — Schutz auch bei alter STT-Bridge. Getestet: Whisper-Bridge mit nachgebautem Runner (Stopp → Text, Muelleimer → kein Transcribe, leer). App: TypeScript ohne Fehler; auf dem Handy noch nicht ausprobiert. Co-Authored-By: Claude Opus 5.5 --- android/src/components/VoiceButton.tsx | 39 ++++++++++++++++++++++++++ android/src/screens/ChatScreen.tsx | 10 +++++++ bridge/aria_bridge.py | 13 +++++++++ xtts/voxtral/bridge.py | 10 +++++-- xtts/whisper/bridge.py | 14 +++++++-- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/android/src/components/VoiceButton.tsx b/android/src/components/VoiceButton.tsx index e62db91..f26d152 100644 --- a/android/src/components/VoiceButton.tsx +++ b/android/src/components/VoiceButton.tsx @@ -35,6 +35,8 @@ interface VoiceButtonProps { onTapStart: () => Promise; /** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */ onTapStop: () => Promise; + /** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */ + onTapCancel?: () => Promise; /** Button deaktivieren */ disabled?: boolean; /** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */ @@ -46,6 +48,7 @@ interface VoiceButtonProps { const VoiceButton: React.FC = ({ onTapStart, onTapStop, + onTapCancel, disabled = false, wakeWordActive = false, }) => { @@ -136,6 +139,16 @@ const VoiceButton: React.FC = ({ } }, [disabled, onTapStart, onTapStop]); + const handleCancel = useCallback(async () => { + if (!onTapCancel || tapBusy.current) return; + tapBusy.current = true; + try { + if (audioService.getRecordingState() === 'recording') await onTapCancel(); + } finally { + tapBusy.current = false; + } + }, [onTapCancel]); + const formatDuration = (ms: number): string => { const seconds = Math.floor(ms / 1000); const tenths = Math.floor((ms % 1000) / 100); @@ -154,6 +167,16 @@ const VoiceButton: React.FC = ({ {wakeWordActive && !isRecording && ( )} + {isRecording && onTapCancel && ( + + 🗑 + + )} { } }, []); + // Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording + // schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert + // dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere + // Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word. + const handleVoiceButtonCancel = useCallback(async (): Promise => { + console.log('[Chat] Aufnahme verworfen (Muelleimer)'); + await ariaStopRecording('user-discard'); + }, [ariaStopRecording]); + // Datei auswaehlen → zur Pending-Liste hinzufuegen const handleFileSelected = useCallback(async (file: FileData) => { setShowFileUpload(false); @@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => { diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 77ecc6a..be73c8f 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -3449,6 +3449,16 @@ class ARIABridge: # stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup # als Leak-Schutz, falls gar kein Endpoint mehr kommt. req_id = payload.get("requestId", "") or "" + # Verworfen (Muelleimer in der App)? Dann darf ein trotzdem noch + # kommender Text NICHT an ARIA — auch wenn eine alte STT-Bridge ihn + # noch transkribiert. + if req_id and str(payload.get("reason", "")).startswith("cancel"): + if not hasattr(self, "_stt_cancelled"): + self._stt_cancelled = {} + self._stt_cancelled[req_id] = time.time() + for rid, t in list(self._stt_cancelled.items()): + if time.time() - t > 120: + self._stt_cancelled.pop(rid, None) if req_id: try: loop = asyncio.get_event_loop() @@ -3473,6 +3483,9 @@ class ARIABridge: logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)", payload.get("reason", "")) return + if (payload.get("requestId") or "") in getattr(self, "_stt_cancelled", {}): + logger.info("[rvs] stt_endpoint einer verworfenen Aufnahme — ignoriert") + return audio_request_id = payload.get("audioRequestId", "") or "" voice = payload.get("voice", "") or "" speed_raw = payload.get("speed") diff --git a/xtts/voxtral/bridge.py b/xtts/voxtral/bridge.py index afeaa2c..23d3813 100644 --- a/xtts/voxtral/bridge.py +++ b/xtts/voxtral/bridge.py @@ -327,6 +327,7 @@ class StreamSession: last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint noise_floor: float = 0.0 closed: bool = False + discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren endpoint_sent: bool = False # Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt # keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein @@ -396,10 +397,12 @@ class SessionManager: sess.last_chunk_at = time.time() return True - def end_session(self, request_id: str) -> None: + def end_session(self, request_id: str, reason: str = "") -> None: sess = self._sessions.get(request_id) if sess is not None: sess.closed = True + if str(reason).startswith("cancel"): + sess.discard = True # Muelleimer in der App: verwerfen def drop(self, request_id: str) -> None: self._sessions.pop(request_id, None) @@ -620,6 +623,9 @@ class SessionManager: # Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort # ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht. _min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1 + if sess.discard: + reason = "cancelled" + _min_voiced = 10 ** 9 # verworfen: nie transkribieren, leerer Text if sess.voiced_frames < _min_voiced: logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint", sess.request_id[:8], sess.voiced_frames, _min_voiced, reason) @@ -810,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None: elif mtype == "stt_audio_chunk": sessions.feed_chunk(payload) elif mtype == "stt_stream_end": - sessions.end_session(payload.get("requestId", "")) + sessions.end_session(payload.get("requestId", ""), payload.get("reason", "")) elif mtype == "stt_transcribe_blob": # One-Shot-Transkription eines PCM-Schnipsels (kein Live- # Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt diff --git a/xtts/whisper/bridge.py b/xtts/whisper/bridge.py index 60da248..3dd350b 100644 --- a/xtts/whisper/bridge.py +++ b/xtts/whisper/bridge.py @@ -390,6 +390,7 @@ class StreamSession: voice_rms_min: float = STREAM_VOICE_RMS_MIN voice_rms_max: float = STREAM_VOICE_RMS_MAX closed: bool = False # nach stream_end gesetzt + discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren endpoint_sent: bool = False # Endpoint nur einmal feuern # Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten # ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit @@ -483,13 +484,16 @@ class SessionManager: session.last_chunk_at = time.time() return True - def end_session(self, request_id: str) -> Optional[StreamSession]: + def end_session(self, request_id: str, reason: str = "") -> Optional[StreamSession]: """Markiert Session als geschlossen. Der Endpointer-Loop macht das - Final-Transcribe + Cleanup.""" + Final-Transcribe + Cleanup. reason "cancel:*" (z.B. Muelleimer in der + App) = verwerfen: kein Transcribe, leerer Text — nichts geht an ARIA.""" session = self._sessions.get(request_id) if session is None: return None session.closed = True + if str(reason).startswith("cancel"): + session.discard = True return session def drop(self, request_id: str) -> None: @@ -807,6 +811,10 @@ class SessionManager: if sess.partial_task is not None and not sess.partial_task.done(): sess.partial_task.cancel() audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) + if sess.discard: + logger.info("Stream %s: verworfen (cancel) — kein Transcribe", sess.request_id[:8]) + audio = audio[:0] + reason = "cancelled" if audio.size == 0: logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8]) final_text = "" @@ -1080,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None: req_id[:8], payload.get("reason", "")) await _debug_log(ws, "stream.end", f"received id={req_id[:12]} reason={payload.get('reason', '')}") - sessions.end_session(req_id) + sessions.end_session(req_id, payload.get("reason", "")) elif mtype == "voice_id_status_request": req_id = payload.get("requestId", "")