@voqalize/avatar 0.0.1 → 0.2.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 (46) hide show
  1. package/README.md +86 -51
  2. package/client/dist/Avatar.d.ts +17 -14
  3. package/client/dist/Avatar.d.ts.map +1 -1
  4. package/client/dist/Avatar.js +3 -3
  5. package/client/dist/Avatar.js.map +1 -1
  6. package/client/dist/AvatarClient.d.ts +23 -57
  7. package/client/dist/AvatarClient.d.ts.map +1 -1
  8. package/client/dist/AvatarClient.js +20 -71
  9. package/client/dist/AvatarClient.js.map +1 -1
  10. package/client/dist/index.d.ts +22 -0
  11. package/client/dist/index.d.ts.map +1 -0
  12. package/client/dist/index.js +22 -0
  13. package/client/dist/index.js.map +1 -0
  14. package/client/dist/types.d.ts +22 -37
  15. package/client/dist/types.d.ts.map +1 -1
  16. package/client/dist/types.js +12 -12
  17. package/client/dist/types.js.map +1 -1
  18. package/client/dist/useAvatar.d.ts +8 -19
  19. package/client/dist/useAvatar.d.ts.map +1 -1
  20. package/client/dist/useAvatar.js +15 -32
  21. package/client/dist/useAvatar.js.map +1 -1
  22. package/client/src/Avatar.tsx +19 -24
  23. package/client/src/AvatarClient.ts +38 -111
  24. package/client/src/index.ts +22 -0
  25. package/client/src/types.ts +24 -47
  26. package/client/src/useAvatar.ts +19 -47
  27. package/docs/contract-avatar.md +37 -3
  28. package/docs/contract-protocol.md +90 -30
  29. package/package.json +3 -11
  30. package/src/avatar.d.ts +38 -6
  31. package/src/avatar.js +72 -37
  32. package/src/hand.js +680 -0
  33. package/src/idle.js +8 -25
  34. package/src/line-art.js +26 -0
  35. package/src/perform.js +8 -4
  36. package/client/dist/pipecat.d.ts +0 -21
  37. package/client/dist/pipecat.d.ts.map +0 -1
  38. package/client/dist/pipecat.js +0 -21
  39. package/client/dist/pipecat.js.map +0 -1
  40. package/client/dist/react.d.ts +0 -16
  41. package/client/dist/react.d.ts.map +0 -1
  42. package/client/dist/react.js +0 -17
  43. package/client/dist/react.js.map +0 -1
  44. package/client/src/pipecat.ts +0 -38
  45. package/client/src/react.ts +0 -34
  46. package/src/audio-fallback.js +0 -100
