diff --git a/deno.lock b/deno.lock index d17a9f6..0fd0b66 100644 --- a/deno.lock +++ b/deno.lock @@ -13,6 +13,7 @@ "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", @@ -967,6 +968,15 @@ "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": [ @@ -1006,6 +1016,14 @@ "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": [ @@ -1051,6 +1069,12 @@ "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": [ @@ -3331,6 +3355,7 @@ "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", diff --git a/package.json b/package.json index bd72b00..9df2323 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@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", diff --git a/src/App.tsx b/src/App.tsx index b6e50b2..b736c21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { CheckIcon, ChevronsUpDownIcon, ClipboardIcon, + MicIcon, Trash2Icon, } from "lucide-solid"; import { Button } from "./components/ui/button"; @@ -26,6 +27,7 @@ import { createListCollection, Select } from "./components/ui/select"; 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 = () => @@ -95,17 +97,18 @@ const App = () => { return ( - + - bsky voice memo + memos
@@ -122,10 +125,7 @@ const App = () => {
- + { )} /> + {/* @@ -234,30 +235,6 @@ const Upload = (props: FileUpload.RootProps) => { */} - - - {(fileUpload) => ( - - {(file) => ( - - - - - - - ( - - - - )} - /> - - )} - - )} - - ); diff --git a/src/components/MicRecorder.tsx b/src/components/MicRecorder.tsx new file mode 100644 index 0000000..3dbdc65 --- /dev/null +++ b/src/components/MicRecorder.tsx @@ -0,0 +1,187 @@ +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 ( + + ( + + + + )} + /> + + + {formatTime(diff)} + + + + ); +}; + +export default MicRecorder; diff --git a/src/lib/render.ts b/src/lib/render.ts index fb810aa..7441b54 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -249,6 +249,7 @@ type RenderOptions = { visualizer: boolean; frameRate: number; bgColor: string; + duration?: number; }; export const render = async (file: File, opts: RenderOptions) => { @@ -270,10 +271,13 @@ 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(), @@ -282,11 +286,10 @@ export const render = async (file: File, opts: RenderOptions) => { 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({ diff --git a/src/lib/task.ts b/src/lib/task.ts index 0d9d500..51cb1c9 100644 --- a/src/lib/task.ts +++ b/src/lib/task.ts @@ -28,7 +28,11 @@ const generateId = () => _idCounter++; const fac = new FastAverageColor(); export const tasks = new ReactiveMap(); -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 { @@ -81,6 +85,7 @@ export const addTask = async (did: AtprotoDid | undefined, file: File) => { visualizer: showVisualizer.get() ?? true, frameRate: frameRate.get() ?? 30, bgColor, + duration, }); tasks.set(id, { file,