ARIA-AGENT/android/src/components/VoiceButton.tsx

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;