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 ────────────────────────────────────────────────────────────────
|
// ── Voice bars ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) {
|
function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) {
|
||||||
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current;
|
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
|
||||||
|
const runningRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const animations = anims.map((a, i) =>
|
if (active && !runningRef.current) {
|
||||||
Animated.loop(
|
runningRef.current = true;
|
||||||
Animated.sequence([
|
const animations = anims.map((a, i) =>
|
||||||
Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
|
Animated.loop(
|
||||||
Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
|
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());
|
);
|
||||||
}, []);
|
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 (
|
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) => (
|
{anims.map((a, i) => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key={i}
|
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>
|
</View>
|
||||||
@ -195,6 +209,9 @@ export default function CoachScreen() {
|
|||||||
const soundRef = useRef<Audio.Sound | null>(null);
|
const soundRef = useRef<Audio.Sound | null>(null);
|
||||||
const micHeld = useRef(false);
|
const micHeld = useRef(false);
|
||||||
const recordingTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
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 typingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
@ -426,12 +443,26 @@ export default function CoachScreen() {
|
|||||||
|
|
||||||
function startRecordingTimer() {
|
function startRecordingTimer() {
|
||||||
setRecordingDuration(0);
|
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() {
|
function stopRecordingTimer() {
|
||||||
if (recordingTimer.current) clearInterval(recordingTimer.current);
|
if (recordingTimer.current) clearInterval(recordingTimer.current);
|
||||||
recordingTimer.current = null;
|
recordingTimer.current = null;
|
||||||
setRecordingDuration(0);
|
setRecordingDuration(0);
|
||||||
|
setAudioLevel(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMicDown() {
|
async function onMicDown() {
|
||||||
@ -449,7 +480,10 @@ export default function CoachScreen() {
|
|||||||
try {
|
try {
|
||||||
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||||||
const rec = new Audio.Recording();
|
const rec = new Audio.Recording();
|
||||||
await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
await rec.prepareToRecordAsync({
|
||||||
|
...Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||||
|
isMeteringEnabled: true,
|
||||||
|
});
|
||||||
await rec.startAsync();
|
await rec.startAsync();
|
||||||
recordingRef.current = rec;
|
recordingRef.current = rec;
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
@ -463,6 +497,9 @@ export default function CoachScreen() {
|
|||||||
|
|
||||||
async function cancelRecording() {
|
async function cancelRecording() {
|
||||||
if (!isRecording) return;
|
if (!isRecording) return;
|
||||||
|
// Kurze rote Flash-Animation bevor der State verschwindet
|
||||||
|
setTrashFlash(true);
|
||||||
|
setTimeout(() => setTrashFlash(false), 400);
|
||||||
micHeld.current = false;
|
micHeld.current = false;
|
||||||
stopRecordingTimer();
|
stopRecordingTimer();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
@ -590,7 +627,7 @@ export default function CoachScreen() {
|
|||||||
</View>
|
</View>
|
||||||
{isSpeaking && (
|
{isSpeaking && (
|
||||||
<View style={styles.speakingRow}>
|
<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>
|
<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}>
|
<TouchableOpacity style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6} activeOpacity={0.7}>
|
||||||
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
||||||
@ -654,21 +691,26 @@ export default function CoachScreen() {
|
|||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
/* ── Instagram-style Recording Bar ─────────────────────── */
|
/* ── Instagram-style Recording Bar ─────────────────────── */
|
||||||
<View style={[styles.recordingBar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
|
<View style={[styles.recordingBar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
|
||||||
{/* Trash - links */}
|
{/* Trash - links, kurz rot bei Klick */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.recSideBtn, { backgroundColor: colors.bg }]}
|
style={[
|
||||||
|
styles.recSideBtn,
|
||||||
|
{ backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg },
|
||||||
|
]}
|
||||||
onPress={cancelRecording}
|
onPress={cancelRecording}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={17}
|
||||||
|
color={trashFlash ? '#ef4444' : colors.textMuted}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Waveform + Timer - mitte */}
|
{/* Waveform + Timer - mitte */}
|
||||||
<View style={styles.recCenter}>
|
<View style={styles.recCenter}>
|
||||||
<View style={[styles.recLiveDot, { backgroundColor: colors.brandOrange }]} />
|
<View style={[styles.recLiveDot, { backgroundColor: colors.brandOrange }]} />
|
||||||
<View style={{ flex: 1 }}>
|
<VoiceBars count={22} baseColor={colors.text} active={audioLevel > 0.1} />
|
||||||
<VoiceBars count={22} baseColor={colors.text} />
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.recTimer, { color: colors.textMuted }]}>
|
<Text style={[styles.recTimer, { color: colors.textMuted }]}>
|
||||||
{formatDuration(recordingDuration)}
|
{formatDuration(recordingDuration)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user