297 lines
8.4 KiB
TypeScript
297 lines
8.4 KiB
TypeScript
/**
|
|
* VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf
|
|
*
|
|
* Zwei Modi:
|
|
* 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden
|
|
* 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille
|
|
* (auch genutzt fuer Wake-Word-getriggerte Aufnahme)
|
|
*
|
|
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
Animated,
|
|
StyleSheet,
|
|
Easing,
|
|
TouchableOpacity,
|
|
Pressable,
|
|
} from 'react-native';
|
|
import audioService, { RecordingResult } from '../services/audio';
|
|
|
|
// --- Typen ---
|
|
|
|
interface VoiceButtonProps {
|
|
/** Wird aufgerufen wenn die Aufnahme fertig ist */
|
|
onRecordingComplete: (result: RecordingResult) => void;
|
|
/** Button deaktivieren */
|
|
disabled?: boolean;
|
|
/** Wake-Word-Modus aktiv (zeigt Indikator) */
|
|
wakeWordActive?: boolean;
|
|
}
|
|
|
|
// --- Komponente ---
|
|
|
|
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|
onRecordingComplete,
|
|
disabled = false,
|
|
wakeWordActive = false,
|
|
}) => {
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [durationMs, setDurationMs] = useState(0);
|
|
const [meterDb, setMeterDb] = useState(-160);
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const isLongPress = useRef(false);
|
|
|
|
// Puls-Animation starten/stoppen
|
|
useEffect(() => {
|
|
if (isRecording) {
|
|
const pulse = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1.2,
|
|
duration: 600,
|
|
easing: Easing.inOut(Easing.ease),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1,
|
|
duration: 600,
|
|
easing: Easing.inOut(Easing.ease),
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
);
|
|
pulse.start();
|
|
return () => pulse.stop();
|
|
} else {
|
|
pulseAnim.setValue(1);
|
|
}
|
|
}, [isRecording, pulseAnim]);
|
|
|
|
// Aufnahmedauer zaehlen + Metering
|
|
useEffect(() => {
|
|
if (isRecording) {
|
|
setDurationMs(0);
|
|
durationTimer.current = setInterval(() => {
|
|
setDurationMs(prev => prev + 100);
|
|
}, 100);
|
|
|
|
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
|
|
return () => {
|
|
unsubMeter();
|
|
if (durationTimer.current) clearInterval(durationTimer.current);
|
|
};
|
|
} else {
|
|
if (durationTimer.current) {
|
|
clearInterval(durationTimer.current);
|
|
durationTimer.current = null;
|
|
}
|
|
}
|
|
}, [isRecording]);
|
|
|
|
// VAD Silence Callback — Auto-Stop.
|
|
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
|
|
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
|
|
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
|
|
// feuert (silenceFired-Latch).
|
|
const onCompleteRef = useRef(onRecordingComplete);
|
|
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
|
|
useEffect(() => {
|
|
const unsubSilence = audioService.onSilenceDetected(async () => {
|
|
if (audioService.getRecordingState() !== 'recording') return;
|
|
const result = await audioService.stopRecording();
|
|
setIsRecording(false);
|
|
if (result && result.durationMs > 500) {
|
|
onCompleteRef.current(result);
|
|
}
|
|
});
|
|
return unsubSilence;
|
|
}, []);
|
|
|
|
// Auto-Start fuer Wake Word (extern getriggert)
|
|
const startAutoRecording = useCallback(async () => {
|
|
if (disabled || isRecording) return;
|
|
const started = await audioService.startRecording(true); // autoStop = true
|
|
if (started) {
|
|
isLongPress.current = false;
|
|
setIsRecording(true);
|
|
}
|
|
}, [disabled, isRecording]);
|
|
|
|
// Push-to-Talk: Lang druecken
|
|
const handlePressIn = async () => {
|
|
if (disabled || isRecording) return;
|
|
isLongPress.current = true;
|
|
const started = await audioService.startRecording(false); // kein autoStop
|
|
if (started) {
|
|
setIsRecording(true);
|
|
}
|
|
};
|
|
|
|
const handlePressOut = async () => {
|
|
if (!isRecording || !isLongPress.current) return;
|
|
isLongPress.current = false;
|
|
setIsRecording(false);
|
|
const result = await audioService.stopRecording();
|
|
if (result && result.durationMs > 300) {
|
|
onRecordingComplete(result);
|
|
}
|
|
};
|
|
|
|
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
|
|
// Guard gegen Doppel-Tap während asyncer Start/Stop.
|
|
const tapBusy = useRef(false);
|
|
const handleTap = async () => {
|
|
if (disabled || tapBusy.current) return;
|
|
tapBusy.current = true;
|
|
try {
|
|
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
|
|
const svcState = audioService.getRecordingState();
|
|
if (svcState === 'recording') {
|
|
// Aufnahme manuell stoppen
|
|
const result = await audioService.stopRecording();
|
|
setIsRecording(false);
|
|
if (result && result.durationMs > 300) {
|
|
onRecordingComplete(result);
|
|
}
|
|
} else if (svcState === 'idle') {
|
|
// Aufnahme mit Auto-Stop starten
|
|
const started = await audioService.startRecording(true);
|
|
if (started) {
|
|
isLongPress.current = false;
|
|
setIsRecording(true);
|
|
}
|
|
}
|
|
// svcState === 'processing': Stopp in progress — nichts tun, User
|
|
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
|
|
// kurz damit der User's UI-Feedback synchron bleibt.
|
|
} finally {
|
|
tapBusy.current = false;
|
|
}
|
|
};
|
|
|
|
// Expose startAutoRecording via ref fuer Wake Word
|
|
React.useImperativeHandle(
|
|
React.createRef(),
|
|
() => ({ startAutoRecording }),
|
|
[startAutoRecording],
|
|
);
|
|
|
|
const formatDuration = (ms: number): string => {
|
|
const seconds = Math.floor(ms / 1000);
|
|
const tenths = Math.floor((ms % 1000) / 100);
|
|
return `${seconds}.${tenths}s`;
|
|
};
|
|
|
|
// Meter-Visualisierung (0-1 Skala)
|
|
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{wakeWordActive && !isRecording && (
|
|
<View style={styles.wakeWordDot} />
|
|
)}
|
|
<Animated.View
|
|
style={[
|
|
styles.buttonOuter,
|
|
isRecording && styles.buttonOuterRecording,
|
|
{ transform: [{ scale: pulseAnim }] },
|
|
]}
|
|
onStartShouldSetResponder={() => true}
|
|
onResponderGrant={handlePressIn}
|
|
onResponderRelease={handlePressOut}
|
|
onResponderTerminate={handlePressOut}
|
|
>
|
|
<TouchableOpacity
|
|
activeOpacity={0.8}
|
|
onPress={handleTap}
|
|
disabled={disabled}
|
|
style={[styles.buttonInner, isRecording && styles.buttonInnerRecording]}
|
|
>
|
|
<Text style={styles.buttonIcon}>{isRecording ? '⏹' : '🎙'}</Text>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
{isRecording && (
|
|
<View style={styles.infoRow}>
|
|
<View style={[styles.meterBar, { width: `${meterLevel * 100}%` }]} />
|
|
<Text style={styles.durationText}>{formatDuration(durationMs)}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Expose startAutoRecording fuer externe Aufrufe (Wake Word)
|
|
export type VoiceButtonHandle = { startAutoRecording: () => Promise<void> };
|
|
|
|
// --- Styles ---
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
wakeWordDot: {
|
|
position: 'absolute',
|
|
top: -4,
|
|
right: -4,
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: '#34C759',
|
|
zIndex: 10,
|
|
},
|
|
buttonOuter: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: 'rgba(0, 150, 255, 0.2)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
buttonOuterRecording: {
|
|
backgroundColor: 'rgba(255, 59, 48, 0.3)',
|
|
},
|
|
buttonInner: {
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: 26,
|
|
backgroundColor: '#0096FF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
elevation: 4,
|
|
shadowColor: '#0096FF',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 4,
|
|
},
|
|
buttonInnerRecording: {
|
|
backgroundColor: '#FF3B30',
|
|
},
|
|
buttonIcon: {
|
|
fontSize: 24,
|
|
},
|
|
infoRow: {
|
|
alignItems: 'center',
|
|
marginTop: 4,
|
|
width: 80,
|
|
},
|
|
meterBar: {
|
|
height: 3,
|
|
backgroundColor: '#FF3B30',
|
|
borderRadius: 2,
|
|
marginBottom: 2,
|
|
},
|
|
durationText: {
|
|
color: '#FF3B30',
|
|
fontSize: 12,
|
|
fontVariant: ['tabular-nums'],
|
|
},
|
|
});
|
|
|
|
export default VoiceButton;
|