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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FoaRoom — the Spat room model in the field (SPATIAL.md §3.3): early reflections encoded at
|
|
3
|
+
* mirror directions + the diffuse tail whose extent IS the envelopment factor. Imports Tone only.
|
|
4
|
+
*/
|
|
5
|
+
import * as Tone from 'tone'
|
|
6
|
+
import { foaGains, DEG } from './sh'
|
|
7
|
+
import { reflectionScaleFromViewport } from './sphere'
|
|
8
|
+
import { roomGain, tailExtent, tailLevel, type PerceptualFactors } from './perceptual'
|
|
9
|
+
import { SourceEncoder, type BusInputs } from './encoder'
|
|
10
|
+
|
|
11
|
+
const ER_BASE_TIMES = [0.013, 0.019, 0.027, 0.034]
|
|
12
|
+
const ER_TAP_LEVELS = [1, 0.85, 0.7, 0.6]
|
|
13
|
+
const ER_DIRECTIONS: Array<[number, number]> = [
|
|
14
|
+
[110 * DEG, 30 * DEG],
|
|
15
|
+
[-110 * DEG, 30 * DEG],
|
|
16
|
+
[110 * DEG, -30 * DEG],
|
|
17
|
+
[-110 * DEG, -30 * DEG],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export class FoaRoom {
|
|
21
|
+
/** Lanes tap their direct signal here (mono sum). */
|
|
22
|
+
readonly erIn: Tone.Gain
|
|
23
|
+
private erMaster: Tone.Gain
|
|
24
|
+
private delays: Tone.Delay[] = []
|
|
25
|
+
private split: Tone.Split
|
|
26
|
+
private tailL: SourceEncoder
|
|
27
|
+
private tailR: SourceEncoder
|
|
28
|
+
private nodes: { dispose(): void }[] = []
|
|
29
|
+
|
|
30
|
+
constructor(bus: BusInputs, reverb: Tone.Reverb, vw: number, factors: PerceptualFactors) {
|
|
31
|
+
this.erIn = new Tone.Gain(1)
|
|
32
|
+
this.erMaster = new Tone.Gain(0.22 * roomGain(factors.roomPresence))
|
|
33
|
+
this.erIn.connect(this.erMaster)
|
|
34
|
+
|
|
35
|
+
const scale = reflectionScaleFromViewport(vw)
|
|
36
|
+
ER_BASE_TIMES.forEach((t, i) => {
|
|
37
|
+
const delay = new Tone.Delay({ delayTime: t * scale, maxDelay: 0.12 })
|
|
38
|
+
const tap = new Tone.Gain(ER_TAP_LEVELS[i] as number)
|
|
39
|
+
const enc = new SourceEncoder(bus)
|
|
40
|
+
const [az, el] = ER_DIRECTIONS[i] as [number, number]
|
|
41
|
+
enc.set(foaGains(az, el, 0.35), 1)
|
|
42
|
+
this.erMaster.connect(delay)
|
|
43
|
+
delay.connect(tap)
|
|
44
|
+
tap.connect(enc.input)
|
|
45
|
+
this.delays.push(delay)
|
|
46
|
+
this.nodes.push(delay, tap, enc)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Diffuse tail: reverb L/R become two wide sources at ±120°; their extent = envelopment.
|
|
50
|
+
this.split = new Tone.Split()
|
|
51
|
+
reverb.connect(this.split)
|
|
52
|
+
this.tailL = new SourceEncoder(bus)
|
|
53
|
+
this.tailR = new SourceEncoder(bus)
|
|
54
|
+
this.split.connect(this.tailL.input, 0)
|
|
55
|
+
this.split.connect(this.tailR.input, 1)
|
|
56
|
+
this.applyTail(factors)
|
|
57
|
+
this.nodes.push(this.erIn, this.erMaster, this.split, this.tailL, this.tailR)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private applyTail(f: PerceptualFactors): void {
|
|
61
|
+
const ext = tailExtent(f.envelopment)
|
|
62
|
+
const level = tailLevel(f.envelopment)
|
|
63
|
+
this.tailL.set(foaGains(120 * DEG, 0, ext), level)
|
|
64
|
+
this.tailR.set(foaGains(-120 * DEG, 0, ext), level)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setFactors(f: PerceptualFactors): void {
|
|
68
|
+
this.erMaster.gain.rampTo(0.22 * roomGain(f.roomPresence), 0.1)
|
|
69
|
+
this.applyTail(f)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setViewport(vw: number): void {
|
|
73
|
+
const scale = reflectionScaleFromViewport(vw)
|
|
74
|
+
this.delays.forEach((d, i) => d.delayTime.rampTo((ER_BASE_TIMES[i] as number) * scale, 0.3))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
for (const n of this.nodes) n.dispose()
|
|
79
|
+
this.nodes = []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure field rotation — SPATIAL.md §3.1. The 3×3 matrix acts on the (X, Y, Z) channel triple
|
|
3
|
+
* (W is rotation-invariant). Row-major: m[r][c], applied as X' = m00·X + m01·Y + m02·Z, etc.
|
|
4
|
+
* Composition R = Rz(yaw) · Ry(pitch) · Rx(roll). Angles in radians.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Mat3 = [
|
|
8
|
+
[number, number, number],
|
|
9
|
+
[number, number, number],
|
|
10
|
+
[number, number, number],
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export const IDENTITY: Mat3 = [
|
|
14
|
+
[1, 0, 0],
|
|
15
|
+
[0, 1, 0],
|
|
16
|
+
[0, 0, 1],
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function mul(a: Mat3, b: Mat3): Mat3 {
|
|
20
|
+
const out: Mat3 = [
|
|
21
|
+
[0, 0, 0],
|
|
22
|
+
[0, 0, 0],
|
|
23
|
+
[0, 0, 0],
|
|
24
|
+
]
|
|
25
|
+
for (let r = 0; r < 3; r++)
|
|
26
|
+
for (let c = 0; c < 3; c++)
|
|
27
|
+
out[r]![c] = a[r]![0]! * b[0]![c]! + a[r]![1]! * b[1]![c]! + a[r]![2]! * b[2]![c]!
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Acts on column vectors (X, Y, Z)ᵀ in the AmbiX frame (+x fwd, +y left, +z up). */
|
|
32
|
+
export function rotationMatrix(yaw: number, pitch: number, roll = 0): Mat3 {
|
|
33
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw)
|
|
34
|
+
const cp = Math.cos(pitch), sp = Math.sin(pitch)
|
|
35
|
+
const cr = Math.cos(roll), sr = Math.sin(roll)
|
|
36
|
+
const Rz: Mat3 = [
|
|
37
|
+
[cy, -sy, 0],
|
|
38
|
+
[sy, cy, 0],
|
|
39
|
+
[0, 0, 1],
|
|
40
|
+
]
|
|
41
|
+
const Ry: Mat3 = [
|
|
42
|
+
[cp, 0, sp],
|
|
43
|
+
[0, 1, 0],
|
|
44
|
+
[-sp, 0, cp],
|
|
45
|
+
]
|
|
46
|
+
const Rx: Mat3 = [
|
|
47
|
+
[1, 0, 0],
|
|
48
|
+
[0, cr, -sr],
|
|
49
|
+
[0, sr, cr],
|
|
50
|
+
]
|
|
51
|
+
return mul(mul(Rz, Ry), Rx)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function applyMat3(m: Mat3, v: [number, number, number]): [number, number, number] {
|
|
55
|
+
return [
|
|
56
|
+
m[0]![0]! * v[0] + m[0]![1]! * v[1] + m[0]![2]! * v[2],
|
|
57
|
+
m[1]![0]! * v[0] + m[1]![1]! * v[1] + m[1]![2]! * v[2],
|
|
58
|
+
m[2]![0]! * v[0] + m[2]![1]! * v[1] + m[2]![2]! * v[2],
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Look semantics (SPATIAL.md §3.1): the field rotation is the INVERSE of the head's intrinsic
|
|
64
|
+
* yaw-then-pitch rotation. Looking right by α (head Rz(−α)) and up by β (head Ry(−β)) gives
|
|
65
|
+
* field R = (Rz(−α)·Ry(−β))⁻¹ = Ry(β)·Rz(α). The sign/composition decision lives here and
|
|
66
|
+
* only here, pinned by tests: look right → right-side sources arrive frontward; look up →
|
|
67
|
+
* overhead sources arrive frontward.
|
|
68
|
+
*/
|
|
69
|
+
export function lookMatrix(yawRight: number, pitchUp: number): Mat3 {
|
|
70
|
+
const pitch = rotationMatrix(0, pitchUp, 0) // = Ry(pitchUp)
|
|
71
|
+
const yaw = rotationMatrix(yawRight, 0, 0) // = Rz(yawRight)
|
|
72
|
+
return mul(pitch, yaw)
|
|
73
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure spherical-harmonic encoding — SPATIAL.md §1–2. AmbiX: ACN order [W, Y, Z, X],
|
|
3
|
+
* SN3D normalization, +x forward, +y left, +z up, +azimuth left. No DOM, no Tone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEG = Math.PI / 180
|
|
7
|
+
|
|
8
|
+
/** ACN channel indices, for readability everywhere else. */
|
|
9
|
+
export const ACN = { W: 0, Y: 1, Z: 2, X: 3 } as const
|
|
10
|
+
|
|
11
|
+
export interface FoaGains {
|
|
12
|
+
w: number
|
|
13
|
+
y: number
|
|
14
|
+
z: number
|
|
15
|
+
x: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* First-order encode of a plane wave from (azimuth, elevation), with extent σ blending the
|
|
20
|
+
* directional components into the omni channel (SPATIAL.md §2). Angles in radians.
|
|
21
|
+
*/
|
|
22
|
+
export function foaGains(azimuth: number, elevation: number, extent = 0): FoaGains {
|
|
23
|
+
const s = Math.min(1, Math.max(0, extent))
|
|
24
|
+
const cosEl = Math.cos(elevation)
|
|
25
|
+
const dir = 1 - s
|
|
26
|
+
return {
|
|
27
|
+
w: 1 + 0.41 * s,
|
|
28
|
+
y: dir * Math.sin(azimuth) * cosEl,
|
|
29
|
+
z: dir * Math.sin(elevation),
|
|
30
|
+
x: dir * Math.cos(azimuth) * cosEl,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Unit direction vector for (azimuth, elevation) in the AmbiX frame. */
|
|
35
|
+
export function unitVector(azimuth: number, elevation: number): [number, number, number] {
|
|
36
|
+
const cosEl = Math.cos(elevation)
|
|
37
|
+
return [Math.cos(azimuth) * cosEl, Math.sin(azimuth) * cosEl, Math.sin(elevation)]
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure sphere mappings — SPATIAL.md §4 (canon rows SP1–SP5). The page is laid on the front
|
|
3
|
+
* hemisphere of a sphere around the listener; geometry becomes spherical source properties.
|
|
4
|
+
* No DOM, no Tone.
|
|
5
|
+
*/
|
|
6
|
+
import { clamp, norm } from '../math/util'
|
|
7
|
+
import { DEG } from './sh'
|
|
8
|
+
import type { Rect } from '../types'
|
|
9
|
+
|
|
10
|
+
export const AZ_MAX = 70 * DEG
|
|
11
|
+
export const EL_MAX = 45 * DEG
|
|
12
|
+
|
|
13
|
+
/** SP1/SP2 — screen position → direction. Screen-right = −azimuth (sign tested). */
|
|
14
|
+
export function sphereFromRect(rect: Rect, vw: number, vh: number): { azimuth: number; elevation: number } {
|
|
15
|
+
const tx = norm(rect.x + rect.w / 2, 0, vw)
|
|
16
|
+
const ty = norm(rect.y + rect.h / 2, 0, vh)
|
|
17
|
+
return {
|
|
18
|
+
azimuth: -(2 * tx - 1) * AZ_MAX,
|
|
19
|
+
elevation: (1 - 2 * ty) * EL_MAX,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** SP4 — big elements wrap around the listener (T2 extended into space). */
|
|
24
|
+
export function extentFromSize(sizeT: number): number {
|
|
25
|
+
return clamp(0.05 + 0.75 * Math.pow(clamp(sizeT, 0, 1), 1.2), 0, 0.95)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** SP5 — Kiki/Bouba in the spatial domain: sharp beams, round radiates. */
|
|
29
|
+
export function directivityFromRoundness(roundness: number): number {
|
|
30
|
+
return clamp(1 - roundness, 0, 1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** SP6 — early-reflection time scale follows the room (viewport). */
|
|
34
|
+
export function reflectionScaleFromViewport(vw: number): number {
|
|
35
|
+
return 0.6 + norm(vw, 360, 2200) * 1.0
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 Composition — themes are plain data (MAPPING.md §3). Adding a theme means adding an
|
|
3
|
+
* object here; no engine code may special-case a theme name.
|
|
4
|
+
*/
|
|
5
|
+
import type { Theme } from '../types'
|
|
6
|
+
|
|
7
|
+
// v0.3: aurora speaks through the woven MatterVoice — geometry IS the instrument
|
|
8
|
+
// (MATTER.md). fm bells (headings) and membrane thuds (media) stay as character voices.
|
|
9
|
+
const aurora: Theme = {
|
|
10
|
+
name: 'aurora',
|
|
11
|
+
defaults: { synthKind: 'matter', octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
|
|
12
|
+
roles: {
|
|
13
|
+
toggle: { synthKind: 'matter', baseVelocity: 0.7, releaseScale: 0.8 },
|
|
14
|
+
button: { synthKind: 'matter', baseVelocity: 0.9 },
|
|
15
|
+
link: { synthKind: 'matter', octaveShift: 1, baseVelocity: 0.6, releaseScale: 0.7 },
|
|
16
|
+
input: { synthKind: 'matter', baseVelocity: 0.55, releaseScale: 1.6 },
|
|
17
|
+
heading: { synthKind: 'fm', octaveShift: 0, baseVelocity: 0.75, releaseScale: 2.2 },
|
|
18
|
+
media: { synthKind: 'membrane', octaveShift: -1, baseVelocity: 0.8 },
|
|
19
|
+
item: { synthKind: 'matter', baseVelocity: 0.6, releaseScale: 0.8 },
|
|
20
|
+
text: { synthKind: 'matter', baseVelocity: 0.35, releaseScale: 1.8 },
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mono: Theme = {
|
|
25
|
+
name: 'mono',
|
|
26
|
+
defaults: { synthKind: 'synth', octaveShift: 0, baseVelocity: 0.7, releaseScale: 0.5 },
|
|
27
|
+
roles: {
|
|
28
|
+
toggle: { synthKind: 'noise', baseVelocity: 0.6 },
|
|
29
|
+
button: { synthKind: 'synth', pinWave: 'square', baseVelocity: 0.7, releaseScale: 0.4 },
|
|
30
|
+
link: { synthKind: 'noise', baseVelocity: 0.45, releaseScale: 0.3 },
|
|
31
|
+
input: { synthKind: 'synth', pinWave: 'sine', octaveShift: -1, baseVelocity: 0.5 },
|
|
32
|
+
heading: { synthKind: 'synth', pinWave: 'sine', octaveShift: 1, baseVelocity: 0.6 },
|
|
33
|
+
media: { synthKind: 'noise', baseVelocity: 0.55 },
|
|
34
|
+
item: { synthKind: 'noise', baseVelocity: 0.4, releaseScale: 0.3 },
|
|
35
|
+
text: { synthKind: 'synth', pinWave: 'sine', baseVelocity: 0 },
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const paper: Theme = {
|
|
40
|
+
name: 'paper',
|
|
41
|
+
defaults: { synthKind: 'pluck', octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
|
|
42
|
+
roles: {
|
|
43
|
+
toggle: { synthKind: 'pluck', baseVelocity: 0.7 },
|
|
44
|
+
button: { synthKind: 'pluck', baseVelocity: 0.9 },
|
|
45
|
+
link: { synthKind: 'pluck', octaveShift: 1, baseVelocity: 0.65 },
|
|
46
|
+
input: { synthKind: 'synth', pinWave: 'triangle', baseVelocity: 0.5, releaseScale: 1.4 },
|
|
47
|
+
heading: { synthKind: 'pluck', octaveShift: -1, baseVelocity: 0.85, releaseScale: 1.6 },
|
|
48
|
+
media: { synthKind: 'membrane', octaveShift: -1, baseVelocity: 0.7 },
|
|
49
|
+
item: { synthKind: 'pluck', baseVelocity: 0.6 },
|
|
50
|
+
text: { synthKind: 'synth', pinWave: 'sine', baseVelocity: 0.3, releaseScale: 1.6 },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const THEMES: Record<string, Theme> = { aurora, mono, paper }
|
|
55
|
+
|
|
56
|
+
export function resolveTheme(theme: string | Theme | undefined): Theme {
|
|
57
|
+
if (!theme) return aurora
|
|
58
|
+
if (typeof theme === 'string') return THEMES[theme] ?? aurora
|
|
59
|
+
return theme
|
|
60
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export type Wave = 'sine' | 'triangle' | 'sawtooth' | 'square'
|
|
2
|
+
|
|
3
|
+
export type Role =
|
|
4
|
+
| 'toggle'
|
|
5
|
+
| 'button'
|
|
6
|
+
| 'link'
|
|
7
|
+
| 'input'
|
|
8
|
+
| 'heading'
|
|
9
|
+
| 'media'
|
|
10
|
+
| 'item'
|
|
11
|
+
| 'container'
|
|
12
|
+
| 'text'
|
|
13
|
+
|
|
14
|
+
export type SynthKind = 'matter' | 'synth' | 'fm' | 'pluck' | 'membrane' | 'noise'
|
|
15
|
+
|
|
16
|
+
export type Articulation =
|
|
17
|
+
| 'hit' | 'preview' | 'tick' | 'strum' | 'whisper' | 'toggle-on' | 'toggle-off' | 'motif'
|
|
18
|
+
| 'echo' | 'phrase' | 'ribbon'
|
|
19
|
+
|
|
20
|
+
export interface Rect {
|
|
21
|
+
x: number
|
|
22
|
+
y: number
|
|
23
|
+
w: number
|
|
24
|
+
h: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Spherical source properties — the 聲球 (SPATIAL.md §2, §4). */
|
|
28
|
+
export interface SphereProps {
|
|
29
|
+
/** Radians, AmbiX convention: +azimuth = left. */
|
|
30
|
+
azimuth: number
|
|
31
|
+
/** Radians, + = up. */
|
|
32
|
+
elevation: number
|
|
33
|
+
/** Apparent angular size σ ∈ [0,1] — point source … wraps around the listener. */
|
|
34
|
+
extent: number
|
|
35
|
+
/** δ ∈ [0,1]: 1 = focused beam at the listener, 0 = omni radiator. */
|
|
36
|
+
directivity: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The spat5.oper surface (SPATIAL.md §5) — all ∈ [0,1], perceptually monotonic. */
|
|
40
|
+
export interface PerceptualFactors {
|
|
41
|
+
presence: number
|
|
42
|
+
roomPresence: number
|
|
43
|
+
envelopment: number
|
|
44
|
+
warmth: number
|
|
45
|
+
brilliance: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Matter weave, resolved per element (MATTER.md) — consumed by MatterVoice + backends. */
|
|
49
|
+
export interface MatterVoiceParams {
|
|
50
|
+
matter: { edge: number; mass: number; texture: number; air: number }
|
|
51
|
+
/** 24 additive partial amplitudes, energy-normalized (the continuous spectrum). */
|
|
52
|
+
partials: number[]
|
|
53
|
+
transient: { lengthS: number; hpHz: number; level: number }
|
|
54
|
+
breath: { level: number; bpRatio: number }
|
|
55
|
+
subShimmer: { interval: number; level: number }
|
|
56
|
+
glideS: number
|
|
57
|
+
jitterCents: number
|
|
58
|
+
envelope: { attackS: number; decayS: number; sustain: number; releaseScale: number }
|
|
59
|
+
filter: { q: number; biteAmount: number; biteDecayS: number }
|
|
60
|
+
reverb: { sendScale: number; sendCutoffHz: number; bloom: number; extentBonus: number }
|
|
61
|
+
/** The modular patch (MODULAR.md): CSS plugs the cables. */
|
|
62
|
+
patch: {
|
|
63
|
+
fold: { drive: number; mix: number }
|
|
64
|
+
fm: { index: number }
|
|
65
|
+
unison: { detuneCents: number; mix: number }
|
|
66
|
+
lfo: { rateHz: number; shape: 'sine' | 'square'; vibratoCents: number; tremolo: number; filterDepth: number }
|
|
67
|
+
portamentoS: number
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The contract between page reading (L1) and the audio substrate (L0). See ARCHITECTURE.md §2. */
|
|
72
|
+
export interface SonicProfile {
|
|
73
|
+
role: Role
|
|
74
|
+
rect: Rect
|
|
75
|
+
pan: { x: number; y: number; z: number }
|
|
76
|
+
sphere: SphereProps
|
|
77
|
+
midi: number
|
|
78
|
+
freqHz: number
|
|
79
|
+
degree: number
|
|
80
|
+
wave: Wave
|
|
81
|
+
attack: number
|
|
82
|
+
release: number
|
|
83
|
+
durationS: number
|
|
84
|
+
filterHz: number
|
|
85
|
+
filterQ: number
|
|
86
|
+
velocityScale: number
|
|
87
|
+
reverbSend: number
|
|
88
|
+
synthKind: SynthKind
|
|
89
|
+
octaveShift: number
|
|
90
|
+
/** The full Matter weave (MATTER.md) — always computed; the 'matter' voice consumes all of
|
|
91
|
+
* it, other synth kinds consume the reverb/filter threads. */
|
|
92
|
+
voice: MatterVoiceParams
|
|
93
|
+
/** The Chroma weave (CHROMA.md): the element's color as mood. */
|
|
94
|
+
chroma: { warmth: number; saturation: number; luminance: number }
|
|
95
|
+
/** Human-readable provenance of every parameter — describe() truth (PLAN.md Invariant #6). */
|
|
96
|
+
reasons: Record<string, string>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface VoiceRecipe {
|
|
100
|
+
synthKind: SynthKind
|
|
101
|
+
/** When set, geometry does not override the waveform (e.g. heading bells stay bells). */
|
|
102
|
+
pinWave?: Wave
|
|
103
|
+
octaveShift: number
|
|
104
|
+
baseVelocity: number
|
|
105
|
+
releaseScale: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Theme {
|
|
109
|
+
name: string
|
|
110
|
+
roles: Partial<Record<Role, Partial<VoiceRecipe>>>
|
|
111
|
+
defaults: VoiceRecipe
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SonariumOptions {
|
|
115
|
+
/** Root element to sonify. Default: document.body */
|
|
116
|
+
root?: Element
|
|
117
|
+
/** Sound palette. Default 'aurora'. */
|
|
118
|
+
theme?: 'aurora' | 'mono' | 'paper' | Theme
|
|
119
|
+
/** 'auto' = deterministic per-hostname key (MAPPING.md §0), or e.g. 'D dorian'. */
|
|
120
|
+
key?: string
|
|
121
|
+
/** Listener mode: cursor as ears, or fixed center. Default 'pointer'. */
|
|
122
|
+
listener?: 'pointer' | 'center'
|
|
123
|
+
/** Ambience level 0..1 (room tone + sparkles). Default 0.12; 0 disables. */
|
|
124
|
+
ambient?: number
|
|
125
|
+
/** Enable device tilt/shake drivers on mobile. Default true. */
|
|
126
|
+
motion?: boolean
|
|
127
|
+
/** Autoplay-unlock & mute UI. 'chip' = floating control, 'none' = bring your own. */
|
|
128
|
+
gate?: 'chip' | 'none'
|
|
129
|
+
/** Master volume in dB. Default -10. */
|
|
130
|
+
volume?: number
|
|
131
|
+
/** Max concurrent voices (pool size). Default 18, hard cap 24. */
|
|
132
|
+
maxVoices?: number
|
|
133
|
+
/** 'hrtf' (default) or 'equalpower' for low-end devices. */
|
|
134
|
+
panning?: 'hrtf' | 'equalpower'
|
|
135
|
+
/**
|
|
136
|
+
* Spatial engine (SPATIAL.md): 'ambisonic' (default) encodes everything into one rotatable
|
|
137
|
+
* FOA field, Spat-style; 'panner' is the v0.1 per-voice HRTF path (also the auto-fallback).
|
|
138
|
+
*/
|
|
139
|
+
spatial?: 'ambisonic' | 'panner'
|
|
140
|
+
/** Spat-style perceptual factors (presence, roomPresence, envelopment, warmth, brilliance). */
|
|
141
|
+
perceptual?: Partial<PerceptualFactors>
|
|
142
|
+
/** Reverb: 'auto' sizes the room from viewport width, or a fixed decay in seconds. */
|
|
143
|
+
reverb?: 'auto' | number
|
|
144
|
+
/** Respect prefers-reduced-motion by softening output. Default true. */
|
|
145
|
+
respectReducedMotion?: boolean
|
|
146
|
+
/** Mirror every trigger to the first Web MIDI output (the page as a MIDI controller). Default false. */
|
|
147
|
+
midi?: boolean
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type SonariumEvent = 'start' | 'trigger' | 'mute' | 'dispose'
|
|
151
|
+
|
|
152
|
+
export interface TriggerDetail {
|
|
153
|
+
el: Element
|
|
154
|
+
profile: SonicProfile
|
|
155
|
+
velocity: number
|
|
156
|
+
articulation: Articulation
|
|
157
|
+
}
|
package/src/ui/gate.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unlock & mute chip — autoplay policy treated as a feature (PLAN.md R2).
|
|
3
|
+
* Shadow DOM so host styles can't break it; ARIA so keyboard/SR users get the same control.
|
|
4
|
+
*/
|
|
5
|
+
const STORAGE_KEY = 'sonarium:muted'
|
|
6
|
+
|
|
7
|
+
export const isMutedPersisted = (): boolean => {
|
|
8
|
+
try { return localStorage.getItem(STORAGE_KEY) === '1' } catch { return false }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const persistMuted = (muted: boolean): void => {
|
|
12
|
+
try { localStorage.setItem(STORAGE_KEY, muted ? '1' : '0') } catch { /* private mode */ }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GateHandle {
|
|
16
|
+
setState: (state: 'armed' | 'on' | 'muted') => void
|
|
17
|
+
dispose: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function mountGate(onToggle: () => void): GateHandle {
|
|
21
|
+
const host = document.createElement('div')
|
|
22
|
+
host.setAttribute('data-sonic', 'off') // the chip itself must never sound
|
|
23
|
+
const shadow = host.attachShadow({ mode: 'open' })
|
|
24
|
+
shadow.innerHTML = `
|
|
25
|
+
<style>
|
|
26
|
+
button {
|
|
27
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483646;
|
|
28
|
+
width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
|
|
29
|
+
background: rgba(20, 22, 30, .82); color: #fff; font-size: 18px; line-height: 1;
|
|
30
|
+
cursor: pointer; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
31
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.35); transition: transform .15s ease, opacity .3s ease;
|
|
32
|
+
}
|
|
33
|
+
button:hover { transform: scale(1.08); }
|
|
34
|
+
button:focus-visible { outline: 2px solid #7cd4fd; outline-offset: 2px; }
|
|
35
|
+
button.armed { animation: pulse 1.6s ease-in-out infinite; }
|
|
36
|
+
@keyframes pulse { 0%,100% { box-shadow: 0 4px 16px rgba(0,0,0,.35); } 50% { box-shadow: 0 0 0 10px rgba(124,212,253,.18); } }
|
|
37
|
+
</style>
|
|
38
|
+
<button type="button" class="armed" aria-label="Enable sound" title="Sonarium — enable sound">🔇</button>
|
|
39
|
+
`
|
|
40
|
+
const btn = shadow.querySelector('button') as HTMLButtonElement
|
|
41
|
+
btn.addEventListener('click', (e) => {
|
|
42
|
+
e.stopPropagation()
|
|
43
|
+
onToggle()
|
|
44
|
+
})
|
|
45
|
+
document.body.appendChild(host)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
setState(state) {
|
|
49
|
+
btn.classList.toggle('armed', state === 'armed')
|
|
50
|
+
if (state === 'on') {
|
|
51
|
+
btn.textContent = '🔊'
|
|
52
|
+
btn.setAttribute('aria-label', 'Mute Sonarium')
|
|
53
|
+
} else if (state === 'muted') {
|
|
54
|
+
btn.textContent = '🔇'
|
|
55
|
+
btn.setAttribute('aria-label', 'Unmute Sonarium')
|
|
56
|
+
} else {
|
|
57
|
+
btn.textContent = '🔇'
|
|
58
|
+
btn.setAttribute('aria-label', 'Enable sound')
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dispose() {
|
|
62
|
+
host.remove()
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|