sonarium 0.6.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/index.d.ts +1000 -0
  4. package/dist/index.js +2716 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sonarium.iife.js +236 -0
  7. package/dist/sonarium.iife.js.map +1 -0
  8. package/package.json +63 -0
  9. package/src/core/engine.ts +468 -0
  10. package/src/core/listener.ts +61 -0
  11. package/src/core/matter-voice.ts +240 -0
  12. package/src/core/profile.ts +313 -0
  13. package/src/core/room.ts +135 -0
  14. package/src/core/scanner.ts +176 -0
  15. package/src/core/voices.ts +222 -0
  16. package/src/index.ts +74 -0
  17. package/src/interact/activate.ts +56 -0
  18. package/src/interact/drag.ts +52 -0
  19. package/src/interact/keyboard.ts +48 -0
  20. package/src/interact/midi.ts +40 -0
  21. package/src/interact/motion.ts +61 -0
  22. package/src/interact/pointer.ts +65 -0
  23. package/src/interact/scroll.ts +43 -0
  24. package/src/math/chroma.ts +111 -0
  25. package/src/math/mapping.ts +100 -0
  26. package/src/math/matter.ts +201 -0
  27. package/src/math/modular.ts +99 -0
  28. package/src/math/pulse.ts +64 -0
  29. package/src/math/scales.ts +90 -0
  30. package/src/math/util.ts +16 -0
  31. package/src/spatial/backend.ts +166 -0
  32. package/src/spatial/bus.ts +114 -0
  33. package/src/spatial/decoder.ts +63 -0
  34. package/src/spatial/encoder.ts +54 -0
  35. package/src/spatial/field.ts +75 -0
  36. package/src/spatial/perceptual.ts +43 -0
  37. package/src/spatial/room-foa.ts +81 -0
  38. package/src/spatial/rotation.ts +73 -0
  39. package/src/spatial/sh.ts +38 -0
  40. package/src/spatial/sphere.ts +36 -0
  41. package/src/themes/index.ts +60 -0
  42. package/src/types.ts +157 -0
  43. package/src/ui/gate.ts +65 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * L2 — embodied motion: device tilt steers the ears (I10), a shake strums the visible
