@juspay/svelte-ui-components 4.25.0 → 4.26.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/VoiceOrb/VoiceOrb.svelte +400 -0
- package/dist/VoiceOrb/VoiceOrb.svelte.d.ts +4 -0
- package/dist/VoiceOrb/orbMath.d.ts +131 -0
- package/dist/VoiceOrb/orbMath.js +267 -0
- package/dist/VoiceOrb/properties.d.ts +105 -0
- package/dist/VoiceOrb/properties.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-wc/index.js +513 -156
- package/package.json +1 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
AUDIO_ATTACK_MS,
|
|
5
|
+
AUDIO_RELEASE_MS,
|
|
6
|
+
LEVEL_REPORT_EPSILON,
|
|
7
|
+
followEnvelope,
|
|
8
|
+
generateOrbParticles,
|
|
9
|
+
mulberry32,
|
|
10
|
+
orbDepthScale,
|
|
11
|
+
parseCssColor,
|
|
12
|
+
resolveExplicitSeed,
|
|
13
|
+
resolveMotionScale,
|
|
14
|
+
resolveMotionSpeed,
|
|
15
|
+
rmsFromTimeDomain,
|
|
16
|
+
rotateOrbPoint,
|
|
17
|
+
type OrbParticle
|
|
18
|
+
} from './orbMath';
|
|
19
|
+
import type { Rgb } from '../types';
|
|
20
|
+
import { prefersReducedMotion } from '../utils';
|
|
21
|
+
import type { VoiceOrbProperties } from './properties';
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
variant = 'idle',
|
|
25
|
+
speedMultiplier = 1,
|
|
26
|
+
seed,
|
|
27
|
+
height = 400,
|
|
28
|
+
particleCount = 1000,
|
|
29
|
+
radius = 140,
|
|
30
|
+
interactive = true,
|
|
31
|
+
repelRadius = 200,
|
|
32
|
+
analyser,
|
|
33
|
+
sensitivity = 1,
|
|
34
|
+
persistKey,
|
|
35
|
+
classes,
|
|
36
|
+
testId,
|
|
37
|
+
onfirstframe,
|
|
38
|
+
onlevel
|
|
39
|
+
}: VoiceOrbProperties = $props();
|
|
40
|
+
|
|
41
|
+
const FOCAL_LENGTH = 600;
|
|
42
|
+
const BASE_ROTATION_X = 0.0021;
|
|
43
|
+
const BASE_ROTATION_Y = 0.0014;
|
|
44
|
+
/*
|
|
45
|
+
* Last resort only, and deliberately a mid-grey that is visible on any
|
|
46
|
+
* ground. It is reached when neither the custom property nor the inherited
|
|
47
|
+
* colour normalises, which a browser has to work at to produce. The real
|
|
48
|
+
* default is the inherited text colour -- see `particleRgb`.
|
|
49
|
+
*/
|
|
50
|
+
const FALLBACK_PARTICLE_RGB: Rgb = { r: 128, g: 128, b: 128 };
|
|
51
|
+
|
|
52
|
+
let canvas: HTMLCanvasElement | null = $state(null);
|
|
53
|
+
let container: HTMLDivElement | null = $state(null);
|
|
54
|
+
|
|
55
|
+
const pointer: { x: number | null; y: number | null } = { x: null, y: null };
|
|
56
|
+
const orb = { width: 0, height: 0, angleX: 0, angleY: 0 };
|
|
57
|
+
|
|
58
|
+
let listeningElapsedMs = 0;
|
|
59
|
+
/* Explicitly over ArrayBuffer: `getByteTimeDomainData` will not accept the
|
|
60
|
+
ArrayBufferLike default that a bare `Uint8Array` annotation now means. */
|
|
61
|
+
let audioBuffer: Uint8Array<ArrayBuffer> | null = null;
|
|
62
|
+
let audioLevel = 0;
|
|
63
|
+
let reportedLevel = 0;
|
|
64
|
+
let resolvedColorInput = '';
|
|
65
|
+
let resolvedRgb: Rgb = FALLBACK_PARTICLE_RGB;
|
|
66
|
+
let frame: number | null = null;
|
|
67
|
+
let previousTime: number | null = null;
|
|
68
|
+
let announcedFirstFrame = false;
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* Resolved once per instance rather than inside `resolveSeed` itself: an
|
|
72
|
+
* unseeded orb should only ever get a new random pattern from an actual new
|
|
73
|
+
* mount, never from `particleCount`/`radius` changing underneath it (see
|
|
74
|
+
* `particles` below, and finding 2 in the review this fixed).
|
|
75
|
+
*/
|
|
76
|
+
let unseededSeed = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
|
77
|
+
|
|
78
|
+
const resolveSeed = (): number => resolveExplicitSeed(seed) ?? unseededSeed;
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* $derived rather than assigned once in `onMount`: `particleCount`, `radius`
|
|
82
|
+
* and `seed` are reactive props, and generating the sphere only at mount
|
|
83
|
+
* meant changing any of them afterwards silently kept the original geometry.
|
|
84
|
+
* `unseededSeed` above is what keeps an unseeded orb from also reshuffling
|
|
85
|
+
* on a change to `particleCount`/`radius` alone.
|
|
86
|
+
*/
|
|
87
|
+
const particles: readonly OrbParticle[] = $derived.by(() =>
|
|
88
|
+
generateOrbParticles(particleCount, radius, mulberry32(resolveSeed()))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* Storage is namespaced by the caller's key and every read is guarded: a
|
|
93
|
+
* browser with storage disabled throws on access rather than returning null,
|
|
94
|
+
* and a persisted value can be anything by the time it is read back.
|
|
95
|
+
*/
|
|
96
|
+
const readPersisted = (suffix: string): number | null => {
|
|
97
|
+
if (typeof persistKey !== 'string' || persistKey.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const raw = localStorage.getItem(`${persistKey}:${suffix}`);
|
|
102
|
+
if (raw === null) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const parsed = Number.parseFloat(raw);
|
|
106
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const writePersisted = (): void => {
|
|
113
|
+
if (typeof persistKey !== 'string' || persistKey.length === 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
localStorage.setItem(`${persistKey}:angleX`, String(orb.angleX));
|
|
118
|
+
localStorage.setItem(`${persistKey}:angleY`, String(orb.angleY));
|
|
119
|
+
localStorage.setItem(`${persistKey}:listeningMs`, String(listeningElapsedMs));
|
|
120
|
+
} catch {
|
|
121
|
+
/* Storage full or blocked: rotation resumes from zero, which is cosmetic. */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const resize = (): void => {
|
|
126
|
+
if (canvas === null || container === null) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
orb.width = container.clientWidth;
|
|
130
|
+
orb.height = Number.isFinite(height) && height > 0 ? height : 400;
|
|
131
|
+
canvas.width = orb.width;
|
|
132
|
+
canvas.height = orb.height;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/*
|
|
136
|
+
* Assigning an unparseable colour to `fillStyle` is a no-op by spec, so the
|
|
137
|
+
* candidate is tried against two different sentinels: agreeing reads mean the
|
|
138
|
+
* browser accepted it, differing reads mean it fell through. Reading it back
|
|
139
|
+
* yields a normalised `#rrggbb`, which is how `oklch()`, `color(display-p3 ...)`
|
|
140
|
+
* and named colours reach the particle maths without this component
|
|
141
|
+
* implementing a CSS colour parser.
|
|
142
|
+
*/
|
|
143
|
+
const normalizeColor = (context: CanvasRenderingContext2D, value: string): string | null => {
|
|
144
|
+
if (value.length === 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const previous = context.fillStyle;
|
|
148
|
+
context.fillStyle = '#000000';
|
|
149
|
+
context.fillStyle = value;
|
|
150
|
+
const onBlack = context.fillStyle;
|
|
151
|
+
context.fillStyle = '#ffffff';
|
|
152
|
+
context.fillStyle = value;
|
|
153
|
+
const onWhite = context.fillStyle;
|
|
154
|
+
context.fillStyle = previous;
|
|
155
|
+
return typeof onBlack === 'string' && onBlack === onWhite ? onBlack : null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The current smoothed audio level, or null when the orb is not being driven
|
|
160
|
+
* by audio -- no analyser, or not listening -- in which case the caller falls
|
|
161
|
+
* back to the sine-wave envelope.
|
|
162
|
+
*
|
|
163
|
+
* Wrapped because a consumer closing their `AudioContext` while the orb is
|
|
164
|
+
* still mounted makes the read throw, and an exception inside the rAF
|
|
165
|
+
* callback kills the loop for good: the orb would freeze rather than degrade.
|
|
166
|
+
*/
|
|
167
|
+
const audioEnvelope = (deltaMs: number): number | null => {
|
|
168
|
+
if (!analyser || variant !== 'listening') {
|
|
169
|
+
audioLevel = 0;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const size = analyser.fftSize;
|
|
174
|
+
if (audioBuffer === null || audioBuffer.length !== size) {
|
|
175
|
+
audioBuffer = new Uint8Array(size);
|
|
176
|
+
}
|
|
177
|
+
analyser.getByteTimeDomainData(audioBuffer);
|
|
178
|
+
const gain = Number.isFinite(sensitivity) && sensitivity > 0 ? sensitivity : 1;
|
|
179
|
+
const target = rmsFromTimeDomain(audioBuffer) * gain;
|
|
180
|
+
audioLevel = followEnvelope(audioLevel, target, deltaMs, AUDIO_ATTACK_MS, AUDIO_RELEASE_MS);
|
|
181
|
+
return audioLevel;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* The default is the inherited text colour, so the orb is legible on whatever
|
|
189
|
+
* ground a consumer puts it on and follows their theme without being told.
|
|
190
|
+
* A fixed default cannot: the first revision of this component shipped a
|
|
191
|
+
* light grey that was invisible on a light page.
|
|
192
|
+
*
|
|
193
|
+
* Resolved every frame rather than once at mount, so a theme switch reaches a
|
|
194
|
+
* running orb. The cache key is the raw strings, so the parse only repeats
|
|
195
|
+
* when the page actually changes -- the `getComputedStyle` read is a colour
|
|
196
|
+
* lookup, which needs no layout.
|
|
197
|
+
*/
|
|
198
|
+
const particleRgb = (context: CanvasRenderingContext2D): Rgb => {
|
|
199
|
+
if (canvas === null) {
|
|
200
|
+
return resolvedRgb;
|
|
201
|
+
}
|
|
202
|
+
const styles = getComputedStyle(canvas);
|
|
203
|
+
const declared = styles.getPropertyValue('--sui-orb-particle-color').trim();
|
|
204
|
+
const inherited = styles.color.trim();
|
|
205
|
+
const input = `${declared}|${inherited}`;
|
|
206
|
+
if (input === resolvedColorInput) {
|
|
207
|
+
return resolvedRgb;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resolvedColorInput = input;
|
|
211
|
+
const normalized = normalizeColor(context, declared) ?? normalizeColor(context, inherited);
|
|
212
|
+
resolvedRgb = (normalized === null ? null : parseCssColor(normalized)) ?? FALLBACK_PARTICLE_RGB;
|
|
213
|
+
return resolvedRgb;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const draw = (time: number): void => {
|
|
217
|
+
if (canvas === null) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const context = canvas.getContext('2d');
|
|
221
|
+
if (context === null) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const delta = previousTime === null ? 16 : Math.min(64, time - previousTime);
|
|
226
|
+
previousTime = time;
|
|
227
|
+
if (variant === 'listening') {
|
|
228
|
+
listeningElapsedMs += delta;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const level = audioEnvelope(delta);
|
|
232
|
+
const reportable = level ?? 0;
|
|
233
|
+
if (Math.abs(reportable - reportedLevel) >= LEVEL_REPORT_EPSILON) {
|
|
234
|
+
reportedLevel = reportable;
|
|
235
|
+
onlevel?.(reportable);
|
|
236
|
+
}
|
|
237
|
+
const speed = resolveMotionSpeed(
|
|
238
|
+
speedMultiplier,
|
|
239
|
+
variant,
|
|
240
|
+
listeningElapsedMs,
|
|
241
|
+
level,
|
|
242
|
+
reducedMotion
|
|
243
|
+
);
|
|
244
|
+
const normalizedDelta = delta / 16;
|
|
245
|
+
orb.angleX += BASE_ROTATION_X * speed * normalizedDelta;
|
|
246
|
+
orb.angleY += BASE_ROTATION_Y * speed * normalizedDelta;
|
|
247
|
+
|
|
248
|
+
const rgb = particleRgb(context);
|
|
249
|
+
|
|
250
|
+
const centreX = orb.width / 2;
|
|
251
|
+
const centreY = orb.height / 2;
|
|
252
|
+
const scale = resolveMotionScale(variant, listeningElapsedMs, level, reducedMotion);
|
|
253
|
+
|
|
254
|
+
context.clearRect(0, 0, orb.width, orb.height);
|
|
255
|
+
context.save();
|
|
256
|
+
context.translate(centreX, centreY);
|
|
257
|
+
context.scale(scale, scale);
|
|
258
|
+
context.translate(-centreX, -centreY);
|
|
259
|
+
|
|
260
|
+
for (const particle of particles) {
|
|
261
|
+
const rotated = rotateOrbPoint(
|
|
262
|
+
{ x: particle.baseX, y: particle.baseY, z: particle.baseZ },
|
|
263
|
+
orb.angleX,
|
|
264
|
+
orb.angleY
|
|
265
|
+
);
|
|
266
|
+
const depth = orbDepthScale(rotated.z, FOCAL_LENGTH);
|
|
267
|
+
let x = centreX + rotated.x * depth;
|
|
268
|
+
let y = centreY + rotated.y * depth;
|
|
269
|
+
|
|
270
|
+
if (interactive && pointer.x !== null && pointer.y !== null) {
|
|
271
|
+
const dx = pointer.x - x;
|
|
272
|
+
const dy = pointer.y - y;
|
|
273
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
274
|
+
if (distance < repelRadius && distance > 0) {
|
|
275
|
+
const force = (repelRadius - distance) / repelRadius;
|
|
276
|
+
x -= (dx / distance) * force * 20;
|
|
277
|
+
y -= (dy / distance) * force * 20;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const gradient = context.createLinearGradient(centreX, centreY, x, y);
|
|
282
|
+
gradient.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`);
|
|
283
|
+
gradient.addColorStop(0.5, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.25})`);
|
|
284
|
+
gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.35})`);
|
|
285
|
+
|
|
286
|
+
context.beginPath();
|
|
287
|
+
context.moveTo(centreX, centreY);
|
|
288
|
+
context.lineTo(x, y);
|
|
289
|
+
context.strokeStyle = gradient;
|
|
290
|
+
context.lineWidth = 0.3;
|
|
291
|
+
context.stroke();
|
|
292
|
+
|
|
293
|
+
context.beginPath();
|
|
294
|
+
context.arc(x, y, particle.size * depth, 0, Math.PI * 2);
|
|
295
|
+
context.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${depth * 0.8})`;
|
|
296
|
+
context.fill();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
context.restore();
|
|
300
|
+
|
|
301
|
+
if (!announcedFirstFrame) {
|
|
302
|
+
announcedFirstFrame = true;
|
|
303
|
+
onfirstframe?.();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
frame = requestAnimationFrame(draw);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/*
|
|
310
|
+
* `matchMedia` builds a fresh `MediaQueryList` on every call and `draw` runs
|
|
311
|
+
* on every animation frame, so reading the preference inside `draw` scaled
|
|
312
|
+
* that work with frame rate. It is read once and kept current by the query's
|
|
313
|
+
* own `change` event instead; `draw` and the two motion resolvers below see
|
|
314
|
+
* one consistent value for the whole frame either way.
|
|
315
|
+
*/
|
|
316
|
+
let reducedMotion = prefersReducedMotion();
|
|
317
|
+
|
|
318
|
+
const trackPointer = (event: PointerEvent): void => {
|
|
319
|
+
if (canvas === null || !interactive) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const rect = canvas.getBoundingClientRect();
|
|
323
|
+
pointer.x = event.clientX - rect.left;
|
|
324
|
+
pointer.y = event.clientY - rect.top;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const releasePointer = (): void => {
|
|
328
|
+
pointer.x = null;
|
|
329
|
+
pointer.y = null;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
onMount(() => {
|
|
333
|
+
orb.angleX = readPersisted('angleX') ?? 0;
|
|
334
|
+
orb.angleY = readPersisted('angleY') ?? 0;
|
|
335
|
+
listeningElapsedMs = readPersisted('listeningMs') ?? 0;
|
|
336
|
+
|
|
337
|
+
resize();
|
|
338
|
+
|
|
339
|
+
const observer =
|
|
340
|
+
typeof ResizeObserver === 'function' ? new ResizeObserver(() => resize()) : null;
|
|
341
|
+
if (observer !== null && container !== null) {
|
|
342
|
+
observer.observe(container);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const motionQuery =
|
|
346
|
+
typeof window.matchMedia === 'function'
|
|
347
|
+
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
348
|
+
: null;
|
|
349
|
+
const onMotionChange = (event: MediaQueryListEvent): void => {
|
|
350
|
+
reducedMotion = event.matches;
|
|
351
|
+
};
|
|
352
|
+
if (motionQuery !== null) {
|
|
353
|
+
reducedMotion = motionQuery.matches;
|
|
354
|
+
motionQuery.addEventListener('change', onMotionChange);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
frame = requestAnimationFrame(draw);
|
|
358
|
+
|
|
359
|
+
return () => {
|
|
360
|
+
if (frame !== null) {
|
|
361
|
+
cancelAnimationFrame(frame);
|
|
362
|
+
frame = null;
|
|
363
|
+
}
|
|
364
|
+
motionQuery?.removeEventListener('change', onMotionChange);
|
|
365
|
+
observer?.disconnect();
|
|
366
|
+
writePersisted();
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
</script>
|
|
370
|
+
|
|
371
|
+
<div
|
|
372
|
+
bind:this={container}
|
|
373
|
+
class="voice-orb {classes ?? ''}"
|
|
374
|
+
data-pw={typeof testId === 'string' ? testId : null}
|
|
375
|
+
testID={typeof testId === 'string' ? testId : null}
|
|
376
|
+
>
|
|
377
|
+
<canvas
|
|
378
|
+
bind:this={canvas}
|
|
379
|
+
class="voice-orb-canvas"
|
|
380
|
+
style:height={`${Number.isFinite(height) && height > 0 ? height : 400}px`}
|
|
381
|
+
onpointermove={trackPointer}
|
|
382
|
+
onpointerleave={releasePointer}
|
|
383
|
+
onpointercancel={releasePointer}
|
|
384
|
+
aria-hidden="true"
|
|
385
|
+
></canvas>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
<style>
|
|
389
|
+
.voice-orb {
|
|
390
|
+
width: 100%;
|
|
391
|
+
overflow: visible;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.voice-orb-canvas {
|
|
395
|
+
width: 100%;
|
|
396
|
+
display: block;
|
|
397
|
+
/* Pointer repulsion needs the move events a default touch gesture would eat. */
|
|
398
|
+
touch-action: none;
|
|
399
|
+
}
|
|
400
|
+
</style>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic half of `VoiceOrb`, extracted so it can be tested.
|
|
3
|
+
*
|
|
4
|
+
* This repo's vitest suite runs no canvas — `getContext` is unimplemented in
|
|
5
|
+
* jsdom — so anything left inside the component is unreachable by a test. The
|
|
6
|
+
* geometry, the seeding and the listening envelope are all pure, so they live
|
|
7
|
+
* here and the component keeps only the drawing. Same split as
|
|
8
|
+
* `Pill/pillTone.ts` and `Table/normalizeColumns.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Rgb } from '../types';
|
|
11
|
+
import type { VoiceOrbVariant } from './properties';
|
|
12
|
+
export type OrbParticle = {
|
|
13
|
+
baseX: number;
|
|
14
|
+
baseY: number;
|
|
15
|
+
baseZ: number;
|
|
16
|
+
size: number;
|
|
17
|
+
};
|
|
18
|
+
export type OrbPoint = {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
z: number;
|
|
22
|
+
};
|
|
23
|
+
export declare const LISTENING_CYCLE_MS = 3000;
|
|
24
|
+
export declare const LISTENING_SPEED_RANGE: {
|
|
25
|
+
min: number;
|
|
26
|
+
max: number;
|
|
27
|
+
};
|
|
28
|
+
export declare const LISTENING_SCALE_RANGE: {
|
|
29
|
+
min: number;
|
|
30
|
+
max: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* FNV-1a. A string seed has to produce the same number on every mount, or the
|
|
34
|
+
* orb a consumer keyed to a session id would look different on each navigation
|
|
35
|
+
* — which is the whole reason the prop exists.
|
|
36
|
+
*/
|
|
37
|
+
export declare const hashSeed: (value: string) => number;
|
|
38
|
+
/**
|
|
39
|
+
* Turns an explicit `seed` prop into the integer `mulberry32` wants, or null
|
|
40
|
+
* when there is nothing explicit to resolve -- the caller then falls back to
|
|
41
|
+
* its own stable per-mount random value rather than treating null as zero.
|
|
42
|
+
*
|
|
43
|
+
* A numeric-looking string is parsed back into the number it names rather
|
|
44
|
+
* than hashed. Every custom-element attribute arrives as a string -- HTML has
|
|
45
|
+
* no way to write a "number" attribute -- so without this, the web
|
|
46
|
+
* component's `<sui-voice-orb seed="42">` would hash `"42"` while the Svelte
|
|
47
|
+
* `seed={42}` it is meant to be the markup spelling of used the number
|
|
48
|
+
* directly, and the same intended seed would paint two different orbs
|
|
49
|
+
* depending on which API reached it. A non-numeric string (a session id)
|
|
50
|
+
* still hashes, unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export declare const resolveExplicitSeed: (seed?: string | number) => number | null;
|
|
53
|
+
/** Mulberry32: small, fast, and reproducible from one integer. */
|
|
54
|
+
export declare const mulberry32: (initial: number) => (() => number);
|
|
55
|
+
/**
|
|
56
|
+
* Points spread evenly over a sphere.
|
|
57
|
+
*
|
|
58
|
+
* `cos(phi)` is taken uniformly in [-1, 1] rather than `phi` itself: sampling
|
|
59
|
+
* the angle uniformly packs points towards the poles, because the rings of
|
|
60
|
+
* constant `phi` near a pole have far less circumference than those at the
|
|
61
|
+
* equator. The visible symptom is two dense caps and a sparse middle.
|
|
62
|
+
*/
|
|
63
|
+
export declare const generateOrbParticles: (count: number, radius: number, random: () => number) => readonly OrbParticle[];
|
|
64
|
+
/** Rotates about X then Y, which is the order the projection assumes. */
|
|
65
|
+
export declare const rotateOrbPoint: (point: OrbPoint, angleX: number, angleY: number) => OrbPoint;
|
|
66
|
+
/**
|
|
67
|
+
* Perspective depth for a point: 1 at the sphere's centre plane, larger as it
|
|
68
|
+
* comes towards the viewer. Used for both scale and opacity, so a particle
|
|
69
|
+
* behind the sphere reads as further away.
|
|
70
|
+
*/
|
|
71
|
+
export declare const orbDepthScale: (z: number, focalLength: number) => number;
|
|
72
|
+
/** The 0..1 breathing envelope, one full cycle per `LISTENING_CYCLE_MS`. */
|
|
73
|
+
export declare const listeningWave: (elapsedMs: number) => number;
|
|
74
|
+
export declare const listeningSpeed: (elapsedMs: number, speedMultiplier: number) => number;
|
|
75
|
+
export declare const listeningScale: (elapsedMs: number) => number;
|
|
76
|
+
/**
|
|
77
|
+
* Parses the two colour shapes a browser hands back: `getComputedStyle().color`
|
|
78
|
+
* is always `rgb()`/`rgba()`, and a canvas `fillStyle` read back after a write
|
|
79
|
+
* is always `#rrggbb` (or `rgba()` when it carries alpha). Between them those
|
|
80
|
+
* cover every CSS colour syntax, because the canvas does the hard parsing --
|
|
81
|
+
* `oklch()`, `color(display-p3 ...)` and named colours all normalise through it.
|
|
82
|
+
*
|
|
83
|
+
* The library's own `hexToRgb` handles only hex, which is why this exists
|
|
84
|
+
* alongside it rather than replacing it.
|
|
85
|
+
*/
|
|
86
|
+
export declare const parseCssColor: (value: string) => Rgb | null;
|
|
87
|
+
/** Scale at silence and at full level. Capped at 1 so a shout cannot clip the sphere. */
|
|
88
|
+
export declare const AUDIO_SCALE_RANGE: {
|
|
89
|
+
min: number;
|
|
90
|
+
max: number;
|
|
91
|
+
};
|
|
92
|
+
/** Rotation multiplier at silence and at full level, before `speedMultiplier`. */
|
|
93
|
+
export declare const AUDIO_SPEED_RANGE: {
|
|
94
|
+
min: number;
|
|
95
|
+
max: number;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* How far the level must move before `onlevel` fires again.
|
|
99
|
+
*
|
|
100
|
+
* Without a threshold the callback runs every frame forever, including through
|
|
101
|
+
* silence, and a consumer who sets component state from it re-renders 60 times
|
|
102
|
+
* a second for no reason. A hundredth of the range is below anything a meter
|
|
103
|
+
* or a caption can show.
|
|
104
|
+
*/
|
|
105
|
+
export declare const LEVEL_REPORT_EPSILON = 0.01;
|
|
106
|
+
/** Envelope-follower time constants. Fast onset, unhurried decay. */
|
|
107
|
+
export declare const AUDIO_ATTACK_MS = 60;
|
|
108
|
+
export declare const AUDIO_RELEASE_MS = 320;
|
|
109
|
+
/**
|
|
110
|
+
* RMS of one buffer of byte time-domain data, as 0..1.
|
|
111
|
+
*
|
|
112
|
+
* `getByteTimeDomainData` centres silence on 128, so each sample is an
|
|
113
|
+
* excursion from that midpoint rather than a magnitude. Treating the raw byte
|
|
114
|
+
* as amplitude reads silence as roughly full scale.
|
|
115
|
+
*/
|
|
116
|
+
export declare const rmsFromTimeDomain: (samples: Uint8Array) => number;
|
|
117
|
+
/**
|
|
118
|
+
* One-pole envelope follower, stepped by wall-clock rather than by frame, so
|
|
119
|
+
* the response is the same on a 60Hz and a 120Hz display.
|
|
120
|
+
*
|
|
121
|
+
* Separate attack and release constants are the point: speech is a burst
|
|
122
|
+
* followed by a gap, and a symmetric filter either chatters through every
|
|
123
|
+
* syllable or lags the onset that makes the orb feel connected to the voice.
|
|
124
|
+
*/
|
|
125
|
+
export declare const followEnvelope: (current: number, target: number, deltaMs: number, attackMs: number, releaseMs: number) => number;
|
|
126
|
+
export declare const audioScale: (level: number) => number;
|
|
127
|
+
export declare const audioSpeed: (level: number, speedMultiplier: number) => number;
|
|
128
|
+
/** Effective rotation speed for one frame. See the reduced-motion note above. */
|
|
129
|
+
export declare const resolveMotionSpeed: (speedMultiplier: number, variant: VoiceOrbVariant, listeningElapsedMs: number, level: number | null, reducedMotion: boolean) => number;
|
|
130
|
+
/** Effective breathing scale for one frame. Same split as `resolveMotionSpeed`. */
|
|
131
|
+
export declare const resolveMotionScale: (variant: VoiceOrbVariant, listeningElapsedMs: number, level: number | null, reducedMotion: boolean) => number;
|