@crievo/omni 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/sound.ts +45 -0
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/index.html +1 -1
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/omnichat.mjs +19 -6
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/* Hold to record. The pill opens into a bar and the level rises in it as bars, newest on the right. Let go and the
|
|
2
|
+
recording becomes a clip: press play and the bars light up as the playhead passes them, drag across them to scrub.
|
|
3
|
+
Slide left while holding and it is thrown away: the bars collapse and the pill closes back to where it started.
|
|
4
|
+
Ported from bencho.dev (MIT).
|
|
5
|
+
|
|
6
|
+
A concept, not a recorder: nothing listens. The level is a stand-in shaped like speech (syllables inside phrases,
|
|
7
|
+
with pauses), so the block is about the gesture and the shapes, and asks nobody for a microphone.
|
|
8
|
+
|
|
9
|
+
The playhead is a number, not a render: progress is one CSS variable on the wave, and each bar decides its own
|
|
10
|
+
brightness from it and its index in calc(). Playing costs no React renders at all.
|
|
11
|
+
|
|
12
|
+
Two ways to use it. With no `state` it is the demo above, alone. With `state` it is controlled, a real recorder's
|
|
13
|
+
face: the parent owns the microphone and the clip and tells the pill where it is (idle, recording, clip), how loud
|
|
14
|
+
(`level`), how long (`elapsed`) and what to play (`audioUrl`); the pill reports `onStart`, `onStop`, `onCancel`,
|
|
15
|
+
`onDiscard`. `mode="tap"` swaps the hold gesture for tap to start and tap to stop (the studio records 30 s to
|
|
16
|
+
3 min, nobody holds a pill that long): the slide to throw away becomes a cancel button. */
|
|
17
|
+
|
|
18
|
+
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
|
19
|
+
import { Microphone, X } from "../icons";
|
|
20
|
+
|
|
21
|
+
/* 56 tall and 17px type, the Slide to confirm pill's size, so the pills read as one family */
|
|
22
|
+
const H = 56;
|
|
23
|
+
const IDLE_W = 216;
|
|
24
|
+
const OPEN_W = 300;
|
|
25
|
+
/* the tap pill carries a stop and a cancel button (and "record another" on the clip) that the hold pill does not */
|
|
26
|
+
const OPEN_W_TAP = 340;
|
|
27
|
+
/* one bar per sample while recording */
|
|
28
|
+
const SAMPLE = 70;
|
|
29
|
+
/* pointer travel, in px, that throws the recording away */
|
|
30
|
+
const CANCEL = 90;
|
|
31
|
+
/* shorter than this is a tap, not a recording */
|
|
32
|
+
const MIN_MS = 500;
|
|
33
|
+
const MAX_MS = 30000;
|
|
34
|
+
|
|
35
|
+
type Phase = "idle" | "rec" | "cancel" | "clip";
|
|
36
|
+
type Clip = { raw: number[]; ms: number };
|
|
37
|
+
|
|
38
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
39
|
+
|
|
40
|
+
/* speech, roughly: syllables inside phrases, and gaps between */
|
|
41
|
+
function standIn(t: number) {
|
|
42
|
+
const syll = Math.abs(Math.sin(t * 6.1) * Math.sin(t * 1.9 + 0.7));
|
|
43
|
+
const phrase = Math.sin(t * 0.9 + 0.4) > -0.55 ? 1 : 0.1;
|
|
44
|
+
return Math.min(1, 0.06 + syll * phrase * (0.5 + Math.random() * 0.5));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* n bars from however many samples, each the loudest it covers */
|
|
48
|
+
function resample(src: number[], n: number) {
|
|
49
|
+
if (!src.length) return Array(n).fill(0);
|
|
50
|
+
return Array.from({ length: n }, (_, i) => {
|
|
51
|
+
const a = Math.floor((i * src.length) / n);
|
|
52
|
+
const b = Math.max(a + 1, Math.floor(((i + 1) * src.length) / n));
|
|
53
|
+
let m = 0;
|
|
54
|
+
for (let k = a; k < b && k < src.length; k++) m = Math.max(m, src[k]);
|
|
55
|
+
return m;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fmt = (ms: number) => {
|
|
60
|
+
const s = Math.floor(ms / 1000);
|
|
61
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* Play into pause, drawn: the same two quadrilaterals, wound the same way, the pause's bars and the play triangle
|
|
65
|
+
split down its own axis. */
|
|
66
|
+
const PLAY = [
|
|
67
|
+
[7, 4, 13, 7.6, 13, 16.4, 7, 20],
|
|
68
|
+
[13, 7.6, 19, 12, 19, 12, 13, 16.4],
|
|
69
|
+
];
|
|
70
|
+
const PAUSE = [
|
|
71
|
+
[7, 5, 10.5, 5, 10.5, 19, 7, 19],
|
|
72
|
+
[13.5, 5, 17, 5, 17, 19, 13.5, 19],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function PlayMark({ on }: { on: boolean }) {
|
|
76
|
+
const [t, setT] = useState(on ? 1 : 0);
|
|
77
|
+
const now = useRef(t);
|
|
78
|
+
now.current = t;
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const from = now.current;
|
|
81
|
+
const to = on ? 1 : 0;
|
|
82
|
+
const t0 = performance.now();
|
|
83
|
+
let raf = 0;
|
|
84
|
+
const step = (ts: number) => {
|
|
85
|
+
const u = Math.min(1, (ts - t0) / 220);
|
|
86
|
+
setT(from + (to - from) * (1 - (1 - u) ** 4));
|
|
87
|
+
if (u < 1) raf = requestAnimationFrame(step);
|
|
88
|
+
};
|
|
89
|
+
raf = requestAnimationFrame(step);
|
|
90
|
+
return () => cancelAnimationFrame(raf);
|
|
91
|
+
}, [on]);
|
|
92
|
+
const d = PLAY.map((q, k) => {
|
|
93
|
+
const p = q.map((v, i) => (v + (PAUSE[k][i] - v) * t).toFixed(2));
|
|
94
|
+
return `M${p[0]} ${p[1]}L${p[2]} ${p[3]}L${p[4]} ${p[5]}L${p[6]} ${p[7]}Z`;
|
|
95
|
+
}).join("");
|
|
96
|
+
return (
|
|
97
|
+
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
|
98
|
+
<path d={d} fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
|
99
|
+
</svg>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* the stop control: a rounded square, the way the play mark is a rounded triangle */
|
|
104
|
+
function StopMark() {
|
|
105
|
+
return (
|
|
106
|
+
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
|
107
|
+
<rect x="5" y="5" width="14" height="14" rx="3" fill="currentColor" />
|
|
108
|
+
</svg>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* the capsule filled, the stand drawn: solid like the play mark it becomes a clip beside, rather than an outline */
|
|
113
|
+
function MicMark() {
|
|
114
|
+
return (
|
|
115
|
+
<svg
|
|
116
|
+
width="21"
|
|
117
|
+
height="21"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
fill="none"
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
strokeWidth="2.2"
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
>
|
|
125
|
+
<rect x="8.5" y="2" width="7" height="12.5" rx="3.5" fill="currentColor" stroke="none" />
|
|
126
|
+
<path d="M5 11.5a7 7 0 0 0 14 0" />
|
|
127
|
+
<path d="M12 18.5V22" />
|
|
128
|
+
</svg>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type VoiceNoteState = "idle" | "recording" | "clip";
|
|
133
|
+
|
|
134
|
+
export type VoiceNoteProps = {
|
|
135
|
+
/* how many bars the wave is drawn with */
|
|
136
|
+
bars?: number;
|
|
137
|
+
/* how tall a given level draws, 0..100 */
|
|
138
|
+
gain?: number;
|
|
139
|
+
/* how far the pill overshoots as it opens and closes, 0..100 */
|
|
140
|
+
bounce?: number;
|
|
141
|
+
corner?: number;
|
|
142
|
+
/* "hold": press and hold, slide left to throw away. "tap": tap to start, tap to stop, a cancel button */
|
|
143
|
+
mode?: "hold" | "tap";
|
|
144
|
+
/* giving a state makes the pill controlled: it stops simulating and follows the props below */
|
|
145
|
+
state?: VoiceNoteState;
|
|
146
|
+
onStart?: () => void;
|
|
147
|
+
/* the recording ended on purpose; the parent turns it into state "clip" (or back to "idle" if it was too short) */
|
|
148
|
+
onStop?: () => void;
|
|
149
|
+
/* the recording was thrown away before it became a clip; the parent drops it */
|
|
150
|
+
onCancel?: () => void;
|
|
151
|
+
/* the clip's discard button; the parent drops the clip and goes back to "idle" */
|
|
152
|
+
onDiscard?: () => void;
|
|
153
|
+
/* a "record another" button on the clip, for a parent that keeps several takes */
|
|
154
|
+
onAgain?: () => void;
|
|
155
|
+
/* live microphone level, 0..1, while state is "recording" */
|
|
156
|
+
level?: number;
|
|
157
|
+
/* seconds recorded so far */
|
|
158
|
+
elapsed?: number;
|
|
159
|
+
/* the recording is stopped when it gets here */
|
|
160
|
+
maxSeconds?: number;
|
|
161
|
+
/* what the clip plays, and how long it is in seconds (a MediaRecorder blob does not know its own length) */
|
|
162
|
+
audioUrl?: string;
|
|
163
|
+
duration?: number;
|
|
164
|
+
disabled?: boolean;
|
|
165
|
+
/* a line under the pill, in red, for a microphone that is blocked or missing */
|
|
166
|
+
error?: string;
|
|
167
|
+
/* the idle label */
|
|
168
|
+
idleLabel?: string;
|
|
169
|
+
className?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export function VoiceNote(props: VoiceNoteProps = {}) {
|
|
173
|
+
const {
|
|
174
|
+
bars = 28,
|
|
175
|
+
gain = 60,
|
|
176
|
+
bounce = 40,
|
|
177
|
+
corner = H / 2,
|
|
178
|
+
mode = "hold",
|
|
179
|
+
state,
|
|
180
|
+
onAgain,
|
|
181
|
+
level = 0,
|
|
182
|
+
elapsed = 0,
|
|
183
|
+
audioUrl,
|
|
184
|
+
duration = 0,
|
|
185
|
+
disabled,
|
|
186
|
+
error,
|
|
187
|
+
idleLabel,
|
|
188
|
+
className,
|
|
189
|
+
} = props;
|
|
190
|
+
const controlled = state !== undefined;
|
|
191
|
+
const tap = mode === "tap";
|
|
192
|
+
|
|
193
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
194
|
+
const [levels, setLevels] = useState<number[]>([]);
|
|
195
|
+
const [ms, setMs] = useState(0);
|
|
196
|
+
const [clip, setClip] = useState<Clip | null>(null);
|
|
197
|
+
const [playing, setPlaying] = useState(false);
|
|
198
|
+
const [pull, setPull] = useState(0);
|
|
199
|
+
const pullRef = useRef(0);
|
|
200
|
+
|
|
201
|
+
const ph = useRef<Phase>("idle");
|
|
202
|
+
const raw = useRef<number[]>([]);
|
|
203
|
+
const t0 = useRef(0);
|
|
204
|
+
const timer = useRef(0);
|
|
205
|
+
const later = useRef(0);
|
|
206
|
+
const x0 = useRef(0);
|
|
207
|
+
const root = useRef<HTMLDivElement>(null);
|
|
208
|
+
const wave = useRef<HTMLDivElement>(null);
|
|
209
|
+
const time = useRef<HTMLSpanElement>(null);
|
|
210
|
+
const prog = useRef(0);
|
|
211
|
+
const praf = useRef(0);
|
|
212
|
+
const scrub = useRef(false);
|
|
213
|
+
const clipRef = useRef<Clip | null>(null);
|
|
214
|
+
clipRef.current = clip;
|
|
215
|
+
/* the latest props, for the sampling interval and the audio events that outlive a render */
|
|
216
|
+
const live = useRef(props);
|
|
217
|
+
live.current = props;
|
|
218
|
+
const audio = useRef<{ el: HTMLAudioElement; url: string } | null>(null);
|
|
219
|
+
/* a click that put focus in the pill: the control it lands on is replaced as the phase changes, and the
|
|
220
|
+
keyboard should not be dropped on the page when it is */
|
|
221
|
+
const focusIn = useRef(false);
|
|
222
|
+
|
|
223
|
+
const go = (p: Phase) => {
|
|
224
|
+
ph.current = p;
|
|
225
|
+
setPhase(p);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const n = Math.round(clamp(bars, 12, 48));
|
|
229
|
+
const g = 0.4 + (clamp(gain, 0, 100) / 100) * 1.2;
|
|
230
|
+
const r = clamp(corner, 0, H / 2);
|
|
231
|
+
/* the play disc sits 6 inside: concentric once there is a curve to follow, square with the pill when there is not */
|
|
232
|
+
const inner = Math.max(0, r - 6 * Math.min(1, r / 12));
|
|
233
|
+
|
|
234
|
+
/* the clip's length: the parent's number when it gave one, else what the pill timed */
|
|
235
|
+
const clipMs = () =>
|
|
236
|
+
live.current.duration && live.current.duration > 0 ? live.current.duration * 1000 : (clipRef.current?.ms ?? 0);
|
|
237
|
+
|
|
238
|
+
const stopPlay = () => {
|
|
239
|
+
cancelAnimationFrame(praf.current);
|
|
240
|
+
praf.current = 0;
|
|
241
|
+
audio.current?.el.pause();
|
|
242
|
+
setPlaying(false);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
useEffect(
|
|
246
|
+
() => () => {
|
|
247
|
+
clearInterval(timer.current);
|
|
248
|
+
clearTimeout(later.current);
|
|
249
|
+
cancelAnimationFrame(praf.current);
|
|
250
|
+
audio.current?.el.pause();
|
|
251
|
+
},
|
|
252
|
+
[],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
/* another clip: the old one's audio is not this one's */
|
|
256
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: audioUrl is the trigger, the body only drops the element
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (audio.current && audio.current.url !== audioUrl) {
|
|
259
|
+
audio.current.el.pause();
|
|
260
|
+
audio.current = null;
|
|
261
|
+
cancelAnimationFrame(praf.current);
|
|
262
|
+
praf.current = 0;
|
|
263
|
+
setPlaying(false);
|
|
264
|
+
}
|
|
265
|
+
}, [audioUrl]);
|
|
266
|
+
|
|
267
|
+
const paintProg = () => {
|
|
268
|
+
wave.current?.style.setProperty("--prog", prog.current.toFixed(4));
|
|
269
|
+
const len = clipMs();
|
|
270
|
+
if (time.current && len) {
|
|
271
|
+
const p = prog.current;
|
|
272
|
+
time.current.textContent = fmt(p > 0 && p < 1 ? p * len : len);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const close = (_how: "whisk" | "tock") => {
|
|
277
|
+
go("cancel");
|
|
278
|
+
clearTimeout(later.current);
|
|
279
|
+
later.current = window.setTimeout(() => {
|
|
280
|
+
setClip(null);
|
|
281
|
+
pullRef.current = 0;
|
|
282
|
+
setPull(0);
|
|
283
|
+
go("idle");
|
|
284
|
+
}, 360);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/* the level history the bars draw: the stand-in when alone, the parent's microphone when controlled */
|
|
288
|
+
const sample = () => {
|
|
289
|
+
clearInterval(timer.current);
|
|
290
|
+
raw.current = [];
|
|
291
|
+
setLevels([]);
|
|
292
|
+
setMs(0);
|
|
293
|
+
setClip(null);
|
|
294
|
+
pullRef.current = 0;
|
|
295
|
+
setPull(0);
|
|
296
|
+
t0.current = performance.now();
|
|
297
|
+
timer.current = window.setInterval(() => {
|
|
298
|
+
const took = performance.now() - t0.current;
|
|
299
|
+
const l = live.current;
|
|
300
|
+
if (controlled) {
|
|
301
|
+
raw.current.push(clamp(l.level ?? 0, 0, 1));
|
|
302
|
+
setMs((l.elapsed ?? 0) * 1000);
|
|
303
|
+
if (l.maxSeconds && (l.elapsed ?? 0) >= l.maxSeconds) {
|
|
304
|
+
clearInterval(timer.current);
|
|
305
|
+
l.onStop?.();
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
raw.current.push(standIn(took / 1000));
|
|
309
|
+
setMs(took);
|
|
310
|
+
if (took >= MAX_MS) end(false);
|
|
311
|
+
}
|
|
312
|
+
setLevels(raw.current.slice(-64));
|
|
313
|
+
}, SAMPLE);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const keepClip = (len: number) => {
|
|
317
|
+
clearInterval(timer.current);
|
|
318
|
+
prog.current = 0;
|
|
319
|
+
setClip({ raw: raw.current.slice(), ms: len });
|
|
320
|
+
pullRef.current = 0;
|
|
321
|
+
setPull(0);
|
|
322
|
+
go("clip");
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const end = (cancel: boolean) => {
|
|
326
|
+
if (ph.current !== "rec") return;
|
|
327
|
+
clearInterval(timer.current);
|
|
328
|
+
if (controlled) {
|
|
329
|
+
/* the clip, if there is one, arrives as state "clip" */
|
|
330
|
+
if (cancel) {
|
|
331
|
+
live.current.onCancel?.();
|
|
332
|
+
close("whisk");
|
|
333
|
+
} else {
|
|
334
|
+
live.current.onStop?.();
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const took = performance.now() - t0.current;
|
|
339
|
+
if (cancel || took < MIN_MS) {
|
|
340
|
+
close(cancel ? "whisk" : "tock");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
keepClip(took);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const begin = () => {
|
|
347
|
+
if (ph.current !== "idle" || disabled) return;
|
|
348
|
+
if (controlled) {
|
|
349
|
+
live.current.onStart?.();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
stopPlay();
|
|
353
|
+
sample();
|
|
354
|
+
go("rec");
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/* controlled: the parent's state is the truth, the phase only follows it (and animates the way in and out) */
|
|
358
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: state and phase are the triggers; the helpers are re-made each render
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (!controlled) return;
|
|
361
|
+
if (state === "recording") {
|
|
362
|
+
if (phase === "idle" || phase === "clip") {
|
|
363
|
+
stopPlay();
|
|
364
|
+
sample();
|
|
365
|
+
go("rec");
|
|
366
|
+
}
|
|
367
|
+
} else if (state === "clip") {
|
|
368
|
+
if (phase === "rec")
|
|
369
|
+
keepClip(live.current.duration ? live.current.duration * 1000 : (live.current.elapsed ?? 0) * 1000);
|
|
370
|
+
else if (phase === "idle") {
|
|
371
|
+
raw.current = [];
|
|
372
|
+
keepClip(clipMs());
|
|
373
|
+
}
|
|
374
|
+
} else if (phase === "rec") {
|
|
375
|
+
/* it ended without becoming a clip: thrown away, or too short */
|
|
376
|
+
clearInterval(timer.current);
|
|
377
|
+
close("tock");
|
|
378
|
+
} else if (phase === "clip") {
|
|
379
|
+
stopPlay();
|
|
380
|
+
close("whisk");
|
|
381
|
+
}
|
|
382
|
+
}, [controlled, state, phase]);
|
|
383
|
+
|
|
384
|
+
/* a click left focus in the pill: hand it to the control that replaces the one that just went */
|
|
385
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the phase is the trigger
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!tap || !focusIn.current) return;
|
|
388
|
+
const el = root.current;
|
|
389
|
+
if (!el) return;
|
|
390
|
+
const target =
|
|
391
|
+
phase === "rec"
|
|
392
|
+
? el.querySelector<HTMLElement>(".bn-vn-stop")
|
|
393
|
+
: phase === "clip"
|
|
394
|
+
? el.querySelector<HTMLElement>(".bn-vn-play")
|
|
395
|
+
: phase === "idle"
|
|
396
|
+
? el
|
|
397
|
+
: null;
|
|
398
|
+
target?.focus({ preventScroll: true });
|
|
399
|
+
}, [phase, tap]);
|
|
400
|
+
|
|
401
|
+
const el = () => {
|
|
402
|
+
if (!audioUrl) return null;
|
|
403
|
+
if (audio.current?.url !== audioUrl) {
|
|
404
|
+
const a = new Audio(audioUrl);
|
|
405
|
+
a.preload = "auto";
|
|
406
|
+
audio.current = { el: a, url: audioUrl };
|
|
407
|
+
}
|
|
408
|
+
return audio.current.el;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const toggle = () => {
|
|
412
|
+
const len = clipMs();
|
|
413
|
+
if (!clipRef.current || !len) return;
|
|
414
|
+
if (praf.current) {
|
|
415
|
+
stopPlay();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (prog.current >= 1) prog.current = 0;
|
|
419
|
+
const a = el();
|
|
420
|
+
if (a) {
|
|
421
|
+
try {
|
|
422
|
+
a.currentTime = prog.current * (len / 1000);
|
|
423
|
+
} catch {
|
|
424
|
+
/* not seekable yet */
|
|
425
|
+
}
|
|
426
|
+
a.onended = () => {
|
|
427
|
+
prog.current = 1;
|
|
428
|
+
paintProg();
|
|
429
|
+
stopPlay();
|
|
430
|
+
};
|
|
431
|
+
a.play().catch(stopPlay);
|
|
432
|
+
}
|
|
433
|
+
setPlaying(true);
|
|
434
|
+
let prev = performance.now();
|
|
435
|
+
const step = (ts: number) => {
|
|
436
|
+
if (!scrub.current) {
|
|
437
|
+
/* with sound the audio is the clock, and never reads as finished before it says so */
|
|
438
|
+
prog.current = a
|
|
439
|
+
? Math.min(0.999, a.currentTime / (len / 1000))
|
|
440
|
+
: Math.min(1, prog.current + (ts - prev) / len);
|
|
441
|
+
}
|
|
442
|
+
prev = ts;
|
|
443
|
+
paintProg();
|
|
444
|
+
if (!a && prog.current >= 1) {
|
|
445
|
+
stopPlay();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
praf.current = requestAnimationFrame(step);
|
|
449
|
+
};
|
|
450
|
+
praf.current = requestAnimationFrame(step);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const seek = (clientX: number) => {
|
|
454
|
+
const w = wave.current;
|
|
455
|
+
if (!w || !clipRef.current) return;
|
|
456
|
+
const b = w.getBoundingClientRect();
|
|
457
|
+
prog.current = clamp((clientX - b.left) / b.width, 0, 1);
|
|
458
|
+
const a = audio.current?.el;
|
|
459
|
+
if (a) {
|
|
460
|
+
try {
|
|
461
|
+
a.currentTime = prog.current * (clipMs() / 1000);
|
|
462
|
+
} catch {
|
|
463
|
+
/* not seekable yet */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
paintProg();
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const discard = () => {
|
|
470
|
+
stopPlay();
|
|
471
|
+
live.current.onDiscard?.();
|
|
472
|
+
close("whisk");
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/* the bars */
|
|
476
|
+
const bar = (v: number, i: number, key: string | number) => (
|
|
477
|
+
<i key={key} className="bn-vn-bar" style={{ "--l": clamp(v * g, 0, 1).toFixed(3), "--i": i } as CSSProperties} />
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
/* recording: the last n samples, right-aligned, and the rest of the row the resting dot a bar starts from */
|
|
481
|
+
const recBars = () => {
|
|
482
|
+
const start = raw.current.length - levels.length;
|
|
483
|
+
const out = [];
|
|
484
|
+
for (let j = 0; j < n; j++) {
|
|
485
|
+
const k = levels.length - n + j;
|
|
486
|
+
out.push(k >= 0 ? bar(levels[k], j, start + k) : bar(0, j, `e${j}`));
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const shift = -pull * CANCEL * 0.5;
|
|
492
|
+
const armed = pull >= 1;
|
|
493
|
+
const open = phase !== "idle";
|
|
494
|
+
const showClip = clip && (phase === "clip" || phase === "cancel");
|
|
495
|
+
const asButton = phase === "idle" || (phase === "rec" && !tap);
|
|
496
|
+
const shown = clipMs() || clip?.ms || 0;
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div className={`bn-vn-frame${className ? ` ${className}` : ""}`} data-mode={mode}>
|
|
500
|
+
<div
|
|
501
|
+
ref={root}
|
|
502
|
+
className="bn-vn"
|
|
503
|
+
data-phase={phase}
|
|
504
|
+
data-mode={mode}
|
|
505
|
+
data-disabled={disabled || undefined}
|
|
506
|
+
role={asButton ? "button" : undefined}
|
|
507
|
+
tabIndex={asButton && !disabled ? 0 : -1}
|
|
508
|
+
aria-disabled={asButton && disabled ? true : undefined}
|
|
509
|
+
aria-label={
|
|
510
|
+
phase === "rec"
|
|
511
|
+
? tap
|
|
512
|
+
? undefined
|
|
513
|
+
: "Gravando. Solte para manter, Esc para descartar."
|
|
514
|
+
: phase === "idle"
|
|
515
|
+
? tap
|
|
516
|
+
? (idleLabel ?? "Gravar")
|
|
517
|
+
: "Segure para gravar uma nota de voz"
|
|
518
|
+
: undefined
|
|
519
|
+
}
|
|
520
|
+
style={
|
|
521
|
+
{
|
|
522
|
+
"--w": `${open ? (tap ? OPEN_W_TAP : OPEN_W) : IDLE_W}px`,
|
|
523
|
+
"--r": `${r}px`,
|
|
524
|
+
"--inner": `${inner}px`,
|
|
525
|
+
"--ov": (1 + (clamp(bounce, 0, 100) / 100) * 0.7).toFixed(3),
|
|
526
|
+
"--pull": pull.toFixed(3),
|
|
527
|
+
} as CSSProperties
|
|
528
|
+
}
|
|
529
|
+
onClick={() => {
|
|
530
|
+
if (tap && ph.current === "idle") begin();
|
|
531
|
+
}}
|
|
532
|
+
onPointerDown={e => {
|
|
533
|
+
if (tap || ph.current !== "idle" || disabled) return;
|
|
534
|
+
try {
|
|
535
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
536
|
+
} catch {
|
|
537
|
+
/* a scripted pointer */
|
|
538
|
+
}
|
|
539
|
+
x0.current = e.clientX;
|
|
540
|
+
begin();
|
|
541
|
+
}}
|
|
542
|
+
onPointerMove={e => {
|
|
543
|
+
if (tap || ph.current !== "rec") return;
|
|
544
|
+
const p = clamp((x0.current - e.clientX) / CANCEL, 0, 1);
|
|
545
|
+
/* all the way arms it (a tick as it catches) and sliding back disarms; letting go is what decides */
|
|
546
|
+
pullRef.current = p;
|
|
547
|
+
setPull(p);
|
|
548
|
+
}}
|
|
549
|
+
onPointerUp={e => {
|
|
550
|
+
if (tap) return;
|
|
551
|
+
try {
|
|
552
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
553
|
+
} catch {
|
|
554
|
+
/* never captured */
|
|
555
|
+
}
|
|
556
|
+
end(pullRef.current >= 1);
|
|
557
|
+
}}
|
|
558
|
+
onPointerCancel={() => {
|
|
559
|
+
if (!tap) end(false);
|
|
560
|
+
}}
|
|
561
|
+
onKeyDown={e => {
|
|
562
|
+
if (e.target !== e.currentTarget && e.key !== "Escape") return;
|
|
563
|
+
if ((e.key === " " || e.key === "Enter") && !e.repeat && ph.current === "idle") {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
begin();
|
|
566
|
+
} else if (e.key === "Escape" && ph.current === "rec") {
|
|
567
|
+
/* a dialog around the pill would take Esc as "close" */
|
|
568
|
+
e.stopPropagation();
|
|
569
|
+
end(true);
|
|
570
|
+
}
|
|
571
|
+
}}
|
|
572
|
+
onKeyUp={e => {
|
|
573
|
+
/* only while recording: the play and discard buttons inside act on keyup, and this would swallow it */
|
|
574
|
+
if (!tap && (e.key === " " || e.key === "Enter") && ph.current === "rec") {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
end(false);
|
|
577
|
+
}
|
|
578
|
+
}}
|
|
579
|
+
onFocusCapture={() => {
|
|
580
|
+
focusIn.current = true;
|
|
581
|
+
}}
|
|
582
|
+
onBlurCapture={e => {
|
|
583
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusIn.current = false;
|
|
584
|
+
}}
|
|
585
|
+
onBlur={() => {
|
|
586
|
+
if (!tap && ph.current === "rec") end(false);
|
|
587
|
+
}}
|
|
588
|
+
onContextMenu={e => e.preventDefault()}
|
|
589
|
+
>
|
|
590
|
+
{phase === "idle" && (
|
|
591
|
+
<span key="idle" className="bn-vn-in bn-vn-idle">
|
|
592
|
+
<MicMark />
|
|
593
|
+
{tap ? (idleLabel ?? "Gravar") : "Segure para gravar"}
|
|
594
|
+
</span>
|
|
595
|
+
)}
|
|
596
|
+
|
|
597
|
+
{(phase === "rec" || (phase === "cancel" && !clip)) && (
|
|
598
|
+
<span key="rec" className="bn-vn-in bn-vn-rec" style={{ translate: `${shift}px 0` }}>
|
|
599
|
+
<span className="bn-vn-dot" />
|
|
600
|
+
<span className="bn-vn-time">{fmt(ms)}</span>
|
|
601
|
+
<span className="bn-vn-wave" style={{ "--n": n } as CSSProperties}>
|
|
602
|
+
{recBars()}
|
|
603
|
+
</span>
|
|
604
|
+
{tap && (
|
|
605
|
+
<>
|
|
606
|
+
<button type="button" className="bn-vn-x" aria-label="Descartar a gravação" onClick={() => end(true)}>
|
|
607
|
+
<X size={18} weight="bold" />
|
|
608
|
+
</button>
|
|
609
|
+
<button
|
|
610
|
+
type="button"
|
|
611
|
+
className="bn-vn-play bn-vn-stop"
|
|
612
|
+
aria-label="Parar a gravação"
|
|
613
|
+
onClick={() => end(false)}
|
|
614
|
+
>
|
|
615
|
+
<StopMark />
|
|
616
|
+
</button>
|
|
617
|
+
</>
|
|
618
|
+
)}
|
|
619
|
+
</span>
|
|
620
|
+
)}
|
|
621
|
+
|
|
622
|
+
{/* slid all the way: the cancel mark takes the dot's place. It sits on the pill rather than in the sliding
|
|
623
|
+
row, which by now has gone half out of the left edge */}
|
|
624
|
+
{!tap && (phase === "rec" || (phase === "cancel" && !clip)) && (
|
|
625
|
+
<span className="bn-vn-cancel" data-on={armed || undefined} aria-hidden="true">
|
|
626
|
+
<X size={16} weight="bold" />
|
|
627
|
+
</span>
|
|
628
|
+
)}
|
|
629
|
+
|
|
630
|
+
{showClip && (
|
|
631
|
+
<span key="clip" className="bn-vn-in bn-vn-clip">
|
|
632
|
+
<button type="button" className="bn-vn-play" aria-label={playing ? "Pausar" : "Tocar"} onClick={toggle}>
|
|
633
|
+
<PlayMark on={playing} />
|
|
634
|
+
</button>
|
|
635
|
+
<span
|
|
636
|
+
ref={wave}
|
|
637
|
+
className="bn-vn-wave bn-vn-scrub"
|
|
638
|
+
style={{ "--n": n, "--prog": prog.current } as CSSProperties}
|
|
639
|
+
onPointerDown={e => {
|
|
640
|
+
try {
|
|
641
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
642
|
+
} catch {
|
|
643
|
+
/* a scripted pointer */
|
|
644
|
+
}
|
|
645
|
+
scrub.current = true;
|
|
646
|
+
seek(e.clientX);
|
|
647
|
+
}}
|
|
648
|
+
onPointerMove={e => {
|
|
649
|
+
if (scrub.current) seek(e.clientX);
|
|
650
|
+
}}
|
|
651
|
+
onPointerUp={e => {
|
|
652
|
+
try {
|
|
653
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
654
|
+
} catch {
|
|
655
|
+
/* never captured */
|
|
656
|
+
}
|
|
657
|
+
scrub.current = false;
|
|
658
|
+
}}
|
|
659
|
+
onPointerCancel={() => {
|
|
660
|
+
scrub.current = false;
|
|
661
|
+
}}
|
|
662
|
+
>
|
|
663
|
+
{resample(clip.raw, n).map((v, i) => bar(v, i, i))}
|
|
664
|
+
</span>
|
|
665
|
+
<span ref={time} className="bn-vn-time">
|
|
666
|
+
{fmt(shown)}
|
|
667
|
+
</span>
|
|
668
|
+
{onAgain && (
|
|
669
|
+
<button
|
|
670
|
+
type="button"
|
|
671
|
+
className="bn-vn-x"
|
|
672
|
+
aria-label="Gravar outra"
|
|
673
|
+
onClick={() => {
|
|
674
|
+
stopPlay();
|
|
675
|
+
onAgain();
|
|
676
|
+
}}
|
|
677
|
+
>
|
|
678
|
+
<Microphone size={18} weight="bold" />
|
|
679
|
+
</button>
|
|
680
|
+
)}
|
|
681
|
+
<button type="button" className="bn-vn-x" aria-label="Descartar" onClick={discard}>
|
|
682
|
+
<X size={18} weight="bold" />
|
|
683
|
+
</button>
|
|
684
|
+
</span>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
{!tap && (
|
|
688
|
+
<span
|
|
689
|
+
className="bn-vn-hint"
|
|
690
|
+
data-on={phase === "rec" || undefined}
|
|
691
|
+
data-armed={(phase === "rec" && armed) || undefined}
|
|
692
|
+
style={{ "--pull": pull.toFixed(3) } as CSSProperties}
|
|
693
|
+
aria-hidden="true"
|
|
694
|
+
>
|
|
695
|
+
{phase === "rec" && armed ? "Solte para cancelar" : "‹ Deslize para cancelar"}
|
|
696
|
+
</span>
|
|
697
|
+
)}
|
|
698
|
+
{error && (
|
|
699
|
+
<p className="bn-vn-note" role="alert">
|
|
700
|
+
{error}
|
|
701
|
+
</p>
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
}
|