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;