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,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
|
+
}
|
package/src/math/util.ts
ADDED
|
@@ -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))
|