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
···
"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",
···
"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-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": [
···
"dependencies": [
"@solid-primitives/rootless",
"@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"
},
···
"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",
+1
package.json
···
"@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",
+9 -32
src/App.tsx
···
CheckIcon,
ChevronsUpDownIcon,
ClipboardIcon,
+
MicIcon,
Trash2Icon,
} from "lucide-solid";
import { Button } from "./components/ui/button";
···
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 = () =>
···
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 />
···
</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={[]}
···
</Button>
)}
/>
+
<MicRecorder selectedAccount={selectedAccount} />
{/*<IconButton
size="sm"
onClick={() =>
···
</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>
);
+187
src/components/MicRecorder.tsx
···
+
import { 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;
+9 -6
src/lib/render.ts
···
visualizer: boolean;
frameRate: number;
bgColor: string;
+
duration?: number;
};
export const render = async (file: File, opts: RenderOptions) => {
···
});
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(),
···
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({
+6 -1
src/lib/task.ts
···
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 {
···
visualizer: showVisualizer.get() ?? true,
frameRate: frameRate.get() ?? 30,
bgColor,
+
duration,
});
tasks.set(id, {
file,