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