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:
parent
7db32ca606
commit
585cb73947
@ -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); // 0–1, 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 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() {
|
||||
</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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user