+25
deno.lock
+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
+1
package.json
+9
-32
src/App.tsx
+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
+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
+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
+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,