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,64 @@
1
+ /**
2
+ * The Pulse — PULSE.md made executable: tempo from the layout, grid offsets for echoes,
3
+ * metered strum spacing, and the calm system. Pure: no DOM, no Tone.
4
+ * THE LATENCY INVARIANT (PULSE.md §0): nothing here ever delays a primary hit.
5
+ */
6
+ import { clamp, lerp } from './util'
7
+
8
+ /** P1+P2 — layout density × page warmth → bpm, clamped to [56, 116]. */
9
+ export function tempoFromPage(elementCount: number, warmthScale: number): number {
10
+ const base = lerp(66, 104, clamp(elementCount / 120, 0, 1))
11
+ return Math.round(clamp(base * warmthScale, 56, 116))
12
+ }
13
+
14
+ export const secondsPerBeat = (bpm: number): number => 60 / Math.max(1, bpm)
15
+
16
+ /**
17
+ * P3 — offset (seconds from now) to the next grid boundary of `gridS` seconds that is at
18
+ * least `minAheadS` away. `phaseS` = seconds elapsed since the grid's epoch.
19
+ */
20
+ export function nextGridOffset(phaseS: number, gridS: number, minAheadS: number): number {
21
+ if (gridS <= 0) return minAheadS
22
+ let offset = gridS - (((phaseS % gridS) + gridS) % gridS)
23
+ while (offset < minAheadS) offset += gridS
24
+ return offset
25
+ }
26
+
27
+ /** Strums step in 32nd notes — the same gesture, now in the groove. Seconds. */
28
+ export const strumStepS = (bpm: number): number => secondsPerBeat(bpm) / 8
29
+
30
+ /** Echoes answer on 8ths, one octave up, quiet (PULSE.md §2). */
31
+ export const ECHO_TRANSPOSE = 12
32
+ export const ECHO_VELOCITY_SCALE = 0.22
33
+ export const ECHO_MIN_AHEAD_S = 0.08
34
+ export const echoGridS = (bpm: number): number => secondsPerBeat(bpm) / 2
35
+
36
+ // ------------------------------------------------------------- the calm system (P5)
37
+
38
+ export const DUCK_RECOVERY_MS = 2000
39
+
40
+ /** Activity count decays linearly: fully forgiven after count·2 s of silence. */
41
+ export const decayCount = (count: number, dtMs: number): number =>
42
+ Math.max(0, count - dtMs / DUCK_RECOVERY_MS)
43
+
44
+ /** Velocity multiplier: the 6th rapid repeat sits at ~40%, never below. */
45
+ export const duckFactor = (count: number): number =>
46
+ Math.max(0.4, Math.pow(0.85, Math.max(0, count)))
47
+
48
+ // ------------------------------------------------------------- phrases (P4)
49
+
50
+ export const PHRASE_PROBABILITY = 0.55
51
+ export const PHRASE_MIN_NOTES = 2
52
+ export const PHRASE_MAX_NOTES = 4
53
+
54
+ /** Reading order: rows of ~80 px, then left→right. Sort key for (top, left). */
55
+ export const readingOrderKey = (top: number, left: number): number =>
56
+ Math.round(top / 80) * 100000 + clamp(left, 0, 99999)
57
+
58
+ /**
59
+ * Window the score by scroll progress: with n elements and a phrase of len, the playhead
60
+ * starts at `progress·(n − len)`.
61
+ */
62
+ export function phraseWindow(n: number, len: number, progress: number): number {
63
+ return Math.round(clamp(progress, 0, 1) * Math.max(0, n - len))
64
+ }
@@ -0,0 +1,90 @@
1
+ import { clamp, fnv1a } from './util'
2
+
3
+ /** MAPPING.md §0 — the five scales reachable from a hostname hash. */
4
+ export const SCALES: Record<string, readonly number[]> = {
5
+ pentMajor: [0, 2, 4, 7, 9],
6
+ pentMinor: [0, 3, 5, 7, 10],
7
+ dorian: [0, 2, 3, 5, 7, 9, 10],
8
+ lydian: [0, 2, 4, 6, 7, 9, 11],
9
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
10
+ } as const
11
+
12
+ export const SCALE_NAMES = Object.keys(SCALES) as (keyof typeof SCALES)[]
13
+
14
+ export const OCTAVES = 3
15
+ /** C3 — the bottom of the 3-octave pitch space. */
16
+ export const BASE_MIDI = 48
17
+
18
+ const NOTE_TO_PC: Record<string, number> = {
19
+ C: 0, 'C#': 1, Db: 1, D: 2, 'D#': 3, Eb: 3, E: 4, F: 5, 'F#': 6,
20
+ Gb: 6, G: 7, 'G#': 8, Ab: 8, A: 9, 'A#': 10, Bb: 10, B: 11,
21
+ }
22
+
23
+ const PC_TO_NOTE = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'] as const
24
+
25
+ export interface SiteKey {
26
+ root: number
27
+ scaleName: string
28
+ scale: readonly number[]
29
+ label: string
30
+ }
31
+
32
+ /** Deterministic per-domain musical identity (T8 audio branding). Same host → same key, always. */
33
+ export function siteKey(hostname: string): SiteKey {
34
+ const host = hostname || 'localhost'
35
+ const h = fnv1a(host)
36
+ const root = h % 12
37
+ const scaleName = SCALE_NAMES[(h >>> 4) % SCALE_NAMES.length] as string
38
+ const scale = SCALES[scaleName] as readonly number[]
39
+ return { root, scaleName, scale, label: `${PC_TO_NOTE[root]} ${scaleName}` }
40
+ }
41
+
42
+ /** Parse "D dorian" / "Eb pentMinor" → SiteKey. Returns null when unrecognized. */
43
+ export function parseKey(spec: string): SiteKey | null {
44
+ const m = spec.trim().match(/^([A-Ga-g][#b]?)\s+(\w+)$/)
45
+ if (!m) return null
46
+ const note = (m[1] as string).charAt(0).toUpperCase() + (m[1] as string).slice(1)
47
+ const root = NOTE_TO_PC[note]
48
+ const scaleName = m[2] as string
49
+ const scale = SCALES[scaleName]
50
+ if (root === undefined || !scale) return null
51
+ return { root, scaleName, scale, label: `${PC_TO_NOTE[root]} ${scaleName}` }
52
+ }
53
+
54
+ /**
55
+ * The Musical Quantizer (Invariant #2): degree ∈ [0,1] → MIDI note inside the key,
56
+ * laid across OCTAVES octaves from BASE_MIDI + root. stepOffset shifts by whole scale steps
57
+ * (sibling melodies S4, heading registers S6, kiki nudge G10).
58
+ */
59
+ export function degreeToMidi(degree: number, key: SiteKey, stepOffset = 0): number {
60
+ const steps = key.scale.length * OCTAVES
61
+ let idx = Math.round(clamp(degree, 0, 1) * (steps - 1)) + Math.round(stepOffset)
62
+ idx = clamp(idx, 0, steps - 1)
63
+ const oct = Math.floor(idx / key.scale.length)
64
+ const pc = key.scale[idx % key.scale.length] as number
65
+ return BASE_MIDI + key.root + oct * 12 + pc
66
+ }
67
+
68
+ export const midiToFreq = (m: number): number => 440 * Math.pow(2, (m - 69) / 12)
69
+
70
+ /**
71
+ * Walk N scale steps up/down from a midi note, staying on the key's pitch classes
72
+ * (the ribbon controller's quantizer — MODULAR.md §3; Invariant #2 holds under drag).
73
+ */
74
+ export function stepInScale(midi: number, key: SiteKey, steps: number): number {
75
+ if (steps === 0) return midi
76
+ const pcs = key.scale.map((s) => (s + key.root) % 12)
77
+ const dir = steps > 0 ? 1 : -1
78
+ let m = midi
79
+ for (let i = 0; i < Math.abs(steps); i++) {
80
+ do {
81
+ m += dir
82
+ } while (!pcs.includes(((m % 12) + 12) % 12) && m > 12 && m < 120)
83
+ }
84
+ return clamp(m, 12, 120)
85
+ }
86
+
87
+ export function midiToNoteName(m: number): string {
88
+ const pc = ((m % 12) + 12) % 12
89
+ return `${PC_TO_NOTE[pc]}${Math.floor(m / 12) - 1}`
90
+ }
@@ -0,0 +1,16 @@
1
+ export const clamp = (v: number, a: number, b: number): number => Math.min(b, Math.max(a, v))
2
+
3
+ export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t
4
+
5
+ /** Normalized position of v in [a, b], clamped to [0, 1]. */
6
+ export const norm = (v: number, a: number, b: number): number => clamp((v - a) / (b - a), 0, 1)
7
+
8
+ /** FNV-1a 32-bit — stable, dependency-free hash for site identity (MAPPING.md §0). */
9
+ export function fnv1a(str: string): number {
10
+ let h = 0x811c9dc5
11
+ for (let i = 0; i < str.length; i++) {
12
+ h ^= str.charCodeAt(i)
13
+ h = Math.imul(h, 0x01000193)
14
+ }
15
+ return h >>> 0
16
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * SpatialBackend — the seam between the voice pool and the spatial engine (SPATIAL.md §6).
3
+ * 'ambisonic' encodes every lane into the shared FOA field; 'panner' is the v0.1 per-voice
4
+ * HRTF path, kept as option and automatic fallback. Imports Tone only.
5
+ */
6
+ import * as Tone from 'tone'
7
+ import { clamp } from '../math/util'
8
+ import type { SonicProfile } from '../types'
9
+ import type { Room } from '../core/room'
10
+ import { AmbisonicBus } from './bus'
11
+ import { SourceEncoder } from './encoder'
12
+ import { FoaRoom } from './room-foa'
13
+ import { foaGains } from './sh'
14
+ import type { Mat3 } from './rotation'
15
+ import {
16
+ directGain, directivityDirectGain, directivitySendScale, resolveFactors,
17
+ roomGain, type PerceptualFactors,
18
+ } from './perceptual'
19
+
20
+ export interface LaneOutput {
21
+ /** The lane's filter connects here. */
22
+ input: Tone.InputNode
23
+ /** Place the voice in space from its profile (called at trigger time). */
24
+ setPlacement(profile: SonicProfile, when?: number): void
25
+ dispose(): void
26
+ }
27
+
28
+ export interface SpatialBackend {
29
+ readonly kind: 'ambisonic' | 'panner'
30
+ createOutput(): LaneOutput
31
+ setFactors(factors: PerceptualFactors): void
32
+ /** Rotate the field (ambisonic) — no-op on the panner backend. */
33
+ setRotation(m: Mat3): void
34
+ onViewport(vw: number): void
35
+ dispose(): void
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------- ambisonic
39
+
40
+ export class AmbisonicBackend implements SpatialBackend {
41
+ readonly kind = 'ambisonic' as const
42
+ private bus: AmbisonicBus
43
+ private foaRoom: FoaRoom
44
+ private factors: PerceptualFactors
45
+
46
+ constructor(private room: Room, decoderKind: 'binaural' | 'stereo', vw: number, factors?: Partial<PerceptualFactors>) {
47
+ this.factors = resolveFactors(factors)
48
+ this.bus = new AmbisonicBus(room.spatialIn, decoderKind)
49
+ this.foaRoom = new FoaRoom(this.bus.inputs, room.reverb, vw, this.factors)
50
+ }
51
+
52
+ createOutput(): LaneOutput {
53
+ const enc = new SourceEncoder(this.bus.inputs)
54
+ const send = new Tone.Gain(0.15)
55
+ // MATTER.md reverb weave: dark sources bloom dark — the tail is colored per voice.
56
+ const sendColor = new Tone.Filter({ type: 'lowpass', frequency: 5000, rolloff: -12 })
57
+ enc.input.connect(send)
58
+ send.connect(sendColor)
59
+ sendColor.connect(this.room.reverb)
60
+ enc.input.connect(this.foaRoom.erIn)
61
+ const backend = this
62
+ return {
63
+ input: enc.input,
64
+ setPlacement(profile: SonicProfile, when?: number) {
65
+ const t = when ?? Tone.now()
66
+ const s = profile.sphere
67
+ const w = profile.voice.reverb
68
+ const extent = clamp(s.extent + w.extentBonus, 0, 0.95)
69
+ const g = foaGains(s.azimuth, s.elevation, extent)
70
+ const direct = directGain(backend.factors.presence) * directivityDirectGain(s.directivity)
71
+ enc.set(g, direct, when)
72
+ const wetSend = clamp(
73
+ profile.reverbSend * directivitySendScale(s.directivity) * roomGain(backend.factors.roomPresence),
74
+ 0,
75
+ 1.5,
76
+ )
77
+ sendColor.frequency.rampTo(w.sendCutoffHz, 0.02, t)
78
+ send.gain.cancelScheduledValues(t)
79
+ if (w.bloom > 0.35) {
80
+ // round sources swell into the room over the note; sharp ones arrive dry-then-done
81
+ send.gain.setValueAtTime(wetSend * 0.35, t)
82
+ send.gain.rampTo(wetSend, Math.max(0.08, profile.durationS), t)
83
+ } else {
84
+ send.gain.rampTo(wetSend, 0.02, t)
85
+ }
86
+ },
87
+ dispose() {
88
+ enc.dispose()
89
+ send.dispose()
90
+ sendColor.dispose()
91
+ },
92
+ }
93
+ }
94
+
95
+ setFactors(factors: PerceptualFactors): void {
96
+ this.factors = factors
97
+ this.foaRoom.setFactors(factors)
98
+ this.room.setFactors(factors)
99
+ }
100
+
101
+ setRotation(m: Mat3): void {
102
+ this.bus.setRotation(m)
103
+ }
104
+
105
+ onViewport(vw: number): void {
106
+ this.foaRoom.setViewport(vw)
107
+ }
108
+
109
+ dispose(): void {
110
+ this.foaRoom.dispose()
111
+ this.bus.dispose()
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------- panner (v0.1)
116
+
117
+ export class PannerBackend implements SpatialBackend {
118
+ readonly kind = 'panner' as const
119
+
120
+ constructor(private room: Room, private panningModel: 'HRTF' | 'equalpower') {}
121
+
122
+ createOutput(): LaneOutput {
123
+ const panner = new Tone.Panner3D({
124
+ panningModel: this.panningModel,
125
+ distanceModel: 'inverse',
126
+ refDistance: 1,
127
+ rolloffFactor: 0.4,
128
+ positionX: 0, positionY: 0, positionZ: -2,
129
+ })
130
+ const dry = new Tone.Gain(1)
131
+ const send = new Tone.Gain(0.18)
132
+ const sendColor = new Tone.Filter({ type: 'lowpass', frequency: 5000, rolloff: -12 })
133
+ panner.connect(dry)
134
+ panner.connect(send)
135
+ send.connect(sendColor)
136
+ dry.connect(this.room.buses.dryIn)
137
+ sendColor.connect(this.room.buses.wetIn)
138
+ return {
139
+ input: panner,
140
+ setPlacement(profile: SonicProfile, when?: number) {
141
+ const t = when ?? Tone.now()
142
+ panner.positionX.rampTo(profile.pan.x, 0.02, t)
143
+ panner.positionY.rampTo(profile.pan.y, 0.02, t)
144
+ panner.positionZ.rampTo(profile.pan.z, 0.02, t)
145
+ sendColor.frequency.rampTo(profile.voice.reverb.sendCutoffHz, 0.02, t)
146
+ send.gain.rampTo(clamp(profile.reverbSend, 0, 1), 0.02, t)
147
+ },
148
+ dispose() {
149
+ panner.dispose()
150
+ dry.dispose()
151
+ send.dispose()
152
+ sendColor.dispose()
153
+ },
154
+ }
155
+ }
156
+
157
+ setFactors(factors: PerceptualFactors): void {
158
+ this.room.setFactors(factors)
159
+ }
160
+
161
+ setRotation(): void { /* the panner world rotates via Tone.Listener (ListenerRig) */ }
162
+
163
+ onViewport(): void { /* room resize handled by Room */ }
164
+
165
+ dispose(): void { /* lane outputs disposed by the pool */ }
166
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * AmbisonicBus — the field itself (SPATIAL.md §3): W/Y/Z/X summing buses → live 3×3 rotation
3
+ * (nine GainNodes; W invariant) → binaural decode through fixed virtual speakers, each rendered
4
+ * by one native HRTF PannerNode. Imports Tone only.
5
+ */
6
+ import * as Tone from 'tone'
7
+ import { CUBE_LAYOUT, decodeMatrix } from './decoder'
8
+ import { IDENTITY, type Mat3 } from './rotation'
9
+ import type { BusInputs } from './encoder'
10
+
11
+ const SPEAKER_RADIUS = 2.5
12
+
13
+ export class AmbisonicBus {
14
+ readonly inputs: BusInputs
15
+ private rot: Tone.Gain[][] // rot[out][in] over (X, Y, Z) = indices 0,1,2
16
+ private outW: Tone.Gain
17
+ private outXYZ: [Tone.Gain, Tone.Gain, Tone.Gain]
18
+ private nodes: { dispose(): void }[] = []
19
+
20
+ constructor(destination: Tone.InputNode, decoderKind: 'binaural' | 'stereo') {
21
+ this.inputs = { w: new Tone.Gain(1), y: new Tone.Gain(1), z: new Tone.Gain(1), x: new Tone.Gain(1) }
22
+ this.outW = new Tone.Gain(1)
23
+ this.outXYZ = [new Tone.Gain(1), new Tone.Gain(1), new Tone.Gain(1)]
24
+ this.inputs.w.connect(this.outW)
25
+
26
+ // Rotation matrix acts on the (X, Y, Z) triple: out = R · in.
27
+ const inXYZ = [this.inputs.x, this.inputs.y, this.inputs.z]
28
+ this.rot = []
29
+ for (let r = 0; r < 3; r++) {
30
+ const row: Tone.Gain[] = []
31
+ for (let c = 0; c < 3; c++) {
32
+ const g = new Tone.Gain(IDENTITY[r]![c]!)
33
+ inXYZ[c]!.connect(g)
34
+ g.connect(this.outXYZ[r]!)
35
+ row.push(g)
36
+ }
37
+ this.rot.push(row)
38
+ }
39
+
40
+ if (decoderKind === 'stereo') this.buildStereoDecode(destination)
41
+ else this.buildVirtualSpeakerDecode(destination)
42
+
43
+ this.nodes.push(this.inputs.w, this.inputs.y, this.inputs.z, this.inputs.x, this.outW, ...this.outXYZ, ...this.rot.flat())
44
+ }
45
+
46
+ /** SPATIAL.md §3.2 — 8 cube speakers, each a static mix of (W, X', Y', Z') into a fixed HRTF panner. */
47
+ private buildVirtualSpeakerDecode(destination: Tone.InputNode): void {
48
+ const rows = decodeMatrix(CUBE_LAYOUT)
49
+ rows.forEach((row, i) => {
50
+ const dir = CUBE_LAYOUT[i]!.dir
51
+ const sum = new Tone.Gain(1)
52
+ const mw = new Tone.Gain(row.w)
53
+ const mx = new Tone.Gain(row.x)
54
+ const my = new Tone.Gain(row.y)
55
+ const mz = new Tone.Gain(row.z)
56
+ this.outW.connect(mw)
57
+ this.outXYZ[0]!.connect(mx)
58
+ this.outXYZ[1]!.connect(my)
59
+ this.outXYZ[2]!.connect(mz)
60
+ mw.connect(sum)
61
+ mx.connect(sum)
62
+ my.connect(sum)
63
+ mz.connect(sum)
64
+ // AmbiX (+x fwd, +y left, +z up) → WebAudio (+x right, +y up, −z fwd).
65
+ const panner = new Tone.Panner3D({
66
+ panningModel: 'HRTF',
67
+ distanceModel: 'inverse',
68
+ refDistance: SPEAKER_RADIUS,
69
+ rolloffFactor: 0, // fixed-radius speakers: direction only, no distance shading
70
+ positionX: -dir[1] * SPEAKER_RADIUS,
71
+ positionY: dir[2] * SPEAKER_RADIUS,
72
+ positionZ: -dir[0] * SPEAKER_RADIUS,
73
+ })
74
+ sum.connect(panner)
75
+ panner.connect(destination)
76
+ this.nodes.push(sum, mw, mx, my, mz, panner)
77
+ })
78
+ }
79
+
80
+ /** Fallback decode without HRTF: two virtual cardioids at ±90° (SPATIAL.md §3.2). */
81
+ private buildStereoDecode(destination: Tone.InputNode): void {
82
+ const L = new Tone.Gain(1)
83
+ const R = new Tone.Gain(1)
84
+ const wL = new Tone.Gain(0.5)
85
+ const wR = new Tone.Gain(0.5)
86
+ const yL = new Tone.Gain(0.5)
87
+ const yR = new Tone.Gain(-0.5)
88
+ this.outW.connect(wL)
89
+ this.outW.connect(wR)
90
+ this.outXYZ[1]!.connect(yL)
91
+ this.outXYZ[1]!.connect(yR)
92
+ wL.connect(L)
93
+ yL.connect(L)
94
+ wR.connect(R)
95
+ yR.connect(R)
96
+ const merge = new Tone.Merge()
97
+ L.connect(merge, 0, 0)
98
+ R.connect(merge, 0, 1)
99
+ merge.connect(destination)
100
+ this.nodes.push(L, R, wL, wR, yL, yR, merge)
101
+ }
102
+
103
+ /** Rotate the whole field (FieldRig calls this with lookMatrix output). Ramped, zipper-free. */
104
+ setRotation(m: Mat3, rampS = 0.04): void {
105
+ for (let r = 0; r < 3; r++)
106
+ for (let c = 0; c < 3; c++)
107
+ this.rot[r]![c]!.gain.rampTo(m[r]![c]!, rampS)
108
+ }
109
+
110
+ dispose(): void {
111
+ for (const n of this.nodes) n.dispose()
112
+ this.nodes = []
113
+ }
114
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pure FOA decoding — SPATIAL.md §3.2. Sampling decoder with max-rE weighting over a fixed
3
+ * virtual-speaker layout; each speaker is later rendered by one native HRTF panner.
4
+ */
5
+ import { unitVector, type FoaGains } from './sh'
6
+
7
+ export interface Speaker {
8
+ /** Unit direction in the AmbiX frame. */
9
+ dir: [number, number, number]
10
+ label: string
11
+ }
12
+
13
+ const C = 1 / Math.sqrt(3)
14
+
15
+ /** Cube vertices: full-sphere coverage including elevation, symmetric, M = 8. */
16
+ export const CUBE_LAYOUT: Speaker[] = [
17
+ { dir: [C, C, C], label: 'front-left-up' },
18
+ { dir: [C, -C, C], label: 'front-right-up' },
19
+ { dir: [C, C, -C], label: 'front-left-down' },
20
+ { dir: [C, -C, -C], label: 'front-right-down' },
21
+ { dir: [-C, C, C], label: 'back-left-up' },
22
+ { dir: [-C, -C, C], label: 'back-right-up' },
23
+ { dir: [-C, C, -C], label: 'back-left-down' },
24
+ { dir: [-C, -C, -C], label: 'back-right-down' },
25
+ ]
26
+
27
+ /** max-rE weights for 3D first order (Zotter & Frank): g0 = 1, g1 = 1/√3. */
28
+ export const MAXRE_G0 = 1
29
+ export const MAXRE_G1 = 1 / Math.sqrt(3)
30
+
31
+ export interface DecodeRow {
32
+ /** Per-ACN gains [w, y, z, x] for one speaker: s = w·W + y·Y + z·Z + x·X. */
33
+ w: number
34
+ y: number
35
+ z: number
36
+ x: number
37
+ }
38
+
39
+ /**
40
+ * Sampling decode matrix for SN3D input: sm = (1/M)·[g0·W + 3·g1·(X·xm + Y·ym + Z·zm)].
41
+ * The 3 re-normalizes first-order SN3D to N3D inside the projection.
42
+ */
43
+ export function decodeMatrix(layout: Speaker[]): DecodeRow[] {
44
+ const M = layout.length
45
+ return layout.map(({ dir }) => ({
46
+ w: (1 / M) * MAXRE_G0,
47
+ x: (3 / M) * MAXRE_G1 * dir[0],
48
+ y: (3 / M) * MAXRE_G1 * dir[1],
49
+ z: (3 / M) * MAXRE_G1 * dir[2],
50
+ }))
51
+ }
52
+
53
+ /** Decode an encoded source to speaker signals (used by tests and the worklet path later). */
54
+ export function decodeGains(rows: DecodeRow[], g: FoaGains): number[] {
55
+ return rows.map((r) => r.w * g.w + r.y * g.y + r.z * g.z + r.x * g.x)
56
+ }
57
+
58
+ /** Total decoded energy for a direction — tests assert flatness across the sphere. */
59
+ export function decodedEnergy(rows: DecodeRow[], azimuth: number, elevation: number): number {
60
+ const v = unitVector(azimuth, elevation)
61
+ const g: FoaGains = { w: 1, x: v[0], y: v[1], z: v[2] }
62
+ return decodeGains(rows, g).reduce((acc, s) => acc + s * s, 0)
63
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SourceEncoder — one per voice lane: four GainNodes carrying the lane's mono signal into the
3
+ * W/Y/Z/X buses with first-order SH gains (SPATIAL.md §2). Imports Tone only.
4
+ */
5
+ import * as Tone from 'tone'
6
+ import type { FoaGains } from './sh'
7
+
8
+ export interface BusInputs {
9
+ w: Tone.Gain
10
+ y: Tone.Gain
11
+ z: Tone.Gain
12
+ x: Tone.Gain
13
+ }
14
+
15
+ export class SourceEncoder {
16
+ readonly input: Tone.Gain
17
+ private gw: Tone.Gain
18
+ private gy: Tone.Gain
19
+ private gz: Tone.Gain
20
+ private gx: Tone.Gain
21
+
22
+ constructor(bus: BusInputs) {
23
+ this.input = new Tone.Gain(1)
24
+ this.gw = new Tone.Gain(1)
25
+ this.gy = new Tone.Gain(0)
26
+ this.gz = new Tone.Gain(0)
27
+ this.gx = new Tone.Gain(1)
28
+ this.input.connect(this.gw)
29
+ this.input.connect(this.gy)
30
+ this.input.connect(this.gz)
31
+ this.input.connect(this.gx)
32
+ this.gw.connect(bus.w)
33
+ this.gy.connect(bus.y)
34
+ this.gz.connect(bus.z)
35
+ this.gx.connect(bus.x)
36
+ }
37
+
38
+ /** Apply SH gains × an overall direct-path gain, ramped to avoid zipper noise. */
39
+ set(g: FoaGains, directGain: number, when?: number, rampS = 0.015): void {
40
+ const t = when ?? Tone.now()
41
+ this.gw.gain.rampTo(g.w * directGain, rampS, t)
42
+ this.gy.gain.rampTo(g.y * directGain, rampS, t)
43
+ this.gz.gain.rampTo(g.z * directGain, rampS, t)
44
+ this.gx.gain.rampTo(g.x * directGain, rampS, t)
45
+ }
46
+
47
+ dispose(): void {
48
+ this.input.dispose()
49
+ this.gw.dispose()
50
+ this.gy.dispose()
51
+ this.gz.dispose()
52
+ this.gx.dispose()
53
+ }
54
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * FieldRig — embodiment for the ambisonic backend (SPATIAL.md §3.1): mouse-look on desktop,
3
+ * device attitude on mobile, lerped per frame into one field rotation. Exposes the same
4
+ * pointTo/tiltTo surface as the legacy ListenerRig so L2 drivers don't care which world they
5
+ * live in.
6
+ */
7
+ import { clamp, lerp } from '../math/util'
8
+ import { DEG } from './sh'
9
+ import { lookMatrix } from './rotation'
10
+ import type { SpatialBackend } from './backend'
11
+
12
+ const POINTER_YAW_MAX = 40 * DEG
13
+ const POINTER_PITCH_MAX = 20 * DEG
14
+ const TILT_YAW_MAX = 35 * DEG
15
+ const TILT_PITCH_MAX = 25 * DEG
16
+
17
+ export class FieldRig {
18
+ private target = { yaw: 0, pitch: 0 }
19
+ private tilt = { yaw: 0, pitch: 0 }
20
+ private look = { yaw: 0, pitch: 0 }
21
+ private raf = 0
22
+ private running = false
23
+
24
+ constructor(
25
+ private backend: SpatialBackend,
26
+ private mode: 'pointer' | 'center',
27
+ ) {}
28
+
29
+ start(): void {
30
+ if (this.running) return
31
+ this.running = true
32
+ const step = () => {
33
+ if (!this.running) return
34
+ const gy = clamp(this.target.yaw + this.tilt.yaw, -Math.PI / 2, Math.PI / 2)
35
+ const gp = clamp(this.target.pitch + this.tilt.pitch, -Math.PI / 3, Math.PI / 3)
36
+ const ny = lerp(this.look.yaw, gy, 0.1)
37
+ const np = lerp(this.look.pitch, gp, 0.1)
38
+ if (Math.abs(ny - this.look.yaw) > 1e-4 || Math.abs(np - this.look.pitch) > 1e-4) {
39
+ this.look.yaw = ny
40
+ this.look.pitch = np
41
+ this.backend.setRotation(lookMatrix(this.look.yaw, this.look.pitch))
42
+ }
43
+ this.raf = requestAnimationFrame(step)
44
+ }
45
+ this.raf = requestAnimationFrame(step)
46
+ }
47
+
48
+ /** I9 — cursor (viewport-normalized 0..1) becomes look direction: right edge = look right. */
49
+ pointTo(tx: number, ty: number): void {
50
+ if (this.mode !== 'pointer') return
51
+ this.target.yaw = (2 * clamp(tx, 0, 1) - 1) * POINTER_YAW_MAX
52
+ this.target.pitch = (1 - 2 * clamp(ty, 0, 1)) * POINTER_PITCH_MAX
53
+ }
54
+
55
+ /** I10 — device attitude: γ right-tilt = look right, β beyond ~40° = look up/down. */
56
+ tiltTo(gamma: number, beta: number): void {
57
+ this.tilt.yaw = clamp(gamma / 45, -1, 1) * TILT_YAW_MAX
58
+ this.tilt.pitch = clamp((beta - 40) / 45, -1, 1) * -TILT_PITCH_MAX
59
+ }
60
+
61
+ /** Public look API (head tracking / WebXR later plugs in here). Radians, right/up positive. */
62
+ lookAt(yawRight: number, pitchUp: number): void {
63
+ this.target.yaw = yawRight
64
+ this.target.pitch = pitchUp
65
+ }
66
+
67
+ get state(): { yaw: number; pitch: number } {
68
+ return { yaw: this.look.yaw, pitch: this.look.pitch }
69
+ }
70
+
71
+ dispose(): void {
72
+ this.running = false
73
+ cancelAnimationFrame(this.raf)
74
+ }
75
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure perceptual-factor formulas — SPATIAL.md §5. The spat5.oper surface: five factors in
3
+ * [0,1], each mapping monotonically to DSP parameters. Defaults mirror the spec table.
4
+ */
5
+ import { clamp, lerp } from '../math/util'
6
+ import type { PerceptualFactors } from '../types'
7
+
8
+ export type { PerceptualFactors }
9
+
10
+ export const DEFAULT_FACTORS: PerceptualFactors = {
11
+ presence: 0.7,
12
+ roomPresence: 0.5,
13
+ envelopment: 0.55,
14
+ warmth: 0.5,
15
+ brilliance: 0.5,
16
+ }
17
+
18
+ export function resolveFactors(partial: Partial<PerceptualFactors> | undefined): PerceptualFactors {
19
+ const f = { ...DEFAULT_FACTORS, ...(partial ?? {}) }
20
+ for (const k of Object.keys(f) as (keyof PerceptualFactors)[]) f[k] = clamp(f[k], 0, 1)
21
+ return f
22
+ }
23
+
24
+ /** presence → direct-path gain (linear). */
25
+ export const directGain = (presence: number): number => lerp(0.5, 1.2, clamp(presence, 0, 1))
26
+
27
+ /** roomPresence → early-reflection + reverb-send master (linear). */
28
+ export const roomGain = (roomPresence: number): number => lerp(0, 1.6, clamp(roomPresence, 0, 1))
29
+
30
+ /** envelopment → diffuse-tail extent (σ of the tail encoders) and tail level. */
31
+ export const tailExtent = (envelopment: number): number => clamp(envelopment, 0, 1)
32
+ export const tailLevel = (envelopment: number): number => lerp(0.7, 1.25, clamp(envelopment, 0, 1))
33
+
34
+ /** warmth → low-shelf dB at 250 Hz. */
35
+ export const warmthDb = (warmth: number): number => lerp(-3, 3, clamp(warmth, 0, 1))
36
+
37
+ /** brilliance → high-shelf dB at 4 kHz. */
38
+ export const brillianceDb = (brilliance: number): number => lerp(-4, 3, clamp(brilliance, 0, 1))
39
+
40
+ /** directivity δ → source presence/brightness/room behaviour (SPATIAL.md §2). */
41
+ export const directivityDirectGain = (d: number): number => lerp(0.75, 1.1, clamp(d, 0, 1))
42
+ export const directivitySendScale = (d: number): number => lerp(1.35, 0.8, clamp(d, 0, 1))
43
+ export const directivityFilterScale = (d: number): number => lerp(0.85, 1.15, clamp(d, 0, 1))