creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet

feat: recording

ptr.pet fe5cdac5 47559ba1

verified
Changed files
+237 -39
src
+25
deno.lock
··· 13 13 "npm:@pandacss/dev@^1.5.1": "1.5.1_typescript@5.9.3", 14 14 "npm:@pandacss/preset-base@^1.5.1": "1.5.1", 15 15 "npm:@park-ui/panda-preset@~0.43.1": "0.43.1_@pandacss+dev@1.5.1__typescript@5.9.3_typescript@5.9.3", 16 + "npm:@solid-primitives/date@^2.1.4": "2.1.4_solid-js@1.9.10__seroval@1.3.2", 16 17 "npm:@solid-primitives/map@~0.7.2": "0.7.2_solid-js@1.9.10__seroval@1.3.2", 17 18 "npm:fast-average-color@^9.5.0": "9.5.0", 18 19 "npm:lucide-solid@0.553": "0.553.0_solid-js@1.9.10__seroval@1.3.2", ··· 967 968 "solid-js" 968 969 ] 969 970 }, 971 + "@solid-primitives/date@2.1.4_solid-js@1.9.10__seroval@1.3.2": { 972 + "integrity": "sha512-HN5r2991UlMP4yQvbSppGzbQWuGqV2aSvIs6R19XwFG1yvlnClYaYDupUssl23mnTbXby0jJK33H3diURtPLMA==", 973 + "dependencies": [ 974 + "@solid-primitives/memo", 975 + "@solid-primitives/timer", 976 + "@solid-primitives/utils", 977 + "solid-js" 978 + ] 979 + }, 970 980 "@solid-primitives/event-listener@2.4.3_solid-js@1.9.10__seroval@1.3.2": { 971 981 "integrity": "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==", 972 982 "dependencies": [ ··· 1006 1016 "solid-js" 1007 1017 ] 1008 1018 }, 1019 + "@solid-primitives/memo@1.4.3_solid-js@1.9.10__seroval@1.3.2": { 1020 + "integrity": "sha512-CA+n9yaoqbYm+My5tY2RWb6EE16tVyehM4GzwQF4vCwvjYPAYk1JSRIVuMC0Xuj5ExD2XQJE5E2yAaKY2HTUsg==", 1021 + "dependencies": [ 1022 + "@solid-primitives/scheduled", 1023 + "@solid-primitives/utils", 1024 + "solid-js" 1025 + ] 1026 + }, 1009 1027 "@solid-primitives/refs@1.1.2_solid-js@1.9.10__seroval@1.3.2": { 1010 1028 "integrity": "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==", 1011 1029 "dependencies": [ ··· 1048 1066 "dependencies": [ 1049 1067 "@solid-primitives/rootless", 1050 1068 "@solid-primitives/utils", 1069 + "solid-js" 1070 + ] 1071 + }, 1072 + "@solid-primitives/timer@1.4.3_solid-js@1.9.10__seroval@1.3.2": { 1073 + "integrity": "sha512-m2h6DgbnBLIh6zj0+BdGRZL0d+USPjmK7u+SqeK5N8hx+gFypZviSufCuC7ig/zGT3/C02nREUmxxjqd0OdHfg==", 1074 + "dependencies": [ 1051 1075 "solid-js" 1052 1076 ] 1053 1077 }, ··· 3331 3355 "npm:@pandacss/dev@^1.5.1", 3332 3356 "npm:@pandacss/preset-base@^1.5.1", 3333 3357 "npm:@park-ui/panda-preset@~0.43.1", 3358 + "npm:@solid-primitives/date@^2.1.4", 3334 3359 "npm:@solid-primitives/map@~0.7.2", 3335 3360 "npm:fast-average-color@^9.5.0", 3336 3361 "npm:lucide-solid@0.553",
+1
package.json
··· 31 31 "@atcute/lexicons": "^1.2.3", 32 32 "@atcute/microcosm": "^1.0.0", 33 33 "@atcute/oauth-browser-client": "^2.0.1", 34 + "@solid-primitives/date": "^2.1.4", 34 35 "@solid-primitives/map": "^0.7.2", 35 36 "fast-average-color": "^9.5.0", 36 37 "lucide-solid": "^0.553.0",
+9 -32
src/App.tsx
··· 4 4 CheckIcon, 5 5 ChevronsUpDownIcon, 6 6 ClipboardIcon, 7 + MicIcon, 7 8 Trash2Icon, 8 9 } from "lucide-solid"; 9 10 import { Button } from "./components/ui/button"; ··· 26 27 import { addTask, tasks, TaskState } from "./lib/task"; 27 28 import Task from "./components/FileTask"; 28 29 import Settings from "./components/Settings"; 30 + import MicRecorder from "./components/MicRecorder"; 29 31 30 32 const App = () => { 31 33 const collection = () => ··· 95 97 96 98 return ( 97 99 <Box 98 - w="100vw" 99 - h="100vh" 100 + py="8" 101 + minH="100vh" 102 + minW="100vw" 100 103 display="flex" 101 104 justifyContent="center" 102 105 alignItems="center" 103 106 > 104 - <Card.Root maxW="3xl" w="90%"> 107 + <Card.Root maxW="3xl" w="94%" h="max"> 105 108 <Card.Header> 106 109 <Card.Title w="full"> 107 110 <Stack direction="row" align="center"> 108 - <Text>bsky voice memo</Text> 111 + <Text>memos</Text> 109 112 <div style="flex-grow: 1;"></div> 110 113 <AccountSelect /> 111 114 <Settings /> ··· 122 125 </Card.Description> 123 126 </Card.Header> 124 127 <Card.Body> 125 - <Stack 126 - gap={{ base: "4", smDown: "0" }} 127 - direction={{ base: "row", smDown: "column" }} 128 - > 128 + <Stack gap="4" direction={{ base: "row", smDown: "column" }}> 129 129 <Upload 130 130 flex="4" 131 131 acceptedFiles={[]} ··· 220 220 </Button> 221 221 )} 222 222 /> 223 + <MicRecorder selectedAccount={selectedAccount} /> 223 224 {/*<IconButton 224 225 size="sm" 225 226 onClick={() => ··· 234 235 </IconButton>*/} 235 236 </HStack> 236 237 </FileUpload.Dropzone> 237 - <FileUpload.ItemGroup> 238 - <FileUpload.Context> 239 - {(fileUpload) => ( 240 - <For each={fileUpload().acceptedFiles}> 241 - {(file) => ( 242 - <FileUpload.Item file={file}> 243 - <FileUpload.ItemPreview type="image/*"> 244 - <FileUpload.ItemPreviewImage /> 245 - </FileUpload.ItemPreview> 246 - <FileUpload.ItemName /> 247 - <FileUpload.ItemSizeText /> 248 - <FileUpload.ItemDeleteTrigger 249 - asChild={(triggerProps) => ( 250 - <IconButton variant="link" size="sm" {...triggerProps()}> 251 - <Trash2Icon /> 252 - </IconButton> 253 - )} 254 - /> 255 - </FileUpload.Item> 256 - )} 257 - </For> 258 - )} 259 - </FileUpload.Context> 260 - </FileUpload.ItemGroup> 261 238 <FileUpload.HiddenInput /> 262 239 </FileUpload.Root> 263 240 );
+187
src/components/MicRecorder.tsx
··· 1 + import { createSignal, onCleanup } from "solid-js"; 2 + import { MicIcon } from "lucide-solid"; 3 + import { IconButton } from "./ui/icon-button"; 4 + import { Popover } from "./ui/popover"; 5 + import { AtprotoDid } from "@atcute/lexicons/syntax"; 6 + import { addTask } from "../lib/task"; 7 + import { toaster } from "./Toaster"; 8 + import { createTimeDifferenceFromNow } from "@solid-primitives/date"; 9 + 10 + type MicRecorderProps = { 11 + selectedAccount: () => AtprotoDid | undefined; 12 + }; 13 + 14 + const 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 + 187 + export default MicRecorder;
+9 -6
src/lib/render.ts
··· 249 249 visualizer: boolean; 250 250 frameRate: number; 251 251 bgColor: string; 252 + duration?: number; 252 253 }; 253 254 254 255 export const render = async (file: File, opts: RenderOptions) => { ··· 270 271 }); 271 272 272 273 const audioTrack = await input.getPrimaryAudioTrack(); 273 - if (!audioTrack) throw new Error("no audio track found."); 274 + if (!audioTrack) throw "no audio track found."; 274 275 275 - const duration = await input.computeDuration(); 276 - if (!duration) throw new Error("couldn't get audio duration."); 276 + if (!(await audioTrack.canDecode())) 277 + throw "audio track cannot be decoded by browser."; 278 + 279 + const duration = opts.duration ?? (await audioTrack.computeDuration()); 280 + if (!duration) throw "couldn't get audio duration."; 277 281 278 282 const videoCodec = await getFirstEncodableVideoCodec( 279 283 new Mp4OutputFormat().getSupportedVideoCodecs(), ··· 282 286 height: renderCanvas.height, 283 287 }, 284 288 ); 285 - if (!videoCodec) 286 - throw new Error("your browser doesn't support video encoding."); 289 + if (!videoCodec) throw "your browser doesn't support video encoding."; 287 290 288 291 const ctx = renderCanvas.getContext("2d"); 289 - if (!ctx) throw new Error("couldn't get canvas context."); 292 + if (!ctx) throw "couldn't get canvas context."; 290 293 291 294 const output = new MediaOutput({ 292 295 format: new Mp4OutputFormat({
+6 -1
src/lib/task.ts
··· 28 28 const fac = new FastAverageColor(); 29 29 30 30 export const tasks = new ReactiveMap<number, TaskState>(); 31 - export const addTask = async (did: AtprotoDid | undefined, file: File) => { 31 + export const addTask = async ( 32 + did: AtprotoDid | undefined, 33 + file: File, 34 + duration?: number, 35 + ) => { 32 36 const id = generateId(); 33 37 tasks.set(id, { status: "processing", file }); 34 38 try { ··· 81 85 visualizer: showVisualizer.get() ?? true, 82 86 frameRate: frameRate.get() ?? 30, 83 87 bgColor, 88 + duration, 84 89 }); 85 90 tasks.set(id, { 86 91 file,