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 file = new File( 103 [blob], 104 `rec-${new Date().toISOString().replace(/:/g, "-")}.${fileExtension}`, 105 { type: usedMime }, 106 ); 107 108 addTask(props.selectedAccount(), file); 109 audioChunks = []; 110 }; 111 112 mediaRecorder.onerror = (event) => { 113 console.error("MediaRecorder error:", event.error); 114 toaster.create({ 115 title: "recording error", 116 description: `an error occurred: ${event.error.message}`, 117 type: "error", 118 }); 119 stopRecording(); 120 }; 121 122 mediaRecorder.start(); 123 124 setIsRecording(true); 125 setRecordingStart(Date.now()); 126 } catch (error) { 127 console.error("error accessing microphone:", error); 128 toaster.create({ 129 title: "error starting recording", 130 description: `could not start recording: ${error}`, 131 type: "error", 132 }); 133 134 if (mediaStream) { 135 mediaStream.getTracks().forEach((track) => track.stop()); 136 mediaStream = null; 137 } 138 } 139 }; 140 141 const stopRecording = () => { 142 if (!isRecording() || !mediaRecorder) return; 143 if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); 144 setIsRecording(false); 145 }; 146 147 onCleanup(() => { 148 stopRecording(); 149 mediaStream?.getTracks().forEach((track) => track.stop()); 150 }); 151 152 const formatTime = (timeDiff: () => number) => { 153 const seconds = Math.round(Math.abs(timeDiff()) / 1000); 154 const mins = Math.floor(seconds / 60); 155 const secs = seconds % 60; 156 return `${mins}:${secs.toString().padStart(2, "0")}`; 157 }; 158 159 return ( 160 <Popover.Root positioning={{ placement: "top" }} open={isRecording()}> 161 <Popover.Anchor 162 asChild={(anchorProps) => ( 163 <IconButton 164 {...anchorProps()} 165 size="md" 166 variant={isRecording() ? "solid" : "subtle"} 167 colorPalette={isRecording() ? "red" : undefined} 168 onMouseDown={startRecording} 169 onMouseUp={stopRecording} 170 onMouseLeave={stopRecording} 171 onTouchStart={startRecording} 172 onTouchEnd={stopRecording} 173 > 174 <MicIcon /> 175 </IconButton> 176 )} 177 /> 178 <Popover.Positioner> 179 <Popover.Content fontFamily="monospace"> 180 {formatTime(diff)} 181 </Popover.Content> 182 </Popover.Positioner> 183 </Popover.Root> 184 ); 185}; 186 187export default MicRecorder;