added audio workword, and recording, editied readme
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* VoiceButton - Push-to-Talk Aufnahmeknopf
|
||||
* 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)
|
||||
*
|
||||
* Grosser runder Button: gedrueckt halten zum Aufnehmen, loslassen zum Senden.
|
||||
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
GestureResponderEvent,
|
||||
Easing,
|
||||
TouchableOpacity,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import audioService, { RecordingResult } from '../services/audio';
|
||||
|
||||
@@ -23,15 +28,23 @@ interface VoiceButtonProps {
|
||||
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 }) => {
|
||||
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(() => {
|
||||
@@ -59,53 +72,111 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({ onRecordingComplete, disabled
|
||||
}
|
||||
}, [isRecording, pulseAnim]);
|
||||
|
||||
// Aufnahmedauer zaehlen
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (durationTimer.current) {
|
||||
clearInterval(durationTimer.current);
|
||||
}
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const handlePressIn = async (_event: GestureResponderEvent) => {
|
||||
if (disabled) return;
|
||||
const started = await audioService.startRecording();
|
||||
// VAD Silence Callback — Auto-Stop
|
||||
useEffect(() => {
|
||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||
if (!isRecording) return;
|
||||
setIsRecording(false);
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 500) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
});
|
||||
return unsubSilence;
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
|
||||
// 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 (_event: GestureResponderEvent) => {
|
||||
if (!isRecording) return;
|
||||
const handlePressOut = async () => {
|
||||
if (!isRecording || !isLongPress.current) return;
|
||||
isLongPress.current = false;
|
||||
setIsRecording(false);
|
||||
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
// Nur senden wenn laenger als 300ms (versehentliches Tippen vermeiden)
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
|
||||
const handleTap = async () => {
|
||||
if (disabled) return;
|
||||
if (isRecording) {
|
||||
// Aufnahme manuell stoppen
|
||||
setIsRecording(false);
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
} else {
|
||||
// Aufnahme mit Auto-Stop starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (started) {
|
||||
isLongPress.current = false;
|
||||
setIsRecording(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
@@ -117,17 +188,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({ onRecordingComplete, disabled
|
||||
onResponderRelease={handlePressOut}
|
||||
onResponderTerminate={handlePressOut}
|
||||
>
|
||||
<View style={[styles.buttonInner, isRecording && styles.buttonInnerRecording]}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={handleTap}
|
||||
disabled={disabled}
|
||||
style={[styles.buttonInner, isRecording && styles.buttonInnerRecording]}
|
||||
>
|
||||
<Text style={styles.buttonIcon}>{isRecording ? '⏹' : '🎙'}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{isRecording && (
|
||||
<Text style={styles.durationText}>{formatDuration(durationMs)}</Text>
|
||||
<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({
|
||||
@@ -135,6 +217,16 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
@@ -165,10 +257,20 @@ const styles = StyleSheet.create({
|
||||
buttonIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
infoRow: {
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
width: 80,
|
||||
},
|
||||
meterBar: {
|
||||
height: 3,
|
||||
backgroundColor: '#FF3B30',
|
||||
borderRadius: 2,
|
||||
marginBottom: 2,
|
||||
},
|
||||
durationText: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user