feat: recording
11 unmodified lines121314151617181920946 unmodified linesExpand all96796896997097197297397497597697797897998098198298331 unmodified lines101510161017101810191020102110221023102410251026102710281029103037 unmodified lines106810691070107110721073107410751076107710781079108010812272 unmodified linesExpand all33543355335633573358335933603361336210 unmodified lines11 unmodified lines "npm:@atcute/oauth-browser-client@^2.0.1": "2.0.1_@atcute+identity@1.1.2", "npm:@pandacss/dev@^1.5.1": "1.5.1_typescript@5.9.3", "npm:@pandacss/preset-base@^1.5.1": "1.5.1", "npm:@park-ui/panda-preset@~0.43.1": "0.43.1_@pandacss+dev@1.5.1__typescript@5.9.3_typescript@5.9.3", "npm:@solid-primitives/date@^2.1.4": "2.1.4_solid-js@1.9.10__seroval@1.3.2", "npm:@solid-primitives/map@~0.7.2": "0.7.2_solid-js@1.9.10__seroval@1.3.2", "npm:fast-average-color@^9.5.0": "9.5.0", "npm:lucide-solid@0.553": "0.553.0_solid-js@1.9.10__seroval@1.3.2", "npm:mediabunny@^1.25.0": "1.25.0",946 unmodified linesExpand all "@solid-primitives/utils", "solid-js" ] }, "@solid-primitives/date@2.1.4_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-HN5r2991UlMP4yQvbSppGzbQWuGqV2aSvIs6R19XwFG1yvlnClYaYDupUssl23mnTbXby0jJK33H3diURtPLMA==", "dependencies": [ "@solid-primitives/memo", "@solid-primitives/timer", "@solid-primitives/utils", "solid-js" ] }, "@solid-primitives/event-listener@2.4.3_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==", "dependencies": [ "@solid-primitives/utils",31 unmodified lines "@solid-primitives/utils", "solid-js" ] }, "@solid-primitives/memo@1.4.3_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-CA+n9yaoqbYm+My5tY2RWb6EE16tVyehM4GzwQF4vCwvjYPAYk1JSRIVuMC0Xuj5ExD2XQJE5E2yAaKY2HTUsg==", "dependencies": [ "@solid-primitives/scheduled", "@solid-primitives/utils", "solid-js" ] }, "@solid-primitives/refs@1.1.2_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==", "dependencies": [ "@solid-primitives/utils",37 unmodified lines "@solid-primitives/utils", "solid-js" ] }, "@solid-primitives/timer@1.4.3_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-m2h6DgbnBLIh6zj0+BdGRZL0d+USPjmK7u+SqeK5N8hx+gFypZviSufCuC7ig/zGT3/C02nREUmxxjqd0OdHfg==", "dependencies": [ "solid-js" ] }, "@solid-primitives/trigger@1.2.2_solid-js@1.9.10__seroval@1.3.2": { "integrity": "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g==", "dependencies": [ "@solid-primitives/utils",2272 unmodified linesExpand all "npm:@atcute/oauth-browser-client@^2.0.1", "npm:@pandacss/dev@^1.5.1", "npm:@pandacss/preset-base@^1.5.1", "npm:@park-ui/panda-preset@~0.43.1", "npm:@solid-primitives/date@^2.1.4", "npm:@solid-primitives/map@~0.7.2", "npm:fast-average-color@^9.5.0", "npm:lucide-solid@0.553", "npm:mediabunny@^1.25.0",10 unmodified lines29 unmodified lines3031323334353637383 unmodified lines29 unmodified lines "@atcute/identity-resolver": "^1.1.4", "@atcute/lexicons": "^1.2.3", "@atcute/microcosm": "^1.0.0", "@atcute/oauth-browser-client": "^2.0.1", "@solid-primitives/date": "^2.1.4", "@solid-primitives/map": "^0.7.2", "fast-average-color": "^9.5.0", "lucide-solid": "^0.553.0", "mediabunny": "^1.25.0",3 unmodified lines2 unmodified lines3456789101114 unmodified lines26272829303132333461 unmodified lines9697989998991001011021031041051061041071081091101081111121131141158 unmodified lines12412512612712512612712812812913013113286 unmodified lines2192202212222232242252262276 unmodified lines2342352362372372382392402412422432442452462472482492502512522532542552562572582592602382392402412 unmodified linesimport { CheckIcon, ChevronsUpDownIcon, ClipboardIcon, MicIcon, Trash2Icon,} from "lucide-solid";import { Button } from "./components/ui/button";import { Card } from "./components/ui/card";14 unmodified lines
import { addTask, tasks, TaskState } from "./lib/task";import Task from "./components/FileTask";import Settings from "./components/Settings";import MicRecorder from "./components/MicRecorder";
const App = () => { const collection = () => createListCollection({61 unmodified lines );
return ( <Box w="100vw" h="100vh" py="8" minH="100vh" minW="100vw" display="flex" justifyContent="center" alignItems="center" > <Card.Root maxW="3xl" w="90%"> <Card.Root maxW="3xl" w="94%" h="max"> <Card.Header> <Card.Title w="full"> <Stack direction="row" align="center"> <Text>bsky voice memo</Text> <Text>memos</Text> <div style="flex-grow: 1;"></div> <AccountSelect /> <Settings /> </Stack>8 unmodified lines </ol> </Card.Description> </Card.Header> <Card.Body> <Stack gap={{ base: "4", smDown: "0" }} direction={{ base: "row", smDown: "column" }} > <Stack gap="4" direction={{ base: "row", smDown: "column" }}> <Upload flex="4" acceptedFiles={[]} onFileAccept={(e) =>86 unmodified lines or pick file </Button> )} /> <MicRecorder selectedAccount={selectedAccount} /> {/*<IconButton size="sm" onClick={() => getAudioClipboard().then((file) => {6 unmodified lines <ClipboardIcon /> </IconButton>*/} </HStack> </FileUpload.Dropzone> <FileUpload.ItemGroup> <FileUpload.Context> {(fileUpload) => ( <For each={fileUpload().acceptedFiles}> {(file) => ( <FileUpload.Item file={file}> <FileUpload.ItemPreview type="image/*"> <FileUpload.ItemPreviewImage /> </FileUpload.ItemPreview> <FileUpload.ItemName /> <FileUpload.ItemSizeText /> <FileUpload.ItemDeleteTrigger asChild={(triggerProps) => ( <IconButton variant="link" size="sm" {...triggerProps()}> <Trash2Icon /> </IconButton> )} /> </FileUpload.Item> )} </For> )} </FileUpload.Context> </FileUpload.ItemGroup> <FileUpload.HiddenInput /> </FileUpload.Root> );};123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187import { createSignal, onCleanup } from "solid-js";import { MicIcon } from "lucide-solid";import { IconButton } from "./ui/icon-button";import { Popover } from "./ui/popover";import { AtprotoDid } from "@atcute/lexicons/syntax";import { addTask } from "../lib/task";import { toaster } from "./Toaster";import { createTimeDifferenceFromNow } from "@solid-primitives/date";
type MicRecorderProps = { selectedAccount: () => AtprotoDid | undefined;};
const MicRecorder = (props: MicRecorderProps) => { const [isRecording, setIsRecording] = createSignal(false); const [recordingStart, setRecordingStart] = createSignal(0); const [diff] = createTimeDifferenceFromNow(recordingStart, () => isRecording() ? 1000 : 0, );
let mediaRecorder: MediaRecorder | null = null; let mediaStream: MediaStream | null = null; let audioChunks: Blob[] = [];
const preferredMimeType = "audio/webm; codecs=opus"; const fallbackMimeType = "audio/webm";
const startRecording = async () => { try { audioChunks = [];
if (!window.MediaRecorder) { toaster.create({ title: "recording not supported", description: "your browser does not support the MediaRecorder API.", type: "error", }); return; }
if (!navigator.mediaDevices) { toaster.create({ title: "recording not supported", description: "website is not running in a secure context.", type: "error", }); return; }
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: { ideal: true }, noiseSuppression: { ideal: true }, echoCancellation: { ideal: true }, }, }); const audioTrack = mediaStream.getAudioTracks()[0] ?? null; if (!audioTrack) throw "no audio track found";
let mimeType = ""; if (MediaRecorder.isTypeSupported(preferredMimeType)) mimeType = preferredMimeType; else if (MediaRecorder.isTypeSupported(fallbackMimeType)) mimeType = fallbackMimeType; else { console.warn( `browser does not support preffered audio / container type. falling back to whatever the browser picks`, ); mimeType = ""; }
const options: MediaRecorderOptions = { audioBitsPerSecond: 128000, bitsPerSecond: 128000, }; if (mimeType) options.mimeType = mimeType;
mediaRecorder = new MediaRecorder(mediaStream, options);
mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) audioChunks.push(event.data); };
mediaRecorder.onstop = () => { mediaStream?.getTracks().forEach((track) => track.stop()); mediaStream = null;
if (audioChunks.length === 0) { toaster.create({ title: "recording error", description: "recording is empty.", type: "error", }); return; }
const usedMime = mediaRecorder?.mimeType || mimeType || fallbackMimeType; const fileExtension = usedMime.split("/")[1]?.split(";")[0] || "webm"; const blob = new Blob(audioChunks, { type: usedMime }); const file = new File( [blob], `rec-${new Date().toISOString().replace(/:/g, "-")}.${fileExtension}`, { type: usedMime }, );
addTask(props.selectedAccount(), file); audioChunks = []; };
mediaRecorder.onerror = (event) => { console.error("MediaRecorder error:", event.error); toaster.create({ title: "recording error", description: `an error occurred: ${event.error.message}`, type: "error", }); stopRecording(); };
mediaRecorder.start();
setIsRecording(true); setRecordingStart(Date.now()); } catch (error) { console.error("error accessing microphone:", error); toaster.create({ title: "error starting recording", description: `could not start recording: ${error}`, type: "error", });
if (mediaStream) { mediaStream.getTracks().forEach((track) => track.stop()); mediaStream = null; } } };
const stopRecording = () => { if (!isRecording() || !mediaRecorder) return; if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); setIsRecording(false); };
onCleanup(() => { stopRecording(); mediaStream?.getTracks().forEach((track) => track.stop()); });
const formatTime = (timeDiff: () => number) => { const seconds = Math.round(Math.abs(timeDiff()) / 1000); const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; };
return ( <Popover.Root positioning={{ placement: "top" }} open={isRecording()}> <Popover.Anchor asChild={(anchorProps) => ( <IconButton {...anchorProps()} size="md" variant={isRecording() ? "solid" : "subtle"} colorPalette={isRecording() ? "red" : undefined} onMouseDown={startRecording} onMouseUp={stopRecording} onMouseLeave={stopRecording} onTouchStart={startRecording} onTouchEnd={stopRecording} > <MicIcon /> </IconButton> )} /> <Popover.Positioner> <Popover.Content fontFamily="monospace"> {formatTime(diff)} </Popover.Content> </Popover.Positioner> </Popover.Root> );};
export default MicRecorder;247 unmodified linesExpand all24824925025125225325425525613 unmodified lines27027127227327327427527527627627727827928028128228328428528628728828528628929029128929229329429529657 unmodified lines247 unmodified linesExpand all pfpUrl: string | undefined; visualizer: boolean; frameRate: number; bgColor: string; duration?: number;};
export const render = async (file: File, opts: RenderOptions) => { // load pfp picture13 unmodified lines formats: ALL_FORMATS, });
const audioTrack = await input.getPrimaryAudioTrack(); if (!audioTrack) throw new Error("no audio track found."); if (!audioTrack) throw "no audio track found.";
const duration = await input.computeDuration(); if (!duration) throw new Error("couldn't get audio duration."); if (!(await audioTrack.canDecode())) throw "audio track cannot be decoded by browser.";
const duration = opts.duration ?? (await audioTrack.computeDuration()); if (!duration) throw "couldn't get audio duration.";
const videoCodec = await getFirstEncodableVideoCodec( new Mp4OutputFormat().getSupportedVideoCodecs(), { width: renderCanvas.width, height: renderCanvas.height, }, ); if (!videoCodec) throw new Error("your browser doesn't support video encoding."); if (!videoCodec) throw "your browser doesn't support video encoding.";
const ctx = renderCanvas.getContext("2d"); if (!ctx) throw new Error("couldn't get canvas context."); if (!ctx) throw "couldn't get canvas context.";
const output = new MediaOutput({ format: new Mp4OutputFormat({ fastStart: "in-memory",57 unmodified lines26 unmodified lines272829303131323334353637383944 unmodified lines84858687888990919211 unmodified lines26 unmodified lines
const fac = new FastAverageColor();
export const tasks = new ReactiveMap<number, TaskState>();export const addTask = async (did: AtprotoDid | undefined, file: File) => {export const addTask = async ( did: AtprotoDid | undefined, file: File, duration?: number,) => { const id = generateId(); tasks.set(id, { status: "processing", file }); try { let pfpUrl: string | undefined = undefined;44 unmodified lines pfpUrl, visualizer: showVisualizer.get() ?? true, frameRate: frameRate.get() ?? 30, bgColor, duration, }); tasks.set(id, { file, status: "success",11 unmodified lines