@@ -31,6 +31,8 @@ export interface VoiceInputButtonProps {
3131 onRecordingChange ?: ( recording : boolean ) => void ;
3232 onStart ?: ( ) => void ;
3333 onStop ?: ( elapsedSeconds : number ) => void ;
34+ onTranscription ?: ( text : string ) => void ;
35+ onError ?: ( error : Error ) => void ;
3436}
3537
3638function formatElapsedTime ( totalSeconds : number ) : string {
@@ -52,6 +54,8 @@ export function VoiceInputButton({
5254 onRecordingChange,
5355 onStart,
5456 onStop,
57+ onTranscription,
58+ onError,
5559} : VoiceInputButtonProps ) {
5660 const isControlled = recording !== undefined ;
5761 const [ internalRecording , setInternalRecording ] = useState ( defaultRecording ) ;
@@ -62,20 +66,135 @@ export function VoiceInputButton({
6266 ) ;
6367 const startedAtRef = useRef < number | null > ( null ) ;
6468 const elapsedSecondsRef = useRef ( 0 ) ;
69+ const mediaRecorderRef = useRef < MediaRecorder | null > ( null ) ;
70+ const audioChunksRef = useRef < Blob [ ] > ( [ ] ) ;
71+ const [ isStarting , setIsStarting ] = useState ( false ) ;
72+ const [ isTranscribing , setIsTranscribing ] = useState ( false ) ;
73+
74+ const reportError = useCallback (
75+ ( error : unknown ) => {
76+ const nextError =
77+ error instanceof Error ? error : new Error ( "Voice input failed." ) ;
78+ console . error ( "Voice input failed" , nextError ) ;
79+ onError ?.( nextError ) ;
80+ } ,
81+ [ onError ]
82+ ) ;
83+
84+ const transcribeAudio = useCallback (
85+ async ( audio : Blob ) => {
86+ const formData = new FormData ( ) ;
87+ formData . append ( "file" , audio , "voice-input.webm" ) ;
88+
89+ const response = await fetch ( "/api/audio/transcriptions" , {
90+ method : "POST" ,
91+ body : formData ,
92+ } ) ;
93+
94+ if ( ! response . ok ) {
95+ throw new Error ( `ASR request failed with status ${ response . status } ` ) ;
96+ }
97+
98+ const transcription = ( await response . json ( ) ) as { text ?: unknown } ;
99+ const text =
100+ typeof transcription . text === "string" ? transcription . text . trim ( ) : "" ;
101+
102+ if ( text ) onTranscription ?.( text ) ;
103+ } ,
104+ [ onTranscription ]
105+ ) ;
106+
107+ const startRecording = useCallback ( async ( ) => {
108+ if (
109+ disabled ||
110+ isRecording ||
111+ isStarting ||
112+ isTranscribing ||
113+ ! navigator . mediaDevices ?. getUserMedia
114+ ) {
115+ if ( ! navigator . mediaDevices ?. getUserMedia ) {
116+ reportError ( new Error ( "Audio recording is not supported in this browser." ) ) ;
117+ }
118+ return ;
119+ }
120+
121+ setIsStarting ( true ) ;
122+
123+ try {
124+ const stream = await navigator . mediaDevices . getUserMedia ( { audio : true } ) ;
125+ const mediaRecorder = new MediaRecorder ( stream ) ;
126+
127+ audioChunksRef . current = [ ] ;
128+ mediaRecorder . ondataavailable = ( event ) => {
129+ if ( event . data . size > 0 ) audioChunksRef . current . push ( event . data ) ;
130+ } ;
131+ mediaRecorder . onstop = ( ) => {
132+ stream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
133+ mediaRecorderRef . current = null ;
134+
135+ const audio = new Blob ( audioChunksRef . current , {
136+ type : mediaRecorder . mimeType || "audio/webm" ,
137+ } ) ;
138+ audioChunksRef . current = [ ] ;
139+
140+ if ( audio . size === 0 ) return ;
141+
142+ setIsTranscribing ( true ) ;
143+ void transcribeAudio ( audio )
144+ . catch ( reportError )
145+ . finally ( ( ) => setIsTranscribing ( false ) ) ;
146+ } ;
147+
148+ mediaRecorderRef . current = mediaRecorder ;
149+ mediaRecorder . start ( ) ;
150+
151+ if ( ! isControlled ) setInternalRecording ( true ) ;
152+ onRecordingChange ?.( true ) ;
153+ onStart ?.( ) ;
154+ } catch ( error ) {
155+ reportError ( error ) ;
156+ } finally {
157+ setIsStarting ( false ) ;
158+ }
159+ } , [
160+ disabled ,
161+ isControlled ,
162+ isRecording ,
163+ isStarting ,
164+ isTranscribing ,
165+ onRecordingChange ,
166+ onStart ,
167+ reportError ,
168+ transcribeAudio ,
169+ ] ) ;
65170
66171 const setRecording = useCallback (
67172 ( nextRecording : boolean ) => {
68- if ( ( disabled && nextRecording ) || nextRecording === isRecording ) return ;
173+ if ( nextRecording ) {
174+ void startRecording ( ) ;
175+ return ;
176+ }
177+
178+ if ( ! isRecording ) return ;
69179
70- if ( ! isControlled ) setInternalRecording ( nextRecording ) ;
71- onRecordingChange ?.( nextRecording ) ;
180+ if ( ! isControlled ) setInternalRecording ( false ) ;
181+ onRecordingChange ?.( false ) ;
182+ onStop ?.( elapsedSecondsRef . current ) ;
72183
73- if ( nextRecording ) onStart ?. ( ) ;
74- else onStop ?. ( elapsedSecondsRef . current ) ;
184+ const mediaRecorder = mediaRecorderRef . current ;
185+ if ( mediaRecorder ?. state !== "inactive" ) mediaRecorder ?. stop ( ) ;
75186 } ,
76- [ disabled , isControlled , isRecording , onRecordingChange , onStart , onStop ]
187+ [ isControlled , isRecording , onRecordingChange , onStop , startRecording ]
77188 ) ;
78189
190+ useEffect ( ( ) => {
191+ return ( ) => {
192+ const mediaRecorder = mediaRecorderRef . current ;
193+ if ( mediaRecorder ?. state !== "inactive" ) mediaRecorder ?. stop ( ) ;
194+ mediaRecorder ?. stream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
195+ } ;
196+ } , [ ] ) ;
197+
79198 useEffect ( ( ) => {
80199 if ( ! isRecording ) {
81200 startedAtRef . current = null ;
@@ -136,15 +255,15 @@ export function VoiceInputButton({
136255 className = { cn (
137256 styles . voiceControl ,
138257 isRecording && styles . recording ,
139- disabled && ! isRecording && styles . disabled ,
258+ ( disabled || isStarting || isTranscribing ) && ! isRecording && styles . disabled ,
140259 className
141260 ) }
142261 data-recording = { isRecording }
143262 >
144263 < button
145264 className = { styles . voiceTrigger }
146265 type = "button"
147- disabled = { disabled && ! isRecording }
266+ disabled = { ( disabled || isStarting || isTranscribing ) && ! isRecording }
148267 aria-label = { isRecording ? activeLabel : startLabel }
149268 aria-pressed = { isRecording }
150269 title = { isRecording ? activeLabel : startLabel }
@@ -189,4 +308,4 @@ export function VoiceInputButton({
189308 </ div >
190309 </ div >
191310 ) ;
192- }
311+ }
0 commit comments