Matthias Nott
2026-03-07 af1543135d42adc2e97dc5243aeef7418cd3b00d
components/chat/VoiceButton.tsx
....@@ -1,59 +1,34 @@
1
-import React, { useCallback, useEffect, useRef, useState } from "react";
1
+import React, { useCallback, useRef, useState } from "react";
22 import { Animated, Pressable, Text, View } from "react-native";
33 import * as Haptics from "expo-haptics";
44 import {
5
- ExpoSpeechRecognitionModule,
6
- useSpeechRecognitionEvent,
7
-} from "expo-speech-recognition";
5
+ useAudioRecorder,
6
+ RecordingPresets,
7
+ requestRecordingPermissionsAsync,
8
+ setAudioModeAsync,
9
+} from "expo-audio";
10
+import { stopPlayback } from "../../services/audio";
811
912 interface VoiceButtonProps {
10
- onTranscript: (text: string) => void;
13
+ onVoiceRecorded: (uri: string) => void;
1114 }
1215
1316 const VOICE_BUTTON_SIZE = 72;
1417
1518 /**
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)
19
+ * Tap-to-toggle voice button using expo-audio recording.
20
+ * Records audio and returns the file URI for the caller to send.
21
+ * - Tap once: start recording
22
+ * - Tap again: stop and send
23
+ * - Long-press while recording: cancel (discard)
2024 */
21
-export function VoiceButton({ onTranscript }: VoiceButtonProps) {
22
- const [isListening, setIsListening] = useState(false);
23
- const [transcript, setTranscript] = useState("");
25
+export function VoiceButton({ onVoiceRecorded }: VoiceButtonProps) {
26
+ const [isRecording, setIsRecording] = useState(false);
2427 const pulseAnim = useRef(new Animated.Value(1)).current;
2528 const glowAnim = useRef(new Animated.Value(0)).current;
2629 const pulseLoop = useRef<Animated.CompositeAnimation | null>(null);
27
- const cancelledRef = useRef(false);
2830
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
- });
31
+ const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
5732
5833 const startPulse = useCallback(() => {
5934 pulseLoop.current = Animated.loop(
....@@ -88,49 +63,73 @@
8863 }).start();
8964 }, [pulseAnim, glowAnim]);
9065
91
- const startListening = useCallback(async () => {
92
- const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
93
- if (!result.granted) return;
66
+ const startRecording = useCallback(async () => {
67
+ try {
68
+ await stopPlayback();
9469
95
- cancelledRef.current = false;
96
- setTranscript("");
97
- startPulse();
70
+ const { granted } = await requestRecordingPermissionsAsync();
71
+ if (!granted) return;
9872
99
- ExpoSpeechRecognitionModule.start({
100
- lang: "en-US",
101
- interimResults: true,
102
- continuous: true,
103
- });
104
- }, [startPulse]);
73
+ await setAudioModeAsync({
74
+ allowsRecording: true,
75
+ playsInSilentMode: true,
76
+ });
10577
106
- const stopAndSend = useCallback(() => {
78
+ startPulse();
79
+ await recorder.prepareToRecordAsync();
80
+ recorder.record();
81
+ setIsRecording(true);
82
+ } catch (err) {
83
+ console.error("Failed to start recording:", err);
84
+ stopPulse();
85
+ setIsRecording(false);
86
+ }
87
+ }, [recorder, startPulse, stopPulse]);
88
+
89
+ const stopAndSend = useCallback(async () => {
10790 stopPulse();
108
- cancelledRef.current = false;
109
- ExpoSpeechRecognitionModule.stop();
110
- }, [stopPulse]);
91
+ setIsRecording(false);
92
+ try {
93
+ await recorder.stop();
94
+ // Reset audio mode for playback
95
+ await setAudioModeAsync({
96
+ allowsRecording: false,
97
+ playsInSilentMode: true,
98
+ });
99
+ const uri = recorder.uri;
100
+ if (uri) {
101
+ onVoiceRecorded(uri);
102
+ }
103
+ } catch (err) {
104
+ console.error("Failed to stop recording:", err);
105
+ }
106
+ }, [recorder, stopPulse, onVoiceRecorded]);
111107
112
- const cancelListening = useCallback(() => {
108
+ const cancelRecording = useCallback(async () => {
113109 Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
114110 stopPulse();
115
- cancelledRef.current = true;
116
- setTranscript("");
117
- ExpoSpeechRecognitionModule.abort();
118
- }, [stopPulse]);
111
+ setIsRecording(false);
112
+ try {
113
+ await recorder.stop();
114
+ } catch {
115
+ // ignore
116
+ }
117
+ }, [recorder, stopPulse]);
119118
120119 const handleTap = useCallback(async () => {
121120 Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
122
- if (isListening) {
123
- stopAndSend();
121
+ if (isRecording) {
122
+ await stopAndSend();
124123 } else {
125
- await startListening();
124
+ await startRecording();
126125 }
127
- }, [isListening, stopAndSend, startListening]);
126
+ }, [isRecording, stopAndSend, startRecording]);
128127
129128 const handleLongPress = useCallback(() => {
130
- if (isListening) {
131
- cancelListening();
129
+ if (isRecording) {
130
+ cancelRecording();
132131 }
133
- }, [isListening, cancelListening]);
132
+ }, [isRecording, cancelRecording]);
134133
135134 return (
136135 <View style={{ alignItems: "center", justifyContent: "center" }}>
....@@ -141,7 +140,7 @@
141140 width: VOICE_BUTTON_SIZE + 24,
142141 height: VOICE_BUTTON_SIZE + 24,
143142 borderRadius: (VOICE_BUTTON_SIZE + 24) / 2,
144
- backgroundColor: isListening ? "rgba(255, 159, 67, 0.12)" : "transparent",
143
+ backgroundColor: isRecording ? "rgba(255, 159, 67, 0.12)" : "transparent",
145144 transform: [{ scale: pulseAnim }],
146145 opacity: glowAnim,
147146 }}
....@@ -158,35 +157,30 @@
158157 width: VOICE_BUTTON_SIZE,
159158 height: VOICE_BUTTON_SIZE,
160159 borderRadius: VOICE_BUTTON_SIZE / 2,
161
- backgroundColor: isListening ? "#FF9F43" : "#4A9EFF",
160
+ backgroundColor: isRecording ? "#FF9F43" : "#4A9EFF",
162161 alignItems: "center",
163162 justifyContent: "center",
164
- shadowColor: isListening ? "#FF9F43" : "#4A9EFF",
163
+ shadowColor: isRecording ? "#FF9F43" : "#4A9EFF",
165164 shadowOffset: { width: 0, height: 4 },
166165 shadowOpacity: 0.4,
167166 shadowRadius: 12,
168167 elevation: 8,
169168 }}
170169 >
171
- <Text style={{ fontSize: 28 }}>{isListening ? "⏹" : "🎤"}</Text>
170
+ <Text style={{ fontSize: 28 }}>{isRecording ? "⏹" : "🎤"}</Text>
172171 </View>
173172 </Pressable>
174173
175
- {/* Label / transcript preview */}
174
+ {/* Label */}
176175 <Text
177176 style={{
178
- color: isListening ? "#FF9F43" : "#5A5A78",
177
+ color: isRecording ? "#FF9F43" : "#5A5A78",
179178 fontSize: 11,
180179 marginTop: 4,
181
- fontWeight: isListening ? "600" : "400",
182
- maxWidth: 200,
183
- textAlign: "center",
180
+ fontWeight: isRecording ? "600" : "400",
184181 }}
185
- numberOfLines={2}
186182 >
187
- {isListening
188
- ? transcript || "Listening..."
189
- : "Tap to talk"}
183
+ {isRecording ? "Recording..." : "Tap to talk"}
190184 </Text>
191185 </View>
192186 );