Matthias Nott
2026-03-02 a0f39302919fbacf7a0d407f01b1a50413ea6f70
components/chat/VoiceButton.tsx
....@@ -1,121 +1,192 @@
1
-import React, { useCallback, useRef, useState } from "react";
1
+import React, { useCallback, useEffect, useRef, useState } from "react";
22 import { Animated, Pressable, Text, View } from "react-native";
33 import * as Haptics from "expo-haptics";
4
-import { startRecording, stopRecording } from "../../services/audio";
5
-import { Audio } from "expo-av";
4
+import {
5
+ ExpoSpeechRecognitionModule,
6
+ useSpeechRecognitionEvent,
7
+} from "expo-speech-recognition";
68
79 interface VoiceButtonProps {
8
- onVoiceMessage: (audioUri: string, durationMs: number) => void;
10
+ onTranscript: (text: string) => void;
911 }
1012
11
-const VOICE_BUTTON_SIZE = 88;
13
+const VOICE_BUTTON_SIZE = 72;
1214
13
-export function VoiceButton({ onVoiceMessage }: VoiceButtonProps) {
14
- const [isRecording, setIsRecording] = useState(false);
15
- const recordingRef = useRef<Audio.Recording | null>(null);
16
- const scaleAnim = useRef(new Animated.Value(1)).current;
15
+/**
16
+ * Tap-to-toggle voice button using on-device speech recognition.
17
+ * - Tap once: start listening
18
+ * - Tap again: stop and send transcript
19
+ * - Long-press while listening: cancel (discard)
20
+ */
21
+export function VoiceButton({ onTranscript }: VoiceButtonProps) {
22
+ const [isListening, setIsListening] = useState(false);
23
+ const [transcript, setTranscript] = useState("");
1724 const pulseAnim = useRef(new Animated.Value(1)).current;
25
+ const glowAnim = useRef(new Animated.Value(0)).current;
1826 const pulseLoop = useRef<Animated.CompositeAnimation | null>(null);
27
+ const cancelledRef = useRef(false);
28
+
29
+ // Speech recognition events
30
+ useSpeechRecognitionEvent("start", () => {
31
+ setIsListening(true);
32
+ });
33
+
34
+ useSpeechRecognitionEvent("end", () => {
35
+ setIsListening(false);
36
+ stopPulse();
37
+
38
+ // Send transcript if we have one and weren't cancelled
39
+ if (!cancelledRef.current && transcript.trim()) {
40
+ onTranscript(transcript.trim());
41
+ }
42
+ setTranscript("");
43
+ cancelledRef.current = false;
44
+ });
45
+
46
+ useSpeechRecognitionEvent("result", (event) => {
47
+ const text = event.results[0]?.transcript ?? "";
48
+ setTranscript(text);
49
+ });
50
+
51
+ useSpeechRecognitionEvent("error", (event) => {
52
+ console.error("Speech recognition error:", event.error, event.message);
53
+ setIsListening(false);
54
+ stopPulse();
55
+ setTranscript("");
56
+ });
1957
2058 const startPulse = useCallback(() => {
2159 pulseLoop.current = Animated.loop(
2260 Animated.sequence([
2361 Animated.timing(pulseAnim, {
2462 toValue: 1.15,
25
- duration: 600,
63
+ duration: 700,
2664 useNativeDriver: true,
2765 }),
2866 Animated.timing(pulseAnim, {
2967 toValue: 1,
30
- duration: 600,
68
+ duration: 700,
3169 useNativeDriver: true,
3270 }),
3371 ])
3472 );
3573 pulseLoop.current.start();
36
- }, [pulseAnim]);
74
+ Animated.timing(glowAnim, {
75
+ toValue: 1,
76
+ duration: 300,
77
+ useNativeDriver: true,
78
+ }).start();
79
+ }, [pulseAnim, glowAnim]);
3780
3881 const stopPulse = useCallback(() => {
3982 pulseLoop.current?.stop();
4083 pulseAnim.setValue(1);
41
- }, [pulseAnim]);
42
-
43
- const handlePressIn = useCallback(async () => {
44
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
45
-
46
- Animated.spring(scaleAnim, {
47
- toValue: 0.92,
84
+ Animated.timing(glowAnim, {
85
+ toValue: 0,
86
+ duration: 200,
4887 useNativeDriver: true,
4988 }).start();
89
+ }, [pulseAnim, glowAnim]);
5090
51
- const recording = await startRecording();
52
- if (recording) {
53
- recordingRef.current = recording;
54
- setIsRecording(true);
55
- startPulse();
56
- }
57
- }, [scaleAnim, startPulse]);
91
+ const startListening = useCallback(async () => {
92
+ const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
93
+ if (!result.granted) return;
5894
59
- const handlePressOut = useCallback(async () => {
60
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
95
+ cancelledRef.current = false;
96
+ setTranscript("");
97
+ startPulse();
6198
62
- Animated.spring(scaleAnim, {
63
- toValue: 1,
64
- useNativeDriver: true,
65
- }).start();
99
+ ExpoSpeechRecognitionModule.start({
100
+ lang: "en-US",
101
+ interimResults: true,
102
+ continuous: true,
103
+ });
104
+ }, [startPulse]);
66105
106
+ const stopAndSend = useCallback(() => {
67107 stopPulse();
68
- setIsRecording(false);
108
+ cancelledRef.current = false;
109
+ ExpoSpeechRecognitionModule.stop();
110
+ }, [stopPulse]);
69111
70
- if (recordingRef.current) {
71
- const result = await stopRecording();
72
- recordingRef.current = null;
112
+ const cancelListening = useCallback(() => {
113
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
114
+ stopPulse();
115
+ cancelledRef.current = true;
116
+ setTranscript("");
117
+ ExpoSpeechRecognitionModule.abort();
118
+ }, [stopPulse]);
73119
74
- if (result && result.durationMs > 500) {
75
- onVoiceMessage(result.uri, result.durationMs);
76
- }
120
+ const handleTap = useCallback(async () => {
121
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
122
+ if (isListening) {
123
+ stopAndSend();
124
+ } else {
125
+ await startListening();
77126 }
78
- }, [scaleAnim, stopPulse, onVoiceMessage]);
127
+ }, [isListening, stopAndSend, startListening]);
128
+
129
+ const handleLongPress = useCallback(() => {
130
+ if (isListening) {
131
+ cancelListening();
132
+ }
133
+ }, [isListening, cancelListening]);
79134
80135 return (
81
- <View className="items-center justify-center py-4">
82
- {/* Pulse ring — only visible while recording */}
136
+ <View style={{ alignItems: "center", justifyContent: "center" }}>
137
+ {/* Outer pulse ring */}
83138 <Animated.View
84139 style={{
85140 position: "absolute",
86141 width: VOICE_BUTTON_SIZE + 24,
87142 height: VOICE_BUTTON_SIZE + 24,
88143 borderRadius: (VOICE_BUTTON_SIZE + 24) / 2,
89
- backgroundColor: isRecording ? "rgba(255, 159, 67, 0.15)" : "transparent",
144
+ backgroundColor: isListening ? "rgba(255, 159, 67, 0.12)" : "transparent",
90145 transform: [{ scale: pulseAnim }],
146
+ opacity: glowAnim,
91147 }}
92148 />
93149
94150 {/* Button */}
95
- <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
96
- <Pressable
97
- onPressIn={handlePressIn}
98
- onPressOut={handlePressOut}
151
+ <Pressable
152
+ onPress={handleTap}
153
+ onLongPress={handleLongPress}
154
+ delayLongPress={600}
155
+ >
156
+ <View
99157 style={{
100158 width: VOICE_BUTTON_SIZE,
101159 height: VOICE_BUTTON_SIZE,
102160 borderRadius: VOICE_BUTTON_SIZE / 2,
103
- backgroundColor: isRecording ? "#FF9F43" : "#4A9EFF",
161
+ backgroundColor: isListening ? "#FF9F43" : "#4A9EFF",
104162 alignItems: "center",
105163 justifyContent: "center",
106
- shadowColor: isRecording ? "#FF9F43" : "#4A9EFF",
164
+ shadowColor: isListening ? "#FF9F43" : "#4A9EFF",
107165 shadowOffset: { width: 0, height: 4 },
108166 shadowOpacity: 0.4,
109167 shadowRadius: 12,
110168 elevation: 8,
111169 }}
112170 >
113
- <Text style={{ fontSize: 32 }}>{isRecording ? "🎙" : "🎤"}</Text>
114
- </Pressable>
115
- </Animated.View>
171
+ <Text style={{ fontSize: 28 }}>{isListening ? "⏹" : "🎤"}</Text>
172
+ </View>
173
+ </Pressable>
116174
117
- <Text className="text-pai-text-muted text-xs mt-3">
118
- {isRecording ? "Release to send" : "Hold to talk"}
175
+ {/* Label / transcript preview */}
176
+ <Text
177
+ style={{
178
+ color: isListening ? "#FF9F43" : "#5A5A78",
179
+ fontSize: 11,
180
+ marginTop: 4,
181
+ fontWeight: isListening ? "600" : "400",
182
+ maxWidth: 200,
183
+ textAlign: "center",
184
+ }}
185
+ numberOfLines={2}
186
+ >
187
+ {isListening
188
+ ? transcript || "Listening..."
189
+ : "Tap to talk"}
119190 </Text>
120191 </View>
121192 );