creates video voice memos from audio clips; with bluesky integration.
trill.ptr.pet
1import { createSignal, onCleanup } from "solid-js";
2import { 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 const isSafari =
26 typeof navigator !== "undefined" &&
27 navigator.vendor &&
28 navigator.vendor.indexOf("Apple") > -1;
29
30 const preferredMimeType = isSafari
31 ? 'audio/mp4; codecs="mp4a.40.2"'
32 : "audio/webm;codecs=opus";
33 const fallbackMimeType = isSafari ? "audio/mp4" : "audio/webm";
34
35 const startRecording = async () => {
36 try {
37 audioChunks = [];
38
39 if (!window.MediaRecorder) {
40 toaster.create({
41 title: "recording not supported",
42 description: "your browser does not support the MediaRecorder API.",
43 type: "error",
44 });
45 return;
46 }
47
48 mediaStream = await navigator.mediaDevices.getUserMedia({
49 audio: {
50 autoGainControl: { ideal: true },
51 noiseSuppression: { ideal: true },
52 echoCancellation: { ideal: true },
53 },
54 });
55 const audioTrack = mediaStream.getAudioTracks()[0] ?? null;
56 if (!audioTrack) throw "no audio track found";
57
58 let mimeType = "";
59 if (MediaRecorder.isTypeSupported(preferredMimeType)) {
60 mimeType = preferredMimeType;
61 } else if (MediaRecorder.isTypeSupported(fallbackMimeType)) {
62 console.warn(`falling back to ${fallbackMimeType} for recording audio`);
63 mimeType = fallbackMimeType;
64 } else {
65 console.warn(
66 `browser does not support preffered audio / container type.
67 falling back to whatever the browser picks`,
68 );
69 mimeType = "";
70 }
71
72 const options: MediaRecorderOptions = {
73 audioBitsPerSecond: 128000,
74 bitsPerSecond: 128000,
75 };
76 if (mimeType) options.mimeType = mimeType;
77
78 mediaRecorder = new MediaRecorder(mediaStream, options);
79
80 mediaRecorder.ondataavailable = (event) => {
81 if (event.data.size > 0) audioChunks.push(event.data);
82 };
83
84 mediaRecorder.onstop = () => {
85 mediaStream?.getTracks().forEach((track) => track.stop());
86 mediaStream = null;
87
88 if (audioChunks.length === 0) {
89 toaster.create({
90 title: "recording error",
91 description: "recording is empty.",
92 type: "error",
93 });
94 return;
95 }
96
97 const usedMime =
98 mediaRecorder?.mimeType || mimeType || fallbackMimeType;
99 const fileExtension = usedMime.split("/")[1]?.split(";")[0] || "webm";
100 const blob = new Blob(audioChunks, { type: usedMime });
101 const fileDate = new Date()
102 .toLocaleTimeString()
103 .replace(/:/g, "-")
104 .replace(/\s+/g, "_");
105 const file = new File([blob], `rec-${fileDate}.${fileExtension}`, {
106 type: usedMime,
107 });
108
109 console.info(usedMime, file.size);
110
111 addTask(props.selectedAccount(), file);
112 audioChunks = [];
113 };
114
115 mediaRecorder.onerror = (event) => {
116 console.error("MediaRecorder error:", event.error);
117 toaster.create({
118 title: "recording error",
119 description: `an error occurred: ${event.error.message}`,
120 type: "error",
121 });
122 stopRecording();
123 };
124
125 mediaRecorder.start();
126
127 setIsRecording(true);
128 setRecordingStart(Date.now());
129 } catch (error) {
130 console.error("error accessing microphone:", error);
131 toaster.create({
132 title: "error starting recording",
133 description: `could not start recording: ${error}`,
134 type: "error",
135 });
136
137 if (mediaStream) {
138 mediaStream.getTracks().forEach((track) => track.stop());
139 mediaStream = null;
140 }
141 }
142 };
143
144 const stopRecording = () => {
145 if (!isRecording() || !mediaRecorder) return;
146 if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
147 setIsRecording(false);
148 };
149
150 onCleanup(() => {
151 stopRecording();
152 mediaStream?.getTracks().forEach((track) => track.stop());
153 });
154
155 const formatTime = (timeDiff: () => number) => {
156 const seconds = Math.round(Math.abs(timeDiff()) / 1000);
157 const mins = Math.floor(seconds / 60);
158 const secs = seconds % 60;
159 return `${mins}:${secs.toString().padStart(2, "0")}`;
160 };
161
162 return (
163 <Popover.Root positioning={{ placement: "top" }} open={isRecording()}>
164 <Popover.Anchor
165 asChild={(anchorProps) => (
166 <IconButton
167 {...anchorProps()}
168 size="md"
169 variant={isRecording() ? "solid" : "subtle"}
170 colorPalette={isRecording() ? "red" : undefined}
171 onMouseDown={startRecording}
172 onMouseUp={stopRecording}
173 onMouseLeave={stopRecording}
174 onTouchStart={startRecording}
175 onTouchEnd={stopRecording}
176 >
177 <MicIcon />
178 </IconButton>
179 )}
180 />
181 <Popover.Positioner>
182 <Popover.Content fontFamily="monospace">
183 {formatTime(diff)}
184 </Popover.Content>
185 </Popover.Positioner>
186 </Popover.Root>
187 );
188};
189
190export default MicRecorder;