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:
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
|
|||||||
onTapStart: () => Promise<boolean>;
|
onTapStart: () => Promise<boolean>;
|
||||||
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
||||||
onTapStop: () => Promise<void>;
|
onTapStop: () => Promise<void>;
|
||||||
|
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
|
||||||
|
onTapCancel?: () => Promise<void>;
|
||||||
/** Button deaktivieren */
|
/** Button deaktivieren */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
||||||
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
|
|||||||
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||||
onTapStart,
|
onTapStart,
|
||||||
onTapStop,
|
onTapStop,
|
||||||
|
onTapCancel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
wakeWordActive = false,
|
wakeWordActive = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
}
|
}
|
||||||
}, [disabled, onTapStart, onTapStop]);
|
}, [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 formatDuration = (ms: number): string => {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
const tenths = Math.floor((ms % 1000) / 100);
|
const tenths = Math.floor((ms % 1000) / 100);
|
||||||
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
{wakeWordActive && !isRecording && (
|
{wakeWordActive && !isRecording && (
|
||||||
<View style={styles.wakeWordDot} />
|
<View style={styles.wakeWordDot} />
|
||||||
)}
|
)}
|
||||||
|
{isRecording && onTapCancel && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCancel}
|
||||||
|
accessibilityLabel="Aufnahme verwerfen"
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
style={styles.cancelButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelIcon}>🗑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.buttonOuter,
|
styles.buttonOuter,
|
||||||
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#34C759',
|
backgroundColor: '#34C759',
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
|
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
|
||||||
|
cancelButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: -46,
|
||||||
|
top: 14,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(60, 60, 70, 0.9)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
cancelIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
buttonOuter: {
|
buttonOuter: {
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
|
|||||||
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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<void> => {
|
||||||
|
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
|
||||||
|
await ariaStopRecording('user-discard');
|
||||||
|
}, [ariaStopRecording]);
|
||||||
|
|
||||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||||
setShowFileUpload(false);
|
setShowFileUpload(false);
|
||||||
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
<VoiceButton
|
<VoiceButton
|
||||||
onTapStart={handleVoiceButtonStart}
|
onTapStart={handleVoiceButtonStart}
|
||||||
onTapStop={handleVoiceButtonStop}
|
onTapStop={handleVoiceButtonStop}
|
||||||
|
onTapCancel={handleVoiceButtonCancel}
|
||||||
disabled={connectionState !== 'connected'}
|
disabled={connectionState !== 'connected'}
|
||||||
wakeWordActive={wakeWordActive}
|
wakeWordActive={wakeWordActive}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3449,6 +3449,16 @@ class ARIABridge:
|
|||||||
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
|
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
|
||||||
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
|
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
|
||||||
req_id = payload.get("requestId", "") or ""
|
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:
|
if req_id:
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -3473,6 +3483,9 @@ class ARIABridge:
|
|||||||
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
|
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
|
||||||
payload.get("reason", ""))
|
payload.get("reason", ""))
|
||||||
return
|
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 ""
|
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||||
voice = payload.get("voice", "") or ""
|
voice = payload.get("voice", "") or ""
|
||||||
speed_raw = payload.get("speed")
|
speed_raw = payload.get("speed")
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ class StreamSession:
|
|||||||
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||||
noise_floor: float = 0.0
|
noise_floor: float = 0.0
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
|
||||||
endpoint_sent: bool = False
|
endpoint_sent: bool = False
|
||||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||||
@@ -396,10 +397,12 @@ class SessionManager:
|
|||||||
sess.last_chunk_at = time.time()
|
sess.last_chunk_at = time.time()
|
||||||
return True
|
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)
|
sess = self._sessions.get(request_id)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
sess.closed = True
|
sess.closed = True
|
||||||
|
if str(reason).startswith("cancel"):
|
||||||
|
sess.discard = True # Muelleimer in der App: verwerfen
|
||||||
|
|
||||||
def drop(self, request_id: str) -> None:
|
def drop(self, request_id: str) -> None:
|
||||||
self._sessions.pop(request_id, None)
|
self._sessions.pop(request_id, None)
|
||||||
@@ -620,6 +623,9 @@ class SessionManager:
|
|||||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
_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:
|
if sess.voiced_frames < _min_voiced:
|
||||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
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":
|
elif mtype == "stt_audio_chunk":
|
||||||
sessions.feed_chunk(payload)
|
sessions.feed_chunk(payload)
|
||||||
elif mtype == "stt_stream_end":
|
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":
|
elif mtype == "stt_transcribe_blob":
|
||||||
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
||||||
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
||||||
|
|||||||
+11
-3
@@ -390,6 +390,7 @@ class StreamSession:
|
|||||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||||
closed: bool = False # nach stream_end gesetzt
|
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
|
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||||
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
|
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
|
||||||
@@ -483,13 +484,16 @@ class SessionManager:
|
|||||||
session.last_chunk_at = time.time()
|
session.last_chunk_at = time.time()
|
||||||
return True
|
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
|
"""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)
|
session = self._sessions.get(request_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
return None
|
return None
|
||||||
session.closed = True
|
session.closed = True
|
||||||
|
if str(reason).startswith("cancel"):
|
||||||
|
session.discard = True
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def drop(self, request_id: str) -> None:
|
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():
|
if sess.partial_task is not None and not sess.partial_task.done():
|
||||||
sess.partial_task.cancel()
|
sess.partial_task.cancel()
|
||||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
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:
|
if audio.size == 0:
|
||||||
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
||||||
final_text = ""
|
final_text = ""
|
||||||
@@ -1080,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
req_id[:8], payload.get("reason", ""))
|
req_id[:8], payload.get("reason", ""))
|
||||||
await _debug_log(ws, "stream.end",
|
await _debug_log(ws, "stream.end",
|
||||||
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
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":
|
elif mtype == "voice_id_status_request":
|
||||||
req_id = payload.get("requestId", "")
|
req_id = payload.get("requestId", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user