@seams/voiceid 0.0.0-stage → 0.24.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/LICENSE.md +201 -0
- package/README.md +74 -2
- package/THIRD_PARTY_NOTICES.md +504 -0
- package/dist/compose-host.mjs +11 -0
- package/dist/esm/browser/ribbon.js +489 -0
- package/dist/esm/capture/prompt.js +515 -0
- package/dist/esm/capture/visual.js +87 -0
- package/dist/esm/descriptor.js +7 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/react/VoiceAssistantCard.css +313 -0
- package/dist/esm/react/VoiceAssistantCard.js +147 -0
- package/dist/esm/react.js +3 -0
- package/dist/hosting.d.mts +4 -0
- package/dist/hosting.mjs +91 -0
- package/dist/public/sdk/voiceid/AURORA_LICENSE.txt +202 -0
- package/dist/public/sdk/voiceid/NOTICE.txt +54 -0
- package/dist/public/sdk/voiceid/SILERO_LICENSE.txt +21 -0
- package/dist/public/sdk/voiceid/addVoiceId-CeBXVHqs.js +1 -0
- package/dist/public/sdk/voiceid/approval-CCbdLhTv.js +1 -0
- package/dist/public/sdk/voiceid/aurora-int8.onnx +0 -0
- package/dist/public/sdk/voiceid/deviceBridge-wRAbqdap.js +1 -0
- package/dist/public/sdk/voiceid/enrollment-DIRoCkx5.js +1 -0
- package/dist/public/sdk/voiceid/host.js +80 -0
- package/dist/public/sdk/voiceid/ort-wasm-simd-threaded.mjs +59 -0
- package/dist/public/sdk/voiceid/ort-wasm-simd-threaded.wasm +0 -0
- package/dist/public/sdk/voiceid/ort.wasm.bundle.min-7y-hlRN8.js +3 -0
- package/dist/public/sdk/voiceid/prompt-CCrXTRRx.js +606 -0
- package/dist/public/sdk/voiceid/redimnet-b1.onnx +0 -0
- package/dist/public/sdk/voiceid/silero-vad.onnx +0 -0
- package/dist/public/sdk/voiceid/unlock-E7YHqBGT.js +1 -0
- package/dist/public/sdk/voiceid/voice-activity-worklet.js +21 -0
- package/dist/types/activation/addVoiceId.d.ts +11 -0
- package/dist/types/activation/manageEnrollment.d.ts +4 -0
- package/dist/types/activation/ownerConfirmation.d.ts +17 -0
- package/dist/types/browser/deviceStore.d.ts +19 -0
- package/dist/types/browser/enrollment.d.ts +37 -0
- package/dist/types/browser/enrollmentStore.d.ts +91 -0
- package/dist/types/browser/inference.d.ts +39 -0
- package/dist/types/browser/microphone.d.ts +61 -0
- package/dist/types/browser/model.d.ts +8 -0
- package/dist/types/browser/phrase.d.ts +35 -0
- package/dist/types/browser/phraseVocabulary.d.ts +3 -0
- package/dist/types/browser/progress.d.ts +2 -0
- package/dist/types/browser/ribbon.d.ts +63 -0
- package/dist/types/browser/verificationError.d.ts +6 -0
- package/dist/types/browser/voiceActivity.d.ts +15 -0
- package/dist/types/capture/approval.d.ts +36 -0
- package/dist/types/capture/bridge.d.ts +12 -0
- package/dist/types/capture/deviceBridge.d.ts +29 -0
- package/dist/types/capture/prompt.d.ts +41 -0
- package/dist/types/capture/surface.d.ts +27 -0
- package/dist/types/capture/unlockFrame.d.ts +13 -0
- package/dist/types/capture/visual.d.ts +24 -0
- package/dist/types/descriptor.d.ts +4 -0
- package/dist/types/host.d.ts +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/react/VoiceAssistantCard.d.ts +37 -0
- package/dist/types/react.d.ts +5 -0
- package/dist/types/session/activation.d.ts +61 -0
- package/dist/types/session/activationStore.d.ts +21 -0
- package/dist/types/session/httpClient.d.ts +39 -0
- package/dist/types/session/unlock.d.ts +4 -0
- package/dist/types/session/unlockFromDevice.d.ts +18 -0
- package/package.json +66 -4
- package/src/README.md +270 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
//#region src/browser/ribbon.ts
|
|
2
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
3
|
+
const WIDTH = 308;
|
|
4
|
+
const MID = 32;
|
|
5
|
+
const CENTRE = WIDTH / 2;
|
|
6
|
+
const TAU = Math.PI * 2;
|
|
7
|
+
const RING_RADIUS = 21.5;
|
|
8
|
+
const RING_ARC = .56;
|
|
9
|
+
const TURN_MS = 1800;
|
|
10
|
+
const ARC_CYCLE_MS = 1500;
|
|
11
|
+
const REDUCED_TURN_MS = 3200;
|
|
12
|
+
const REDUCED_ARC_CYCLE_MS = 2600;
|
|
13
|
+
const IDLE_AMPLITUDE = .085;
|
|
14
|
+
const IDLE_PERIOD_MS = 2600;
|
|
15
|
+
const CAPTURE_MS = 560;
|
|
16
|
+
const MIDDLE_FADE_MS = 180;
|
|
17
|
+
const BEND_WIDTH = Math.sqrt(12) * RING_RADIUS;
|
|
18
|
+
const BEND_START = CENTRE - BEND_WIDTH;
|
|
19
|
+
const BEND = tabulateBend();
|
|
20
|
+
const RING_ENTRY = BEND_START + BEND.length;
|
|
21
|
+
const HALF_ARC = RING_ARC * TAU * RING_RADIUS / 2;
|
|
22
|
+
const JOIN_MS = 360;
|
|
23
|
+
const JOIN_LEAD_SHARE = .75;
|
|
24
|
+
const SPLIT_FLATTEN_MS = 200;
|
|
25
|
+
const SPLIT_OPEN_MS = 200;
|
|
26
|
+
const SEGMENT_GAP = 8;
|
|
27
|
+
const PULSE_MS = 900;
|
|
28
|
+
const PULSE_STAGGER_MS = 150;
|
|
29
|
+
const PULSE_FLOOR = .35;
|
|
30
|
+
const LINE_REST_OPACITY = .55;
|
|
31
|
+
const SUCCESS_MS = 1140;
|
|
32
|
+
const SEAL_RADIUS = 22.5;
|
|
33
|
+
const CHECK_PATH = "M161 26.8 151.4 36.3 147.1 32";
|
|
34
|
+
const easeMove = cubicBezier(.65, 0, .35, 1);
|
|
35
|
+
const easeArc = cubicBezier(.4, 0, .2, 1);
|
|
36
|
+
const easeSeal = cubicBezier(.34, 1.56, .64, 1);
|
|
37
|
+
const easeOut = cubicBezier(.2, 0, 0, 1);
|
|
38
|
+
/** Visual feedback from recorder activity. Never opens or retains an audio stream. */
|
|
39
|
+
var VoiceRibbon = class {
|
|
40
|
+
element;
|
|
41
|
+
lines = [];
|
|
42
|
+
ripple;
|
|
43
|
+
track;
|
|
44
|
+
middle;
|
|
45
|
+
top;
|
|
46
|
+
bottom;
|
|
47
|
+
seal;
|
|
48
|
+
check;
|
|
49
|
+
motion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
50
|
+
phase = "idle";
|
|
51
|
+
stage = { kind: "line" };
|
|
52
|
+
settleSuccess = null;
|
|
53
|
+
successBegun = null;
|
|
54
|
+
frame = 0;
|
|
55
|
+
previousTime = 0;
|
|
56
|
+
position = 0;
|
|
57
|
+
target = 0;
|
|
58
|
+
level = 0;
|
|
59
|
+
stopped = false;
|
|
60
|
+
constructor(document, checking = "ring") {
|
|
61
|
+
this.checking = checking;
|
|
62
|
+
this.element = document.createElementNS(SVG_NAMESPACE, "svg");
|
|
63
|
+
this.element.classList.add("seams-voiceid-ribbon");
|
|
64
|
+
this.element.setAttribute("viewBox", `0 0 ${WIDTH} 64`);
|
|
65
|
+
this.element.setAttribute("aria-hidden", "true");
|
|
66
|
+
this.ripple = this.append(document, "circle", "1.5");
|
|
67
|
+
this.track = this.append(document, "circle", "2");
|
|
68
|
+
this.middle = this.append(document, "path", "1");
|
|
69
|
+
this.top = this.append(document, "path", "2");
|
|
70
|
+
this.bottom = this.append(document, "path", "2");
|
|
71
|
+
this.seal = this.append(document, "circle", "0");
|
|
72
|
+
this.seal.setAttribute("fill", "currentColor");
|
|
73
|
+
this.seal.setAttribute("stroke", "none");
|
|
74
|
+
this.check = this.append(document, "path", "2.6");
|
|
75
|
+
this.check.classList.add("seams-voiceid-ribbon-check");
|
|
76
|
+
this.check.setAttribute("d", CHECK_PATH);
|
|
77
|
+
this.check.setAttribute("stroke-linejoin", "round");
|
|
78
|
+
this.check.setAttribute("pathLength", "1");
|
|
79
|
+
this.check.setAttribute("stroke-dasharray", "1");
|
|
80
|
+
for (const ring of [
|
|
81
|
+
this.ripple,
|
|
82
|
+
this.track,
|
|
83
|
+
this.seal
|
|
84
|
+
]) {
|
|
85
|
+
ring.setAttribute("cx", String(CENTRE));
|
|
86
|
+
ring.setAttribute("cy", String(MID));
|
|
87
|
+
}
|
|
88
|
+
this.track.setAttribute("r", String(RING_RADIUS));
|
|
89
|
+
if (checking === "segments") {
|
|
90
|
+
this.element.dataset.seamsProgressLine = "";
|
|
91
|
+
for (const [index, segment] of [
|
|
92
|
+
this.top,
|
|
93
|
+
this.middle,
|
|
94
|
+
this.bottom
|
|
95
|
+
].entries()) segment.dataset.seamsProgressSegment = String(index);
|
|
96
|
+
}
|
|
97
|
+
for (let layer = 3; layer >= 0; layer -= 1) this.lines.push(this.append(document, "path", layer === 0 ? "1" : "0.65"));
|
|
98
|
+
this.paint(performance.now());
|
|
99
|
+
}
|
|
100
|
+
readLevel = () => this.level;
|
|
101
|
+
setPhase(phase) {
|
|
102
|
+
if (phase === "processing" && this.stage.kind === "line") {
|
|
103
|
+
const amplitude = Math.max(this.level, this.idleAmplitude(performance.now()));
|
|
104
|
+
this.stage = {
|
|
105
|
+
kind: this.checking === "segments" ? "segments" : "capture",
|
|
106
|
+
startedAt: performance.now(),
|
|
107
|
+
amplitude
|
|
108
|
+
};
|
|
109
|
+
} else if (phase !== "processing") {
|
|
110
|
+
this.stage = { kind: "line" };
|
|
111
|
+
this.settle();
|
|
112
|
+
}
|
|
113
|
+
this.phase = phase;
|
|
114
|
+
this.target = 0;
|
|
115
|
+
this.element.dataset.phase = phase;
|
|
116
|
+
this.wake();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Closes the ring into the success seal; resolves when the check is drawn.
|
|
120
|
+
* `begin` runs once the formed ring starts to close, after any capture in flight.
|
|
121
|
+
*/
|
|
122
|
+
succeed(begin) {
|
|
123
|
+
if (this.stopped || this.stage.kind === "sealed") return Promise.resolve();
|
|
124
|
+
if (this.stage.kind === "line") this.setPhase("processing");
|
|
125
|
+
const settled = new Promise(this.awaitSuccess);
|
|
126
|
+
this.successBegun = begin;
|
|
127
|
+
if (this.stage.kind === "spin") this.beginSuccess(performance.now());
|
|
128
|
+
this.wake();
|
|
129
|
+
return settled;
|
|
130
|
+
}
|
|
131
|
+
add(event) {
|
|
132
|
+
if (this.phase !== "listening" || this.stopped) return;
|
|
133
|
+
const signal = Math.max(0, event.rms - .003);
|
|
134
|
+
this.target = Math.pow(signal / (signal + .055), .65) * (event.speech ? 1 : .15);
|
|
135
|
+
this.element.dataset.speech = String(event.speech);
|
|
136
|
+
this.wake();
|
|
137
|
+
}
|
|
138
|
+
dispose() {
|
|
139
|
+
this.stopped = true;
|
|
140
|
+
cancelAnimationFrame(this.frame);
|
|
141
|
+
this.frame = 0;
|
|
142
|
+
this.level = 0;
|
|
143
|
+
this.target = 0;
|
|
144
|
+
this.settle();
|
|
145
|
+
}
|
|
146
|
+
awaitSuccess = (resolve) => {
|
|
147
|
+
this.settle();
|
|
148
|
+
this.settleSuccess = resolve;
|
|
149
|
+
};
|
|
150
|
+
settle() {
|
|
151
|
+
const settle = this.settleSuccess;
|
|
152
|
+
this.settleSuccess = null;
|
|
153
|
+
this.successBegun = null;
|
|
154
|
+
settle?.();
|
|
155
|
+
}
|
|
156
|
+
append(document, tag, strokeWidth) {
|
|
157
|
+
const element = document.createElementNS(SVG_NAMESPACE, tag);
|
|
158
|
+
element.setAttribute("fill", "none");
|
|
159
|
+
element.setAttribute("stroke", "currentColor");
|
|
160
|
+
element.setAttribute("stroke-width", strokeWidth);
|
|
161
|
+
element.setAttribute("stroke-linecap", "round");
|
|
162
|
+
element.setAttribute("vector-effect", "non-scaling-stroke");
|
|
163
|
+
element.setAttribute("opacity", "0");
|
|
164
|
+
this.element.append(element);
|
|
165
|
+
return element;
|
|
166
|
+
}
|
|
167
|
+
wake() {
|
|
168
|
+
if (!this.stopped && this.frame === 0) {
|
|
169
|
+
this.previousTime = performance.now();
|
|
170
|
+
this.frame = requestAnimationFrame(this.draw);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
draw = (time) => {
|
|
174
|
+
this.frame = 0;
|
|
175
|
+
if (this.stopped) return;
|
|
176
|
+
const dt = Math.min(50, Math.max(0, time - this.previousTime));
|
|
177
|
+
this.previousTime = time;
|
|
178
|
+
this.level += (this.target - this.level) * (1 - Math.exp(-dt / (this.target > this.level ? 55 : 200)));
|
|
179
|
+
if (!this.motion.matches) this.position += dt * (.0012 + this.level * .003);
|
|
180
|
+
this.paint(time);
|
|
181
|
+
if (!(!this.element.isConnected || this.stage.kind === "sealed" || this.stage.kind === "line" && this.phase === "idle" && this.level <= .001)) this.frame = requestAnimationFrame(this.draw);
|
|
182
|
+
};
|
|
183
|
+
idleAmplitude(time) {
|
|
184
|
+
if (this.phase !== "listening" || this.motion.matches) return 0;
|
|
185
|
+
return IDLE_AMPLITUDE * (.72 + .28 * Math.sin(time / IDLE_PERIOD_MS * TAU));
|
|
186
|
+
}
|
|
187
|
+
paint(time) {
|
|
188
|
+
for (const part of [
|
|
189
|
+
this.ripple,
|
|
190
|
+
this.track,
|
|
191
|
+
this.middle,
|
|
192
|
+
this.top,
|
|
193
|
+
this.bottom,
|
|
194
|
+
this.seal,
|
|
195
|
+
this.check
|
|
196
|
+
]) part.setAttribute("opacity", "0");
|
|
197
|
+
for (const line of this.lines) line.setAttribute("opacity", "0");
|
|
198
|
+
switch (this.stage.kind) {
|
|
199
|
+
case "line":
|
|
200
|
+
this.paintLine(Math.max(this.level, this.idleAmplitude(time)));
|
|
201
|
+
return;
|
|
202
|
+
case "capture":
|
|
203
|
+
this.paintCapture(time, this.stage);
|
|
204
|
+
return;
|
|
205
|
+
case "segments":
|
|
206
|
+
this.paintSegments(time, this.stage);
|
|
207
|
+
return;
|
|
208
|
+
case "spin":
|
|
209
|
+
this.paintArc(this.spinnerArc(time, this.stage), 1);
|
|
210
|
+
return;
|
|
211
|
+
case "success":
|
|
212
|
+
this.paintSuccess(time, this.stage);
|
|
213
|
+
return;
|
|
214
|
+
case "sealed":
|
|
215
|
+
this.paintSeal({
|
|
216
|
+
start: 0,
|
|
217
|
+
sweep: TAU
|
|
218
|
+
}, 1, 1);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
paintLine(amplitude) {
|
|
223
|
+
for (let index = 0; index < this.lines.length; index += 1) {
|
|
224
|
+
const layer = 3 - index;
|
|
225
|
+
this.lines[index].setAttribute("d", this.linePath(layer, amplitude, 0, WIDTH, 1));
|
|
226
|
+
this.lines[index].setAttribute("opacity", String(layer === 0 ? LINE_REST_OPACITY + amplitude * .4 : .1 + amplitude * .1));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
paintSegments(time, stage) {
|
|
230
|
+
const elapsed = time - stage.startedAt;
|
|
231
|
+
if (!this.motion.matches && elapsed < SPLIT_FLATTEN_MS) {
|
|
232
|
+
this.paintLine(stage.amplitude * (1 - easeOut(elapsed / SPLIT_FLATTEN_MS)));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const split = elapsed - SPLIT_FLATTEN_MS;
|
|
236
|
+
const gap = SEGMENT_GAP * (this.motion.matches ? 1 : easeOut(clamp01(split / SPLIT_OPEN_MS)));
|
|
237
|
+
const width = (WIDTH - 2 * gap) / 3;
|
|
238
|
+
for (const [index, segment] of [
|
|
239
|
+
this.top,
|
|
240
|
+
this.middle,
|
|
241
|
+
this.bottom
|
|
242
|
+
].entries()) {
|
|
243
|
+
const start = index * (width + gap);
|
|
244
|
+
segment.setAttribute("d", `M${start.toFixed(2)} ${MID}H${(start + width).toFixed(2)}`);
|
|
245
|
+
segment.setAttribute("stroke-width", "1");
|
|
246
|
+
segment.setAttribute("opacity", this.segmentOpacity(split, index));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Each segment brightens one beat after the one before it, from the resting line's opacity. */
|
|
250
|
+
segmentOpacity(split, index) {
|
|
251
|
+
if (this.motion.matches) return "0.8";
|
|
252
|
+
const beat = .5 + .5 * Math.cos(TAU * (split - index * PULSE_STAGGER_MS) / PULSE_MS);
|
|
253
|
+
return lerp(LINE_REST_OPACITY, PULSE_FLOOR + (1 - PULSE_FLOOR) * beat, easeOut(clamp01(split / SPLIT_OPEN_MS))).toFixed(3);
|
|
254
|
+
}
|
|
255
|
+
paintCapture(time, stage) {
|
|
256
|
+
const elapsed = this.motion.matches ? CAPTURE_MS + JOIN_MS : time - stage.startedAt;
|
|
257
|
+
if (elapsed >= CAPTURE_MS) {
|
|
258
|
+
this.paintJoin(time, Math.min(elapsed - CAPTURE_MS, JOIN_MS));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const amplitude = stage.amplitude;
|
|
262
|
+
const progress = elapsed / CAPTURE_MS;
|
|
263
|
+
const waveScale = 1 - easeOut(clamp01(elapsed / 150));
|
|
264
|
+
const settled = easeMove(clamp01(progress / .6));
|
|
265
|
+
const exitSpeed = turnSpeed(false) * RING_RADIUS * CAPTURE_MS;
|
|
266
|
+
const tail = hermite(progress, RING_ENTRY, exitSpeed);
|
|
267
|
+
const head = BEND_START + hermite(progress, RING_ENTRY + HALF_ARC - BEND_START, exitSpeed);
|
|
268
|
+
const lineOpacity = LINE_REST_OPACITY + amplitude * .4;
|
|
269
|
+
for (const [half, turned] of [[this.top, false], [this.bottom, true]]) {
|
|
270
|
+
half.setAttribute("d", this.halfPath(tail, head, turned, amplitude, waveScale));
|
|
271
|
+
half.setAttribute("stroke-width", lerp(1, 2, settled).toFixed(2));
|
|
272
|
+
half.setAttribute("opacity", lerp(lineOpacity, 1, settled).toFixed(3));
|
|
273
|
+
}
|
|
274
|
+
this.middle.setAttribute("d", this.linePath(0, amplitude, BEND_START, WIDTH - BEND_START, waveScale));
|
|
275
|
+
this.middle.setAttribute("opacity", (lineOpacity * (1 - easeOut(clamp01(elapsed / MIDDLE_FADE_MS)))).toFixed(3));
|
|
276
|
+
for (let index = 0; index < 3; index += 1) {
|
|
277
|
+
const layer = 3 - index;
|
|
278
|
+
this.lines[index].setAttribute("d", this.linePath(layer, amplitude, 0, WIDTH, waveScale));
|
|
279
|
+
this.lines[index].setAttribute("opacity", ((.1 + amplitude * .1) * (1 - clamp01(elapsed / 200))).toFixed(3));
|
|
280
|
+
}
|
|
281
|
+
this.track.setAttribute("opacity", (.1 * clamp01((progress - .4) / .6)).toFixed(3));
|
|
282
|
+
}
|
|
283
|
+
paintJoin(time, elapsed) {
|
|
284
|
+
const joined = (1 - Math.cos(Math.PI * elapsed / JOIN_MS)) / 2;
|
|
285
|
+
const base = -Math.PI / 2 + turnSpeed(false) * elapsed;
|
|
286
|
+
const sweep = HALF_ARC / RING_RADIUS;
|
|
287
|
+
const gap = Math.PI - sweep;
|
|
288
|
+
const lead = this.motion.matches ? -Math.PI / 2 : base + JOIN_LEAD_SHARE * gap * joined;
|
|
289
|
+
if (this.motion.matches || elapsed >= JOIN_MS) {
|
|
290
|
+
this.stage = {
|
|
291
|
+
kind: "spin",
|
|
292
|
+
startedAt: time,
|
|
293
|
+
base: lead - TAU * .14
|
|
294
|
+
};
|
|
295
|
+
if (this.settleSuccess) this.beginSuccess(time);
|
|
296
|
+
this.paint(time);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.track.setAttribute("opacity", "0.1");
|
|
300
|
+
for (const [half, start] of [[this.top, lead], [this.bottom, base + Math.PI - (1 - JOIN_LEAD_SHARE) * gap * joined]]) {
|
|
301
|
+
half.setAttribute("d", arcPath({
|
|
302
|
+
start,
|
|
303
|
+
sweep
|
|
304
|
+
}));
|
|
305
|
+
half.setAttribute("stroke-width", "2");
|
|
306
|
+
half.setAttribute("opacity", "1");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** The auth menu ring's arc: it grows and shrinks on its own clock while the ring turns. */
|
|
310
|
+
spinnerArc(time, stage) {
|
|
311
|
+
const elapsed = time - stage.startedAt;
|
|
312
|
+
const cycle = this.motion.matches ? REDUCED_ARC_CYCLE_MS : ARC_CYCLE_MS;
|
|
313
|
+
const phase = (elapsed + cycle / 2) % cycle / cycle;
|
|
314
|
+
const growing = phase < .5;
|
|
315
|
+
const eased = easeArc(growing ? phase / .5 : (phase - .5) / .5);
|
|
316
|
+
const dash = growing ? lerp(1, 56, eased) : lerp(56, 1, eased);
|
|
317
|
+
const offset = growing ? lerp(0, -14, eased) : lerp(-14, -99, eased);
|
|
318
|
+
return {
|
|
319
|
+
start: stage.base + turnSpeed(this.motion.matches) * elapsed - TAU * offset / 100,
|
|
320
|
+
sweep: TAU * dash / 100
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
beginSuccess(time) {
|
|
324
|
+
if (this.stage.kind !== "spin") return;
|
|
325
|
+
this.stage = {
|
|
326
|
+
kind: "success",
|
|
327
|
+
startedAt: time,
|
|
328
|
+
from: this.spinnerArc(time, this.stage)
|
|
329
|
+
};
|
|
330
|
+
const begun = this.successBegun;
|
|
331
|
+
this.successBegun = null;
|
|
332
|
+
begun?.();
|
|
333
|
+
}
|
|
334
|
+
paintSuccess(time, stage) {
|
|
335
|
+
const elapsed = time - stage.startedAt;
|
|
336
|
+
if (elapsed >= SUCCESS_MS) {
|
|
337
|
+
this.stage = { kind: "sealed" };
|
|
338
|
+
this.settle();
|
|
339
|
+
this.paint(time);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const turned = clamp01(elapsed / 650);
|
|
343
|
+
const spin = this.motion.matches ? 0 : turnSpeed(false) * 650 * (turned - turned * turned / 2);
|
|
344
|
+
const closed = easeMove(clamp01((elapsed - 150) / 500));
|
|
345
|
+
this.paintSeal({
|
|
346
|
+
start: stage.from.start + spin,
|
|
347
|
+
sweep: lerp(stage.from.sweep, TAU, closed)
|
|
348
|
+
}, easeSeal(clamp01((elapsed - 600) / 420)), easeMove(clamp01((elapsed - 820) / 320)));
|
|
349
|
+
const ripple = clamp01((elapsed - 700) / 700);
|
|
350
|
+
if (!this.motion.matches && ripple > 0 && ripple < 1) {
|
|
351
|
+
const spread = easeOut(ripple);
|
|
352
|
+
this.ripple.setAttribute("r", lerp(SEAL_RADIUS, 34, spread).toFixed(2));
|
|
353
|
+
this.ripple.setAttribute("opacity", ((1 - spread) * .45).toFixed(3));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
paintSeal(arc, seal, check) {
|
|
357
|
+
this.paintArc(arc, 1);
|
|
358
|
+
this.seal.setAttribute("r", (SEAL_RADIUS * Math.max(0, seal)).toFixed(2));
|
|
359
|
+
this.seal.setAttribute("opacity", seal > 0 ? "1" : "0");
|
|
360
|
+
this.check.setAttribute("stroke-dashoffset", (1 - check).toFixed(3));
|
|
361
|
+
this.check.setAttribute("opacity", check > 0 ? "1" : "0");
|
|
362
|
+
}
|
|
363
|
+
paintArc(arc, trackOpacity) {
|
|
364
|
+
this.track.setAttribute("opacity", String(.1 * trackOpacity));
|
|
365
|
+
this.top.setAttribute("d", arcPath(arc));
|
|
366
|
+
this.top.setAttribute("stroke-width", "2");
|
|
367
|
+
this.top.setAttribute("opacity", "1");
|
|
368
|
+
}
|
|
369
|
+
displacement(layer, amplitude, x) {
|
|
370
|
+
const u = x / WIDTH;
|
|
371
|
+
const taper = Math.pow(Math.sin(Math.PI * u), 1.7);
|
|
372
|
+
if (this.motion.matches) return amplitude * 5 * taper;
|
|
373
|
+
const wave = Math.sin(u * Math.PI * 4 - this.position + layer * .5) * .7 + Math.sin(u * Math.PI * 7 + this.position * .7 + layer) * .3;
|
|
374
|
+
return amplitude * (26 - layer * 4) * taper * wave;
|
|
375
|
+
}
|
|
376
|
+
linePath(layer, amplitude, from, to, waveScale) {
|
|
377
|
+
let path = "";
|
|
378
|
+
const steps = Math.max(2, Math.ceil((to - from) / 2));
|
|
379
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
380
|
+
const x = from + (to - from) * step / steps;
|
|
381
|
+
const y = MID + this.displacement(layer, amplitude, x) * waveScale;
|
|
382
|
+
path += `${step === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)} `;
|
|
383
|
+
}
|
|
384
|
+
return path;
|
|
385
|
+
}
|
|
386
|
+
/** The stroke between two distances on a half's capture track. */
|
|
387
|
+
halfPath(from, to, turned, amplitude, waveScale) {
|
|
388
|
+
let path = "";
|
|
389
|
+
const steps = Math.max(2, Math.ceil((to - from) / 1.5));
|
|
390
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
391
|
+
const point = trackPoint(from + (to - from) * step / steps);
|
|
392
|
+
const x = turned ? WIDTH - point.x : point.x;
|
|
393
|
+
let y = turned ? 2 * MID - point.y : point.y;
|
|
394
|
+
if (point.onLine) y += this.displacement(0, amplitude, x) * waveScale;
|
|
395
|
+
path += `${step === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)} `;
|
|
396
|
+
}
|
|
397
|
+
return path;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
function turnSpeed(reduced) {
|
|
401
|
+
return TAU / (reduced ? REDUCED_TURN_MS : TURN_MS);
|
|
402
|
+
}
|
|
403
|
+
/** A point at a distance along the left half's track: line, bend, then the ring clockwise from the top. */
|
|
404
|
+
function trackPoint(distance) {
|
|
405
|
+
if (distance <= BEND_START) return {
|
|
406
|
+
x: distance,
|
|
407
|
+
y: MID,
|
|
408
|
+
onLine: true
|
|
409
|
+
};
|
|
410
|
+
if (distance < RING_ENTRY) {
|
|
411
|
+
const along = distance - BEND_START;
|
|
412
|
+
let low = 0;
|
|
413
|
+
let high = BEND.lengths.length - 1;
|
|
414
|
+
while (high - low > 1) {
|
|
415
|
+
const middle = low + high >> 1;
|
|
416
|
+
if (BEND.lengths[middle] < along) low = middle;
|
|
417
|
+
else high = middle;
|
|
418
|
+
}
|
|
419
|
+
const fraction = (along - BEND.lengths[low]) / (BEND.lengths[high] - BEND.lengths[low] || 1);
|
|
420
|
+
return {
|
|
421
|
+
x: lerp(BEND.points[low][0], BEND.points[high][0], fraction),
|
|
422
|
+
y: lerp(BEND.points[low][1], BEND.points[high][1], fraction),
|
|
423
|
+
onLine: false
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const angle = -Math.PI / 2 + (distance - RING_ENTRY) / RING_RADIUS;
|
|
427
|
+
return {
|
|
428
|
+
x: CENTRE + RING_RADIUS * Math.cos(angle),
|
|
429
|
+
y: MID + RING_RADIUS * Math.sin(angle),
|
|
430
|
+
onLine: false
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function tabulateBend() {
|
|
434
|
+
const points = [[BEND_START, MID]];
|
|
435
|
+
const lengths = [0];
|
|
436
|
+
let length = 0;
|
|
437
|
+
for (let step = 1; step <= 240; step += 1) {
|
|
438
|
+
const u = step / 240;
|
|
439
|
+
const point = [BEND_START + BEND_WIDTH * u, MID - RING_RADIUS * (4 * u ** 3 - 3 * u ** 4)];
|
|
440
|
+
const previous = points[points.length - 1];
|
|
441
|
+
length += Math.hypot(point[0] - previous[0], point[1] - previous[1]);
|
|
442
|
+
points.push(point);
|
|
443
|
+
lengths.push(length);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
points,
|
|
447
|
+
lengths,
|
|
448
|
+
length
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function arcPath(arc) {
|
|
452
|
+
const steps = Math.max(2, Math.ceil(80 * arc.sweep / TAU));
|
|
453
|
+
let path = "";
|
|
454
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
455
|
+
const angle = arc.start + arc.sweep * step / steps;
|
|
456
|
+
const x = CENTRE + RING_RADIUS * Math.cos(angle);
|
|
457
|
+
const y = MID + RING_RADIUS * Math.sin(angle);
|
|
458
|
+
path += `${step === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)} `;
|
|
459
|
+
}
|
|
460
|
+
return path;
|
|
461
|
+
}
|
|
462
|
+
/** Distance covered from rest to an exit speed (cubic Hermite in normalised time). */
|
|
463
|
+
function hermite(progress, distance, exitSpeed) {
|
|
464
|
+
const square = progress * progress;
|
|
465
|
+
return distance * (3 * square - 2 * square * progress) + exitSpeed * (square * progress - square);
|
|
466
|
+
}
|
|
467
|
+
function cubicBezier(x1, y1, x2, y2) {
|
|
468
|
+
const at = (t, p1, p2) => ((1 - 3 * p2 + 3 * p1) * t + (3 * p2 - 6 * p1)) * t * t + 3 * p1 * t;
|
|
469
|
+
const slope = (t, p1, p2) => 3 * (1 - 3 * p2 + 3 * p1) * t * t + 2 * (3 * p2 - 6 * p1) * t + 3 * p1;
|
|
470
|
+
return (x) => {
|
|
471
|
+
if (x <= 0) return 0;
|
|
472
|
+
if (x >= 1) return 1;
|
|
473
|
+
let t = x;
|
|
474
|
+
for (let iteration = 0; iteration < 6; iteration += 1) {
|
|
475
|
+
const gradient = slope(t, x1, x2);
|
|
476
|
+
if (gradient === 0) break;
|
|
477
|
+
t -= (at(t, x1, x2) - x) / gradient;
|
|
478
|
+
}
|
|
479
|
+
return at(clamp01(t), y1, y2);
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function clamp01(value) {
|
|
483
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
484
|
+
}
|
|
485
|
+
function lerp(from, to, amount) {
|
|
486
|
+
return from + (to - from) * amount;
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
export { VoiceRibbon };
|