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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/index.d.ts +1000 -0
- package/dist/index.js +2716 -0
- package/dist/index.js.map +1 -0
- package/dist/sonarium.iife.js +236 -0
- package/dist/sonarium.iife.js.map +1 -0
- package/package.json +63 -0
- package/src/core/engine.ts +468 -0
- package/src/core/listener.ts +61 -0
- package/src/core/matter-voice.ts +240 -0
- package/src/core/profile.ts +313 -0
- package/src/core/room.ts +135 -0
- package/src/core/scanner.ts +176 -0
- package/src/core/voices.ts +222 -0
- package/src/index.ts +74 -0
- package/src/interact/activate.ts +56 -0
- package/src/interact/drag.ts +52 -0
- package/src/interact/keyboard.ts +48 -0
- package/src/interact/midi.ts +40 -0
- package/src/interact/motion.ts +61 -0
- package/src/interact/pointer.ts +65 -0
- package/src/interact/scroll.ts +43 -0
- package/src/math/chroma.ts +111 -0
- package/src/math/mapping.ts +100 -0
- package/src/math/matter.ts +201 -0
- package/src/math/modular.ts +99 -0
- package/src/math/pulse.ts +64 -0
- package/src/math/scales.ts +90 -0
- package/src/math/util.ts +16 -0
- package/src/spatial/backend.ts +166 -0
- package/src/spatial/bus.ts +114 -0
- package/src/spatial/decoder.ts +63 -0
- package/src/spatial/encoder.ts +54 -0
- package/src/spatial/field.ts +75 -0
- package/src/spatial/perceptual.ts +43 -0
- package/src/spatial/room-foa.ts +81 -0
- package/src/spatial/rotation.ts +73 -0
- package/src/spatial/sh.ts +38 -0
- package/src/spatial/sphere.ts +36 -0
- package/src/themes/index.ts +60 -0
- package/src/types.ts +157 -0
- 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
|
+
}
|