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.
- package/BRAND.md +85 -0
- package/LICENSE.md +104 -0
- package/README.md +135 -0
- package/dist/canvas2d/avatar.d.ts +264 -0
- package/dist/canvas2d/avatar.d.ts.map +1 -0
- package/dist/canvas2d/avatar.js +811 -0
- package/dist/canvas2d/avatar.js.map +1 -0
- package/dist/canvas2d/face.d.ts +74 -0
- package/dist/canvas2d/face.d.ts.map +1 -0
- package/dist/canvas2d/face.js +298 -0
- package/dist/canvas2d/face.js.map +1 -0
- package/dist/canvas2d/paths.d.ts +13 -0
- package/dist/canvas2d/paths.d.ts.map +1 -0
- package/dist/canvas2d/paths.js +24 -0
- package/dist/canvas2d/paths.js.map +1 -0
- package/dist/characters/colourways.d.ts +78 -0
- package/dist/characters/colourways.d.ts.map +1 -0
- package/dist/characters/colourways.js +76 -0
- package/dist/characters/colourways.js.map +1 -0
- package/dist/characters/index.d.ts +8 -0
- package/dist/characters/index.d.ts.map +1 -0
- package/dist/characters/index.js +8 -0
- package/dist/characters/index.js.map +1 -0
- package/dist/characters/mochi.d.ts +19 -0
- package/dist/characters/mochi.d.ts.map +1 -0
- package/dist/characters/mochi.js +66 -0
- package/dist/characters/mochi.js.map +1 -0
- package/dist/core/colour.d.ts +25 -0
- package/dist/core/colour.d.ts.map +1 -0
- package/dist/core/colour.js +37 -0
- package/dist/core/colour.js.map +1 -0
- package/dist/core/envelope.d.ts +153 -0
- package/dist/core/envelope.d.ts.map +1 -0
- package/dist/core/envelope.js +141 -0
- package/dist/core/envelope.js.map +1 -0
- package/dist/core/geometry.d.ts +106 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +157 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/idle.d.ts +163 -0
- package/dist/core/idle.d.ts.map +1 -0
- package/dist/core/idle.js +262 -0
- package/dist/core/idle.js.map +1 -0
- package/dist/core/layout.d.ts +142 -0
- package/dist/core/layout.d.ts.map +1 -0
- package/dist/core/layout.js +172 -0
- package/dist/core/layout.js.map +1 -0
- package/dist/core/lens.d.ts +47 -0
- package/dist/core/lens.d.ts.map +1 -0
- package/dist/core/lens.js +67 -0
- package/dist/core/lens.js.map +1 -0
- package/dist/core/looks.d.ts +77 -0
- package/dist/core/looks.d.ts.map +1 -0
- package/dist/core/looks.js +168 -0
- package/dist/core/looks.js.map +1 -0
- package/dist/core/motion.d.ts +168 -0
- package/dist/core/motion.d.ts.map +1 -0
- package/dist/core/motion.js +527 -0
- package/dist/core/motion.js.map +1 -0
- package/dist/core/mouth.d.ts +93 -0
- package/dist/core/mouth.d.ts.map +1 -0
- package/dist/core/mouth.js +92 -0
- package/dist/core/mouth.js.map +1 -0
- package/dist/core/plain.d.ts +23 -0
- package/dist/core/plain.d.ts.map +1 -0
- package/dist/core/plain.js +63 -0
- package/dist/core/plain.js.map +1 -0
- package/dist/core/spec.d.ts +156 -0
- package/dist/core/spec.d.ts.map +1 -0
- package/dist/core/spec.js +236 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/spring.d.ts +53 -0
- package/dist/core/spring.d.ts.map +1 -0
- package/dist/core/spring.js +68 -0
- package/dist/core/spring.js.map +1 -0
- package/dist/core/vocabulary.d.ts +182 -0
- package/dist/core/vocabulary.d.ts.map +1 -0
- package/dist/core/vocabulary.js +66 -0
- package/dist/core/vocabulary.js.map +1 -0
- package/dist/element/dough-avatar.d.ts +34 -0
- package/dist/element/dough-avatar.d.ts.map +1 -0
- package/dist/element/dough-avatar.js +189 -0
- package/dist/element/dough-avatar.js.map +1 -0
- package/dist/element/index.d.ts +3 -0
- package/dist/element/index.d.ts.map +1 -0
- package/dist/element/index.js +35 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/svg/silhouette.d.ts +97 -0
- package/dist/svg/silhouette.d.ts.map +1 -0
- package/dist/svg/silhouette.js +143 -0
- package/dist/svg/silhouette.js.map +1 -0
- 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"}
|