profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
PushToTalkButton.tsx 6,045 bytes
1 import { useCallback, useEffect, useRef, useState } from 'react'
2 import { createPushToTalkRecorder } from '../audio'
3
4 interface PushToTalkButtonProps {
5 disabled?: boolean
6 onRecorded: (blob: Blob) => void
7 onRecordingChange?: (recording: boolean) => void
8 onLevel?: (level: number) => void
9 }
10
11 function isInteractiveTarget(target: EventTarget | null): boolean {
12 if (!(target instanceof HTMLElement)) return false
13 return (
14 ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName) ||
15 target.isContentEditable
16 )
17 }
18
19 const MAX_RECORDING_SECONDS = 120
20
21 function formatElapsed(seconds: number): string {
22 const minutes = Math.floor(seconds / 60)
23 const remainingSeconds = seconds % 60
24 return `${minutes}:${String(remainingSeconds).padStart(2, '0')}`
25 }
26
27 export function PushToTalkButton({
28 disabled,
29 onRecorded,
30 onRecordingChange,
31 onLevel,
32 }: PushToTalkButtonProps) {
33 const [recording, setRecording] = useState(false)
34 const [sending, setSending] = useState(false)
35 const [elapsed, setElapsed] = useState(0)
36 const [error, setError] = useState<string | null>(null)
37 const recorderRef = useRef(createPushToTalkRecorder())
38 const activeRef = useRef(false)
39 const startingRef = useRef(false)
40 const stopRequestedRef = useRef(false)
41 const sendingRef = useRef(false)
42 const parentSubmissionSeenRef = useRef(false)
43
44 const finishRecording = useCallback(async () => {
45 if (!activeRef.current || sendingRef.current) return
46 activeRef.current = false
47 sendingRef.current = true
48 setSending(true)
49 setRecording(false)
50 onRecordingChange?.(false)
51 try {
52 const blob = await recorderRef.current.stop()
53 onRecorded(blob)
54 } catch {
55 sendingRef.current = false
56 setSending(false)
57 setError('Could not send the recording. Try again, or type your answer below.')
58 }
59 }, [onRecorded, onRecordingChange])
60
61 const startRecording = useCallback(async () => {
62 if (disabled || sendingRef.current || activeRef.current || startingRef.current) return
63 startingRef.current = true
64 stopRequestedRef.current = false
65 setError(null)
66 try {
67 await recorderRef.current.start(onLevel)
68 startingRef.current = false
69 activeRef.current = true
70 setRecording(true)
71 onRecordingChange?.(true)
72 if (stopRequestedRef.current) {
73 stopRequestedRef.current = false
74 await finishRecording()
75 }
76 } catch {
77 startingRef.current = false
78 activeRef.current = false
79 setError('Could not access your microphone. Check browser permission, or type your answer below.')
80 }
81 }, [disabled, finishRecording, onLevel, onRecordingChange])
82
83 const stopRecording = useCallback(async () => {
84 if (startingRef.current) {
85 stopRequestedRef.current = true
86 return
87 }
88 await finishRecording()
89 }, [finishRecording])
90
91 const toggleRecording = useCallback(async () => {
92 if (disabled || sendingRef.current) return
93 if (activeRef.current || startingRef.current) {
94 await stopRecording()
95 return
96 }
97 await startRecording()
98 }, [disabled, startRecording, stopRecording])
99
100 useEffect(() => {
101 if (!sendingRef.current) return
102 if (disabled) {
103 parentSubmissionSeenRef.current = true
104 return
105 }
106 if (parentSubmissionSeenRef.current) {
107 parentSubmissionSeenRef.current = false
108 sendingRef.current = false
109 setSending(false)
110 }
111 }, [disabled])
112
113 useEffect(() => {
114 if (!recording) {
115 setElapsed(0)
116 return
117 }
118 const startedAt = Date.now()
119 const interval = window.setInterval(() => {
120 const seconds = Math.floor((Date.now() - startedAt) / 1000)
121 setElapsed(seconds)
122 if (seconds >= MAX_RECORDING_SECONDS) void stopRecording()
123 }, 500)
124 return () => window.clearInterval(interval)
125 }, [recording, stopRecording])
126
127 useEffect(() => {
128 function handleKeyDown(event: KeyboardEvent) {
129 if (event.code !== 'Space' || event.repeat || isInteractiveTarget(event.target)) return
130 event.preventDefault()
131 void startRecording()
132 }
133
134 function handleKeyUp(event: KeyboardEvent) {
135 if (event.code !== 'Space' || isInteractiveTarget(event.target)) return
136 event.preventDefault()
137 void stopRecording()
138 }
139
140 window.addEventListener('keydown', handleKeyDown)
141 window.addEventListener('keyup', handleKeyUp)
142 return () => {
143 window.removeEventListener('keydown', handleKeyDown)
144 window.removeEventListener('keyup', handleKeyUp)
145 }
146 }, [startRecording, stopRecording])
147
148 const unavailable = disabled || sending
149 const buttonLabel = sending
150 ? 'Sending recording...'
151 : disabled
152 ? 'Waiting...'
153 : recording
154 ? 'Send recording'
155 : 'Start talking'
156 const hint = sending
157 ? 'Turning your recording into text.'
158 : disabled
159 ? 'Preparing the next question.'
160 : recording
161 ? `${formatElapsed(elapsed)} recording. Click again to send.`
162 : 'Click once to start, or hold Space while you talk.'
163
164 return (
165 <div className="push-to-talk">
166 <button
167 type="button"
168 className={`mic-button ${recording ? 'recording' : ''}`}
169 disabled={unavailable}
170 aria-label={buttonLabel}
171 aria-pressed={recording}
172 aria-describedby="microphone-hint"
173 onClick={() => void toggleRecording()}
174 >
175 <svg
176 viewBox="0 0 24 24"
177 width="22"
178 height="22"
179 fill="none"
180 stroke="currentColor"
181 strokeWidth="1.8"
182 strokeLinecap="round"
183 aria-hidden="true"
184 >
185 <rect x="9" y="3" width="6" height="11" rx="3" />
186 <path d="M5 11a7 7 0 0 0 14 0" />
187 <path d="M12 18v3" />
188 </svg>
189 <span>{buttonLabel}</span>
190 </button>
191 <p className="mic-hint" id="microphone-hint" role="status" aria-live="polite">
192 {hint}
193 </p>
194 {error && (
195 <p className="error" role="alert">
196 {error}
197 </p>
198 )}
199 </div>
200 )
201 }
202