creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet
at main 7.2 kB view raw
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;