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;