feat(coach): voice bar silence/speech detection + trash flash + timer fix

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 10:54:20 +02:00
parent 7db32ca606
commit 585cb73947

View File

@ -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 (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, height: 20 }}>
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{ width: 2.5, height: a, borderRadius: 2, backgroundColor: baseColor, opacity: 0.75 }}
style={{
width: active ? 2.5 : 2,
height: active ? a : 2, // Stille: kleine Punkte
borderRadius: 2,
backgroundColor: baseColor,
opacity: active ? 0.75 : 0.4,
}}
/>
))}
</View>
@ -195,6 +209,9 @@ export default function CoachScreen() {
const soundRef = useRef<Audio.Sound | null>(null);
const micHeld = useRef(false);
const recordingTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const recordingStartTime = useRef<number>(0);
const [audioLevel, setAudioLevel] = useState(0); // 01, live metering
const [trashFlash, setTrashFlash] = useState(false); // kurze rote Animation beim Cancel
const typingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const emotionTimer = useRef<ReturnType<typeof setTimeout> | 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 01.
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() {
</View>
{isSpeaking && (
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<VoiceBars count={5} baseColor={colors.brandOrange} active={true} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<TouchableOpacity style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6} activeOpacity={0.7}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
@ -654,21 +691,26 @@ export default function CoachScreen() {
{isRecording ? (
/* ── Instagram-style Recording Bar ─────────────────────── */
<View style={[styles.recordingBar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
{/* Trash - links */}
{/* Trash - links, kurz rot bei Klick */}
<TouchableOpacity
style={[styles.recSideBtn, { backgroundColor: colors.bg }]}
style={[
styles.recSideBtn,
{ backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg },
]}
onPress={cancelRecording}
activeOpacity={0.7}
>
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
<Ionicons
name="trash-outline"
size={17}
color={trashFlash ? '#ef4444' : colors.textMuted}
/>
</TouchableOpacity>
{/* Waveform + Timer - mitte */}
<View style={styles.recCenter}>
<View style={[styles.recLiveDot, { backgroundColor: colors.brandOrange }]} />
<View style={{ flex: 1 }}>
<VoiceBars count={22} baseColor={colors.text} />
</View>
<VoiceBars count={22} baseColor={colors.text} active={audioLevel > 0.1} />
<Text style={[styles.recTimer, { color: colors.textMuted }]}>
{formatDuration(recordingDuration)}
</Text>