panelui-web 0.5.0 → 0.7.0
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/dist/components/avatar/index.d.ts +66 -0
- package/dist/components/avatar/index.js +251 -0
- package/dist/components/flow/index.d.ts +26 -4
- package/dist/components/flow/index.js +81 -20
- package/dist/components/label/index.d.ts +89 -0
- package/dist/components/label/index.js +64 -0
- package/dist/components/message/index.d.ts +161 -0
- package/dist/components/message/index.js +295 -0
- package/dist/components/sheet/index.d.ts +43 -4
- package/dist/components/sheet/index.js +78 -10
- package/dist/components/soundwave/index.d.ts +195 -0
- package/dist/components/soundwave/index.js +732 -0
- package/dist/components/switch/index.d.ts +51 -0
- package/dist/components/switch/index.js +88 -0
- package/dist/components/tabs/index.d.ts +94 -0
- package/dist/components/tabs/index.js +196 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Soundwave — what a voice looks like while an app listens to it.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <Soundwave variant="dots" state="listening" level={level} />
|
|
8
|
+
*
|
|
9
|
+
* <Soundwave variant="radial" state="speaking">
|
|
10
|
+
* <Soundwave.Center>
|
|
11
|
+
* <Avatar name="Khalid Abdi" size="xl" />
|
|
12
|
+
* </Soundwave.Center>
|
|
13
|
+
* </Soundwave>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Seven looks. Four are the app's: `pills` over a microphone button, `bars`
|
|
17
|
+
* for a metering strip, `line` for a travelling wave, `ambient` for a glow
|
|
18
|
+
* behind a whole screen. Three are drawn in dots and spokes: `dots`, a field
|
|
19
|
+
* of dots lit into a wave; `radial`, spokes around a person; and `ring`,
|
|
20
|
+
* rings of dots lit outward by angle.
|
|
21
|
+
*
|
|
22
|
+
* ## It draws a level; it does not record one
|
|
23
|
+
*
|
|
24
|
+
* Nothing here touches the microphone. The app owns the recorder and hands
|
|
25
|
+
* over a number from 0 to 1, so the same wave works against a real meter, a
|
|
26
|
+
* synthesised one or a remote peer's. With no level it runs its own motion
|
|
27
|
+
* for the current `state`, so a voice screen can be built before any audio
|
|
28
|
+
* exists.
|
|
29
|
+
*
|
|
30
|
+
* ## Levels arrive faster than React should render
|
|
31
|
+
*
|
|
32
|
+
* A meter reports dozens of times a second. `level` takes a ref as well as a
|
|
33
|
+
* number: write each reading into `ref.current` and the wave picks it up on
|
|
34
|
+
* its next frame with nothing above it rendering. Each frame writes the `d`
|
|
35
|
+
* of one or two SVG paths, never React state, and the dots are zero-length
|
|
36
|
+
* round-capped segments, so a field of four hundred dots is a single path.
|
|
37
|
+
*
|
|
38
|
+
* Either way the level is smoothed with a fast attack and a slow release,
|
|
39
|
+
* which is what makes it read as a meter: it snaps up on a syllable and falls
|
|
40
|
+
* back gently instead of chattering on every sample.
|
|
41
|
+
*/
|
|
42
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from 'react';
|
|
43
|
+
import { tv } from 'tailwind-variants';
|
|
44
|
+
/** What each state is doing, for anyone who cannot see it. */
|
|
45
|
+
const STATE_LABEL = {
|
|
46
|
+
idle: 'Idle',
|
|
47
|
+
listening: 'Listening',
|
|
48
|
+
thinking: 'Thinking',
|
|
49
|
+
speaking: 'Speaking',
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* How fast the smoothed level rises and falls, per second. Lopsided on
|
|
53
|
+
* purpose: snapping up and easing down is how an ear expects a meter to move.
|
|
54
|
+
*/
|
|
55
|
+
const ATTACK = 16;
|
|
56
|
+
const RELEASE = 5;
|
|
57
|
+
/** Never quite silent: a wave flat at zero reads as broken rather than quiet. */
|
|
58
|
+
const FLOOR = 0.04;
|
|
59
|
+
/** How often `scrolling` takes a sample, in seconds. */
|
|
60
|
+
const SAMPLE_INTERVAL = 1 / 24;
|
|
61
|
+
/** Opacity of the part of a recording that has not played yet. */
|
|
62
|
+
const UNPLAYED = 0.3;
|
|
63
|
+
/* -------------------------------------------------------------------------- */
|
|
64
|
+
/* Maths */
|
|
65
|
+
/* -------------------------------------------------------------------------- */
|
|
66
|
+
const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
|
|
67
|
+
/** Deterministic hash in `[0, 1)`, the same on the server and in the browser. */
|
|
68
|
+
function hash(a, b) {
|
|
69
|
+
const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
|
|
70
|
+
return h - Math.floor(h);
|
|
71
|
+
}
|
|
72
|
+
/** One decimal place. Paths are rebuilt every frame, and every digit costs. */
|
|
73
|
+
const q = (value) => Math.round(value * 10) / 10;
|
|
74
|
+
/**
|
|
75
|
+
* The motion a state runs with no level supplied. Layered at unrelated tempi
|
|
76
|
+
* so it never repeats on a beat; a single sine reads as a progress pulse.
|
|
77
|
+
*/
|
|
78
|
+
function idleEnergy(state, t) {
|
|
79
|
+
if (state === 'listening') {
|
|
80
|
+
return clamp01(0.34 + 0.22 * Math.sin(t * 2.3) + 0.16 * Math.sin(t * 3.9 + 1.3) + 0.1 * Math.sin(t * 7.1 + 0.6));
|
|
81
|
+
}
|
|
82
|
+
if (state === 'speaking') {
|
|
83
|
+
return clamp01(0.46 + 0.27 * Math.sin(t * 3.1) + 0.18 * Math.sin(t * 5.7 + 0.8) + 0.09 * Math.sin(t * 9.3));
|
|
84
|
+
}
|
|
85
|
+
if (state === 'thinking') {
|
|
86
|
+
return clamp01(0.18 + 0.1 * Math.sin(t * 1.6) + 0.05 * Math.sin(t * 2.9 + 2.1));
|
|
87
|
+
}
|
|
88
|
+
return clamp01(0.07 + 0.05 * Math.sin(t * 1.1));
|
|
89
|
+
}
|
|
90
|
+
/** A supplied series read at `i` of `count`, so the two lengths need not match. */
|
|
91
|
+
const resample = (values, i, count) => clamp01(values[Math.floor((i / count) * values.length)] ?? 0);
|
|
92
|
+
/** One strip bar's height, 0–1, from whichever source drives the wave. */
|
|
93
|
+
function stripValue(i, count, g, f) {
|
|
94
|
+
if (g.scrolling) {
|
|
95
|
+
// The buffer fills from empty, so the newest sample sits at the trailing
|
|
96
|
+
// edge from the first frame rather than sliding in from nowhere.
|
|
97
|
+
const offset = i - (count - f.samples.length);
|
|
98
|
+
return offset >= 0 ? (f.samples[offset] ?? 0) : 0;
|
|
99
|
+
}
|
|
100
|
+
if (g.levels?.length)
|
|
101
|
+
return resample(g.levels, i, count);
|
|
102
|
+
const rate = 2.1 + hash(i, 1.3) * 2.9;
|
|
103
|
+
const phase = hash(i, 4.7) * Math.PI * 2;
|
|
104
|
+
const wobble = 0.5 + 0.5 * Math.sin(f.clock * rate + phase);
|
|
105
|
+
// The middle leads. A flat profile reads as a progress bar, not a voice.
|
|
106
|
+
const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1;
|
|
107
|
+
// A slow swell travelling along the strip, so neighbouring bars rise
|
|
108
|
+
// together into a shape rather than each one flickering on its own.
|
|
109
|
+
const swell = 0.7 + 0.3 * Math.sin((i / count) * 7 - f.clock * 1.3);
|
|
110
|
+
return clamp01(f.energy * 1.8 * (0.3 + 0.7 * wobble) * (0.35 + 0.65 * centre) * swell);
|
|
111
|
+
}
|
|
112
|
+
/** One spoke's or angle's value for the round looks, which have no middle to lead. */
|
|
113
|
+
function roundValue(i, count, g, f) {
|
|
114
|
+
if (g.levels?.length)
|
|
115
|
+
return resample(g.levels, i, count);
|
|
116
|
+
const angle = (i / count) * Math.PI * 2;
|
|
117
|
+
const rate = 1.8 + hash(i, 2.1) * 3.2;
|
|
118
|
+
const phase = hash(i, 6.3) * Math.PI * 2;
|
|
119
|
+
const wobble = 0.5 + 0.5 * Math.sin(f.clock * rate + phase);
|
|
120
|
+
// Two slow lobes travelling round, so the outline swells unevenly rather
|
|
121
|
+
// than every spoke breathing as one.
|
|
122
|
+
const lobe = 0.72 + 0.28 * Math.sin(angle * 3 + f.clock * 0.9) * Math.cos(angle * 2 - f.clock * 0.6);
|
|
123
|
+
return clamp01(f.energy * 1.6 * (0.3 + 0.7 * wobble) * lobe);
|
|
124
|
+
}
|
|
125
|
+
const dot = (x, y) => `M${q(x)} ${q(y)}h0`;
|
|
126
|
+
const segment = (x1, y1, x2, y2) => `M${q(x1)} ${q(y1)}L${q(x2)} ${q(y2)}`;
|
|
127
|
+
function paintPills(g, f) {
|
|
128
|
+
const { count, barWidth: w, barGap: gap, height: h } = g;
|
|
129
|
+
let ink = '';
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
// Each capsule keeps its own tempo and phase, so the group answers one
|
|
132
|
+
// level without moving as a block.
|
|
133
|
+
const rate = 2.2 + hash(i, 1.7) * 2.6;
|
|
134
|
+
const phase = hash(i, 5.1) * Math.PI * 2;
|
|
135
|
+
const wobble = 0.55 + 0.45 * Math.sin(f.clock * rate + phase);
|
|
136
|
+
const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1;
|
|
137
|
+
const value = g.levels?.length
|
|
138
|
+
? resample(g.levels, i, count)
|
|
139
|
+
: clamp01(Math.max(FLOOR, f.energy * 1.5 * wobble * (0.6 + 0.4 * centre)));
|
|
140
|
+
const length = (h - w) * value;
|
|
141
|
+
const x = w / 2 + i * (w + gap);
|
|
142
|
+
ink += segment(x, (h - length) / 2, x, (h + length) / 2);
|
|
143
|
+
}
|
|
144
|
+
return { ink };
|
|
145
|
+
}
|
|
146
|
+
function paintBars(g, f) {
|
|
147
|
+
const { count, width, height: h, barWidth: w } = g;
|
|
148
|
+
if (!width)
|
|
149
|
+
return {};
|
|
150
|
+
const pitch = width / count;
|
|
151
|
+
const played = g.progress === undefined ? count : clamp01(g.progress) * count;
|
|
152
|
+
let ink = '';
|
|
153
|
+
let rest = '';
|
|
154
|
+
for (let i = 0; i < count; i++) {
|
|
155
|
+
const value = Math.max(FLOOR, stripValue(i, count, g, f));
|
|
156
|
+
const length = Math.max(0, value * h - w);
|
|
157
|
+
const x = pitch * (i + 0.5);
|
|
158
|
+
const y1 = g.centered ? (h - length) / 2 : h - w / 2 - length;
|
|
159
|
+
const path = segment(x, y1, x, y1 + length);
|
|
160
|
+
if (i < played)
|
|
161
|
+
ink += path;
|
|
162
|
+
else
|
|
163
|
+
rest += path;
|
|
164
|
+
}
|
|
165
|
+
return { ink, rest };
|
|
166
|
+
}
|
|
167
|
+
const LINE_POINTS = 56;
|
|
168
|
+
function paintLine(g, f) {
|
|
169
|
+
const { width, height: h, barWidth: w } = g;
|
|
170
|
+
if (!width)
|
|
171
|
+
return {};
|
|
172
|
+
const amplitude = h / 2 - w;
|
|
173
|
+
let ink = '';
|
|
174
|
+
for (let j = 0; j < LINE_POINTS; j++) {
|
|
175
|
+
const u = j / (LINE_POINTS - 1);
|
|
176
|
+
// Tapered to nothing at both ends, so it leaves the edges flat instead of
|
|
177
|
+
// stopping at a hard cut.
|
|
178
|
+
const taper = Math.pow(Math.sin(Math.PI * u), 1.6);
|
|
179
|
+
const wave = 0.62 * Math.sin(u * 9 - f.clock * 3.4) + 0.38 * Math.sin(u * 15.5 + f.clock * 2.3 + 1.1);
|
|
180
|
+
const y = h / 2 + amplitude * taper * Math.max(FLOOR, f.energy * 1.3) * wave;
|
|
181
|
+
ink += `${j ? 'L' : 'M'}${q(u * width)} ${q(y)}`;
|
|
182
|
+
}
|
|
183
|
+
return { ink };
|
|
184
|
+
}
|
|
185
|
+
function paintDots(g, f) {
|
|
186
|
+
const { width, height, barWidth: size, barGap: gap } = g;
|
|
187
|
+
if (!width)
|
|
188
|
+
return {};
|
|
189
|
+
const pitch = size + gap;
|
|
190
|
+
const columns = Math.max(1, Math.floor((width + gap) / pitch));
|
|
191
|
+
let rows = Math.max(3, Math.floor((height + gap) / pitch));
|
|
192
|
+
if (g.centered && rows % 2 === 0)
|
|
193
|
+
rows -= 1;
|
|
194
|
+
const left = (width - (columns * pitch - gap)) / 2 + size / 2;
|
|
195
|
+
const top = (height - (rows * pitch - gap)) / 2 + size / 2;
|
|
196
|
+
const half = (rows - 1) / 2;
|
|
197
|
+
const played = g.progress === undefined ? columns : clamp01(g.progress) * columns;
|
|
198
|
+
let ink = '';
|
|
199
|
+
let rest = '';
|
|
200
|
+
let edge = '';
|
|
201
|
+
let track = '';
|
|
202
|
+
for (let c = 0; c < columns; c++) {
|
|
203
|
+
const raw = Math.max(FLOOR, stripValue(c, columns, g, f));
|
|
204
|
+
/*
|
|
205
|
+
* A live wave is eased up, because a field of dots has few steps and on a
|
|
206
|
+
* straight scale an ordinary voice lit only the middle row or two. Easing
|
|
207
|
+
* flattens the shape as well, so the middle is made to lead again after
|
|
208
|
+
* it. Supplied `levels` are drawn as given.
|
|
209
|
+
*/
|
|
210
|
+
const centre = Math.sin((Math.PI * (c + 0.5)) / columns);
|
|
211
|
+
const value = g.levels?.length || g.scrolling ? raw : Math.pow(raw, 0.6) * (0.3 + 0.7 * Math.pow(centre, 0.7));
|
|
212
|
+
// How many dots out from the middle (or up from the bottom) are lit. The
|
|
213
|
+
// part of a dot left over lights the next one faintly, so the outline
|
|
214
|
+
// moves smoothly rather than a whole dot at a time.
|
|
215
|
+
const reach = value * (g.centered ? half : rows - 1);
|
|
216
|
+
const full = Math.floor(reach);
|
|
217
|
+
const partial = reach - full > 0.35;
|
|
218
|
+
const x = left + c * pitch;
|
|
219
|
+
for (let r = 0; r < rows; r++) {
|
|
220
|
+
const out = g.centered ? Math.abs(r - half) : rows - 1 - r;
|
|
221
|
+
const path = dot(x, top + r * pitch);
|
|
222
|
+
if (out <= full) {
|
|
223
|
+
if (c < played)
|
|
224
|
+
ink += path;
|
|
225
|
+
else
|
|
226
|
+
rest += path;
|
|
227
|
+
}
|
|
228
|
+
else if (out === full + 1 && partial)
|
|
229
|
+
edge += path;
|
|
230
|
+
else
|
|
231
|
+
track += path;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { ink, rest, edge, track };
|
|
235
|
+
}
|
|
236
|
+
/** Where the ring sits for `radial` and `ring`, as a fraction of the diameter. */
|
|
237
|
+
const HOLE = 0.3;
|
|
238
|
+
function paintRadial(g, f) {
|
|
239
|
+
const { count, height: size, barWidth: w } = g;
|
|
240
|
+
const c = size / 2;
|
|
241
|
+
const start = size * (HOLE + 0.05);
|
|
242
|
+
const reach = c - start - w / 2 - 1;
|
|
243
|
+
let ink = '';
|
|
244
|
+
let tip = '';
|
|
245
|
+
for (let i = 0; i < count; i++) {
|
|
246
|
+
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
|
247
|
+
const value = roundValue(i, count, g, f);
|
|
248
|
+
const length = reach * (0.1 + 0.9 * value);
|
|
249
|
+
// The inner part carries the ink and the outer part is fainter, so a
|
|
250
|
+
// spoke reads as a level with a softer reach beyond it.
|
|
251
|
+
const split = start + length * 0.58;
|
|
252
|
+
const cos = Math.cos(angle);
|
|
253
|
+
const sin = Math.sin(angle);
|
|
254
|
+
ink += segment(c + cos * start, c + sin * start, c + cos * split, c + sin * split);
|
|
255
|
+
tip += segment(c + cos * split, c + sin * split, c + cos * (start + length), c + sin * (start + length));
|
|
256
|
+
}
|
|
257
|
+
return { ink, tip, glow: g.levels?.length ? 0.4 : f.energy };
|
|
258
|
+
}
|
|
259
|
+
function paintRing(g, f) {
|
|
260
|
+
const { count, height: size, barWidth: d, barGap: gap } = g;
|
|
261
|
+
const c = size / 2;
|
|
262
|
+
const pitch = d + gap;
|
|
263
|
+
const inner = size * HOLE;
|
|
264
|
+
const outer = c - d / 2 - 0.5;
|
|
265
|
+
const rings = Math.max(2, Math.floor((outer - inner) / pitch) + 1);
|
|
266
|
+
let ink = '';
|
|
267
|
+
let edge = '';
|
|
268
|
+
let track = '';
|
|
269
|
+
for (let i = 0; i < count; i++) {
|
|
270
|
+
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
|
271
|
+
const reach = Math.max(FLOOR, roundValue(i, count, g, f)) * rings;
|
|
272
|
+
const full = Math.floor(reach);
|
|
273
|
+
const partial = reach - full > 0.35;
|
|
274
|
+
const cos = Math.cos(angle);
|
|
275
|
+
const sin = Math.sin(angle);
|
|
276
|
+
for (let k = 0; k < rings; k++) {
|
|
277
|
+
const r = inner + k * pitch;
|
|
278
|
+
const path = dot(c + cos * r, c + sin * r);
|
|
279
|
+
if (k < full)
|
|
280
|
+
ink += path;
|
|
281
|
+
else if (k === full && partial)
|
|
282
|
+
edge += path;
|
|
283
|
+
else
|
|
284
|
+
track += path;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { ink, edge, track };
|
|
288
|
+
}
|
|
289
|
+
function paint(g, f) {
|
|
290
|
+
switch (g.variant) {
|
|
291
|
+
case 'pills':
|
|
292
|
+
return paintPills(g, f);
|
|
293
|
+
case 'bars':
|
|
294
|
+
return paintBars(g, f);
|
|
295
|
+
case 'line':
|
|
296
|
+
return paintLine(g, f);
|
|
297
|
+
case 'dots':
|
|
298
|
+
return paintDots(g, f);
|
|
299
|
+
case 'radial':
|
|
300
|
+
return paintRadial(g, f);
|
|
301
|
+
case 'ring':
|
|
302
|
+
return paintRing(g, f);
|
|
303
|
+
default:
|
|
304
|
+
return { glow: f.energy };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** How many bars the strip keeps in its history, for `scrolling`. */
|
|
308
|
+
function historyLength(g) {
|
|
309
|
+
if (!g.scrolling)
|
|
310
|
+
return 0;
|
|
311
|
+
if (g.variant === 'bars')
|
|
312
|
+
return g.count;
|
|
313
|
+
if (g.variant === 'dots' && g.width)
|
|
314
|
+
return Math.max(1, Math.floor((g.width + g.barGap) / (g.barWidth + g.barGap)));
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A frame that still looks like a wave, for the first render, `paused` and
|
|
319
|
+
* reduced motion. A stopped wave is not an empty one.
|
|
320
|
+
*/
|
|
321
|
+
function stillFrame(level, history) {
|
|
322
|
+
const energy = level === undefined ? 0.42 : clamp01(level);
|
|
323
|
+
return {
|
|
324
|
+
clock: 0,
|
|
325
|
+
energy,
|
|
326
|
+
nextSample: 0,
|
|
327
|
+
samples: Array.from({ length: history }, (_, i) => clamp01(energy * (0.45 + 0.55 * Math.abs(Math.sin(i * 0.7))))),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/* -------------------------------------------------------------------------- */
|
|
331
|
+
/* Hooks */
|
|
332
|
+
/* -------------------------------------------------------------------------- */
|
|
333
|
+
const REDUCE = '(prefers-reduced-motion: reduce)';
|
|
334
|
+
function subscribeReducedMotion(onChange) {
|
|
335
|
+
const query = window.matchMedia(REDUCE);
|
|
336
|
+
query.addEventListener('change', onChange);
|
|
337
|
+
return () => query.removeEventListener('change', onChange);
|
|
338
|
+
}
|
|
339
|
+
function useReducedMotion() {
|
|
340
|
+
return useSyncExternalStore(subscribeReducedMotion, () => window.matchMedia(REDUCE).matches, () => false);
|
|
341
|
+
}
|
|
342
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
343
|
+
const isRef = (value) => typeof value === 'object' && value !== null && 'current' in value;
|
|
344
|
+
/* -------------------------------------------------------------------------- */
|
|
345
|
+
/* Component */
|
|
346
|
+
/* -------------------------------------------------------------------------- */
|
|
347
|
+
/** Per-look geometry, so a bare `<Soundwave variant="…" />` looks right. */
|
|
348
|
+
const DEFAULTS = {
|
|
349
|
+
pills: { bars: 4, barWidth: 28, barGap: 10, height: 96 },
|
|
350
|
+
bars: { bars: 40, barWidth: 3, barGap: 3, height: 56 },
|
|
351
|
+
line: { bars: 0, barWidth: 3, barGap: 0, height: 72 },
|
|
352
|
+
ambient: { bars: 0, barWidth: 0, barGap: 0, height: 0 },
|
|
353
|
+
dots: { bars: 0, barWidth: 4, barGap: 3, height: 60 },
|
|
354
|
+
radial: { bars: 60, barWidth: 3, barGap: 0, height: 176 },
|
|
355
|
+
ring: { bars: 72, barWidth: 3.5, barGap: 3, height: 208 },
|
|
356
|
+
};
|
|
357
|
+
const COLOR_VAR = {
|
|
358
|
+
foreground: 'var(--foreground)',
|
|
359
|
+
primary: 'var(--primary)',
|
|
360
|
+
info: 'var(--info)',
|
|
361
|
+
success: 'var(--success)',
|
|
362
|
+
warning: 'var(--warning)',
|
|
363
|
+
destructive: 'var(--destructive)',
|
|
364
|
+
'chart-1': 'var(--chart-1)',
|
|
365
|
+
'chart-2': 'var(--chart-2)',
|
|
366
|
+
'chart-3': 'var(--chart-3)',
|
|
367
|
+
'chart-4': 'var(--chart-4)',
|
|
368
|
+
'chart-5': 'var(--chart-5)',
|
|
369
|
+
};
|
|
370
|
+
const soundwaveVariants = tv({
|
|
371
|
+
slots: {
|
|
372
|
+
root: 'relative text-(--sw-color,currentColor)',
|
|
373
|
+
svg: 'absolute inset-0 overflow-visible',
|
|
374
|
+
// Behind the spokes, in the wave's own colour, and only as strong as the voice.
|
|
375
|
+
glow: 'pointer-events-none absolute inset-[4%] rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--sw-color,currentColor)_30%,transparent)_30%,transparent_70%)]',
|
|
376
|
+
center: 'absolute top-1/2 left-1/2 flex size-(--sw-hole) -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full',
|
|
377
|
+
// Ambient: a bloom off the bottom edge and a rim round the rest, both on the level.
|
|
378
|
+
// Drawn with gradients rather than blur filters, which are costly to
|
|
379
|
+
// repaint every frame and band badly at this size.
|
|
380
|
+
bloom: [
|
|
381
|
+
'absolute inset-x-[-20%] bottom-0 h-[85%] origin-bottom bg-(--sw-fill,var(--sw-color))',
|
|
382
|
+
'[mask-image:radial-gradient(ellipse_50%_100%_at_50%_100%,black,transparent)]',
|
|
383
|
+
'opacity-[calc(0.18+var(--sw-e,0.4)*0.5)] [scale:1_calc(0.55+var(--sw-e,0.4)*0.6)]',
|
|
384
|
+
],
|
|
385
|
+
rim: 'absolute inset-0 rounded-[inherit] opacity-[calc(0.25+var(--sw-e,0.4)*0.75)] shadow-[inset_0_0_28px_color-mix(in_oklab,var(--sw-color)_45%,transparent)]',
|
|
386
|
+
},
|
|
387
|
+
variants: {
|
|
388
|
+
variant: {
|
|
389
|
+
pills: { root: 'inline-block shrink-0' },
|
|
390
|
+
bars: { root: 'block w-full' },
|
|
391
|
+
line: { root: 'block w-full' },
|
|
392
|
+
dots: { root: 'block w-full' },
|
|
393
|
+
radial: { root: 'inline-block shrink-0' },
|
|
394
|
+
ring: { root: 'inline-block shrink-0' },
|
|
395
|
+
ambient: { root: 'pointer-events-none absolute inset-0 overflow-hidden' },
|
|
396
|
+
},
|
|
397
|
+
fadeEdges: {
|
|
398
|
+
true: {
|
|
399
|
+
svg: '[mask-image:linear-gradient(to_right,transparent,black_14%,black_86%,transparent)]',
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
/** A voice as a wave. Draws a `level` it is handed, or runs its own motion for `state`. */
|
|
405
|
+
function SoundwaveRoot({ variant = 'pills', state = 'listening', level: levelProp, source, levels, progress, bars, barWidth, barGap, height, mode = 'static', centered = true, fadeEdges = false, sensitivity = 1, speed = 1, color, gradient, trackColor, paused = false, label, className, style, children, ...props }) {
|
|
406
|
+
const sourceLevel = useAudioLevel(source);
|
|
407
|
+
const level = source ? sourceLevel : levelProp;
|
|
408
|
+
const defaults = DEFAULTS[variant];
|
|
409
|
+
const count = bars ?? defaults.bars;
|
|
410
|
+
const w = barWidth ?? defaults.barWidth;
|
|
411
|
+
const gap = barGap ?? defaults.barGap;
|
|
412
|
+
const h = height ?? defaults.height;
|
|
413
|
+
const strip = variant === 'bars' || variant === 'line' || variant === 'dots';
|
|
414
|
+
const round = variant === 'radial' || variant === 'ring';
|
|
415
|
+
const scrolling = mode === 'scrolling' && (variant === 'bars' || variant === 'dots');
|
|
416
|
+
const root = useRef(null);
|
|
417
|
+
const layers = useRef({});
|
|
418
|
+
const glow = useRef(null);
|
|
419
|
+
// `useId` can hold characters a `url(#…)` reference does not survive.
|
|
420
|
+
const gradientId = `sw-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
421
|
+
const reducedMotion = useReducedMotion();
|
|
422
|
+
// Strips take the width of their container; the rest know their own.
|
|
423
|
+
const [measured, setMeasured] = useState(0);
|
|
424
|
+
const width = variant === 'pills' ? count * w + (count - 1) * gap : round ? h : strip ? measured : 0;
|
|
425
|
+
useIsomorphicLayoutEffect(() => {
|
|
426
|
+
const node = root.current;
|
|
427
|
+
if (!strip || !node)
|
|
428
|
+
return;
|
|
429
|
+
setMeasured(Math.round(node.getBoundingClientRect().width));
|
|
430
|
+
if (typeof ResizeObserver === 'undefined')
|
|
431
|
+
return;
|
|
432
|
+
const observer = new ResizeObserver(([entry]) => setMeasured(Math.round(entry.contentRect.width)));
|
|
433
|
+
observer.observe(node);
|
|
434
|
+
return () => observer.disconnect();
|
|
435
|
+
}, [strip]);
|
|
436
|
+
const geometry = {
|
|
437
|
+
variant,
|
|
438
|
+
width,
|
|
439
|
+
height: h,
|
|
440
|
+
count,
|
|
441
|
+
barWidth: w,
|
|
442
|
+
barGap: gap,
|
|
443
|
+
centered,
|
|
444
|
+
scrolling,
|
|
445
|
+
levels: scrolling ? undefined : levels,
|
|
446
|
+
progress,
|
|
447
|
+
};
|
|
448
|
+
const readLevel = () => {
|
|
449
|
+
if (level === undefined)
|
|
450
|
+
return undefined;
|
|
451
|
+
const value = isRef(level) ? level.current : level;
|
|
452
|
+
return value === null || value === undefined ? undefined : value * sensitivity;
|
|
453
|
+
};
|
|
454
|
+
// Fixed `levels` need no clock; neither does a held frame.
|
|
455
|
+
const fixed = !scrolling && !!levels?.length && variant !== 'ambient' && variant !== 'line';
|
|
456
|
+
const animate = !fixed && !paused && !reducedMotion;
|
|
457
|
+
// What the first render and any held frame draw.
|
|
458
|
+
const still = stillFrame(readLevel(), historyLength(geometry));
|
|
459
|
+
const painted = paint(geometry, still);
|
|
460
|
+
/*
|
|
461
|
+
* Everything the frame loop reads, refreshed on each render, so a prop
|
|
462
|
+
* change reaches the next frame without restarting the loop.
|
|
463
|
+
*/
|
|
464
|
+
const live = useRef({ geometry, state, speed, readLevel });
|
|
465
|
+
live.current = { geometry, state, speed, readLevel };
|
|
466
|
+
const frame = useRef(still);
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
if (!animate)
|
|
469
|
+
return;
|
|
470
|
+
const node = root.current;
|
|
471
|
+
let raf = 0;
|
|
472
|
+
let last = 0;
|
|
473
|
+
let visible = true;
|
|
474
|
+
const draw = (f) => {
|
|
475
|
+
const next = paint(live.current.geometry, f);
|
|
476
|
+
for (const key of ['ink', 'rest', 'edge', 'tip', 'track']) {
|
|
477
|
+
const path = layers.current[key];
|
|
478
|
+
if (path && next[key] !== undefined)
|
|
479
|
+
path.setAttribute('d', next[key]);
|
|
480
|
+
}
|
|
481
|
+
if (next.glow !== undefined) {
|
|
482
|
+
if (glow.current)
|
|
483
|
+
glow.current.style.opacity = String(0.25 + next.glow * 0.75);
|
|
484
|
+
node?.style.setProperty('--sw-e', String(q(next.glow * 100) / 100));
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const tick = (now) => {
|
|
488
|
+
raf = 0;
|
|
489
|
+
// A long gap (a hidden tab, a slow frame) is clamped rather than
|
|
490
|
+
// honoured: a 300ms hitch played back at full speed is a lurch.
|
|
491
|
+
const delta = Math.min(last ? now - last : 16, 48) / 1000;
|
|
492
|
+
last = now;
|
|
493
|
+
const { state: current, speed: rate, readLevel: read, geometry: g } = live.current;
|
|
494
|
+
const f = frame.current;
|
|
495
|
+
f.clock += delta * rate;
|
|
496
|
+
const driven = read();
|
|
497
|
+
const target = driven === undefined ? idleEnergy(current, f.clock) : clamp01(driven);
|
|
498
|
+
const smoothing = target > f.energy ? ATTACK : RELEASE;
|
|
499
|
+
f.energy += (target - f.energy) * Math.min(1, delta * smoothing);
|
|
500
|
+
const history = historyLength(g);
|
|
501
|
+
if (history > 0) {
|
|
502
|
+
while (f.samples.length > history)
|
|
503
|
+
f.samples.shift();
|
|
504
|
+
if (f.clock >= f.nextSample) {
|
|
505
|
+
f.nextSample = f.clock + SAMPLE_INTERVAL;
|
|
506
|
+
// A little texture per sample, so a held note is a band of
|
|
507
|
+
// varying bars rather than a solid block.
|
|
508
|
+
const jitter = 0.82 + 0.36 * hash(f.clock, 3.3);
|
|
509
|
+
if (f.samples.length >= history)
|
|
510
|
+
f.samples.shift();
|
|
511
|
+
f.samples.push(clamp01(f.energy * jitter));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
draw(f);
|
|
515
|
+
schedule();
|
|
516
|
+
};
|
|
517
|
+
const schedule = () => {
|
|
518
|
+
if (raf || !visible || document.hidden)
|
|
519
|
+
return;
|
|
520
|
+
raf = requestAnimationFrame(tick);
|
|
521
|
+
};
|
|
522
|
+
const stop = () => {
|
|
523
|
+
if (raf)
|
|
524
|
+
cancelAnimationFrame(raf);
|
|
525
|
+
raf = 0;
|
|
526
|
+
last = 0;
|
|
527
|
+
};
|
|
528
|
+
// Off screen, or in a background tab, nothing is drawn.
|
|
529
|
+
const observer = node && typeof IntersectionObserver !== 'undefined'
|
|
530
|
+
? new IntersectionObserver(([entry]) => {
|
|
531
|
+
visible = !!entry?.isIntersecting;
|
|
532
|
+
if (visible)
|
|
533
|
+
schedule();
|
|
534
|
+
else
|
|
535
|
+
stop();
|
|
536
|
+
})
|
|
537
|
+
: null;
|
|
538
|
+
if (node)
|
|
539
|
+
observer?.observe(node);
|
|
540
|
+
const onVisibility = () => (document.hidden ? stop() : schedule());
|
|
541
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
542
|
+
schedule();
|
|
543
|
+
return () => {
|
|
544
|
+
stop();
|
|
545
|
+
observer?.disconnect();
|
|
546
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
547
|
+
};
|
|
548
|
+
}, [animate]);
|
|
549
|
+
const slots = soundwaveVariants({ variant, fadeEdges: fadeEdges && strip });
|
|
550
|
+
const ink = color ? COLOR_VAR[color] : variant === 'ambient' ? COLOR_VAR.info : undefined;
|
|
551
|
+
// Unlit dots are the track turned well down, so a field stays a quiet
|
|
552
|
+
// ground under the wave whichever colour it is given.
|
|
553
|
+
const track = variant === 'dots' || variant === 'ring'
|
|
554
|
+
? `color-mix(in oklab, ${trackColor ? COLOR_VAR[trackColor] : 'var(--sw-color, currentColor)'} ${trackColor ? 30 : 14}%, transparent)`
|
|
555
|
+
: trackColor
|
|
556
|
+
? COLOR_VAR[trackColor]
|
|
557
|
+
: undefined;
|
|
558
|
+
const stroke = gradient?.length ? `url(#${gradientId})` : 'currentColor';
|
|
559
|
+
const ambientFill = gradient?.length
|
|
560
|
+
? `linear-gradient(to top, ${gradient.map((role) => COLOR_VAR[role]).join(', ')})`
|
|
561
|
+
: undefined;
|
|
562
|
+
const rootStyle = {
|
|
563
|
+
...(ink ? { ['--sw-color']: ink } : null),
|
|
564
|
+
...(ambientFill ? { ['--sw-fill']: ambientFill } : null),
|
|
565
|
+
...(variant === 'ambient' ? null : { height: h }),
|
|
566
|
+
...(variant === 'pills' || round ? { width } : null),
|
|
567
|
+
...(round ? { ['--sw-hole']: `${q(h * HOLE * 2 - (variant === 'ring' ? w + gap : h * 0.04))}px` } : null),
|
|
568
|
+
...(painted.glow !== undefined ? { ['--sw-e']: q(painted.glow * 10) / 10 } : null),
|
|
569
|
+
...style,
|
|
570
|
+
};
|
|
571
|
+
const layer = (key, extra = {}) => (_jsx("path", { ref: (node) => {
|
|
572
|
+
layers.current[key] = node;
|
|
573
|
+
}, d: painted[key] ?? '', fill: "none", stroke: extra.stroke ?? stroke, strokeOpacity: extra.opacity, strokeWidth: extra.strokeWidth ?? w, strokeLinecap: "round", strokeLinejoin: "round" }));
|
|
574
|
+
return (_jsx("div", { ref: root, role: "img", "aria-label": label ?? STATE_LABEL[state], "data-slot": "soundwave", "data-variant": variant, "data-state": state, className: slots.root({ className }), style: rootStyle, ...props, children: variant === 'ambient' ? (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", className: slots.bloom() }), _jsx("div", { "aria-hidden": "true", className: slots.rim() })] })) : (_jsxs(_Fragment, { children: [variant === 'radial' ? (_jsx("div", { ref: glow, "aria-hidden": "true", className: slots.glow(), style: { opacity: 0.25 + (painted.glow ?? 0) * 0.75 } })) : null, width > 0 ? (_jsxs("svg", { "aria-hidden": "true", width: width, height: h, viewBox: `0 0 ${width} ${h}`, className: slots.svg(), children: [gradient?.length ? (_jsx("defs", { children: _jsx("linearGradient", { id: gradientId, x1: "0", x2: "1", y1: "0", y2: "0", children: gradient.map((role, index) => (_jsx("stop", { offset: gradient.length === 1 ? 0 : index / (gradient.length - 1), style: { stopColor: COLOR_VAR[role] } }, `${role}-${index}`))) }) })) : null, variant === 'dots' || variant === 'ring' ? layer('track', { stroke: track }) : null, variant === 'radial' ? (_jsx("circle", { cx: h / 2, cy: h / 2, r: h * HOLE, fill: "none", stroke: "currentColor", strokeOpacity: 0.22, strokeWidth: 1.5 })) : null, variant === 'radial' ? layer('tip', { opacity: 0.42 }) : null, variant === 'dots' || variant === 'ring' ? layer('edge', { opacity: 0.45 }) : null, variant === 'bars' || variant === 'dots'
|
|
575
|
+
? layer('rest', trackColor && variant === 'bars' ? { stroke: track } : { opacity: UNPLAYED })
|
|
576
|
+
: null, layer('ink')] })) : null, children] })) }));
|
|
577
|
+
}
|
|
578
|
+
SoundwaveRoot.displayName = 'Soundwave';
|
|
579
|
+
/**
|
|
580
|
+
* What sits in the hole of a `radial` or `ring` wave: a person's avatar, a
|
|
581
|
+
* figure, a button. It is sized to the hole and centred in it.
|
|
582
|
+
*/
|
|
583
|
+
function SoundwaveCenter({ className, ...props }) {
|
|
584
|
+
return _jsx("div", { "data-slot": "soundwave-center", className: soundwaveVariants().center({ className }), ...props });
|
|
585
|
+
}
|
|
586
|
+
SoundwaveCenter.displayName = 'Soundwave.Center';
|
|
587
|
+
export const Soundwave = Object.assign(SoundwaveRoot, { Center: SoundwaveCenter });
|
|
588
|
+
export { SoundwaveCenter };
|
|
589
|
+
let sharedContext = null;
|
|
590
|
+
/** One audio context for every wave on the page. Browsers limit how many may run. */
|
|
591
|
+
function audioContext() {
|
|
592
|
+
if (typeof window === 'undefined')
|
|
593
|
+
return null;
|
|
594
|
+
if (!sharedContext) {
|
|
595
|
+
const Context = window.AudioContext ?? window.webkitAudioContext;
|
|
596
|
+
if (!Context)
|
|
597
|
+
return null;
|
|
598
|
+
sharedContext = new Context();
|
|
599
|
+
}
|
|
600
|
+
// Created or resumed outside a click, it starts suspended. Asking again is harmless.
|
|
601
|
+
if (sharedContext.state === 'suspended')
|
|
602
|
+
void sharedContext.resume().catch(() => { });
|
|
603
|
+
return sharedContext;
|
|
604
|
+
}
|
|
605
|
+
/*
|
|
606
|
+
* An element can be routed into an audio graph only once, so its node is
|
|
607
|
+
* kept. The node is wired to the speakers as well as to the analyser, since
|
|
608
|
+
* routing an element through the graph otherwise takes its sound away.
|
|
609
|
+
*/
|
|
610
|
+
const elementNodes = new WeakMap();
|
|
611
|
+
function sourceNode(context, source) {
|
|
612
|
+
if (typeof MediaStream !== 'undefined' && source instanceof MediaStream) {
|
|
613
|
+
// Not wired to the speakers: a microphone played back is feedback.
|
|
614
|
+
return context.createMediaStreamSource(source);
|
|
615
|
+
}
|
|
616
|
+
const element = source;
|
|
617
|
+
let node = elementNodes.get(element);
|
|
618
|
+
if (!node) {
|
|
619
|
+
node = context.createMediaElementSource(element);
|
|
620
|
+
node.connect(context.destination);
|
|
621
|
+
elementNodes.set(element, node);
|
|
622
|
+
}
|
|
623
|
+
return node;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* How loud `source` is, from 0 to 1, kept in a ref that is updated every
|
|
627
|
+
* frame. Pass the ref to `Soundwave` as `level`, or read `ref.current`
|
|
628
|
+
* yourself. With no source the ref holds 0.
|
|
629
|
+
*/
|
|
630
|
+
export function useAudioLevel(source, { floor = -60, ceiling = -12 } = {}) {
|
|
631
|
+
const level = useRef(0);
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
level.current = 0;
|
|
634
|
+
if (!source)
|
|
635
|
+
return;
|
|
636
|
+
const context = audioContext();
|
|
637
|
+
if (!context)
|
|
638
|
+
return;
|
|
639
|
+
let input;
|
|
640
|
+
try {
|
|
641
|
+
input = sourceNode(context, source);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const analyser = context.createAnalyser();
|
|
647
|
+
analyser.fftSize = 1024;
|
|
648
|
+
input.connect(analyser);
|
|
649
|
+
const samples = new Float32Array(analyser.fftSize);
|
|
650
|
+
const resume = () => void context.resume().catch(() => { });
|
|
651
|
+
const element = typeof MediaStream !== 'undefined' && source instanceof MediaStream ? null : source;
|
|
652
|
+
element?.addEventListener('play', resume);
|
|
653
|
+
let frame = 0;
|
|
654
|
+
const read = () => {
|
|
655
|
+
analyser.getFloatTimeDomainData(samples);
|
|
656
|
+
let sum = 0;
|
|
657
|
+
for (let i = 0; i < samples.length; i++)
|
|
658
|
+
sum += samples[i] * samples[i];
|
|
659
|
+
// Loudness in decibels is negative and logarithmic; the wave wants 0 to 1.
|
|
660
|
+
const db = 20 * Math.log10(Math.sqrt(sum / samples.length) || 1e-9);
|
|
661
|
+
level.current = clamp01((db - floor) / (ceiling - floor));
|
|
662
|
+
frame = requestAnimationFrame(read);
|
|
663
|
+
};
|
|
664
|
+
read();
|
|
665
|
+
return () => {
|
|
666
|
+
cancelAnimationFrame(frame);
|
|
667
|
+
element?.removeEventListener('play', resume);
|
|
668
|
+
try {
|
|
669
|
+
input.disconnect(analyser);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// Already gone with its context.
|
|
673
|
+
}
|
|
674
|
+
if (!element)
|
|
675
|
+
input.disconnect();
|
|
676
|
+
level.current = 0;
|
|
677
|
+
};
|
|
678
|
+
}, [source, floor, ceiling]);
|
|
679
|
+
return level;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The microphone, ready to draw. Nothing is asked for until `start`, and the
|
|
683
|
+
* microphone is released on `stop` and when the component unmounts.
|
|
684
|
+
*
|
|
685
|
+
* ```tsx
|
|
686
|
+
* const mic = useMicrophone();
|
|
687
|
+
* <Soundwave level={mic.active ? mic.level : undefined} />
|
|
688
|
+
* <Button onClick={mic.toggle}>{mic.active ? 'Stop' : 'Speak'}</Button>
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
export function useMicrophone({ constraints, ...options } = {}) {
|
|
692
|
+
const [stream, setStream] = useState(null);
|
|
693
|
+
const [status, setStatus] = useState('idle');
|
|
694
|
+
const level = useAudioLevel(stream, options);
|
|
695
|
+
const current = useRef(null);
|
|
696
|
+
const asking = useRef(false);
|
|
697
|
+
const stop = () => {
|
|
698
|
+
current.current?.getTracks().forEach((track) => track.stop());
|
|
699
|
+
current.current = null;
|
|
700
|
+
setStream(null);
|
|
701
|
+
setStatus((value) => (value === 'listening' || value === 'requesting' ? 'idle' : value));
|
|
702
|
+
};
|
|
703
|
+
const start = async () => {
|
|
704
|
+
if (current.current || asking.current)
|
|
705
|
+
return;
|
|
706
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
|
707
|
+
setStatus('unsupported');
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
asking.current = true;
|
|
711
|
+
setStatus('requesting');
|
|
712
|
+
// Made here, inside the click, so the browser lets it run.
|
|
713
|
+
audioContext();
|
|
714
|
+
try {
|
|
715
|
+
const next = await navigator.mediaDevices.getUserMedia({
|
|
716
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, ...constraints },
|
|
717
|
+
});
|
|
718
|
+
current.current = next;
|
|
719
|
+
setStream(next);
|
|
720
|
+
setStatus('listening');
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
setStatus('denied');
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
asking.current = false;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
useEffect(() => () => current.current?.getTracks().forEach((track) => track.stop()), []);
|
|
730
|
+
const active = status === 'listening' && stream !== null;
|
|
731
|
+
return { level, stream, status, active, start, stop, toggle: () => (active ? stop() : void start()) };
|
|
732
|
+
}
|