package/src/idle.js CHANGED
@@ -392,8 +392,10 @@ export class IdleLayer {
392
392
  * that acknowledges on a metronome reads as distracting
393
393
  * (docs/research-biomechanics.md §3.5). So this engine is contingent first:
394
394
  *
395
- * · The host tells it about the user's voice — a coarse speaking flag
396
- * (setUserSpeaking) or a measured level (observeLevel), flag wins.
395
+ * · The server tells it about the user's voice — a coarse speaking flag
396
+ * (setUserSpeaking), off its own endpointer. With no signal at all the
397
+ * scheduler falls back to a timer, which is the weakest mode and is meant
398
+ * to be: contingency is the whole point.
397
399
  * · Acknowledgements fire at PAUSE ONSETS: when the user stops talking, a
398
400
  * nod lands 250–600 ms later, about half the time, never more often than
399
401
  * every 2.5 s. That timing is where a human listener's nod sits.
@@ -420,10 +422,7 @@ export class ListeningEngine {
420
422
  this._next = 0;
421
423
  // --- user-signal state
422
424
  this._hasSignal = false;
423
- this._explicit = null; // host-declared flag; null = not driven
424
- this._derived = false; // level-derived VAD
425
- this._levelOn = false; // raw hysteresis state behind _derived
426
- this._flipT = 0; // how long the level has disagreed with _levelOn
425
+ this._explicit = null; // server-declared flag; null = not driven
427
426
  this._speaking = false; // merged VAD, after hysteresis
428
427
  this._spokeAt = -1e9; // start of the current speech stretch
429
428
  this._silentAt = 0; // end of the last one
@@ -439,29 +438,13 @@ export class ListeningEngine {
439
438
  this._pending = -1;
440
439
  }
441
440
 
442
- /** Host-declared user speech. Pass null to hand control back to the level VAD. */
441
+ /** Server-declared user speech, off the pipeline's own endpointer. Pass null
442
+ * to hand back to the no-signal timer. */
443
443
  setUserSpeaking(b) {
444
444
  if (b !== null) this._hasSignal = true;
445
445
  this._explicit = b === null ? null : !!b;
446
446
  }
447
447
 
448
- /** Feed the smoothed user audio level (an AudioFallback.level). Same scale,
449
- * thresholds and asymmetry as the demo's RMS VAD: quick in (80 ms), slow
450
- * out (250 ms) — declaring the turn over early is the expensive mistake,
451
- * and the 250 ms quiet-hold IS the pause detector the contingent
452
- * scheduler keys off. */
453
- observeLevel(level) {
454
- this._hasSignal = true;
455
- const on = !!this._levelOn;
456
- const wants = level > (on ? 0.018 : 0.030);
457
- if (wants === on) this._flipT = this.t;
458
- else if (this.t - this._flipT >= (on ? 0.25 : 0.08)) {
459
- this._levelOn = wants;
460
- this._flipT = this.t;
461
- }
462
- this._derived = !!this._levelOn;
463
- }
464
-
465
448
  /** The one seam for choosing an acknowledgement. Context-aware: what the
466
449
  * user just did decides the weight class of the reply
467
450
  * (docs/research-biomechanics.md §3.3 — continuers co-occur with ongoing
@@ -486,7 +469,7 @@ export class ListeningEngine {
486
469
  return r < 0.55 ? 'NOD_SMALL' : r < 0.8 ? 'BROW_ACK' : 'NOD_SLOW';
487
470
  }
488
471
 
489
- get speaking() { return this._explicit !== null ? this._explicit : this._derived; }
472
+ get speaking() { return this._explicit === true; }
490
473
 
491
474
  update(dt) {
492
475
  this.t += dt;
package/src/line-art.js CHANGED
@@ -104,6 +104,32 @@ export function region(flat) {
104
104
  return d + 'Z';
105
105
  }
106
106
 
107
+ /**
108
+ * On-curve points -> the polybezier form above, Catmull-Rom at the standard 1/6
109
+ * tension. The house idiom for a FACE is hand-authored control points: a brow or
110
+ * a lip contour is tuned a handle at a time and interpolation would fight that.
111
+ * This is for marks whose geometry is easier to read as a list of places the
112
+ * line goes through than as three-in-four control points — the hand's contours,
113
+ * where the authoring question is "how far does the thumb clear the knuckles"
114
+ * and every point is measured against another point. Curve control is worth
115
+ * less there than a shape whose numbers can be argued about.
116
+ */
117
+ export function smooth(pts) {
118
+ const out = [pts[0]];
119
+ for (let i = 0; i < pts.length - 1; i++) {
120
+ const p0 = pts[i - 1] || pts[i];
121
+ const p1 = pts[i];
122
+ const p2 = pts[i + 1];
123
+ const p3 = pts[i + 2] || p2;
124
+ out.push(
125
+ [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6],
126
+ [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6],
127
+ p2
128
+ );
129
+ }
130
+ return out;
131
+ }
132
+
107
133
  /** Deterministic jitter, so a drawing is the same every load. */
108
134
  export function rng(seed) {
109
135
  let s = seed >>> 0;
package/src/perform.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A performance is the server's choreography: timed verbs `{t, do, ...}` fired
5
5
  * against a clock, where every verb resolves to one of the widget's own enums —
6
- * states, emotions, gaze targets, interjections. The vocabulary is deliberately
6
+ * states, emotions, gaze targets, interjections, hand gestures. The vocabulary is deliberately
7
7
  * closed: the backend sequences what the rig already does well, it cannot
8
8
  * invent motion. That constraint is what makes the wire format assemblable by
9
9
  * a dialogue manager and reviewable by a human.
@@ -21,7 +21,10 @@
21
21
  * effects, and replaying a nod is worse than missing one.
22
22
  */
23
23
 
24
- const VERBS = new Set(['state', 'emotion', 'gaze', 'interject']);
24
+ const VERBS = new Set(['state', 'emotion', 'gaze', 'interject', 'gesture']);
25
+ // The verbs addressed by `id` rather than `name`. Both id verbs name a clip the
26
+ // widget already owns; both name-verbs name an enum value.
27
+ const ID_VERBS = new Set(['interject', 'gesture']);
25
28
 
26
29
  /**
27
30
  * Shape hygiene for action arrays, in the spirit of normalizeCues: sort by
@@ -45,8 +48,9 @@ export function normalizeActions(actions) {
45
48
  console.warn(`perform: dropped unknown verb "${a && a.do}"`, a);
46
49
  continue;
47
50
  }
48
- if ((a.do === 'interject' ? a.id : a.name) == null) {
49
- console.warn(`perform: dropped ${a.do} with no ${a.do === 'interject' ? 'id' : 'name'}`, a);
51
+ const idVerb = ID_VERBS.has(a.do);
52
+ if ((idVerb ? a.id : a.name) == null) {
53
+ console.warn(`perform: dropped ${a.do} with no ${idVerb ? 'id' : 'name'}`, a);
50
54
  continue;
51
55
  }
52
56
  out.push(a);
@@ -1,21 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/pipecat` — drive the widget from a pipecat session.
3
- *
4
- * Framework-free: everything here is plain TypeScript over the `AvatarApi` the
5
- * root export returns. `AvatarClient` is the whole surface — construct it
6
- * around a mounted widget and either `attach()` it to a live `PipecatClient`
7
- * or feed it messages yourself with `dispatch()`.
8
- *
9
- * import { createAvatar } from "@voqalize/avatar";
10
- * import { AvatarClient } from "@voqalize/avatar/pipecat";
11
- *
12
- * const avatar = createAvatar({ mount: "#tile" });
13
- * const detach = new AvatarClient(avatar).attach(pipecatClient);
14
- *
15
- * `@pipecat-ai/client-js` is a peer dependency of this subpath only — the root
16
- * export has no dependencies at all, and a host that carries avatar commands
17
- * over its own transport can import this module and never call `attach()`.
18
- */
19
- export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
20
- export { isAvatarMessage, AVATAR_MESSAGE_TYPE, AVATAR_PROTOCOL_VERSION, type AvatarCommand, type AvatarCue, type AvatarCuesCmd, type AvatarHintCmd, type AvatarInterjectCmd, type AvatarPerformAction, type AvatarPerformCmd, type AvatarServerMessage, type AvatarSpeechCmd, type AvatarStateCmd, type AvatarUnknownCmd, type AvatarUserCmd, } from "./types.js";
21
- //# sourceMappingURL=pipecat.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"pipecat.d.ts","sourceRoot":"","sources":["../src/pipecat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,uBAAuB,EACvB,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC"}
@@ -1,21 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/pipecat` — drive the widget from a pipecat session.
3
- *
4
- * Framework-free: everything here is plain TypeScript over the `AvatarApi` the
5
- * root export returns. `AvatarClient` is the whole surface — construct it
6
- * around a mounted widget and either `attach()` it to a live `PipecatClient`
7
- * or feed it messages yourself with `dispatch()`.
8
- *
9
- * import { createAvatar } from "@voqalize/avatar";
10
- * import { AvatarClient } from "@voqalize/avatar/pipecat";
11
- *
12
- * const avatar = createAvatar({ mount: "#tile" });
13
- * const detach = new AvatarClient(avatar).attach(pipecatClient);
14
- *
15
- * `@pipecat-ai/client-js` is a peer dependency of this subpath only — the root
16
- * export has no dependencies at all, and a host that carries avatar commands
17
- * over its own transport can import this module and never call `attach()`.
18
- */
19
- export { AvatarClient } from "./AvatarClient.js";
20
- export { isAvatarMessage, AVATAR_MESSAGE_TYPE, AVATAR_PROTOCOL_VERSION, } from "./types.js";
21
- //# sourceMappingURL=pipecat.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"pipecat.js","sourceRoot":"","sources":["../src/pipecat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,YAAY,EAA4B,MAAM,mBAAmB,CAAC;AAE3E,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,uBAAuB,GAaxB,MAAM,YAAY,CAAC"}
@@ -1,16 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/react` — the React binding.
3
- *
4
- * import { Avatar } from "@voqalize/avatar/react";
5
- *
6
- * <Avatar client={pipecatClient} className="tile" />
7
- *
8
- * Peers: `react >= 18` and `@pipecat-ai/client-js`. Everything a non-React
9
- * host needs is in `@voqalize/avatar/pipecat`; this module adds a mount
10
- * lifecycle and nothing else.
11
- */
12
- export { useAvatar, type UseAvatarHandle, type UseAvatarOptions } from "./useAvatar.js";
13
- export { Avatar, type AvatarProps } from "./Avatar.js";
14
- export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
15
- export { isAvatarMessage, AVATAR_MESSAGE_TYPE, AVATAR_PROTOCOL_VERSION, type AvatarCommand, type AvatarCue, type AvatarCuesCmd, type AvatarHintCmd, type AvatarInterjectCmd, type AvatarPerformAction, type AvatarPerformCmd, type AvatarServerMessage, type AvatarSpeechCmd, type AvatarStateCmd, type AvatarUnknownCmd, type AvatarUserCmd, } from "./types.js";
16
- //# sourceMappingURL=react.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACxF,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,uBAAuB,EACvB,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC"}
@@ -1,17 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/react` — the React binding.
3
- *
4
- * import { Avatar } from "@voqalize/avatar/react";
5
- *
6
- * <Avatar client={pipecatClient} className="tile" />
7
- *
8
- * Peers: `react >= 18` and `@pipecat-ai/client-js`. Everything a non-React
9
- * host needs is in `@voqalize/avatar/pipecat`; this module adds a mount
10
- * lifecycle and nothing else.
11
- */
12
- export { useAvatar } from "./useAvatar.js";
13
- export { Avatar } from "./Avatar.js";
14
- // Re-exported so a React consumer needs one import for the common case.
15
- export { AvatarClient } from "./AvatarClient.js";
16
- export { isAvatarMessage, AVATAR_MESSAGE_TYPE, AVATAR_PROTOCOL_VERSION, } from "./types.js";
17
- //# sourceMappingURL=react.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,SAAS,EAA+C,MAAM,gBAAgB,CAAC;AACxF,OAAO,EAAE,MAAM,EAAoB,MAAM,aAAa,CAAC;AAEvD,wEAAwE;AACxE,OAAO,EAAE,YAAY,EAA4B,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,uBAAuB,GAaxB,MAAM,YAAY,CAAC"}
@@ -1,38 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/pipecat` — drive the widget from a pipecat session.
3
- *
4
- * Framework-free: everything here is plain TypeScript over the `AvatarApi` the
5
- * root export returns. `AvatarClient` is the whole surface — construct it
6
- * around a mounted widget and either `attach()` it to a live `PipecatClient`
7
- * or feed it messages yourself with `dispatch()`.
8
- *
9
- * import { createAvatar } from "@voqalize/avatar";
10
- * import { AvatarClient } from "@voqalize/avatar/pipecat";
11
- *
12
- * const avatar = createAvatar({ mount: "#tile" });
13
- * const detach = new AvatarClient(avatar).attach(pipecatClient);
14
- *
15
- * `@pipecat-ai/client-js` is a peer dependency of this subpath only — the root
16
- * export has no dependencies at all, and a host that carries avatar commands
17
- * over its own transport can import this module and never call `attach()`.
18
- */
19
-
20
- export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
21
-
22
- export {
23
- isAvatarMessage,
24
- AVATAR_MESSAGE_TYPE,
25
- AVATAR_PROTOCOL_VERSION,
26
- type AvatarCommand,
27
- type AvatarCue,
28
- type AvatarCuesCmd,
29
- type AvatarHintCmd,
30
- type AvatarInterjectCmd,
31
- type AvatarPerformAction,
32
- type AvatarPerformCmd,
33
- type AvatarServerMessage,
34
- type AvatarSpeechCmd,
35
- type AvatarStateCmd,
36
- type AvatarUnknownCmd,
37
- type AvatarUserCmd,
38
- } from "./types.js";
@@ -1,34 +0,0 @@
1
- /**
2
- * `@voqalize/avatar/react` — the React binding.
3
- *
4
- * import { Avatar } from "@voqalize/avatar/react";
5
- *
6
- * <Avatar client={pipecatClient} className="tile" />
7
- *
8
- * Peers: `react >= 18` and `@pipecat-ai/client-js`. Everything a non-React
9
- * host needs is in `@voqalize/avatar/pipecat`; this module adds a mount
10
- * lifecycle and nothing else.
11
- */
12
-
13
- export { useAvatar, type UseAvatarHandle, type UseAvatarOptions } from "./useAvatar.js";
14
- export { Avatar, type AvatarProps } from "./Avatar.js";
15
-
16
- // Re-exported so a React consumer needs one import for the common case.
17
- export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
18
- export {
19
- isAvatarMessage,
20
- AVATAR_MESSAGE_TYPE,
21
- AVATAR_PROTOCOL_VERSION,
22
- type AvatarCommand,
23
- type AvatarCue,
24
- type AvatarCuesCmd,
25
- type AvatarHintCmd,
26
- type AvatarInterjectCmd,
27
- type AvatarPerformAction,
28
- type AvatarPerformCmd,
29
- type AvatarServerMessage,
30
- type AvatarSpeechCmd,
31
- type AvatarStateCmd,
32
- type AvatarUnknownCmd,
33
- type AvatarUserCmd,
34
- } from "./types.js";
@@ -1,100 +0,0 @@
1
- /**
2
- * Amplitude-driven lipsync — the degradation path.
3
- *
4
- * When cues are late, missing, or the TTS vendor can't emit them, we read the
5
- * audio directly with a WebAudio AnalyserNode and guess. It is obviously worse
6
- * than real visemes, but it is *far* better than a still mouth, it costs
7
- * nothing, and it keeps working when the network doesn't.
8
- *
9
- * The guess uses two cheap signals:
10
- * · RMS -> how far the mouth opens
11
- * · spectral tilt -> which shape family (sibilant / open vowel / rounded)
12
- */
13
-
14
- const GATE = 0.012;
15
-
16
- export class AudioFallback {
17
- constructor() {
18
- this.ctx = null;
19
- this.analyser = null;
20
- this.time = null;
21
- this.freq = null;
22
- this.level = 0;
23
- this.active = false;
24
- this._letter = 'X';
25
- this._holdUntil = 0;
26
- this._t = 0;
27
- }
28
-
29
- /** @param {MediaStream|HTMLMediaElement|AudioNode} source */
30
- attach(source) {
31
- this.detach();
32
- const Ctx = window.AudioContext || window.webkitAudioContext;
33
- this.ctx = new Ctx();
34
- let node;
35
- if (source instanceof MediaStream) node = this.ctx.createMediaStreamSource(source);
36
- else if (source instanceof AudioNode) node = source;
37
- else node = this.ctx.createMediaElementSource(source);
38
-
39
- this.analyser = this.ctx.createAnalyser();
40
- this.analyser.fftSize = 1024;
41
- this.analyser.smoothingTimeConstant = 0.55;
42
- node.connect(this.analyser);
43
- // Media-element sources must still reach the speakers.
44
- if (!(source instanceof MediaStream)) this.analyser.connect(this.ctx.destination);
45
-
46
- this.time = new Uint8Array(this.analyser.fftSize);
47
- this.freq = new Uint8Array(this.analyser.frequencyBinCount);
48
- this.active = true;
49
- if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
50
- return this;
51
- }
52
-
53
- detach() {
54
- if (this.ctx) { try { this.ctx.close(); } catch (e) { /* already closed */ } }
55
- this.ctx = null; this.analyser = null; this.active = false; this.level = 0;
56
- }
57
-
58
- /** @returns {{letter:string,intensity:number}|null} */
59
- sample(dt) {
60
- if (!this.active) return null;
61
- this._t += dt;
62
- this.analyser.getByteTimeDomainData(this.time);
63
- this.analyser.getByteFrequencyData(this.freq);
64
-
65
- let sum = 0;
66
- for (let i = 0; i < this.time.length; i++) {
67
- const v = (this.time[i] - 128) / 128;
68
- sum += v * v;
69
- }
70
- const rms = Math.sqrt(sum / this.time.length);
71
- this.level += (rms - this.level) * (1 - Math.exp(-dt / 0.045));
72
-
73
- if (this.level < GATE) { this._letter = 'X'; return { letter: 'X', intensity: 1 }; }
74
-
75
- // Bin edges assume ~48kHz; exact boundaries don't matter much here.
76
- const n = this.freq.length;
77
- const band = (a, b) => {
78
- let s = 0;
79
- const lo = Math.floor(n * a), hi = Math.floor(n * b);
80
- for (let i = lo; i < hi; i++) s += this.freq[i];
81
- return s / Math.max(1, hi - lo);
82
- };
83
- const low = band(0.00, 0.035); // ~0-800 Hz
84
- const mid = band(0.035, 0.13); // ~800-3k
85
- const high = band(0.13, 0.40); // ~3k-9.6k
86
- const total = low + mid + high + 1e-6;
87
-
88
- let letter;
89
- if (high / total > 0.34) letter = 'B'; // sibilant
90
- else if (low / total > 0.62) letter = this.level > 0.16 ? 'F' : 'E'; // rounded / back
91
- else letter = this.level > 0.20 ? 'D' : this.level > 0.09 ? 'C' : 'B';
92
-
93
- // Hold each guess briefly; frame-rate shape churn looks like chattering.
94
- if (this._t < this._holdUntil) letter = this._letter;
95
- else { this._letter = letter; this._holdUntil = this._t + 0.055; }
96
-
97
- const intensity = Math.min(1, this.level / 0.22);
98
- return { letter, intensity };
99
- }
100
- }