3
+ * page left→right (I11). iOS 13+ requires a permission request from a user gesture; attach()
4
+ * runs inside the unlock gesture, so we ask immediately and degrade silently if refused.
5
+ */
6
+ import type { Engine } from '../core/engine'
7
+
8
+ const SHAKE_THRESHOLD = 18 // m/s² deviation from gravity
9
+ const SHAKE_REFRACTORY_MS = 600
10
+
11
+ export function attachMotion(engine: Engine): () => void {
12
+ if (typeof DeviceOrientationEvent === 'undefined') return () => {}
13
+
14
+ let lastShake = 0
15
+ let attached = false
16
+
17
+ const onOrientation = (e: DeviceOrientationEvent) => {
18
+ if (e.gamma == null || e.beta == null) return
19
+ engine.rig?.tiltTo(e.gamma, e.beta)
20
+ }
21
+
22
+ const onMotion = (e: DeviceMotionEvent) => {
23
+ const a = e.accelerationIncludingGravity
24
+ if (!a || a.x == null || a.y == null || a.z == null) return
25
+ const magnitude = Math.abs(Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z) - 9.81)
26
+ const now = performance.now()
27
+ if (now - lastShake <= SHAKE_REFRACTORY_MS) return
28
+ if (magnitude > SHAKE_THRESHOLD) {
29
+ lastShake = now
30
+ engine.strum(engine.scanner.visibleElements(), 0.5)
31
+ return
32
+ }
33
+ // Directional flick (v0.6 gesture grammar): a horizontal jab strums in its direction.
34
+ if (Math.abs(a.x) > 12 && Math.abs(a.x) > 2 * Math.abs(a.y)) {
35
+ lastShake = now
36
+ engine.strum(engine.scanner.visibleElements(), 0.4, 'strum', a.x > 0)
37
+ }
38
+ }
39
+
40
+ const listen = () => {
41
+ if (attached) return
42
+ attached = true
43
+ window.addEventListener('deviceorientation', onOrientation, { passive: true })
44
+ window.addEventListener('devicemotion', onMotion, { passive: true })
45
+ }
46
+
47
+ const request = (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<string> }).requestPermission
48
+ if (typeof request === 'function') {
49
+ request.call(DeviceOrientationEvent)
50
+ .then((state) => { if (state === 'granted') listen() })
51
+ .catch(() => { /* user said no, or we were outside a gesture — stay silent */ })
52
+ } else {
53
+ listen()
54
+ }
55
+
56
+ return () => {
57
+ if (!attached) return
58
+ window.removeEventListener('deviceorientation', onOrientation)
59
+ window.removeEventListener('devicemotion', onMotion)
60
+ }
61
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * L2 — pointer: hover previews (I1) + look-around (I9).
3
+ * Ghost-trigger guards (v0.5, user-reported): a "hover" only counts when the POINTER moved onto
4
+ * the element. Scrolling moves elements under a stationary cursor and DOM mutations appear under
5
+ * it — both fire pointerover without any user hover intent, and both are suppressed here.
6
+ */
7
+ import type { Engine } from '../core/engine'
8
+ import type { Role } from '../types'
9
+
10
+ const PREVIEW_ROLES: ReadonlySet<Role> = new Set(['button', 'link', 'toggle', 'input', 'item', 'heading', 'media', 'text'])
11
+ const HOVER_THROTTLE_MS = 80
12
+ /** pointerover within this window after a scroll = the page moved, not the hand. */
13
+ const SCROLL_SUPPRESS_MS = 250
14
+ /** pointerover needs pointer movement at most this long ago to count as intentional. */
15
+ const MOVE_FRESHNESS_MS = 400
16
+ /** The resolved element must be within this many ancestors of the actual target —
17
+ * hovering a blank region must not preview some distant registered container. */
18
+ const MAX_RESOLVE_HOPS = 4
19
+
20
+ export function attachPointer(engine: Engine): () => void {
21
+ const lastHover = new WeakMap<Element, number>()
22
+ let lastScrollT = -Infinity
23
+ let lastMoveT = -Infinity
24
+
25
+ const onMove = (e: PointerEvent) => {
26
+ lastMoveT = performance.now()
27
+ engine.rig?.pointTo(e.clientX / window.innerWidth, e.clientY / window.innerHeight)
28
+ }
29
+
30
+ const onScroll = () => {
31
+ lastScrollT = performance.now()
32
+ }
33
+
34
+ const withinHops = (target: Element, resolved: Element): boolean => {
35
+ let n: Element | null = target
36
+ for (let d = 0; n && d <= MAX_RESOLVE_HOPS; d++) {
37
+ if (n === resolved) return true
38
+ n = n.parentElement
39
+ }
40
+ return false
41
+ }
42
+
43
+ const onOver = (e: PointerEvent) => {
44
+ const now = performance.now()
45
+ if (now - lastScrollT < SCROLL_SUPPRESS_MS) return // page slid under the cursor
46
+ if (now - lastMoveT > MOVE_FRESHNESS_MS) return // nothing moved the pointer here
47
+ const target = e.target as Element | null
48
+ const el = engine.scanner?.resolve(target)
49
+ if (!el || !target || !withinHops(target, el)) return
50
+ const profile = engine.scanner.profileFor(el)
51
+ if (!profile || !PREVIEW_ROLES.has(profile.role)) return
52
+ if (now - (lastHover.get(el) ?? -Infinity) < HOVER_THROTTLE_MS) return
53
+ lastHover.set(el, now)
54
+ engine.excite(el, 0.25, 'preview')
55
+ }
56
+
57
+ window.addEventListener('pointermove', onMove, { passive: true })
58
+ window.addEventListener('pointerover', onOver, { passive: true })
59
+ window.addEventListener('scroll', onScroll, { passive: true, capture: true })
60
+ return () => {
61
+ window.removeEventListener('pointermove', onMove)
62
+ window.removeEventListener('pointerover', onOver)
63
+ window.removeEventListener('scroll', onScroll, { capture: true })
64
+ }
65
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * L2 — scroll & resize: cached rects go stale (I8), the room follows the viewport (S7/S8),
3
+ * and scroll velocity becomes air rushing past (MATTER.md §2.2).
4
+ */
5
+ import { airRushGain } from '../math/matter'
6
+ import type { Engine } from '../core/engine'
7
+
8
+ export function attachScroll(engine: Engine): () => void {
9
+ let scrollQueued = false
10
+ let lastY = window.scrollY
11
+ let lastT = performance.now()
12
+ const onScroll = () => {
13
+ if (scrollQueued) return
14
+ scrollQueued = true
15
+ requestAnimationFrame(() => {
16
+ scrollQueued = false
17
+ engine.geometryChanged()
18
+ const now = performance.now()
19
+ const dt = Math.max(1, now - lastT)
20
+ const v = Math.abs(window.scrollY - lastY) / dt // px per ms
21
+ lastY = window.scrollY
22
+ lastT = now
23
+ engine.airRush(airRushGain(v))
24
+ })
25
+ }
26
+
27
+ let resizeQueued = false
28
+ const onResize = () => {
29
+ if (resizeQueued) return
30
+ resizeQueued = true
31
+ requestAnimationFrame(() => {
32
+ resizeQueued = false
33
+ engine.roomResized()
34
+ })
35
+ }
36
+
37
+ window.addEventListener('scroll', onScroll, { passive: true, capture: true })
38
+ window.addEventListener('resize', onResize, { passive: true })
39
+ return () => {
40
+ window.removeEventListener('scroll', onScroll, { capture: true })
41
+ window.removeEventListener('resize', onResize)
42
+ }
43
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The Chroma weave — CHROMA.md made executable. Color tints the Matter voice per element and
3
+ * chooses the musical mode per page. Pure: no DOM, no Tone.
4
+ */
5
+ import { clamp, lerp } from './util'
6
+
7
+ export interface Rgb {
8
+ r: number
9
+ g: number
10
+ b: number
11
+ a: number
12
+ }
13
+
14
+ export interface Hsl {
15
+ h: number
16
+ s: number
17
+ l: number
18
+ }
19
+
20
+ export interface Chroma {
21
+ /** 0 cool … 1 warm (desaturated colors regress to 0.5) */
22
+ warmth: number
23
+ saturation: number
24
+ luminance: number
25
+ }
26
+
27
+ /** Computed styles emit rgb()/rgba(). Returns null for anything else (treat as no color). */
28
+ export function parseCssColor(css: string): Rgb | null {
29
+ const m = css.trim().match(/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*(?:,\s*(\d*(?:\.\d+)?)\s*)?\)$/)
30
+ if (!m) return null
31
+ return {
32
+ r: clamp(parseFloat(m[1] as string) / 255, 0, 1),
33
+ g: clamp(parseFloat(m[2] as string) / 255, 0, 1),
34
+ b: clamp(parseFloat(m[3] as string) / 255, 0, 1),
35
+ a: m[4] === undefined ? 1 : clamp(parseFloat(m[4] as string), 0, 1),
36
+ }
37
+ }
38
+
39
+ export function rgbToHsl({ r, g, b }: Rgb): Hsl {
40
+ const max = Math.max(r, g, b)
41
+ const min = Math.min(r, g, b)
42
+ const l = (max + min) / 2
43
+ const d = max - min
44
+ if (d < 1e-6) return { h: 0, s: 0, l }
45
+ const s = d / (1 - Math.abs(2 * l - 1))
46
+ let h: number
47
+ if (max === r) h = ((g - b) / d) % 6
48
+ else if (max === g) h = (b - r) / d + 2
49
+ else h = (r - g) / d + 4
50
+ h *= 60
51
+ if (h < 0) h += 360
52
+ return { h, s: clamp(s, 0, 1), l }
53
+ }
54
+
55
+ /** CHROMA.md §1 — cosine distance of hue from 30° (orange); greys regress to neutral 0.5. */
56
+ export function warmthFromHue(h: number, s: number): number {
57
+ const raw = 0.5 + 0.5 * Math.cos(((h - 30) * Math.PI) / 180)
58
+ return lerp(0.5, raw, clamp(s * 2, 0, 1))
59
+ }
60
+
61
+ export function chromaOf(rgb: Rgb | null): Chroma {
62
+ if (!rgb || rgb.a < 0.05) return { warmth: 0.5, saturation: 0, luminance: 0.5 }
63
+ const { h, s, l } = rgbToHsl(rgb)
64
+ return { warmth: warmthFromHue(h, s), saturation: s, luminance: l }
65
+ }
66
+
67
+ // ------------------------------------------------------------- element weave (CH1–CH5)
68
+
69
+ /** CH1 — dark UI sounds dark. */
70
+ export const brightnessFromLuminance = (l: number): number => lerp(0.78, 1.22, clamp(l, 0, 1))
71
+
72
+ /** CH2 — bright lifts, gently. */
73
+ export const velocityFromLuminance = (l: number): number => lerp(0.92, 1.06, clamp(l, 0, 1))
74
+
75
+ /** CH3 — warm = energetic onset (thesis T5). Multiplier on the woven attack. */
76
+ export const attackScaleFromWarmth = (w: number): number => lerp(1.18, 0.82, clamp(w, 0, 1))
77
+
78
+ /** CH4 — warm = full-bodied: bonus on the sub oscillator level (subs only, not shimmer). */
79
+ export const subBonusFromWarmth = (w: number): number => 0.08 * clamp(w, 0, 1)
80
+
81
+ /** CH5 — vivid color = vivid spectrum: richness passed to genPartials (rolloff reduction). */
82
+ export const richnessFromSaturation = (s: number): number => 0.35 * clamp(s, 0, 1)
83
+
84
+ // ------------------------------------------------------------- page weave (CH6–CH8)
85
+
86
+ export type ModeName = 'lydian' | 'mixolydian' | 'dorian' | 'pentMinor' | null
87
+
88
+ /**
89
+ * CH6 — the palette chooses the mode; null = neutral, keep the hostname-hashed scale
90
+ * (identity unchanged). Root pitch-class always stays hostname-hashed (S9).
91
+ */
92
+ export function modeFromPalette(pal: Chroma): ModeName {
93
+ if (pal.warmth >= 0.55) return pal.luminance >= 0.5 ? 'lydian' : 'mixolydian'
94
+ if (pal.warmth <= 0.45) return pal.luminance >= 0.5 ? 'dorian' : 'pentMinor'
95
+ return null
96
+ }
97
+
98
+ /** Visual weight of the page: bg dominates, text tints. */
99
+ export function pagePalette(bg: Chroma, text: Chroma): Chroma {
100
+ return {
101
+ warmth: bg.warmth * 0.7 + text.warmth * 0.3,
102
+ saturation: bg.saturation * 0.7 + text.saturation * 0.3,
103
+ luminance: bg.luminance * 0.7 + text.luminance * 0.3,
104
+ }
105
+ }
106
+
107
+ /** CH7 — warm rooms hum warmer. Multiplier on the ambience cutoff. */
108
+ export const roomToneScaleFromWarmth = (w: number): number => lerp(0.85, 1.25, clamp(w, 0, 1))
109
+
110
+ /** CH8 — warm pages run slightly faster (consumed by PULSE.md P2). */
111
+ export const tempoScaleFromWarmth = (w: number): number => lerp(0.94, 1.06, clamp(w, 0, 1))
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Every formula in MAPPING.md §1–2, one named pure function each.
3
+ * This file is the executable form of the Mapping Canon: no DOM, no Tone, no side effects.
4
+ * Change a formula here ⇄ change MAPPING.md in the same commit (PLAN.md Invariant #3).
5
+ */
6
+ import { clamp, lerp, norm } from './util'
7
+ import type { Rect, Wave } from '../types'
8
+
9
+ export const ROOM_HALF_W = 8 // meters — G1
10
+ export const ROOM_HALF_H = 4 // meters — G2
11
+
12
+ /** G1 — element center x → azimuth (positionX, meters). */
13
+ export function panX(rect: Rect, vw: number): number {
14
+ return (2 * norm(rect.x + rect.w / 2, 0, vw) - 1) * ROOM_HALF_W
15
+ }
16
+
17
+ /** G2 — element center y → elevation (positionY, meters; screen-top = up). */
18
+ export function panY(rect: Rect, vh: number): number {
19
+ return (1 - 2 * norm(rect.y + rect.h / 2, 0, vh)) * ROOM_HALF_H
20
+ }
21
+
22
+ /** G3 — vertical position → brightness tilt multiplier on the filter cutoff. */
23
+ export function brightnessTilt(rect: Rect, vh: number): number {
24
+ return lerp(1.45, 0.7, norm(rect.y + rect.h / 2, 0, vh))
25
+ }
26
+
27
+ /** G4 — log-normalized size 0 (tiny) … 1 (viewport-filling). */
28
+ export function sizeT(rect: Rect, vw: number, vh: number): number {
29
+ const a = Math.max(1, rect.w * rect.h)
30
+ const A = Math.max(1, vw * vh)
31
+ return norm(Math.log(1 + (100 * a) / A), 0, Math.log(101))
32
+ }
33
+
34
+ /**
35
+ * G4 — pitch degree, inverted: big elements speak low (T2). Compressed into [0.1, 0.85] so
36
+ * sibling/heading step offsets have melodic headroom instead of clamping at the edges.
37
+ */
38
+ export const degreeFromSize = (t: number): number => 0.1 + 0.75 * (1 - t)
39
+
40
+ /** G5 — big elements speak louder (T2). */
41
+ export const velocityFromSize = (t: number): number => lerp(0.85, 1.25, t)
42
+
43
+ /** G6 — roundness r ∈ [0,1]: 0 = razor corner (kiki), 1 = pill/circle (bouba). */
44
+ export function roundness(radiusPx: number, rect: Rect): number {
45
+ const half = Math.min(rect.w, rect.h) / 2
46
+ return half <= 0 ? 0 : clamp(radiusPx / half, 0, 1)
47
+ }
48
+
49
+ /** G7 — the Kiki/Bouba waveform ladder. */
50
+ export function waveFromRoundness(r: number): Wave {
51
+ if (r < 0.15) return 'square'
52
+ if (r < 0.45) return 'sawtooth'
53
+ if (r < 0.8) return 'triangle'
54
+ return 'sine'
55
+ }
56
+
57
+ /** G8 — sharp = plosive onset, round = soft bloom. Seconds. */
58
+ export const attackFromRoundness = (r: number): number => lerp(0.002, 0.045, r)
59
+
60
+ /** G9 — sharp edges ring with resonance. */
61
+ export const qFromRoundness = (r: number): number => lerp(2.4, 0.5, r)
62
+
63
+ /** G10 — sharper ↔ slightly higher (T1), in whole scale steps. */
64
+ export const pitchNudgeFromRoundness = (r: number): number => (1 - r) * 1
65
+
66
+ /** G11 — elongation → duration: long elements sweep, squares tick. Seconds. */
67
+ export function durationFromElongation(rect: Rect): number {
68
+ const e = Math.max(rect.w, rect.h) / Math.max(1, Math.min(rect.w, rect.h))
69
+ return clamp(0.18 * Math.sqrt(e), 0.12, 1.6)
70
+ }
71
+
72
+ /** G13 — box-shadow blur lifts the element into the room's reverb. */
73
+ export const sendFromShadowBlur = (blurPx: number): number => clamp(blurPx / 40, 0, 0.5)
74
+
75
+ /** S1 — DOM depth → distance behind the sound stage (listener at z=0 facing −z). */
76
+ export const zFromDepth = (depth: number): number => -(1 + 0.7 * Math.min(depth, 10))
77
+
78
+ /** S2 — deeper nesting = duller voice. Hz. */
79
+ export const cutoffFromDepth = (depth: number): number => Math.max(700, 9000 * Math.pow(0.82, depth))
80
+
81
+ /** S3 — deeper nesting = quieter (distance loudness, Zahorik 2002). */
82
+ export const velocityFromDepth = (depth: number): number => Math.max(0.55, Math.pow(0.97, depth))
83
+
84
+ /** S5 — positive z-index pulls the element toward the listener. Meters toward z=0. */
85
+ export const zBonusFromZIndex = (z: number): number => clamp(z / 50, 0, 1) * 0.8
86
+
87
+ /** S4 — siblings climb the scale, wrapping each octave (do-re-mi…-do) so long lists stay melodic. */
88
+ export const stepsFromSiblingIndex = (i: number, scaleLen = 7): number => i % Math.max(1, scaleLen)
89
+
90
+ /** S6 — headings: h1 lands lowest/grandest. Whole scale steps (negative = down). */
91
+ export const stepsFromHeadingLevel = (level: number): number => -(7 - clamp(level, 1, 6)) * 2
92
+
93
+ /** S7/S8 — the viewport is the room. */
94
+ export function reverbFromViewport(vw: number): { decay: number; wet: number } {
95
+ const t = norm(vw, 360, 2200)
96
+ return { decay: lerp(0.6, 4.5, t), wet: lerp(0.08, 0.32, t) }
97
+ }
98
+
99
+ /** I13 — room tone color follows room size. Hz. */
100
+ export const ambienceCutoffFromViewport = (vw: number): number => lerp(400, 1400, norm(vw, 360, 2200))
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Matter weave — MATTER.md made executable. Four macro-dimensions condensed from visual
3
+ * properties, each woven into bundles of co-varying synthesis parameters so the ear hears one
4
+ * coherent object (Bregman fusion; Grey/McAdams timbre axes). Pure: no DOM, no Tone.
5
+ * Change a formula here ⇄ change MATTER.md §2 in the same commit (Invariant #3).
6
+ */
7
+ import { clamp, lerp } from './util'
8
+
9
+ export interface Matter {
10
+ /** boundary abruptness, kiki↔bouba: 1 − roundness */
11
+ edge: number
12
+ /** size/weight: log-area sizeT */
13
+ mass: number
14
+ /** surface noisiness/airiness: shadows, translucency, dashed borders, media */
15
+ texture: number
16
+ /** distance into the room: normalized DOM depth */
17
+ air: number
18
+ }
19
+
20
+ export interface MatterVisuals {
21
+ roundness: number
22
+ sizeT: number
23
+ depth: number
24
+ shadowBlurPx: number
25
+ opacity: number
26
+ dashedBorder: boolean
27
+ isMedia: boolean
28
+ backdropBlurPx: number
29
+ /** MODULAR.md M7 — typography enters the weave (bold text carries weight). Default 0. */
30
+ massBonus?: number
31
+ }
32
+
33
+ export function deriveMatter(v: MatterVisuals): Matter {
34
+ const texture = clamp(
35
+ clamp(v.shadowBlurPx / 40, 0, 0.4) +
36
+ (1 - clamp(v.opacity, 0, 1)) * 0.6 +
37
+ (v.dashedBorder ? 0.15 : 0) +
38
+ (v.isMedia ? 0.35 : 0) +
39
+ clamp(v.backdropBlurPx / 40, 0, 0.2),
40
+ 0,
41
+ 1,
42
+ )
43
+ return {
44
+ edge: clamp(1 - v.roundness, 0, 1),
45
+ mass: clamp(v.sizeT + (v.massBonus ?? 0), 0, 1),
46
+ texture,
47
+ air: clamp(Math.min(v.depth, 10) / 10, 0, 1),
48
+ }
49
+ }
50
+
51
+ // ------------------------------------------------------------------ spectrum (MATTER.md §2.1)
52
+
53
+ export const PARTIAL_COUNT = 24
54
+
55
+ /**
56
+ * Continuous spectrum: EDGE sets the rolloff (bright↔pure), elongation sets hollowness
57
+ * (long thin elements = pipes = odd harmonics), richness (CHROMA.md CH5: saturation)
58
+ * subtracts from the rolloff exponent — vivid color = vivid spectrum. Normalized to Σa² = 1
59
+ * so the whole continuum sits at equal loudness.
60
+ */
61
+ export function genPartials(edge: number, elongation: number, richness = 0): Float32Array {
62
+ const p = Math.max(0.8, 1 + 2.6 * (1 - clamp(edge, 0, 1)) - clamp(richness, 0, 0.5))
63
+ const evenness = lerp(1, 0.12, clamp((elongation - 1) / 4, 0, 1))
64
+ const a = new Float32Array(PARTIAL_COUNT)
65
+ let energy = 0
66
+ for (let k = 1; k <= PARTIAL_COUNT; k++) {
67
+ const amp = (k % 2 === 1 ? 1 : evenness) / Math.pow(k, p)
68
+ a[k - 1] = amp
69
+ energy += amp * amp
70
+ }
71
+ const norm = 1 / Math.sqrt(energy || 1)
72
+ for (let i = 0; i < PARTIAL_COUNT; i++) a[i] = (a[i] as number) * norm
73
+ return a
74
+ }
75
+
76
+ // ------------------------------------------------------------------ noise weaves
77
+
78
+ export interface TransientSpec {
79
+ /** noise burst length, seconds */
80
+ lengthS: number
81
+ /** high-pass corner of the burst, Hz */
82
+ hpHz: number
83
+ /** burst level relative to the voice (×velocity) */
84
+ level: number
85
+ }
86
+
87
+ /** EDGE → the /k/ of kiki: a filtered click at onset. edge 0 → none. */
88
+ export function transient(edge: number): TransientSpec {
89
+ const e = clamp(edge, 0, 1)
90
+ return {
91
+ lengthS: lerp(0.005, 0.025, e),
92
+ hpHz: 2000 + 4000 * e,
93
+ level: 0.7 * e,
94
+ }
95
+ }
96
+
97
+ export interface BreathSpec {
98
+ /** sustained airy layer level (0 = pure tone) */
99
+ level: number
100
+ /** band-pass center as a ratio of the fundamental */
101
+ bpRatio: number
102
+ }
103
+
104
+ /** TEXTURE → breath: translucent/soft-shadowed things are airy. */
105
+ export function breath(texture: number): BreathSpec {
106
+ const t = clamp(texture, 0, 1)
107
+ return { level: 0.22 * t, bpRatio: lerp(2.5, 1.2, t) }
108
+ }
109
+
110
+ /** TEXTURE → detune jitter in cents (rough surfaces are pitch-unstable). */
111
+ export const detuneJitterCents = (texture: number): number => 6 * clamp(texture, 0, 1)
112
+
113
+ // ------------------------------------------------------------------ pitch behaviour
114
+
115
+ export interface SubShimmerSpec {
116
+ /** semitone offset of osc B: −12 chest sub for massive, +12 sparkle for tiny */
117
+ interval: number
118
+ level: number
119
+ }
120
+
121
+ /** MASS → the second oscillator: big = chest, tiny = sparkle. */
122
+ export function subShimmer(mass: number): SubShimmerSpec {
123
+ const m = clamp(mass, 0, 1)
124
+ return {
125
+ interval: m >= 0.45 ? -12 : 12,
126
+ level: lerp(0.1, 0.38, clamp(Math.abs(m - 0.45) * 2, 0, 1)),
127
+ }
128
+ }
129
+
130
+ /** ROUND → glide: the bouba swoop-in. Returns portamento seconds (0 for sharp). */
131
+ export const glideS = (edge: number): number => lerp(0.028, 0, clamp(edge, 0, 1))
132
+
133
+ // ------------------------------------------------------------------ envelope weave
134
+
135
+ export interface EnvelopeSpec {
136
+ attackS: number
137
+ decayS: number
138
+ sustain: number
139
+ releaseScale: number
140
+ }
141
+
142
+ /** EDGE strikes, MASS adds inertia. */
143
+ export function envelopeWeave(edge: number, mass: number): EnvelopeSpec {
144
+ const e = clamp(edge, 0, 1)
145
+ const m = clamp(mass, 0, 1)
146
+ return {
147
+ attackS: lerp(0.045, 0.002, e) + 0.008 * m,
148
+ decayS: lerp(0.4, 0.12, e),
149
+ sustain: lerp(0.35, 0.15, e),
150
+ releaseScale: lerp(0.8, 1.5, m),
151
+ }
152
+ }
153
+
154
+ // ------------------------------------------------------------------ filter weave
155
+
156
+ export interface FilterWeaveSpec {
157
+ q: number
158
+ /** transient brightness bite: cutoff multiplier at onset, decaying to 1 */
159
+ biteAmount: number
160
+ biteDecayS: number
161
+ }
162
+
163
+ /** EDGE rings and bites. (Base cutoff itself comes from AIR via S2 + tilt, as before.) */
164
+ export function filterWeave(edge: number): FilterWeaveSpec {
165
+ const e = clamp(edge, 0, 1)
166
+ return {
167
+ q: lerp(0.5, 2.4, e),
168
+ biteAmount: 1 + 3 * e,
169
+ biteDecayS: lerp(0.15, 0.06, e),
170
+ }
171
+ }
172
+
173
+ // ------------------------------------------------------------------ reverb weave
174
+
175
+ export interface ReverbWeaveSpec {
176
+ /** multiplier on the profile's base send */
177
+ sendScale: number
178
+ /** low-pass on the way into the reverb: dark sources bloom dark. Hz */
179
+ sendCutoffHz: number
180
+ /** 0 = arrive at full send immediately (sharp); 1 = swell from 35% over the note (round) */
181
+ bloom: number
182
+ /** spatial extent bonus from airy texture */
183
+ extentBonus: number
184
+ }
185
+
186
+ export function reverbWeave(edge: number, mass: number, texture: number): ReverbWeaveSpec {
187
+ const e = clamp(edge, 0, 1)
188
+ const m = clamp(mass, 0, 1)
189
+ const t = clamp(texture, 0, 1)
190
+ return {
191
+ sendScale: lerp(1.3, 0.7, e) * lerp(0.85, 1.15, m),
192
+ sendCutoffHz: lerp(1200, 7000, e),
193
+ bloom: 1 - e,
194
+ extentBonus: 0.15 * t,
195
+ }
196
+ }
197
+
198
+ // ------------------------------------------------------------------ interaction weave
199
+
200
+ /** I8 — scroll velocity (px/ms) → air-rush gain on the room tone, decaying over ~400 ms. */
201
+ export const airRushGain = (pxPerMs: number): number => clamp(pxPerMs * 0.06, 0, 0.18)
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The Modular patch — MODULAR.md made executable. CSS properties plug modulation cables:
3
+ * border-width drives the wavefolder, texture×edge sets FM, mass thickens unison, animation
4
+ * and dashed borders patch the LFO. Pure: no DOM, no Tone.
5
+ */
6
+ import { clamp } from './util'
7
+
8
+ export interface ModularVisuals {
9
+ /** seconds; 0 = no animation */
10
+ animationS: number
11
+ borderStyle: 'solid' | 'dashed' | 'dotted' | 'none'
12
+ borderWidthPx: number
13
+ /** seconds; 0 = no transition */
14
+ transitionS: number
15
+ }
16
+
17
+ export interface PatchParams {
18
+ fold: { drive: number; mix: number }
19
+ fm: { index: number }
20
+ unison: { detuneCents: number; mix: number }
21
+ lfo: { rateHz: number; shape: 'sine' | 'square'; vibratoCents: number; tremolo: number; filterDepth: number }
22
+ portamentoS: number
23
+ }
24
+
25
+ /** M1 — heavy borders saturate: drive ∈ [0,1], mix follows. */
26
+ export function foldFromBorder(borderWidthPx: number, edge: number): PatchParams['fold'] {
27
+ const drive = clamp(borderWidthPx / 6, 0, 1) * (0.25 + 0.75 * clamp(edge, 0, 1))
28
+ return { drive, mix: drive > 0.01 ? 0.25 + 0.55 * drive : 0 }
29
+ }
30
+
31
+ /** M2 — rough sharp surfaces ring metallic (audio-rate FM index, × carrier Hz in-voice). */
32
+ export const fmIndex = (texture: number, edge: number): number =>
33
+ 1.5 * clamp(texture, 0, 1) * clamp(edge, 0, 1)
34
+
35
+ /** M3 — big elements are thick, not just low. */
36
+ export function unisonFromMass(mass: number): PatchParams['unison'] {
37
+ const m = clamp(mass, 0, 1)
38
+ return m >= 0.45 ? { detuneCents: 4 + 10 * m, mix: 0.4 } : { detuneCents: 0, mix: 0 }
39
+ }
40
+
41
+ /** M4/M5 — the LFO patch bay: animation owns it; dashed/dotted borders chop; else idle. */
42
+ export function lfoFromCss(animationS: number, borderStyle: ModularVisuals['borderStyle']): PatchParams['lfo'] {
43
+ if (animationS > 0.05) {
44
+ return {
45
+ rateHz: clamp(1 / animationS, 0.08, 8),
46
+ shape: borderStyle === 'dashed' || borderStyle === 'dotted' ? 'square' : 'sine',
47
+ vibratoCents: 6,
48
+ tremolo: 0.18,
49
+ filterDepth: 0.25,
50
+ }
51
+ }
52
+ if (borderStyle === 'dashed' || borderStyle === 'dotted') {
53
+ return {
54
+ rateHz: borderStyle === 'dotted' ? 7 : 3.5,
55
+ shape: 'square',
56
+ vibratoCents: 0,
57
+ tremolo: 0.3,
58
+ filterDepth: 0.35,
59
+ }
60
+ }
61
+ return { rateHz: 0, shape: 'sine', vibratoCents: 0, tremolo: 0, filterDepth: 0 }
62
+ }
63
+
64
+ /** M6 — elements that ease visually ease in pitch. Seconds added to the glide. */
65
+ export const portamentoFromTransition = (transitionS: number): number =>
66
+ clamp(transitionS / 2, 0, 0.25)
67
+
68
+ /** M7 — typography enters the weave: bold text carries weight (MASS bonus). */
69
+ export const massBonusFromFontWeight = (weight: number): number =>
70
+ clamp((weight - 400) / 300, 0, 1) * 0.12
71
+
72
+ export function patchFrom(matter: { edge: number; mass: number; texture: number }, visuals: ModularVisuals): PatchParams {
73
+ return {
74
+ fold: foldFromBorder(visuals.borderWidthPx, matter.edge),
75
+ fm: { index: fmIndex(matter.texture, matter.edge) },
76
+ unison: unisonFromMass(matter.mass),
77
+ lfo: lfoFromCss(visuals.animationS, visuals.borderStyle),
78
+ portamentoS: portamentoFromTransition(visuals.transitionS),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The wavefolder transfer curve: y = sin(2.5·(π/2)·x). Near-linear for quiet signals,
84
+ * folding for hot ones — the amp envelope sweeps the spectrum through the fold every note.
85
+ */
86
+ export function foldCurve(samples = 2049): Float32Array {
87
+ const out = new Float32Array(samples)
88
+ for (let i = 0; i < samples; i++) {
89
+ const x = (i / (samples - 1)) * 2 - 1
90
+ out[i] = Math.sin(2.5 * (Math.PI / 2) * x)
91
+ }
92
+ return out
93
+ }
94
+
95
+ /** Drag-glissando ribbon (MODULAR.md §3): horizontal travel → scale-step offset, ±1 octave. */
96
+ export function ribbonSteps(dxPx: number, vw: number, scaleLen: number): number {
97
+ const t = clamp(dxPx / (vw * 0.6), -1, 1)
98
+ return Math.round(t * scaleLen)
99
+ }