panelui-web 0.6.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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Soundwave — what a voice looks like while an app listens to it.
3
+ *
4
+ * ```tsx
5
+ * <Soundwave variant="dots" state="listening" level={level} />
6
+ *
7
+ * <Soundwave variant="radial" state="speaking">
8
+ * <Soundwave.Center>
9
+ * <Avatar name="Khalid Abdi" size="xl" />
10
+ * </Soundwave.Center>
11
+ * </Soundwave>
12
+ * ```
13
+ *
14
+ * Seven looks. Four are the app's: `pills` over a microphone button, `bars`
15
+ * for a metering strip, `line` for a travelling wave, `ambient` for a glow
16
+ * behind a whole screen. Three are drawn in dots and spokes: `dots`, a field
17
+ * of dots lit into a wave; `radial`, spokes around a person; and `ring`,
18
+ * rings of dots lit outward by angle.
19
+ *
20
+ * ## It draws a level; it does not record one
21
+ *
22
+ * Nothing here touches the microphone. The app owns the recorder and hands
23
+ * over a number from 0 to 1, so the same wave works against a real meter, a
24
+ * synthesised one or a remote peer's. With no level it runs its own motion
25
+ * for the current `state`, so a voice screen can be built before any audio
26
+ * exists.
27
+ *
28
+ * ## Levels arrive faster than React should render
29
+ *
30
+ * A meter reports dozens of times a second. `level` takes a ref as well as a
31
+ * number: write each reading into `ref.current` and the wave picks it up on
32
+ * its next frame with nothing above it rendering. Each frame writes the `d`
33
+ * of one or two SVG paths, never React state, and the dots are zero-length
34
+ * round-capped segments, so a field of four hundred dots is a single path.
35
+ *
36
+ * Either way the level is smoothed with a fast attack and a slow release,
37
+ * which is what makes it read as a meter: it snaps up on a syllable and falls
38
+ * back gently instead of chattering on every sample.
39
+ */
40
+ import { type ComponentProps, type ReactNode, type RefObject } from 'react';
41
+ export type SoundwaveVariant = 'pills' | 'bars' | 'line' | 'ambient' | 'dots' | 'radial' | 'ring';
42
+ export type SoundwaveState = 'idle' | 'listening' | 'thinking' | 'speaking';
43
+ /** A role from the theme. The wave never carries a colour of its own. */
44
+ export type SoundwaveColor = 'foreground' | 'primary' | 'info' | 'success' | 'warning' | 'destructive' | 'chart-1' | 'chart-2' | 'chart-3' | 'chart-4' | 'chart-5';
45
+ export interface SoundwaveProps extends Omit<ComponentProps<'div'>, 'color' | 'children'> {
46
+ /**
47
+ * Which look to draw: `pills` for a few capsules over a microphone button,
48
+ * `bars` for a metering strip, `line` for a travelling wave, `ambient` for a
49
+ * glow that fills its parent, `dots` for a field of dots lit into a wave,
50
+ * `radial` for spokes around a centre, and `ring` for rings of dots lit
51
+ * outward by angle.
52
+ */
53
+ variant?: SoundwaveVariant;
54
+ /**
55
+ * What the app is doing. With no `level` it picks the motion the wave runs
56
+ * on its own, and it always sets what a screen reader announces.
57
+ */
58
+ state?: SoundwaveState;
59
+ /**
60
+ * How loud the input is now, from 0 to 1. Pass a ref and write each
61
+ * reading into `ref.current` to keep updates out of React entirely. Leave
62
+ * it out and the wave runs its own motion for `state`.
63
+ */
64
+ level?: number | RefObject<number | null>;
65
+ /**
66
+ * Sound to draw, in place of `level`: a `MediaStream` from a microphone or
67
+ * a call, or an `<audio>` or `<video>` element. A media file has to be on
68
+ * the same site or sent with CORS headers, or the browser hides its sound
69
+ * from the page and the wave stays still.
70
+ */
71
+ source?: SoundwaveSource | null;
72
+ /**
73
+ * Fixed values, 0 to 1 each: the stored shape of a recording, a frequency
74
+ * analysis, or one value per angle for `ring` (24 for the hours of a day).
75
+ * Resampled to the bar count, and drawn still rather than animated.
76
+ */
77
+ levels?: readonly number[];
78
+ /**
79
+ * How far a recording has played, from 0 to 1. Bars and dots behind it keep
80
+ * full ink and the rest are dimmed. `bars` and `dots`, with `levels`.
81
+ */
82
+ progress?: number;
83
+ /** How many capsules, bars or spokes, or how many angles for `ring`. `dots` fits as many columns as the width allows. */
84
+ bars?: number;
85
+ /** Capsule or bar width, the stroke of `line` and `radial`, or the size of a dot. */
86
+ barWidth?: number;
87
+ /** Space between capsules, or between dots. `bars` spaces itself across the width. */
88
+ barGap?: number;
89
+ /** Drawing height. For `radial` and `ring` it is the diameter. `ambient` fills its parent instead. */
90
+ height?: number;
91
+ /**
92
+ * `static` gives every bar a share of the current level; `scrolling` keeps a
93
+ * history that slides across, newest at the trailing edge. `bars` and `dots`.
94
+ */
95
+ mode?: 'static' | 'scrolling';
96
+ /** Grow bars and dots from the middle out rather than up from the bottom. */
97
+ centered?: boolean;
98
+ /** Fade a strip out at both ends, so it does not stop at a hard edge. */
99
+ fadeEdges?: boolean;
100
+ /** Multiplier on the incoming level, applied before it is capped at 1. */
101
+ sensitivity?: number;
102
+ /** Multiplier on the wave's own tempo, including how fast a history scrolls. */
103
+ speed?: number;
104
+ /**
105
+ * The ink, as a role from the theme. Left unset the wave is drawn in the
106
+ * text colour of whatever it sits in, which is right on most surfaces;
107
+ * `ambient` defaults to `info`, since a glow in the text colour reads as a
108
+ * shadow.
109
+ */
110
+ color?: SoundwaveColor;
111
+ /**
112
+ * Colours across the wave instead of one ink, as theme roles: left to right
113
+ * for the strips, up from the bottom for `ambient`.
114
+ */
115
+ gradient?: readonly SoundwaveColor[];
116
+ /**
117
+ * The colour of the unlit dots in `dots` and `ring`, and of the part of a
118
+ * recording not yet played in `bars`. Defaults to the ink, faint.
119
+ */
120
+ trackColor?: SoundwaveColor;
121
+ /** Hold the current frame. */
122
+ paused?: boolean;
123
+ /** What a screen reader calls it. Defaults to the state: "Listening". */
124
+ label?: string;
125
+ /** For `radial` and `ring`: a `Soundwave.Center` holding what sits in the middle. */
126
+ children?: ReactNode;
127
+ }
128
+ /** A voice as a wave. Draws a `level` it is handed, or runs its own motion for `state`. */
129
+ declare function SoundwaveRoot({ variant, state, level: levelProp, source, levels, progress, bars, barWidth, barGap, height, mode, centered, fadeEdges, sensitivity, speed, color, gradient, trackColor, paused, label, className, style, children, ...props }: SoundwaveProps): import("react").JSX.Element;
130
+ declare namespace SoundwaveRoot {
131
+ var displayName: string;
132
+ }
133
+ export interface SoundwaveCenterProps extends ComponentProps<'div'> {
134
+ }
135
+ /**
136
+ * What sits in the hole of a `radial` or `ring` wave: a person's avatar, a
137
+ * figure, a button. It is sized to the hole and centred in it.
138
+ */
139
+ declare function SoundwaveCenter({ className, ...props }: SoundwaveCenterProps): import("react").JSX.Element;
140
+ declare namespace SoundwaveCenter {
141
+ var displayName: string;
142
+ }
143
+ export declare const Soundwave: typeof SoundwaveRoot & {
144
+ Center: typeof SoundwaveCenter;
145
+ };
146
+ export { SoundwaveCenter };
147
+ /** Something with sound in it: a microphone or call stream, or an `<audio>` or `<video>` element. */
148
+ export type SoundwaveSource = MediaStream | HTMLMediaElement;
149
+ export interface AudioLevelOptions {
150
+ /**
151
+ * The quietest sound that still moves the wave, in decibels below full
152
+ * scale. Default `-60`. Raise it towards `-45` in a noisy room.
153
+ */
154
+ floor?: number;
155
+ /** The loudness that fills the wave, in decibels below full scale. Default `-12`. */
156
+ ceiling?: number;
157
+ }
158
+ /**
159
+ * How loud `source` is, from 0 to 1, kept in a ref that is updated every
160
+ * frame. Pass the ref to `Soundwave` as `level`, or read `ref.current`
161
+ * yourself. With no source the ref holds 0.
162
+ */
163
+ export declare function useAudioLevel(source: SoundwaveSource | null | undefined, { floor, ceiling }?: AudioLevelOptions): RefObject<number>;
164
+ export type MicrophoneStatus = 'idle' | 'requesting' | 'listening' | 'denied' | 'unsupported';
165
+ export interface UseMicrophoneOptions extends AudioLevelOptions {
166
+ /** Passed to `getUserMedia` as `audio`, to pick a device or change its processing. */
167
+ constraints?: MediaTrackConstraints;
168
+ }
169
+ export interface MicrophoneControls {
170
+ /** How loud the microphone is, from 0 to 1. Pass it to `Soundwave` as `level`. */
171
+ level: RefObject<number>;
172
+ /** The live stream while listening, for a recorder or a call. `null` otherwise. */
173
+ stream: MediaStream | null;
174
+ /** `requesting` while the browser asks for permission, `denied` if it was refused or there is no microphone. */
175
+ status: MicrophoneStatus;
176
+ /** Whether it is listening now. */
177
+ active: boolean;
178
+ /** Ask for the microphone and start listening. Call it from a click: browsers only allow sound to start from one. */
179
+ start: () => Promise<void>;
180
+ /** Stop listening and release the microphone, which turns the browser's recording light off. */
181
+ stop: () => void;
182
+ /** `start` when stopped, `stop` when listening. */
183
+ toggle: () => void;
184
+ }
185
+ /**
186
+ * The microphone, ready to draw. Nothing is asked for until `start`, and the
187
+ * microphone is released on `stop` and when the component unmounts.
188
+ *
189
+ * ```tsx
190
+ * const mic = useMicrophone();
191
+ * <Soundwave level={mic.active ? mic.level : undefined} />
192
+ * <Button onClick={mic.toggle}>{mic.active ? 'Stop' : 'Speak'}</Button>
193
+ * ```
194
+ */
195
+ export declare function useMicrophone({ constraints, ...options }?: UseMicrophoneOptions): MicrophoneControls;
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -26,3 +26,4 @@ export { Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckboxItem, MenuRadioGr
26
26
  export { Avatar, AvatarBadge, AvatarGroup, type AvatarProps, type AvatarBadgeProps, type AvatarGroupProps, type AvatarSize, type AvatarShape, type AvatarColor, type AvatarStatus, } from './components/avatar/index.js';
27
27
  export { Tabs, TabsList, TabsTrigger, TabsBadge, TabsContent, type TabsProps, type TabsVariant, type TabsListProps, type TabsTriggerProps, type TabsBadgeProps, type TabsContentProps, } from './components/tabs/index.js';
28
28
  export { Message, MessageGroup, MessageAvatar, MessageContent, MessageHeader, MessageBubble, MessageBubbleContent, MessageFooter, MessageActions, MessageAction, MessageBranch, MessageStatus, MessageAttachments, MessageAttachment, type MessageFrom, type MessageProps, type MessageGroupProps, type MessageAvatarProps, type MessageContentProps, type MessageHeaderProps, type MessageBubbleProps, type MessageBubbleContentProps, type MessageFooterProps, type MessageActionsProps, type MessageActionProps, type MessageBranchProps, type MessageStatusProps, type MessageAttachmentsProps, type MessageAttachmentProps, } from './components/message/index.js';
29
+ export { Soundwave, SoundwaveCenter, useAudioLevel, useMicrophone, type SoundwaveProps, type SoundwaveCenterProps, type SoundwaveVariant, type SoundwaveState, type SoundwaveColor, type SoundwaveSource, type AudioLevelOptions, type UseMicrophoneOptions, type MicrophoneControls, type MicrophoneStatus, } from './components/soundwave/index.js';
package/dist/index.js CHANGED
@@ -26,3 +26,4 @@ export { Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckboxItem, MenuRadioGr
26
26
  export { Avatar, AvatarBadge, AvatarGroup, } from './components/avatar/index.js';
27
27
  export { Tabs, TabsList, TabsTrigger, TabsBadge, TabsContent, } from './components/tabs/index.js';
28
28
  export { Message, MessageGroup, MessageAvatar, MessageContent, MessageHeader, MessageBubble, MessageBubbleContent, MessageFooter, MessageActions, MessageAction, MessageBranch, MessageStatus, MessageAttachments, MessageAttachment, } from './components/message/index.js';
29
+ export { Soundwave, SoundwaveCenter, useAudioLevel, useMicrophone, } from './components/soundwave/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "PanelUI for the web: animated, data-rich React components styled with Tailwind CSS on shadcn tokens.",
5
5
  "type": "module",
6
6
  "sideEffects": false,