@seams/voice-capture 0.0.0-stage → 0.66.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.
Files changed (48) hide show
  1. package/LICENSE.md +201 -0
  2. package/README.md +174 -2
  3. package/dist/assets/NOTICE.txt +3 -0
  4. package/dist/assets/SILERO_LICENSE.txt +21 -0
  5. package/dist/assets/ort-wasm-simd-threaded.mjs +59 -0
  6. package/dist/assets/ort-wasm-simd-threaded.wasm +0 -0
  7. package/dist/assets/silero-vad.onnx +0 -0
  8. package/dist/assets/voice-activity-worklet.js +21 -0
  9. package/dist/browser/card.js +800 -0
  10. package/dist/browser/chunks/microphone-CU5gQG6w.js +1 -0
  11. package/dist/browser/chunks/ort.wasm.bundle.min-CWqlff_l.js +3 -0
  12. package/dist/browser/index.js +1 -0
  13. package/dist/esm/card/index.js +6 -0
  14. package/dist/esm/card/phraseCard.js +459 -0
  15. package/dist/esm/card/prompt.js +1074 -0
  16. package/dist/esm/card/ribbon.js +489 -0
  17. package/dist/esm/card/surface.js +224 -0
  18. package/dist/esm/card/types.js +0 -0
  19. package/dist/esm/card/visual.js +2 -0
  20. package/dist/esm/chunks/visual-Di3ojTED.js +1520 -0
  21. package/dist/esm/config.js +42 -0
  22. package/dist/esm/index.js +8 -0
  23. package/dist/esm/inference.js +193 -0
  24. package/dist/esm/microphone.js +428 -0
  25. package/dist/esm/microphoneInput.js +117 -0
  26. package/dist/esm/speechPause.js +33 -0
  27. package/dist/esm/voiceActivity.js +69 -0
  28. package/dist/esm/wav.js +36 -0
  29. package/dist/example/example.js +71 -0
  30. package/dist/example/index.html +29 -0
  31. package/dist/hosting.d.mts +4 -0
  32. package/dist/hosting.mjs +26 -0
  33. package/dist/types/card/index.d.ts +6 -0
  34. package/dist/types/card/phraseCard.d.ts +72 -0
  35. package/dist/types/card/prompt.d.ts +122 -0
  36. package/dist/types/card/ribbon.d.ts +62 -0
  37. package/dist/types/card/surface.d.ts +28 -0
  38. package/dist/types/card/types.d.ts +65 -0
  39. package/dist/types/card/visual.d.ts +25 -0
  40. package/dist/types/config.d.ts +47 -0
  41. package/dist/types/index.d.ts +7 -0
  42. package/dist/types/inference.d.ts +47 -0
  43. package/dist/types/microphone.d.ts +81 -0
  44. package/dist/types/microphoneInput.d.ts +19 -0
  45. package/dist/types/speechPause.d.ts +8 -0
  46. package/dist/types/voiceActivity.d.ts +15 -0
  47. package/dist/types/wav.d.ts +9 -0
  48. package/package.json +57 -4
