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