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