feat(app): Aufnahme verwerfen — Muelleimer neben dem Aufnahmeknopf

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 20:05:35 +01:00
co-authored by Claude Opus 5.5
parent 91619d9307
commit 9529bb0b16
5 changed files with 81 additions and 5 deletions
+8 -2
View File
@@ -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