@@ -0,0 +1,459 @@
1
+ //#region src/card/phraseCard.ts
2
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
3
+ const WIDTH = 308;
4
+ const MID = 32;
5
+ const SEGMENTS = 6;
6
+ const GAP = 8;
7
+ const FADE_OUT_MS = 150;
8
+ const FADE_IN_MS = 300;
9
+ const FADE_IN_DELAY_MS = 60;
10
+ const ROW_STAGGER_MS = 40;
11
+ const RIBBON_MOVE_MS = 180;
12
+ const FLATTEN_MS = 200;
13
+ const OPEN_MS = 200;
14
+ const TRACK_MS = 240;
15
+ const FILL_STAGGER_MS = 70;
16
+ const BLOCKER_HOLD_MS = 240;
17
+ const DETAIL_ROTATE_MS = 1300;
18
+ const DETAIL_OUT_MS = 90;
19
+ const DETAIL_IN_MS = 160;
20
+ const GAP_CLOSE_MS = 240;
21
+ const RING_MS = 440;
22
+ const SEAL_MS = 320;
23
+ const CHECK_MS = 260;
24
+ const RING_RADIUS = 21.5;
25
+ const SEAL_RADIUS = 22.5;
26
+ const CHECK_PATH = "M161 26.8 151.4 36.3 147.1 32";
27
+ const EASE_IN_OUT = "cubic-bezier(0.2, 0, 0, 1)";
28
+ const EASE_MOVE = "cubic-bezier(0.2, 0.7, 0.2, 1)";
29
+ const EASE_RING = "cubic-bezier(0.65, 0, 0.35, 1)";
30
+ const EASE_SEAL = "cubic-bezier(0.34, 1.56, 0.64, 1)";
31
+ const PASSKEY_PATHS = [
32
+ "M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3",
33
+ "M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6",
34
+ "M12 11v2a14 14 0 0 0 2.5 8",
35
+ "M8 15a18 18 0 0 0 1.8 6",
36
+ "M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95"
37
+ ];
38
+ /** Draws the states of the phrase card. The surface owns the recording and the choices. */
39
+ var PhraseCard = class {
40
+ line;
41
+ segments = [];
42
+ bar;
43
+ ring;
44
+ seal;
45
+ check;
46
+ motion = matchMedia("(prefers-reduced-motion: reduce)");
47
+ rows;
48
+ states = [];
49
+ rendering = Promise.resolve();
50
+ morph = null;
51
+ rotation = 0;
52
+ version = 0;
53
+ disposed = false;
54
+ constructor(prompt, copy, handedOver = null) {
55
+ this.prompt = prompt;
56
+ this.copy = copy;
57
+ const document = prompt.element.ownerDocument;
58
+ this.line = document.createElementNS(SVG_NAMESPACE, "svg");
59
+ this.line.classList.add("seams-voiceid-status-line");
60
+ this.line.setAttribute("viewBox", `0 0 ${WIDTH} 64`);
61
+ this.line.setAttribute("aria-hidden", "true");
62
+ for (let index = 0; index < SEGMENTS; index += 1) {
63
+ const segment = document.createElementNS(SVG_NAMESPACE, "rect");
64
+ segment.setAttribute("rx", "1.5");
65
+ this.line.append(segment);
66
+ this.segments.push(segment);
67
+ }
68
+ this.bar = document.createElementNS(SVG_NAMESPACE, "path");
69
+ this.bar.classList.add("seams-voiceid-status-bar");
70
+ this.bar.setAttribute("d", `M1.5 ${MID}H${WIDTH - 1.5}`);
71
+ this.ring = document.createElementNS(SVG_NAMESPACE, "circle");
72
+ this.ring.classList.add("seams-voiceid-status-ring");
73
+ this.ring.setAttribute("r", String(RING_RADIUS));
74
+ this.seal = document.createElementNS(SVG_NAMESPACE, "circle");
75
+ this.seal.classList.add("seams-voiceid-status-seal");
76
+ this.seal.setAttribute("r", String(SEAL_RADIUS));
77
+ for (const circle of [this.ring, this.seal]) {
78
+ circle.setAttribute("cx", String(WIDTH / 2));
79
+ circle.setAttribute("cy", String(MID));
80
+ }
81
+ this.check = document.createElementNS(SVG_NAMESPACE, "path");
82
+ this.check.classList.add("seams-voiceid-status-check");
83
+ this.check.setAttribute("d", CHECK_PATH);
84
+ this.check.setAttribute("pathLength", "1");
85
+ this.line.append(this.bar, this.ring, this.seal, this.check);
86
+ prompt.ribbon.after(this.line);
87
+ this.rows = handedOver ? {
88
+ ...this.promptRows(),
89
+ label: handedOver.label,
90
+ caption: `handed-over:${handedOver.message}`,
91
+ action: ""
92
+ } : this.promptRows();
93
+ this.layoutSegments(0, 3);
94
+ }
95
+ /** The sentence with Record, after a check or a result. The wave comes back. */
96
+ showPrompt() {
97
+ const version = this.begin();
98
+ return this.queue(async () => {
99
+ if (this.stale(version)) return;
100
+ const element = this.prompt.element;
101
+ element.removeAttribute("aria-busy");
102
+ await this.transition(this.promptRows(), version, () => {
103
+ delete element.dataset.phraseView;
104
+ delete element.dataset.phraseLine;
105
+ this.writeCaption(this.copy.message, []);
106
+ this.writeAction({ label: this.copy.action });
107
+ this.resetLine();
108
+ this.prompt.ready(this.copy.message);
109
+ });
110
+ if (!this.stale(version)) this.prompt.record.focus();
111
+ });
112
+ }
113
+ /** The end of speech: the wave flattens and opens into segments, the first one filled. */
114
+ beginCheck() {
115
+ if (this.disposed || this.morph) return;
116
+ this.prompt.flattenLine();
117
+ this.morph = this.openSegments();
118
+ }
119
+ show(view) {
120
+ const version = this.begin();
121
+ return this.queue(async () => {
122
+ if (this.stale(version)) return;
123
+ const element = this.prompt.element;
124
+ this.prompt.flattenLine();
125
+ this.morph ??= this.openSegments();
126
+ if (view.kind === "check") {
127
+ element.setAttribute("aria-busy", "true");
128
+ const detail = view.details[0] ?? "";
129
+ const rows = {
130
+ label: view.label,
131
+ head: this.copy.sentence,
132
+ headline: false,
133
+ dim: true,
134
+ caption: `check:${view.caption}:${detail}`,
135
+ action: ""
136
+ };
137
+ await this.transition(rows, version, () => {
138
+ element.dataset.phraseView = "check";
139
+ this.writeCaption(view.caption, [detail]);
140
+ });
141
+ await this.morph;
142
+ if (this.stale(version)) return;
143
+ await this.setSegments(view.segments);
144
+ if (!this.stale(version)) this.rotateDetails(view.details, version);
145
+ return;
146
+ }
147
+ element.removeAttribute("aria-busy");
148
+ await this.morph;
149
+ if (this.stale(version)) return;
150
+ if (view.line === "seal") await this.sealLine(version);
151
+ else await this.setSegments(view.line);
152
+ if (this.stale(version)) return;
153
+ this.prompt.quietLine();
154
+ const rows = {
155
+ label: view.label,
156
+ head: view.headline,
157
+ headline: true,
158
+ dim: false,
159
+ caption: `result:${JSON.stringify([
160
+ view.caption,
161
+ view.detail,
162
+ view.link ?? null
163
+ ])}`,
164
+ action: `result:${view.action.icon ?? ""}:${view.action.label}`
165
+ };
166
+ await this.transition(rows, version, () => {
167
+ element.dataset.phraseView = "result";
168
+ this.writeCaption(this.markedText(view.caption), this.detailNodes(view));
169
+ this.writeAction(view.action);
170
+ this.prompt.record.hidden = false;
171
+ });
172
+ if (this.stale(version)) return;
173
+ this.prompt.record.disabled = view.action.busy === true;
174
+ if (!view.action.busy) this.prompt.record.focus();
175
+ });
176
+ }
177
+ /** The line stays in the card, which fades out as a whole when its surface closes. */
178
+ dispose() {
179
+ this.disposed = true;
180
+ this.version += 1;
181
+ window.clearInterval(this.rotation);
182
+ }
183
+ /** A newer state supersedes any state still on its way in. */
184
+ begin() {
185
+ window.clearInterval(this.rotation);
186
+ this.version += 1;
187
+ return this.version;
188
+ }
189
+ stale(version) {
190
+ return this.disposed || version !== this.version;
191
+ }
192
+ queue(step) {
193
+ this.rendering = this.rendering.then(step, step);
194
+ return this.rendering;
195
+ }
196
+ promptRows() {
197
+ return {
198
+ label: this.copy.label,
199
+ head: this.copy.sentence,
200
+ headline: false,
201
+ dim: false,
202
+ caption: `prompt:${this.copy.message}`,
203
+ action: `prompt:${this.copy.action}`
204
+ };
205
+ }
206
+ /**
207
+ * Rows whose content changes fade out together; `apply` then writes the new state, the card
208
+ * takes its new height, the ribbon row glides to its new place, and the new rows fade in one
209
+ * after another.
210
+ */
211
+ async transition(next, version, apply) {
212
+ const prompt = this.prompt;
213
+ const changed = [
214
+ ["label", prompt.title],
215
+ ["head", prompt.phrase],
216
+ ["caption", prompt.status],
217
+ ["action", prompt.actionRow]
218
+ ].filter(([key]) => this.rows[key] !== next[key] || key === "head" && this.rows.headline !== next.headline).map(([, element]) => element);
219
+ prompt.phrase.classList.toggle("seams-voiceid-phrase-dim", next.dim);
220
+ if (changed.length > 0 && !this.motion.matches) await Promise.all(changed.map((element) => fade(element, FADE_OUT_MS)));
221
+ if (this.stale(version)) return;
222
+ const ribbonTop = prompt.ribbon.getBoundingClientRect().top;
223
+ const heightBefore = prompt.element.getBoundingClientRect().height;
224
+ prompt.title.textContent = next.label;
225
+ prompt.phrase.textContent = next.head;
226
+ prompt.phrase.classList.toggle("seams-voiceid-success-headline", next.headline);
227
+ apply();
228
+ this.rows = next;
229
+ prompt.fillFrame(heightBefore);
230
+ const moved = ribbonTop - prompt.ribbon.getBoundingClientRect().top;
231
+ if (Math.abs(moved) > .5 && !this.motion.matches) for (const element of [prompt.ribbon, this.line]) element.animate([{ transform: `translateY(${moved}px)` }, { transform: "none" }], {
232
+ duration: RIBBON_MOVE_MS,
233
+ easing: EASE_MOVE
234
+ });
235
+ for (const [index, element] of changed.entries()) fadeIn(element, this.motion.matches, FADE_IN_DELAY_MS + index * ROW_STAGGER_MS);
236
+ }
237
+ markedText(text) {
238
+ if (typeof text === "string") return [text];
239
+ const document = this.prompt.element.ownerDocument;
240
+ return text.map((part) => {
241
+ if (typeof part === "string") return part;
242
+ const mark = document.createElement("mark");
243
+ mark.className = "seams-voiceid-heard-mark";
244
+ mark.textContent = part.mark;
245
+ return mark;
246
+ });
247
+ }
248
+ detailNodes(view) {
249
+ if (!view.link) return [view.detail];
250
+ const link = this.prompt.element.ownerDocument.createElement("a");
251
+ link.href = view.link.href;
252
+ link.target = "_blank";
253
+ link.rel = "noopener noreferrer";
254
+ link.textContent = view.link.text;
255
+ return [
256
+ view.detail,
257
+ " ",
258
+ link
259
+ ];
260
+ }
261
+ /** One line for the prompt; a caption and a detail line for the check and the result. */
262
+ writeCaption(first, second) {
263
+ const status = this.prompt.status;
264
+ if (typeof first === "string" && second.length === 0) {
265
+ status.classList.remove("seams-voiceid-two-lines");
266
+ status.textContent = first;
267
+ return;
268
+ }
269
+ const document = status.ownerDocument;
270
+ const one = document.createElement("span");
271
+ one.className = "seams-voiceid-caption";
272
+ one.append(...typeof first === "string" ? [first] : first);
273
+ const two = document.createElement("span");
274
+ two.className = "seams-voiceid-caption-detail";
275
+ two.append(...second);
276
+ status.replaceChildren(one, two);
277
+ status.classList.add("seams-voiceid-two-lines");
278
+ }
279
+ writeAction(action) {
280
+ const button = this.prompt.record;
281
+ button.replaceChildren();
282
+ if (action.icon === "passkey") button.append(passkeyIcon(button.ownerDocument));
283
+ button.append(action.label);
284
+ }
285
+ /** Changes the second caption line every 1.3 s and stays on the last line. */
286
+ rotateDetails(details, version) {
287
+ if (details.length < 2) return;
288
+ let index = 0;
289
+ this.rotation = window.setInterval(() => {
290
+ index += 1;
291
+ if (index >= details.length - 1 || this.stale(version)) window.clearInterval(this.rotation);
292
+ if (index < details.length && !this.stale(version)) this.swapDetail(details[index], version);
293
+ }, DETAIL_ROTATE_MS);
294
+ }
295
+ async swapDetail(text, version) {
296
+ const detail = this.prompt.status.querySelector(".seams-voiceid-caption-detail");
297
+ if (!detail) return;
298
+ if (!this.motion.matches) await fade(detail, DETAIL_OUT_MS);
299
+ if (this.stale(version)) return;
300
+ detail.textContent = text;
301
+ detail.getAnimations().forEach((animation) => animation.cancel());
302
+ detail.animate([{
303
+ opacity: 0,
304
+ transform: this.motion.matches ? "none" : "translateY(2px)"
305
+ }, {
306
+ opacity: 1,
307
+ transform: "none"
308
+ }], {
309
+ duration: DETAIL_IN_MS,
310
+ easing: EASE_IN_OUT,
311
+ fill: "both"
312
+ });
313
+ }
314
+ /** The flat line opens into six segments; the first stays filled, the rest turn to track. */
315
+ async openSegments() {
316
+ await wait(this.motion.matches ? 0 : FLATTEN_MS);
317
+ if (this.disposed) return;
318
+ this.states = Array.from({ length: SEGMENTS }, () => "done");
319
+ this.paintStates();
320
+ this.prompt.element.dataset.phraseLine = "status";
321
+ await this.tween(this.motion.matches ? 0 : OPEN_MS, (progress) => this.layoutSegments(GAP * progress, 1 + 2 * progress));
322
+ if (this.disposed) return;
323
+ this.states = this.states.map((_, index) => index === 0 ? "done" : "idle");
324
+ this.paintStates();
325
+ await wait(this.motion.matches ? 0 : TRACK_MS);
326
+ }
327
+ /**
328
+ * Segments that finish fill one after another, 70 ms apart, and stop at a failed or held
329
+ * segment, which then shows its colour. Resolves when the result may follow.
330
+ */
331
+ async setSegments(next) {
332
+ const filling = next.map((state, index) => state === "done" && this.states[index] === "run" ? index : -1).filter((index) => index >= 0);
333
+ const target = [...next];
334
+ this.states = target.map((state, index) => filling.includes(index) ? "run" : state === "fail" || state === "wait" || state === "skip" ? this.states[index] ?? "idle" : state);
335
+ this.paintStates();
336
+ for (const index of filling) {
337
+ await wait(this.motion.matches ? 0 : FILL_STAGGER_MS);
338
+ if (this.disposed) return;
339
+ this.states[index] = "done";
340
+ this.paintStates();
341
+ }
342
+ const blocked = target.some((state) => state === "fail" || state === "wait");
343
+ this.states = target;
344
+ this.paintStates();
345
+ if (blocked) await wait(this.motion.matches ? 0 : BLOCKER_HOLD_MS);
346
+ }
347
+ /** The segments fill, the gaps close, the line shrinks as the ring draws, then seal and check. */
348
+ async sealLine(version) {
349
+ await this.setSegments(Array.from({ length: SEGMENTS }, () => "done"));
350
+ if (this.stale(version)) return;
351
+ const reduced = this.motion.matches;
352
+ await this.tween(reduced ? 0 : GAP_CLOSE_MS, (progress) => this.layoutSegments(GAP * (1 - progress), 3));
353
+ if (this.stale(version)) return;
354
+ this.line.dataset.seal = "";
355
+ const circumference = 2 * Math.PI * RING_RADIUS;
356
+ this.ring.style.strokeDasharray = String(circumference);
357
+ const timing = (duration, easing) => ({
358
+ duration: reduced ? 0 : duration,
359
+ easing,
360
+ fill: "forwards"
361
+ });
362
+ this.bar.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], timing(RING_MS, EASE_RING));
363
+ await this.ring.animate([{ strokeDashoffset: circumference }, { strokeDashoffset: 0 }], timing(RING_MS, EASE_RING)).finished;
364
+ if (this.stale(version)) return;
365
+ this.seal.animate([{ transform: "scale(0)" }, { transform: "scale(1)" }], timing(SEAL_MS, EASE_SEAL));
366
+ await wait(reduced ? 0 : SEAL_MS - 100);
367
+ if (this.stale(version)) return;
368
+ await this.check.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], timing(CHECK_MS, EASE_IN_OUT)).finished;
369
+ }
370
+ resetLine() {
371
+ this.morph = null;
372
+ this.states = [];
373
+ delete this.line.dataset.seal;
374
+ for (const part of [
375
+ this.bar,
376
+ this.ring,
377
+ this.seal,
378
+ this.check
379
+ ]) part.getAnimations().forEach((animation) => animation.cancel());
380
+ this.layoutSegments(0, 3);
381
+ this.paintStates();
382
+ }
383
+ paintStates() {
384
+ for (const [index, segment] of this.segments.entries()) {
385
+ const state = this.states[index];
386
+ if (state) segment.dataset.state = state;
387
+ else delete segment.dataset.state;
388
+ }
389
+ }
390
+ layoutSegments(gap, height) {
391
+ const width = (WIDTH - (SEGMENTS - 1) * gap) / SEGMENTS;
392
+ for (const [index, segment] of this.segments.entries()) {
393
+ segment.setAttribute("x", (index * (width + gap)).toFixed(2));
394
+ segment.setAttribute("width", width.toFixed(2));
395
+ segment.setAttribute("y", (MID - height / 2).toFixed(2));
396
+ segment.setAttribute("height", height.toFixed(2));
397
+ }
398
+ }
399
+ tween(duration, frame) {
400
+ if (duration <= 0) {
401
+ frame(1);
402
+ return Promise.resolve();
403
+ }
404
+ return new Promise((resolve) => {
405
+ const start = performance.now();
406
+ const step = (time) => {
407
+ if (this.disposed) return resolve();
408
+ const progress = Math.min(1, (time - start) / duration);
409
+ frame(1 - Math.pow(1 - progress, 3));
410
+ if (progress < 1) requestAnimationFrame(step);
411
+ else resolve();
412
+ };
413
+ requestAnimationFrame(step);
414
+ });
415
+ }
416
+ };
417
+ function passkeyIcon(document) {
418
+ const icon = document.createElementNS(SVG_NAMESPACE, "svg");
419
+ icon.setAttribute("viewBox", "0 0 24 24");
420
+ icon.setAttribute("width", "18");
421
+ icon.setAttribute("height", "18");
422
+ icon.setAttribute("aria-hidden", "true");
423
+ icon.classList.add("seams-voiceid-action-icon");
424
+ for (const d of PASSKEY_PATHS) {
425
+ const path = document.createElementNS(SVG_NAMESPACE, "path");
426
+ path.setAttribute("d", d);
427
+ icon.append(path);
428
+ }
429
+ return icon;
430
+ }
431
+ /** Fades out from where the element is, so a row that is still arriving never flashes. */
432
+ function fade(element, duration) {
433
+ const from = Number(getComputedStyle(element).opacity);
434
+ return element.animate([{ opacity: from }, { opacity: 0 }], {
435
+ duration: duration * from,
436
+ easing: "ease-in",
437
+ fill: "forwards"
438
+ }).finished;
439
+ }
440
+ function fadeIn(element, reduced, delay) {
441
+ element.getAnimations().forEach((animation) => animation.cancel());
442
+ element.animate([{
443
+ opacity: 0,
444
+ transform: reduced ? "none" : "translateY(4px)"
445
+ }, {
446
+ opacity: 1,
447
+ transform: "none"
448
+ }], {
449
+ delay: reduced ? 0 : delay,
450
+ duration: reduced ? 150 : FADE_IN_MS,
451
+ easing: EASE_IN_OUT,
452
+ fill: "both"
453
+ });
454
+ }
455
+ function wait(ms) {
456
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
457
+ }
458
+ //#endregion
459
+ export { PhraseCard };