From 585cb73947f18b286772cb1d8c5527ce59e94abe Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 10:54:20 +0200 Subject: [PATCH] feat(coach): voice bar silence/speech detection + trash flash + timer fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoiceBars: active=false → kleine Punkte (Stille), active=true → animierte Bars (Sprechen). Übergang fließend via Animated.timing. - Metering via isMeteringEnabled:true + getStatusAsync() alle 200ms. audioLevel (0-1) aus dBFS normalisiert. Threshold >0.1 = Sprechen. - Trash-Button: 400ms roter Flash (backgroundColor + Icon-Farbe) beim Klick bevor Recording verschwindet — wie Instagram. - Timer: Date.now()-basiert statt Increment → kein Android-setInterval-Jitter. - VoiceBars volle Breite via flex:1 + justifyContent:space-evenly. Co-Authored-By: Claude Sonnet 4.6 --- apps/rebreak-native/app/lyra.tsx | 90 +++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/apps/rebreak-native/app/lyra.tsx b/apps/rebreak-native/app/lyra.tsx index 56d3e57..7d99c8f 100644 --- a/apps/rebreak-native/app/lyra.tsx +++ b/apps/rebreak-native/app/lyra.tsx @@ -94,28 +94,42 @@ function ThinkingDots() { // ── Voice bars ──────────────────────────────────────────────────────────────── -function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) { - const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current; +function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) { + const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current; + const runningRef = useRef(false); useEffect(() => { - const animations = anims.map((a, i) => - Animated.loop( - Animated.sequence([ - Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }), - Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }), - ]) - ) - ); - animations.forEach((a) => a.start()); - return () => animations.forEach((a) => a.stop()); - }, []); + if (active && !runningRef.current) { + runningRef.current = true; + const animations = anims.map((a, i) => + Animated.loop( + Animated.sequence([ + Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }), + Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }), + ]) + ) + ); + animations.forEach((a) => a.start()); + return () => { animations.forEach((a) => a.stop()); runningRef.current = false; }; + } else if (!active) { + // Stille: alle Bars auf minimale Höhe zurück + anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start()); + runningRef.current = false; + } + }, [active]); return ( - + {anims.map((a, i) => ( ))} @@ -195,6 +209,9 @@ export default function CoachScreen() { const soundRef = useRef(null); const micHeld = useRef(false); const recordingTimer = useRef | null>(null); + const recordingStartTime = useRef(0); + const [audioLevel, setAudioLevel] = useState(0); // 0–1, live metering + const [trashFlash, setTrashFlash] = useState(false); // kurze rote Animation beim Cancel const typingTimer = useRef | null>(null); const emotionTimer = useRef | null>(null); const isNearBottomRef = useRef(true); @@ -426,12 +443,26 @@ export default function CoachScreen() { function startRecordingTimer() { setRecordingDuration(0); - recordingTimer.current = setInterval(() => setRecordingDuration((d) => d + 1), 1000); + setAudioLevel(0); + recordingStartTime.current = Date.now(); + recordingTimer.current = setInterval(async () => { + setRecordingDuration(Math.floor((Date.now() - recordingStartTime.current) / 1000)); + // Metering: Audio-Pegel für Stille/Sprechen-Unterscheidung + try { + const status = await recordingRef.current?.getStatusAsync(); + if (status?.isRecording && status.metering !== undefined) { + // metering ist in dBFS (-160 bis 0). Normalisieren auf 0–1. + const normalized = Math.max(0, Math.min(1, (status.metering + 60) / 60)); + setAudioLevel(normalized); + } + } catch { /* kein Metering auf diesem Gerät */ } + }, 200); } function stopRecordingTimer() { if (recordingTimer.current) clearInterval(recordingTimer.current); recordingTimer.current = null; setRecordingDuration(0); + setAudioLevel(0); } async function onMicDown() { @@ -449,7 +480,10 @@ export default function CoachScreen() { try { await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); const rec = new Audio.Recording(); - await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); + await rec.prepareToRecordAsync({ + ...Audio.RecordingOptionsPresets.HIGH_QUALITY, + isMeteringEnabled: true, + }); await rec.startAsync(); recordingRef.current = rec; setIsRecording(true); @@ -463,6 +497,9 @@ export default function CoachScreen() { async function cancelRecording() { if (!isRecording) return; + // Kurze rote Flash-Animation bevor der State verschwindet + setTrashFlash(true); + setTimeout(() => setTrashFlash(false), 400); micHeld.current = false; stopRecordingTimer(); setIsRecording(false); @@ -590,7 +627,7 @@ export default function CoachScreen() { {isSpeaking && ( - + {t('coach.speaking')} @@ -654,21 +691,26 @@ export default function CoachScreen() { {isRecording ? ( /* ── Instagram-style Recording Bar ─────────────────────── */ - {/* Trash - links */} + {/* Trash - links, kurz rot bei Klick */} - + {/* Waveform + Timer - mitte */} - - - + 0.1} /> {formatDuration(recordingDuration)}