creates video voice memos from audio clips; with bluesky integration.
trill.ptr.pet
1import { createSignal, onCleanup, Show } from "solid-js";
2import { CircleStopIcon, MicIcon } from "lucide-solid";
3import { IconButton } from "./ui/icon-button";
4import { Popover } from "./ui/popover";
5import { AtprotoDid } from "@atcute/lexicons/syntax";
6import { addTask } from "../lib/task";
7import { toaster } from "./Toaster";
8import { createTimeDifferenceFromNow } from "@solid-primitives/date";
9
10type MicRecorderProps = {
11 selectedAccount: () => AtprotoDid | undefined;
12};
13
14const MicRecorder = (props: MicRecorderProps) => {
15 const [isRecording, setIsRecording] = createSignal(false);
16 const [recordingStart, setRecordingStart] = createSignal(0);
17 const [diff] = createTimeDifferenceFromNow(recordingStart, () =>
18 isRecording() ? 1000 : 0,
19 );
20
21 let mediaRecorder: MediaRecorder | null = null;
22 let mediaStream: MediaStream | null = null;
23 let audioChunks: Blob[] = [];
24
25 // Flag to handle case where user releases hold before recording actually starts
26 let stopRequestPending = false;
27
28 const isSafari =
29 typeof navigator !== "undefined" &&
30 navigator.vendor &&
31 navigator.vendor.indexOf("Apple") > -1;
32
33 const preferredMimeType = isSafari
34 ? 'audio/mp4; codecs="mp4a.40.2"'
35 : "audio/webm;codecs=opus";
36 const fallbackMimeType = isSafari ? "audio/mp4" : "audio/webm";
37
38 const startRecording = async () => {
39 if (isRecording()) return;
40 stopRequestPending = false;
41
42 try {
43 audioChunks = [];
44
45 if (!window.MediaRecorder) {
46 toaster.create({
47 title: "recording not supported",
48 description: "your browser does not support the MediaRecorder API.",
49 type: "error",
50 });
51 return;
52 }
53
54 mediaStream = await navigator.mediaDevices.getUserMedia({
55 audio: {
56 autoGainControl: { ideal: true },
57 noiseSuppression: { ideal: true },
58 echoCancellation: { ideal: true },
59 },
60 });
61
62 // check if holding stopped while waiting for permission/stream
63 if (stopRequestPending) {
64 mediaStream.getTracks().forEach((track) => track.stop());
65 mediaStream = null;
66 return;
67 }
68
69 const audioTrack = mediaStream.getAudioTracks()[0] ?? null;
70 if (!audioTrack) throw "no audio track found";
71
72 let mimeType = "";
73 if (MediaRecorder.isTypeSupported(preferredMimeType)) {
74 mimeType = preferredMimeType;
75 } else if (MediaRecorder.isTypeSupported(fallbackMimeType)) {
76 console.warn(`falling back to ${fallbackMimeType} for recording audio`);
77 mimeType = fallbackMimeType;
78 } else {
79 console.warn(
80 `browser does not support preffered audio / container type.
81 falling back to whatever the browser picks`,
82 );
83 mimeType = "";
84 }
85
86 const options: MediaRecorderOptions = {
87 audioBitsPerSecond: 128000,
88 bitsPerSecond: 128000,
89 };
90 if (mimeType) options.mimeType = mimeType;
91
92 mediaRecorder = new MediaRecorder(mediaStream, options);
93
94 mediaRecorder.ondataavailable = (event) => {
95 if (event.data.size > 0) audioChunks.push(event.data);
96 };
97
98 mediaRecorder.onstop = () => {
99 mediaStream?.getTracks().forEach((track) => track.stop());
100 mediaStream = null;
101
102 if (audioChunks.length === 0) {
103 toaster.create({
104 title: "recording error",
105 description: "recording is empty.",
106 type: "error",
107 });
108 return;
109 }
110
111 const usedMime =
112 mediaRecorder?.mimeType || mimeType || fallbackMimeType;
113 const fileExtension = usedMime.split("/")[1]?.split(";")[0] || "webm";
114 const blob = new Blob(audioChunks, { type: usedMime });
115 const fileDate = new Date()
116 .toLocaleTimeString()
117 .replace(/:/g, "-")
118 .replace(/\s+/g, "_");
119 const file = new File([blob], `rec-${fileDate}.${fileExtension}`, {
120 type: usedMime,
121 });
122
123 console.info(usedMime, file.size);
124
125 addTask(props.selectedAccount(), file);
126 audioChunks = [];
127 };
128
129 mediaRecorder.onerror = (event) => {
130 console.error("MediaRecorder error:", event.error);
131 toaster.create({
132 title: "recording error",
133 description: `an error occurred: ${event.error.message}`,
134 type: "error",
135 });
136 stopRecording();
137 };
138
139 mediaRecorder.start();
140
141 setIsRecording(true);
142 setRecordingStart(Date.now());
143
144 // delayed hold release
145 if (stopRequestPending) stopRecording();
146 } catch (error) {
147 console.error("error accessing microphone:", error);
148 toaster.create({
149 title: "error starting recording",
150 description: `could not start recording: ${error}`,
151 type: "error",
152 });
153
154 if (mediaStream) {
155 mediaStream.getTracks().forEach((track) => track.stop());
156 mediaStream = null;
157 }
158 }
159 };
160
161 const stopRecording = () => {
162 if (!isRecording() || !mediaRecorder) {
163 stopRequestPending = true;
164 return;
165 }
166 if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
167 setIsRecording(false);
168 };
169
170 onCleanup(() => {
171 stopRecording();
172 mediaStream?.getTracks().forEach((track) => track.stop());
173 });
174
175 const formatTime = (timeDiff: () => number) => {
176 const seconds = Math.round(Math.abs(timeDiff()) / 1000);
177 const mins = Math.floor(seconds / 60);
178 const secs = seconds % 60;
179 return `${mins}:${secs.toString().padStart(2, "0")}`;
180 };
181
182 let pressStartTime = 0;
183 let startedSession = false;
184
185 const handlePointerDown = (e: PointerEvent) => {
186 if (isRecording()) {
187 stopRecording();
188 startedSession = false;
189 } else {
190 startRecording();
191 pressStartTime = Date.now();
192 startedSession = true;
193 }
194 };
195
196 const handlePointerUp = (e: PointerEvent) => {
197 if (startedSession) {
198 const duration = Date.now() - pressStartTime;
199 if (duration >= 500) stopRecording();
200
201 startedSession = false;
202 }
203 };
204
205 const handlePointerLeave = (e: PointerEvent) => {
206 if (startedSession && isRecording()) {
207 stopRecording();
208 startedSession = false;
209 }
210 };
211
212 return (
213 <Popover.Root positioning={{ placement: "top" }} open={isRecording()}>
214 <Popover.Anchor
215 asChild={(anchorProps) => (
216 <IconButton
217 {...anchorProps()}
218 size="md"
219 variant={isRecording() ? "solid" : "subtle"}
220 colorPalette={isRecording() ? "red" : undefined}
221 onPointerDown={handlePointerDown}
222 onPointerUp={handlePointerUp}
223 onPointerLeave={handlePointerLeave}
224 onContextMenu={(e) => e.preventDefault()}
225 >
226 {isRecording() ? <CircleStopIcon /> : <MicIcon />}
227 </IconButton>
228 )}
229 />
230 <Popover.Positioner>
231 <Popover.Content fontFamily="monospace">
232 {formatTime(diff)}
233 </Popover.Content>
234 </Popover.Positioner>
235 </Popover.Root>
236 );
237};
238
239export default MicRecorder;