Skip to content
Source
Sign inSign up
Open History to see the last commit

MicrophoneTest.tsx

347 lines12.0 KB

1import { MICROPHONE_PERMISSION_ERROR } from "@t3tools/client-runtime/voice-input";
2import { MicIcon, PauseIcon, PlayIcon, RotateCcwIcon, SquareIcon, XIcon } from "lucide-react";
3import { useEffect, useRef, useState } from "react";
4​
5import { useStableCallback } from "~/hooks/useStableCallback";
6import { requestMicrophone } from "../../speech/requestMicrophone";
7import workletUrl from "../../speech/pcmWorklet.ts?worker&url";
8import { VoiceWaveform } from "../chat/VoiceInputPill";
9import { cn } from "../../lib/utils";
10import { Button } from "../ui/button";
11import { SettingsRow } from "./settingsLayout";
12import { searchableSetting } from "./settingsSearch";
13​
14const LOOPBACK_SECONDS = 10;
15const formatTime = (seconds: number) =>
16 `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
17​
18type Capture = {
19 stream?: MediaStream;
20 recorder?: MediaRecorder;
21 context?: AudioContext;
22 worklet?: AudioWorkletNode;
23 timer?: ReturnType<typeof setTimeout>;
24 elapsedTimer?: ReturnType<typeof setInterval>;
25 startedAt?: number;
26};
27​
28/**
29 * Records a few seconds and plays them back in the browser. The audio never leaves this
30 * device: no environment, transcriber, or provider is involved, so a silent result points
31 * at the microphone rather than at transcription.
32 */
33export function MicrophoneTest({
34 microphoneId,
35 onMicrophoneAccess,
36}: {
37 microphoneId: string;
38 /** Labels and device ids become readable once the browser grants access. */
39 onMicrophoneAccess: () => void;
40}) {
41 const [phase, setPhase] = useState<"idle" | "starting" | "recording" | "ready">("idle");
42 const [recording, setRecording] = useState<{ url: string; duration: number } | null>(null);
43 const [error, setError] = useState<string | null>(null);
44 const [level, setLevel] = useState(0);
45 const [elapsed, setElapsed] = useState(0);
46 const capture = useRef<Capture | null>(null);
47 const recordingUrl = useRef<string | null>(null);
48​
49 const releaseCapture = useStableCallback(() => {
50 const current = capture.current;
51 if (!current) return;
52 capture.current = null;
53 clearTimeout(current.timer);
54 clearInterval(current.elapsedTimer);
55 current.worklet?.disconnect();
56 current.worklet?.port.close();
57 if (current.recorder && current.recorder.state !== "inactive") current.recorder.stop();
58 current.stream?.getTracks().forEach((track) => track.stop());
59 void current.context?.close().catch(() => undefined);
60 });
61 const discard = () => {
62 if (recordingUrl.current) URL.revokeObjectURL(recordingUrl.current);
63 recordingUrl.current = null;
64 setRecording(null);
65 setPhase("idle");
66 };
67 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the microphone and the recording's object URL on unmount
68 useEffect(
69 () => () => {
70 releaseCapture();
71 if (recordingUrl.current) URL.revokeObjectURL(recordingUrl.current);
72 },
73 [releaseCapture],
74 );
75​
76 const start = async () => {
77 releaseCapture();
78 discard();
79 setError(null);
80 setLevel(0);
81 setElapsed(0);
82 setPhase("starting");
83 if (
84 !navigator.mediaDevices?.getUserMedia ||
85 typeof MediaRecorder === "undefined" ||
86 typeof AudioWorkletNode === "undefined"
87 ) {
88 setError("Microphone testing is unavailable in this browser. Use HTTPS or the desktop app.");
89 setPhase("idle");
90 return;
91 }
92 const current: Capture = {};
93 capture.current = current;
94 try {
95 const stream = await requestMicrophone(microphoneId);
96 if (capture.current !== current) {
97 stream.getTracks().forEach((track) => track.stop());
98 return;
99 }
100 current.stream = stream;
101 onMicrophoneAccess();
102 const recorder = new MediaRecorder(stream);
103 current.recorder = recorder;
104 const chunks: Blob[] = [];
105 recorder.addEventListener("dataavailable", ({ data }) => {
106 if (capture.current === current && data.size) chunks.push(data);
107 });
108 recorder.addEventListener("stop", () => {
109 if (capture.current !== current) return;
110 const blob = new Blob(chunks, { type: recorder.mimeType });
111 const duration =
112 current.startedAt === undefined ? 0 : (performance.now() - current.startedAt) / 1_000;
113 releaseCapture();
114 setLevel(0);
115 if (!blob.size) {
116 setError("No audio was recorded. Try again.");
117 setPhase("idle");
118 return;
119 }
120 recordingUrl.current = URL.createObjectURL(blob);
121 setRecording({ url: recordingUrl.current, duration });
122 setPhase("ready");
123 });
124 const onRecordingError = () => {
125 if (capture.current !== current) return;
126 releaseCapture();
127 setLevel(0);
128 setError("Could not record microphone audio. Try again.");
129 setPhase("idle");
130 };
131 recorder.addEventListener("error", onRecordingError);
132 for (const track of stream.getAudioTracks()) {
133 track.addEventListener(
134 "ended",
135 () => {
136 if (capture.current !== current) return;
137 setError("The microphone disconnected.");
138 if (recorder.state !== "inactive") recorder.stop();
139 },
140 { once: true },
141 );
142 }
143 const context = new AudioContext();
144 current.context = context;
145 await context.audioWorklet.addModule(workletUrl);
146 if (capture.current !== current) return;
147 const worklet = new AudioWorkletNode(context, "t3-pcm-capture", {
148 channelCount: 1,
149 processorOptions: { meterOnly: true },
150 });
151 current.worklet = worklet;
152 worklet.port.onmessage = ({ data }: MessageEvent<number | "stopped">) => {
153 if (capture.current === current && typeof data === "number") setLevel(data);
154 };
155 worklet.onprocessorerror = onRecordingError;
156 context.createMediaStreamSource(stream).connect(worklet);
157 worklet.connect(context.destination);
158 await context.resume();
159 if (capture.current !== current) return;
160 const startedAt = performance.now();
161 current.startedAt = startedAt;
162 current.elapsedTimer = setInterval(() => {
163 setElapsed(Math.min(LOOPBACK_SECONDS, Math.floor((performance.now() - startedAt) / 1_000)));
164 }, 100);
165 recorder.start();
166 worklet.port.postMessage("start", []);
167 setPhase("recording");
168 current.timer = setTimeout(() => {
169 if (recorder.state !== "inactive") recorder.stop();
170 }, LOOPBACK_SECONDS * 1_000);
171 } catch (cause) {
172 if (capture.current !== current) return;
173 releaseCapture();
174 setPhase("idle");
175 const name = typeof cause === "object" && cause !== null && "name" in cause && cause.name;
176 setError(
177 name === "NotAllowedError"
178 ? MICROPHONE_PERMISSION_ERROR
179 : name === "NotFoundError"
180 ? "No microphone found."
181 : cause instanceof Error
182 ? cause.message
183 : "Could not access the microphone.",
184 );
185 }
186 };
187 const cancel = () => {
188 releaseCapture();
189 discard();
190 setError(null);
191 };
192​
193 const control =
194 phase === "recording" || phase === "starting" ? (
195 <Button
196 size="sm"
197 variant="outline"
198 aria-label="Stop recording"
199 disabled={phase !== "recording"}
200 onClick={() => {
201 const recorder = capture.current?.recorder;
202 if (recorder && recorder.state !== "inactive") recorder.stop();
203 }}
204 >
205 <SquareIcon />
206 Stop
207 </Button>
208 ) : (
209 <Button size="sm" variant="outline" onClick={() => void start()}>
210 {phase === "ready" ? <RotateCcwIcon /> : <MicIcon />}
211 {phase === "ready" ? "Record again" : "Record"}
212 </Button>
213 );
214 const panel = error ? (
215 <p role="alert" className="min-w-0 flex-1 text-xs text-destructive">
216 {error}
217 </p>
218 ) : phase === "starting" ? (
219 <span role="status" className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
220 Opening microphone…
221 </span>
222 ) : phase === "recording" ? (
223 <>
224 <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-destructive" />
225 <div className="flex w-24 min-w-0">
226 <VoiceWaveform level={level} />
227 </div>
228 <span
229 className="ms-auto shrink-0 text-xs tabular-nums text-muted-foreground"
230 aria-label="Recording time"
231 >
232 {formatTime(elapsed)} / {formatTime(LOOPBACK_SECONDS)}
233 </span>
234 <meter
235 aria-label="Microphone input level"
236 min={0}
237 max={1}
238 value={level}
239 className="sr-only"
240 />
241 </>
242 ) : phase === "ready" && recording ? (
243 <MicrophonePlayback key={recording.url} src={recording.url} duration={recording.duration} />
244 ) : null;
245​
246 return (
247 <SettingsRow
248 {...searchableSetting("microphone-test")}
249 title="Test microphone"
250 description="Record ten seconds and play it back. The audio stays on this device."
251 control={control}
252 >
253 {panel ? (
254 <div
255 className={cn(
256 "mt-3 mb-2 flex min-h-10 items-center gap-3 rounded-lg border border-border/60 bg-muted/30 py-1 pe-1",
257 phase === "ready" && !error ? "ps-1" : "ps-3",
258 )}
259 >
260 {panel}
261 <Button
262 size="icon-sm"
263 variant="ghost-muted"
264 aria-label={phase === "ready" ? "Discard recording" : "Cancel microphone test"}
265 onClick={cancel}
266 >
267 <XIcon />
268 </Button>
269 </div>
270 ) : null}
271 </SettingsRow>
272 );
274​
275function MicrophonePlayback({ src, duration }: { src: string; duration: number }) {
276 const audio = useRef<HTMLAudioElement>(null);
277 const [playing, setPlaying] = useState(false);
278 const [position, setPosition] = useState(0);
279 const [failed, setFailed] = useState(false);
280 const progress = duration > 0 ? Math.min(position / duration, 1) * 100 : 0;
281​
282 const togglePlayback = async () => {
283 const player = audio.current;
284 if (!player) return;
285 if (!player.paused) {
286 player.pause();
287 return;
288 }
289 setFailed(false);
290 if (player.ended) player.currentTime = 0;
291 try {
292 await player.play();
293 } catch {
294 setFailed(true);
295 }
296 };
297​
298 return (
299 <>
300 <audio
301 ref={audio}
302 src={src}
303 preload="metadata"
304 onPlay={() => setPlaying(true)}
305 onPause={() => setPlaying(false)}
306 onEnded={() => setPlaying(false)}
307 onTimeUpdate={() => setPosition(audio.current?.currentTime ?? 0)}
308 onError={() => setFailed(true)}
309 />
310 <Button
311 size="icon-sm-pill"
312 variant="outline"
313 aria-label={playing ? "Pause recording" : "Play recording"}
314 onClick={() => void togglePlayback()}
315 >
316 {playing ? <PauseIcon /> : <PlayIcon />}
317 </Button>
318 {failed ? (
319 <p role="alert" className="min-w-0 flex-1 text-xs text-destructive">
320 Could not play this recording. Try recording again.
321 </p>
322 ) : (
323 <input
324 type="range"
325 aria-label="Recording playback position"
326 min={0}
327 max={duration}
328 step={0.01}
329 value={Math.min(position, duration)}
330 style={{
331 background: `linear-gradient(to right, var(--primary) ${progress}%, var(--muted) ${progress}%)`,
332 }}
333 disabled={duration <= 0}
334 onChange={(event) => {
335 const next = Number(event.target.value);
336 if (audio.current) audio.current.currentTime = next;
337 setPosition(next);
338 }}
339 className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-primary"
340 />
341 )}
342 <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
343 {formatTime(position)} / {formatTime(duration)}
344 </span>
345 </>
346 );