mochi-avatar 1.0.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 (96) hide show
  1. package/BRAND.md +85 -0
  2. package/LICENSE.md +104 -0
  3. package/README.md +135 -0
  4. package/dist/canvas2d/avatar.d.ts +264 -0
  5. package/dist/canvas2d/avatar.d.ts.map +1 -0
  6. package/dist/canvas2d/avatar.js +811 -0
  7. package/dist/canvas2d/avatar.js.map +1 -0
  8. package/dist/canvas2d/face.d.ts +74 -0
  9. package/dist/canvas2d/face.d.ts.map +1 -0
  10. package/dist/canvas2d/face.js +298 -0
  11. package/dist/canvas2d/face.js.map +1 -0
  12. package/dist/canvas2d/paths.d.ts +13 -0
  13. package/dist/canvas2d/paths.d.ts.map +1 -0
  14. package/dist/canvas2d/paths.js +24 -0
  15. package/dist/canvas2d/paths.js.map +1 -0
  16. package/dist/characters/colourways.d.ts +78 -0
  17. package/dist/characters/colourways.d.ts.map +1 -0
  18. package/dist/characters/colourways.js +76 -0
  19. package/dist/characters/colourways.js.map +1 -0
  20. package/dist/characters/index.d.ts +8 -0
  21. package/dist/characters/index.d.ts.map +1 -0
  22. package/dist/characters/index.js +8 -0
  23. package/dist/characters/index.js.map +1 -0
  24. package/dist/characters/mochi.d.ts +19 -0
  25. package/dist/characters/mochi.d.ts.map +1 -0
  26. package/dist/characters/mochi.js +66 -0
  27. package/dist/characters/mochi.js.map +1 -0
  28. package/dist/core/colour.d.ts +25 -0
  29. package/dist/core/colour.d.ts.map +1 -0
  30. package/dist/core/colour.js +37 -0
  31. package/dist/core/colour.js.map +1 -0
  32. package/dist/core/envelope.d.ts +153 -0
  33. package/dist/core/envelope.d.ts.map +1 -0
  34. package/dist/core/envelope.js +141 -0
  35. package/dist/core/envelope.js.map +1 -0
  36. package/dist/core/geometry.d.ts +106 -0
  37. package/dist/core/geometry.d.ts.map +1 -0
  38. package/dist/core/geometry.js +157 -0
  39. package/dist/core/geometry.js.map +1 -0
  40. package/dist/core/idle.d.ts +163 -0
  41. package/dist/core/idle.d.ts.map +1 -0
  42. package/dist/core/idle.js +262 -0
  43. package/dist/core/idle.js.map +1 -0
  44. package/dist/core/layout.d.ts +142 -0
  45. package/dist/core/layout.d.ts.map +1 -0
  46. package/dist/core/layout.js +172 -0
  47. package/dist/core/layout.js.map +1 -0
  48. package/dist/core/lens.d.ts +47 -0
  49. package/dist/core/lens.d.ts.map +1 -0
  50. package/dist/core/lens.js +67 -0
  51. package/dist/core/lens.js.map +1 -0
  52. package/dist/core/looks.d.ts +77 -0
  53. package/dist/core/looks.d.ts.map +1 -0
  54. package/dist/core/looks.js +168 -0
  55. package/dist/core/looks.js.map +1 -0
  56. package/dist/core/motion.d.ts +168 -0
  57. package/dist/core/motion.d.ts.map +1 -0
  58. package/dist/core/motion.js +527 -0
  59. package/dist/core/motion.js.map +1 -0
  60. package/dist/core/mouth.d.ts +93 -0
  61. package/dist/core/mouth.d.ts.map +1 -0
  62. package/dist/core/mouth.js +92 -0
  63. package/dist/core/mouth.js.map +1 -0
  64. package/dist/core/plain.d.ts +23 -0
  65. package/dist/core/plain.d.ts.map +1 -0
  66. package/dist/core/plain.js +63 -0
  67. package/dist/core/plain.js.map +1 -0
  68. package/dist/core/spec.d.ts +156 -0
  69. package/dist/core/spec.d.ts.map +1 -0
  70. package/dist/core/spec.js +236 -0
  71. package/dist/core/spec.js.map +1 -0
  72. package/dist/core/spring.d.ts +53 -0
  73. package/dist/core/spring.d.ts.map +1 -0
  74. package/dist/core/spring.js +68 -0
  75. package/dist/core/spring.js.map +1 -0
  76. package/dist/core/vocabulary.d.ts +182 -0
  77. package/dist/core/vocabulary.d.ts.map +1 -0
  78. package/dist/core/vocabulary.js +66 -0
  79. package/dist/core/vocabulary.js.map +1 -0
  80. package/dist/element/dough-avatar.d.ts +34 -0
  81. package/dist/element/dough-avatar.d.ts.map +1 -0
  82. package/dist/element/dough-avatar.js +189 -0
  83. package/dist/element/dough-avatar.js.map +1 -0
  84. package/dist/element/index.d.ts +3 -0
  85. package/dist/element/index.d.ts.map +1 -0
  86. package/dist/element/index.js +35 -0
  87. package/dist/element/index.js.map +1 -0
  88. package/dist/index.d.ts +38 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +42 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/svg/silhouette.d.ts +97 -0
  93. package/dist/svg/silhouette.d.ts.map +1 -0
  94. package/dist/svg/silhouette.js +143 -0
  95. package/dist/svg/silhouette.js.map +1 -0
  96. package/package.json +90 -0
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The characters this package ships, as distinct from the engine that draws
3
+ * them. Everything under this directory carries the character licence in
4
+ * `LICENSE.md`; the engine around it is MIT.
5
+ */
6
+ export { MOCHI } from './mochi.js';
7
+ export { COLOURWAYS, mochiIn, type Colourway, type ColourwayName } from './colourways.js';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/characters/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The characters this package ships, as distinct from the engine that draws
3
+ * them. Everything under this directory carries the character licence in
4
+ * `LICENSE.md`; the engine around it is MIT.
5
+ */
6
+ export { MOCHI } from './mochi.js';
7
+ export { COLOURWAYS, mochiIn } from './colourways.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/characters/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAsC,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,19 @@
1
+ import type { FaceSpec } from '../core/spec.js';
2
+ /**
3
+ * Mochi.
4
+ *
5
+ * A soft green ovoid with a low face, measured against `assets/mochi-icon.png`
6
+ * rather than tuned by eye — `silhouette-vs-icon.test.ts` fails the build if the
7
+ * rendered profile drifts from that artwork at any 5% of her height.
8
+ *
9
+ * ## She is not MIT
10
+ *
11
+ * Everything else in this package is. This directory is not: see
12
+ * `LICENSE.md`. You may use her as she is, including in things you give away.
13
+ * You may not rebrand her, sell her, or present her as your own character.
14
+ *
15
+ * If you want a character of your own, that is what the format is for — start
16
+ * from `PLAIN`, move the numbers, and the result owes nothing to this file.
17
+ */
18
+ export declare const MOCHI: FaceSpec;
19
+ //# sourceMappingURL=mochi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mochi.d.ts","sourceRoot":"","sources":["../../src/characters/mochi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,KAAK,EAAE,QAsDnB,CAAA"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Mochi.
3
+ *
4
+ * A soft green ovoid with a low face, measured against `assets/mochi-icon.png`
5
+ * rather than tuned by eye — `silhouette-vs-icon.test.ts` fails the build if the
6
+ * rendered profile drifts from that artwork at any 5% of her height.
7
+ *
8
+ * ## She is not MIT
9
+ *
10
+ * Everything else in this package is. This directory is not: see
11
+ * `LICENSE.md`. You may use her as she is, including in things you give away.
12
+ * You may not rebrand her, sell her, or present her as your own character.
13
+ *
14
+ * If you want a character of your own, that is what the format is for — start
15
+ * from `PLAIN`, move the numbers, and the result owes nothing to this file.
16
+ */
17
+ export const MOCHI = {
18
+ size: 100,
19
+ bodyW: 100,
20
+ bodyH: 78,
21
+ waist: 0.295,
22
+ upperShoulder: 1.86,
23
+ lowerShoulder: 2.58,
24
+ gripX: 0.82,
25
+ gripY: 0.82,
26
+ eyeX: 0.3,
27
+ eyeY: 0.46,
28
+ // 4, not the 5 these were tuned at. Two separate reductions land on the eyes
29
+ // -- the body itself is now drawn at 80% (see rig/mochi.ts) and these are 80%
30
+ // of what they were -- so in absolute pixels they end up near 64% of the
31
+ // first version. That compounding is intended, not an accident of two edits:
32
+ // the eyes read as too heavy for her at BOTH sizes.
33
+ eyeHw: 4,
34
+ eyeUpper: 4,
35
+ eyeLower: 4,
36
+ eyeTilt: 0,
37
+ eyeRound: 2,
38
+ eyeGlint: 1.4,
39
+ gazeTravel: 0.42,
40
+ mouthY: 0.24,
41
+ mouthHw: 9,
42
+ mouthUpper: 0,
43
+ mouthLower: 3.2,
44
+ mouthRound: 2,
45
+ mouthOpenGain: 9,
46
+ cheekAlpha: 0.34,
47
+ cheekX: 0.62,
48
+ cheekY: 0.33,
49
+ cheekR: 12,
50
+ // 0.024, down from 0.045, and the two numbers are not comparable: 0.045 was a
51
+ // HALF-amplitude either side of rest, so the body swung 0.09 peak to peak --
52
+ // 9% of her height and 9% of her width, on a 94 x 73px body. This is the
53
+ // whole excursion, in one direction, from a resting shape she now returns to.
54
+ breathAmp: 0.024,
55
+ breathMs: 3400,
56
+ stiffness: 190,
57
+ damping: 20,
58
+ shadowX: 0.059,
59
+ shadowY: 0.102,
60
+ colBody: '#8ec8a8',
61
+ colShadow: '#7dbd99',
62
+ colInk: '#24463a',
63
+ colCheek: '#ef8f86',
64
+ colGlint: '#ffffff',
65
+ };
66
+ //# sourceMappingURL=mochi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mochi.js","sourceRoot":"","sources":["../../src/characters/mochi.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,KAAK,GAAa;IAC7B,IAAI,EAAE,GAAG;IACT,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,EAAE;IACT,KAAK,EAAE,KAAK;IACZ,aAAa,EAAE,IAAI;IACnB,aAAa,EAAE,IAAI;IACnB,KAAK,EAAE,IAAI;IACX,KAAK,EAAE,IAAI;IAEX,IAAI,EAAE,GAAG;IACT,IAAI,EAAE,IAAI;IACV,6EAA6E;IAC7E,8EAA8E;IAC9E,yEAAyE;IACzE,6EAA6E;IAC7E,oDAAoD;IACpD,KAAK,EAAE,CAAC;IACR,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,CAAC;IACX,OAAO,EAAE,CAAC;IACV,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,GAAG;IACb,UAAU,EAAE,IAAI;IAEhB,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,CAAC;IACV,UAAU,EAAE,CAAC;IACb,UAAU,EAAE,GAAG;IACf,UAAU,EAAE,CAAC;IACb,aAAa,EAAE,CAAC;IAEhB,UAAU,EAAE,IAAI;IAChB,MAAM,EAAE,IAAI;IACZ,MAAM,EAAE,IAAI;IACZ,MAAM,EAAE,EAAE;IAEV,8EAA8E;IAC9E,6EAA6E;IAC7E,yEAAyE;IACzE,8EAA8E;IAC9E,SAAS,EAAE,KAAK;IAChB,QAAQ,EAAE,IAAI;IACd,SAAS,EAAE,GAAG;IACd,OAAO,EAAE,EAAE;IAEX,OAAO,EAAE,KAAK;IACd,OAAO,EAAE,KAAK;IAEd,OAAO,EAAE,SAAS;IAClB,SAAS,EAAE,SAAS;IACpB,MAAM,EAAE,SAAS;IACjB,QAAQ,EAAE,SAAS;IACnB,QAAQ,EAAE,SAAS;CACpB,CAAA"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reading a hex colour, which is the only interpretation this engine does.
3
+ *
4
+ * Inlined rather than imported. In the host application this lived beside the
5
+ * accent-colour machinery, which also knows about WCAG luminance, CSS custom
6
+ * properties and a theme — none of which an engine that fills two flat tones
7
+ * has any business depending on. Twenty lines is cheaper than the coupling.
8
+ *
9
+ * The forms accepted here are exactly the forms `parseFaceSpec` accepts, and
10
+ * that is not a coincidence: a colour that passes validation must be one this
11
+ * can read, or a face loads and then fails to paint. That equivalence used to
12
+ * be approximate — this trimmed its input and the validator did not, so
13
+ * `' #abc '` was readable here and rejected there. It does not trim any more.
14
+ *
15
+ * **Alpha is discarded.** `#rgba` and `#rrggbbaa` parse, and their alpha
16
+ * channel is dropped: the return type is RGB, and the one caller wants the
17
+ * channels so it can build its own transparent stop. Named for what it returns.
18
+ */
19
+ export interface Rgb {
20
+ readonly r: number;
21
+ readonly g: number;
22
+ readonly b: number;
23
+ }
24
+ export declare function parseHexRgb(hex: string): Rgb | null;
25
+ //# sourceMappingURL=colour.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"colour.d.ts","sourceRoot":"","sources":["../../src/core/colour.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,MAAM,WAAW,GAAG;IAClB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,IAAI,CAenD"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading a hex colour, which is the only interpretation this engine does.
3
+ *
4
+ * Inlined rather than imported. In the host application this lived beside the
5
+ * accent-colour machinery, which also knows about WCAG luminance, CSS custom
6
+ * properties and a theme — none of which an engine that fills two flat tones
7
+ * has any business depending on. Twenty lines is cheaper than the coupling.
8
+ *
9
+ * The forms accepted here are exactly the forms `parseFaceSpec` accepts, and
10
+ * that is not a coincidence: a colour that passes validation must be one this
11
+ * can read, or a face loads and then fails to paint. That equivalence used to
12
+ * be approximate — this trimmed its input and the validator did not, so
13
+ * `' #abc '` was readable here and rejected there. It does not trim any more.
14
+ *
15
+ * **Alpha is discarded.** `#rgba` and `#rrggbbaa` parse, and their alpha
16
+ * channel is dropped: the return type is RGB, and the one caller wants the
17
+ * channels so it can build its own transparent stop. Named for what it returns.
18
+ */
19
+ import { HEX_COLOUR } from './spec.js';
20
+ export function parseHexRgb(hex) {
21
+ const text = hex;
22
+ // Checked BEFORE parsing, not after. `red` is three characters, so it took
23
+ // the shorthand path and `parseInt('rr', 16)` handed back NaN channels --
24
+ // a colour object that passes every null check and paints nothing.
25
+ if (!HEX_COLOUR.test(text))
26
+ return null;
27
+ const body = text.slice(1);
28
+ const expand = (index) => Number.parseInt(body.charAt(index).repeat(2), 16);
29
+ if (body.length <= 4) {
30
+ return { r: expand(0), g: expand(1), b: expand(2) };
31
+ }
32
+ // No NaN check and no trailing `return null`: the regex admits exactly the
33
+ // four lengths handled above and below, so both were unreachable.
34
+ const value = Number.parseInt(body.slice(0, 6), 16);
35
+ return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
36
+ }
37
+ //# sourceMappingURL=colour.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"colour.js","sourceRoot":"","sources":["../../src/core/colour.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAQtC,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,MAAM,IAAI,GAAG,GAAG,CAAA;IAChB,2EAA2E;IAC3E,0EAA0E;IAC1E,mEAAmE;IACnE,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACvC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC1B,MAAM,MAAM,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAC3F,IAAI,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QACrB,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAA;IACrD,CAAC;IACD,2EAA2E;IAC3E,kEAAkE;IAClE,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IACnD,OAAO,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,KAAK,GAAG,GAAG,EAAE,CAAA;AAC1E,CAAC"}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Audio loudness to mouth openness.
3
+ *
4
+ * The coarse path, and behind cloud speech-to-speech the ONLY path: an audio
5
+ * stream carries no phoneme timings, so this is not a shortcut here -- it is
6
+ * the whole option.
7
+ *
8
+ * ## Why this is relative rather than a fixed threshold
9
+ *
10
+ * mochi's version gated at an absolute 0.01 with a gain of 12. Measured against
11
+ * a live gpt-realtime session (2026-08-11) those
12
+ * constants turn out to WORK -- and to work for a reason nobody wrote down, on
13
+ * a scale nobody had checked:
14
+ *
15
+ * - the gap floor is 8.4e-5 median, 8.1e-4 at p99. So the gate cleared it by
16
+ * roughly 12x. Comfortable, but by luck rather than by design.
17
+ * - speech sits at 0.057 median and 0.137 at p90, while gain 12 saturates at
18
+ * 0.093. So more than a tenth of speech frames were pinned fully open --
19
+ * a mouth hitting its stop instead of modulating.
20
+ *
21
+ * Both numbers belong to one voice on one provider. A different voice, a
22
+ * provider change, or anyone adding a volume `GainNode` upstream moves them,
23
+ * and nothing in an absolute design would notice: her mouth would simply hang
24
+ * open, or stop moving, with nothing to say why.
25
+ *
26
+ * So openness is expressed against the signal's OWN recent floor and peak. The
27
+ * headline property is scale invariance -- multiply the input by any constant
28
+ * and the output is unchanged -- which is what makes it survive all three of
29
+ * those changes without being retuned.
30
+ *
31
+ * ## What the floor is NOT
32
+ *
33
+ * It is not room tone. There is no microphone on this path. Between phrases the
34
+ * stream carries codec comfort noise at ~1e-4, and it does NOT return to
35
+ * digital silence once media is flowing (measured: exact zero appears only
36
+ * before the first audio arrives). So a gate is genuinely required -- a zero
37
+ * gate leaves the mouth twitching on noise -- but it needs to sit just above a
38
+ * floor three orders of magnitude below speech, not at a hand-picked constant.
39
+ */
40
+ export interface EnvelopeSettings {
41
+ /**
42
+ * Gate, as a multiple of the tracked noise floor.
43
+ *
44
+ * 12 puts it at ~1e-3 against the measured floor, just clear of the loudest
45
+ * gap frame at 8.9e-4, and still 50x below median speech.
46
+ */
47
+ readonly floorMargin: number;
48
+ /** How much of the reference peak survives one second of quiet. */
49
+ readonly peakDecayPerSecond: number;
50
+ /**
51
+ * Floor under the reference peak, as a FRACTION of the loudest she has been.
52
+ *
53
+ * Without a floor, a long quiet passage decays the reference until a murmur
54
+ * throws her mouth wide open. It has to be a fraction rather than a level:
55
+ * an absolute one silently reintroduces exactly the scale dependence this
56
+ * module exists to remove, and it does it invisibly, because the arithmetic
57
+ * still looks relative.
58
+ */
59
+ readonly minPeakFraction: number;
60
+ /** How slowly the "loudest so far" reference fades, per second. */
61
+ readonly loudestDecayPerSecond: number;
62
+ /**
63
+ * How far the peak must stand above the floor before ANY of this counts as
64
+ * speech.
65
+ *
66
+ * The one thing a purely relative design cannot do on its own is notice that
67
+ * nothing is happening: given only comfort noise it will happily normalise
68
+ * that noise to full scale and mouth silently for as long as the gap lasts.
69
+ * Measured, speech stands ~680x above the floor and noise-only sits within
70
+ * ~2x of it, so the two are not close and this does not need to be delicate.
71
+ *
72
+ * A RATIO rather than a level, so it stays scale invariant -- which is the
73
+ * whole reason the absolute gate was removed.
74
+ */
75
+ readonly speechRatio: number;
76
+ /**
77
+ * Headroom above the running peak.
78
+ *
79
+ * Without it the loudest frame of every syllable maps to exactly 1, because
80
+ * it IS the peak -- so the mouth spends a seventh of her speech pinned at the
81
+ * stop instead of modulating. This buys back the top of the range.
82
+ */
83
+ readonly peakHeadroom: number;
84
+ /** How fast the tracked noise floor is allowed to rise, per second. */
85
+ readonly floorRisePerSecond: number;
86
+ /**
87
+ * How long `speaking` survives the last frame that cleared the ratio.
88
+ *
89
+ * A HOLD, in seconds, rather than a decay -- because bridging the dip between
90
+ * two syllables and dropping promptly at the end of a phrase are a problem
91
+ * about TIME, and every attempt to solve them with one decay rate trades one
92
+ * against the other. The judgement used to read the reference peak, which
93
+ * decays at 0.4 per second: from a 0.057 speech level down to 20x the
94
+ * measured 8.4e-5 floor is `ln(0.02)/ln(0.4)` = 4.27 SECONDS of "still
95
+ * talking" after she has stopped, on top of the caller's own 500ms debounce.
96
+ * The farewell waits on exactly this signal, so she could not finish saying
97
+ * goodbye and settle.
98
+ *
99
+ * 180ms spans an inter-syllable dip comfortably -- those land near a tenth of
100
+ * speech level, two orders above the gate, nowhere near the floor -- while
101
+ * the p99 gap frame at 8.1e-4 can only ever re-arm a hold this short.
102
+ */
103
+ readonly speechHoldSeconds: number;
104
+ /**
105
+ * Hard cap on the gate, as a fraction of the usable range.
106
+ *
107
+ * A backstop, not a tuning knob. The floor estimator can be wrong -- if the
108
+ * first audio frame of a session is loud it seeds high, and a signal with no
109
+ * gaps never gives it a chance to fall. Uncapped, the gate then sits above
110
+ * everything and her mouth never opens again, silently, for the rest of the
111
+ * run. This bounds that failure to a slightly insensitive mouth.
112
+ */
113
+ readonly maxGateFraction: number;
114
+ /** One-pole smoothing per frame. Raw per-frame RMS makes the mouth buzz. */
115
+ readonly smoothing: number;
116
+ }
117
+ export declare const DEFAULT_ENVELOPE: EnvelopeSettings;
118
+ export interface EnvelopeState {
119
+ /** 0..1, what to hand to setMouthOpen. */
120
+ readonly mouthOpen: number;
121
+ /** Fast-decaying reference for "as loud as this phrase gets". */
122
+ readonly peak: number;
123
+ /** Very slowly decaying reference for "as loud as she ever gets". */
124
+ readonly loudest: number;
125
+ /** Slow estimate of the between-phrase noise floor. */
126
+ readonly floor: number;
127
+ /**
128
+ * Whether this step judged the signal to be speech.
129
+ *
130
+ * Exposed because it is the only adaptive answer to "is she talking", and it
131
+ * was previously computed here and thrown away -- which left the caller to
132
+ * invent an absolute RMS threshold of its own. That contradicted the entire
133
+ * point of a relative envelope: comfort noise above the constant pinned her
134
+ * as permanently speaking, and quiet speech below it never registered at all.
135
+ */
136
+ readonly speaking: boolean;
137
+ /**
138
+ * Seconds since the last frame that stood clear of the floor.
139
+ *
140
+ * Carried in the state because the hold is the only part of this module with
141
+ * a memory measured in time rather than in levels.
142
+ */
143
+ readonly quietFor: number;
144
+ }
145
+ export declare const SILENT: EnvelopeState;
146
+ /** Root mean square of a block of samples, in 0..1 for normalised audio. */
147
+ export declare function rms(samples: Float32Array): number;
148
+ /**
149
+ * One step. The caller owns the state, so this is pure and testable against a
150
+ * recorded trace rather than against a live session.
151
+ */
152
+ export declare function advanceEnvelope(level: number, state: EnvelopeState, dtSeconds: number, settings?: EnvelopeSettings): EnvelopeState;
153
+ //# sourceMappingURL=envelope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.d.ts","sourceRoot":"","sources":["../../src/core/envelope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,mEAAmE;IACnE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,mEAAmE;IACnE,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAA;IACtC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,uEAAuE;IACvE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,eAAO,MAAM,gBAAgB,EAAE,gBAW9B,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qEAAqE;IACrE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,eAAO,MAAM,MAAM,EAAE,aAOpB,CAAA;AAED,4EAA4E;AAC5E,wBAAgB,GAAG,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAWjD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,aAAa,EACpB,SAAS,EAAE,MAAM,EACjB,QAAQ,GAAE,gBAAmC,GAC5C,aAAa,CAoEf"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Audio loudness to mouth openness.
3
+ *
4
+ * The coarse path, and behind cloud speech-to-speech the ONLY path: an audio
5
+ * stream carries no phoneme timings, so this is not a shortcut here -- it is
6
+ * the whole option.
7
+ *
8
+ * ## Why this is relative rather than a fixed threshold
9
+ *
10
+ * mochi's version gated at an absolute 0.01 with a gain of 12. Measured against
11
+ * a live gpt-realtime session (2026-08-11) those
12
+ * constants turn out to WORK -- and to work for a reason nobody wrote down, on
13
+ * a scale nobody had checked:
14
+ *
15
+ * - the gap floor is 8.4e-5 median, 8.1e-4 at p99. So the gate cleared it by
16
+ * roughly 12x. Comfortable, but by luck rather than by design.
17
+ * - speech sits at 0.057 median and 0.137 at p90, while gain 12 saturates at
18
+ * 0.093. So more than a tenth of speech frames were pinned fully open --
19
+ * a mouth hitting its stop instead of modulating.
20
+ *
21
+ * Both numbers belong to one voice on one provider. A different voice, a
22
+ * provider change, or anyone adding a volume `GainNode` upstream moves them,
23
+ * and nothing in an absolute design would notice: her mouth would simply hang
24
+ * open, or stop moving, with nothing to say why.
25
+ *
26
+ * So openness is expressed against the signal's OWN recent floor and peak. The
27
+ * headline property is scale invariance -- multiply the input by any constant
28
+ * and the output is unchanged -- which is what makes it survive all three of
29
+ * those changes without being retuned.
30
+ *
31
+ * ## What the floor is NOT
32
+ *
33
+ * It is not room tone. There is no microphone on this path. Between phrases the
34
+ * stream carries codec comfort noise at ~1e-4, and it does NOT return to
35
+ * digital silence once media is flowing (measured: exact zero appears only
36
+ * before the first audio arrives). So a gate is genuinely required -- a zero
37
+ * gate leaves the mouth twitching on noise -- but it needs to sit just above a
38
+ * floor three orders of magnitude below speech, not at a hand-picked constant.
39
+ */
40
+ export const DEFAULT_ENVELOPE = {
41
+ floorMargin: 12,
42
+ peakDecayPerSecond: 0.4,
43
+ minPeakFraction: 0.35,
44
+ loudestDecayPerSecond: 0.94,
45
+ speechRatio: 20,
46
+ peakHeadroom: 1.15,
47
+ floorRisePerSecond: 1.6,
48
+ speechHoldSeconds: 0.18,
49
+ maxGateFraction: 0.25,
50
+ smoothing: 0.35,
51
+ };
52
+ export const SILENT = {
53
+ mouthOpen: 0,
54
+ peak: 0,
55
+ loudest: 0,
56
+ floor: 0,
57
+ speaking: false,
58
+ quietFor: Number.POSITIVE_INFINITY,
59
+ };
60
+ /** Root mean square of a block of samples, in 0..1 for normalised audio. */
61
+ export function rms(samples) {
62
+ let sum = 0;
63
+ let counted = 0;
64
+ for (const sample of samples) {
65
+ // One bad sample from a decoder glitch would otherwise produce NaN and,
66
+ // through the filter, freeze the mouth for the rest of the session.
67
+ if (!Number.isFinite(sample))
68
+ continue;
69
+ sum += sample * sample;
70
+ counted++;
71
+ }
72
+ return counted === 0 ? 0 : Math.sqrt(sum / counted);
73
+ }
74
+ /**
75
+ * One step. The caller owns the state, so this is pure and testable against a
76
+ * recorded trace rather than against a live session.
77
+ */
78
+ export function advanceEnvelope(level, state, dtSeconds, settings = DEFAULT_ENVELOPE) {
79
+ const value = Number.isFinite(level) ? Math.max(0, level) : 0;
80
+ const dt = Number.isFinite(dtSeconds) ? Math.min(Math.max(dtSeconds, 0), 0.25) : 0;
81
+ // The floor falls to any new minimum at once and rises only slowly. That
82
+ // asymmetry is the point: a quiet moment is evidence about the floor, a loud
83
+ // one is not, so speech must not be able to drag the estimate up after it.
84
+ //
85
+ // A frame of DIGITAL SILENCE is skipped, and that is not a nicety. Zero is
86
+ // doing double duty here as "no estimate yet", and an exact zero -- a muted
87
+ // mic, a gap in the stream, a decoder handing back an empty block -- used to
88
+ // be accepted as a new minimum. The floor then read as uninitialised again,
89
+ // so the next frame with any signal in it seeded the floor AT SPEAKING
90
+ // LEVEL. From there `value > floor * 20` can never be true, and zero is an
91
+ // absorbing state for a multiplicative rise, so she stayed silent-looking
92
+ // through continuous speech with every flag reporting success.
93
+ //
94
+ // Skipping is also the physically honest reading: a dropout is the absence
95
+ // of a measurement, not a measurement of an extremely quiet room.
96
+ const rise = Math.pow(settings.floorRisePerSecond, dt);
97
+ const floor = value === 0
98
+ ? state.floor
99
+ : state.floor === 0
100
+ ? value
101
+ : value < state.floor
102
+ ? value
103
+ : Math.min(value, state.floor * rise);
104
+ // The peak rises instantly and decays slowly, so a single loud syllable sets
105
+ // the reference for the phrase around it rather than only for its own frame.
106
+ const peak = Math.max(value, state.peak * Math.pow(settings.peakDecayPerSecond, dt));
107
+ // And a much slower one behind it, so the phrase reference cannot fall so far
108
+ // during a pause that the next murmur reads as a shout.
109
+ const loudest = Math.max(value, state.loudest * Math.pow(settings.loudestDecayPerSecond, dt));
110
+ // Nothing is happening unless the signal stands well clear of the floor. This
111
+ // is the one judgement a relative design cannot derive from the ratio it is
112
+ // currently computing, because on noise alone that ratio is 1.
113
+ //
114
+ // Against the CURRENT value, held open for `speechHoldSeconds`. It used to
115
+ // read `peak`, whose whole purpose is to decay slowly so one loud syllable
116
+ // sets the reference for the phrase around it -- a good property for scaling
117
+ // the mouth and a disastrous one for deciding she has stopped, because the
118
+ // decay outlives the speech by seconds. Two jobs, one number; they are now
119
+ // two numbers.
120
+ const clear = value > floor * settings.speechRatio;
121
+ const quietFor = clear ? 0 : state.quietFor + dt;
122
+ const speaking = quietFor < settings.speechHoldSeconds;
123
+ const ceiling = Math.max(peak * settings.peakHeadroom, loudest * settings.minPeakFraction);
124
+ // Capped, so a floor estimate that seeded high cannot swallow the whole
125
+ // range. Both terms scale with the signal, which is what keeps the result
126
+ // invariant under a change of level.
127
+ const gate = Math.min(floor * settings.floorMargin, ceiling * settings.maxGateFraction);
128
+ const span = ceiling - gate;
129
+ const target = !speaking || span <= 0 ? 0 : Math.min(1, Math.max(0, (value - gate) / span));
130
+ const smoothing = Math.min(1, Math.max(0, settings.smoothing));
131
+ const previous = Number.isFinite(state.mouthOpen) ? state.mouthOpen : 0;
132
+ return {
133
+ mouthOpen: previous + (target - previous) * smoothing,
134
+ peak,
135
+ loudest,
136
+ floor,
137
+ speaking,
138
+ quietFor,
139
+ };
140
+ }
141
+ //# sourceMappingURL=envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.js","sourceRoot":"","sources":["../../src/core/envelope.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The mochi's silhouette, as arithmetic.
3
+ *
4
+ * Deliberately canvas-free: these functions return points, and the backend
5
+ * turns points into a Path2D. That is what makes the shape testable without a
6
+ * rasteriser, and it is also what makes hit testing exact -- the polygon that
7
+ * is filled and the polygon that is hit-tested are the same array.
8
+ *
9
+ * Local space throughout: origin at the point where she meets the surface,
10
+ * +y UP. The renderer flips. Heights read as positive numbers here, which is
11
+ * worth the one transform at the boundary.
12
+ *
13
+ * The shape is an OVOID, not a dome, and that came from measuring
14
+ * `rig/__fixtures__/mochi-icon.png` rather than from taste. The first version of this file
15
+ * was a single superellipse planted on a flat base, which put the widest point
16
+ * at the bottom -- and the icon's widest point is at 0.295 of its height, with
17
+ * a rounded underside. Those are different creatures, and the difference is
18
+ * exactly what makes one read as a dumpling and the other as a mochi.
19
+ */
20
+ export interface Point {
21
+ readonly x: number;
22
+ readonly y: number;
23
+ }
24
+ export interface BodyShape {
25
+ readonly halfWidth: number;
26
+ readonly height: number;
27
+ /**
28
+ * Height of the widest point, as a fraction of height.
29
+ *
30
+ * Measured 0.295 on the icon. At 0 this degenerates to the old flat-based
31
+ * dome; at 0.5 it is a symmetric egg.
32
+ */
33
+ readonly waist: number;
34
+ /**
35
+ * Superellipse exponent ABOVE the waist. Measured 1.86.
36
+ *
37
+ * Below 2 is pointier than an ellipse, which is what gives the icon its soft
38
+ * peak rather than a hemispherical top.
39
+ */
40
+ readonly upperShoulder: number;
41
+ /**
42
+ * Superellipse exponent BELOW the waist. Measured 2.58.
43
+ *
44
+ * Above 2 is fuller than an ellipse: the underside stays wide and then turns
45
+ * quickly, which is what makes her look like she is resting her weight on the
46
+ * desk instead of balancing on a curve.
47
+ */
48
+ readonly lowerShoulder: number;
49
+ /** -1..1. Shear that grows with height, so she leans like dough rather than tipping like a box. */
50
+ readonly lean: number;
51
+ }
52
+ /**
53
+ * Squash, preserving area.
54
+ *
55
+ * `halfWidth * factor` and `height / factor`: the ovoid's area is
56
+ * proportional to the product for fixed exponents, so this is exactly
57
+ * volume-preserving. That is the property that makes it read as dough being
58
+ * compressed rather than as a picture being scaled.
59
+ *
60
+ * The factor is floored well above zero. A squash of -1 would otherwise divide
61
+ * by zero and produce an infinitely tall mochi.
62
+ */
63
+ export declare function squashed(shape: BodyShape, amount: number): BodyShape;
64
+ /**
65
+ * Horizontal displacement at a given height above the base.
66
+ *
67
+ * Zero at the base and maximal at the apex, which is what distinguishes a lean
68
+ * from a translation: her contact point stays where it was.
69
+ */
70
+ export declare function shearAt(shape: BodyShape, heightAboveBase: number): number;
71
+ /**
72
+ * Half-width at a height, as a fraction of halfWidth.
73
+ *
74
+ * Exported because it is the cheapest way to compare this shape against the
75
+ * measured icon: the test walks heights and checks the profile, which pins the
76
+ * silhouette to the artwork rather than to whatever looked right on the day.
77
+ */
78
+ export declare function widthAt(shape: BodyShape, heightFraction: number): number;
79
+ /**
80
+ * The closed outline, from the contact point up the right side and back down
81
+ * the left.
82
+ *
83
+ * The base sits exactly on y = 0 and the body occupies y > 0, so "resting on a
84
+ * surface" is a property of the coordinates rather than something the caller
85
+ * has to arrange. Squash therefore grows upward, which is how something resting
86
+ * on a table actually deforms.
87
+ */
88
+ export declare function domeOutline(shape: BodyShape, steps?: number): Point[];
89
+ /**
90
+ * Where a feature sits, in body-local space.
91
+ *
92
+ * `grip` is the point of this function. At 1 the face deforms exactly as much
93
+ * as the body and looks printed on; at 0 it floats, unaffected. Real features
94
+ * on something soft resist deformation without escaping it, so the placement
95
+ * interpolates between the squashed and unsquashed frames. That single number
96
+ * is most of what makes it read as a face ON something rather than a decal.
97
+ *
98
+ * The lean is measured in that SAME interpolated frame. It used to be taken
99
+ * from the squashed one unconditionally, which quietly broke the contract: a
100
+ * zero-grip feature was placed at its undeformed width and then displaced by a
101
+ * shear computed from the wider, shorter squashed body -- so it moved
102
+ * horizontally during a squash it was supposed to be ignoring. At grip 1 the
103
+ * interpolated frame IS the squashed frame, so nothing about her changes.
104
+ */
105
+ export declare function placeFeature(base: BodyShape, squashedShape: BodyShape, normalisedX: number, normalisedY: number, gripX: number, gripY: number): Point;
106
+ //# sourceMappingURL=geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"geometry.d.ts","sourceRoot":"","sources":["../../src/core/geometry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAoBH,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,mGAAmG;IACnG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,CAIpE;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,CAIzE;AAQD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAWxE;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,SAAK,GAAG,KAAK,EAAE,CAuCjE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,SAAS,EACf,aAAa,EAAE,SAAS,EACxB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,KAAK,CAMP"}