feat: add spatial audio simulator with camera-as-listener model

Implements the simulator described in README.md: a 3D room in which a
loudspeaker can be moved and aimed, heard binaurally from the camera's
position, with every acoustic term shown live so the user can read why it
sounds the way it does.

Only README.md was previously committed. An earlier AI-generated
implementation existed in the working tree but was never committed; it is
superseded rather than modified. That implementation did not work:
renderer.setSize() was never called and the stylesheet had no rule for
html, body or the canvas container, so the WebGL view rendered into a
300x150 box in the corner of an unstyled page. Its acoustics were also
wrong in ways that voided the stated goal, which is what motivated a
rewrite rather than a repair:

  - AudioContext.listener orientation (forwardX/Y/Z, upX/Y/Z) was never
    written, only position. Orbiting the camera therefore produced no
    change in the sound field at all.
  - The listener was a separate avatar mesh, not the camera, contradicting
    the README. Its syncFromCamera() had zero callers.
  - The reverb send was tapped after the distance, cone and occlusion
    gains, making the direct-to-reverberant ratio mathematically constant.
    Walking away sounded like a fader move rather than like distance.
  - Doppler was derived from a single-frame velocity finite difference
    smoothed with a per-frame (not per-second) lerp, so any drag produced
    an octave-wide pitch chirp and the response varied with refresh rate.

Design decisions worth recording, since each has a cheaper alternative
that was rejected:

The camera IS the Web Audio listener. Position and orientation are both
published every frame. OrbitControls panning is disabled on purpose: it
moves the orbit target without moving the camera, which would silently
desync the ear from the view and break that invariant.

Doppler is emergent, not computed. A DelayNode holds distance /
speedOfSound; chasing it resamples the signal exactly as air does. This
removes velocity estimation from the codebase entirely, which is what
eliminated the drag-chirp class of bug. The reported ratio is 1 - dD/dt,
the shift a delay line actually produces. The textbook moving-source form
1/(1 - v/c) agrees only to first order and has a pole that inverts the
sign of the shift when a source closes fast.

The reverb send is tapped BEFORE distance, directivity and occlusion,
because a room's reverberant field is roughly uniform: it depends on how
much power the source radiates, not on where the listener stands. This is
what makes the critical distance audible, and it is the one thing the
simulator teaches that a fader cannot fake.

Room acoustics are derived from geometry via Sabine (T60 = 0.161V/Sa)
rather than from named presets, so the room-size sliders genuinely change
the sound. The impulse response is normalised to unit energy per channel,
not unit peak, because a convolution's output level follows total energy;
peak normalisation would make a long tail far louder than a short one and
the send gain would stop meaning anything.

Source presets are built additively from a bounded harmonic series instead
of being sampled from ideal waveforms, leaving an octave of clean headroom
so Doppler can pitch them up without folding harmonics back down as
aliasing.

User-supplied audio files are summed to mono. A stereo mix fed into the
panner would leave part of the image fixed to the listener's ears no
matter where the source moved, which is precisely the illusion this app
exists to break.

Dark theme only. The viewport is a lit 3D room; a light chrome around it
reads as a white frame on a dark photograph, and re-lighting the scene for
a light theme would need a second set of materials for no real gain.

User-visible behaviour, relative to the uncommitted prior version:

  - The 3D view fills its pane and is sized on construction plus via a
    ResizeObserver.
  - Orbiting the view swings the sound between the ears (measured at
    roughly 14 dB of L-R swing across a full orbit in an anechoic room).
  - Walking away attenuates the direct path while the reverberant field
    holds steady, and a HUD chip reports which of the two dominates.
  - A signal-path table lists every stage between source and ear in dB on
    one shared -60..0 dB scale. The stage losses sum exactly to the total;
    air absorption deliberately reports no dB because it has no broadband
    loss, only a cutoff.
  - Audio files can be loaded by drop or picker and behave as the source.
  - Three guided demos, a first-run explainer, and a keyboard model.
  - Leaked agent artifacts are gone: the panel header no longer reads
    "M4 OVERLAY", sections are no longer lettered "A.", "B.", and the
    milestone scratch directories and docs are deleted.

Known limitations, also listed in README.md:

  - Reverb is a single static impulse response per room; it does not vary
    with listener position, so walking into a corner is not audible.
  - No precedence effect. Real ears localise well beyond the critical
    distance because the first wavefront wins; here localisation degrades
    with the direct-to-reverberant ratio, so a very live room smears the
    image more than it would in life.
  - Occlusion tests one obstacle and models diffraction as a fixed
    broadband loss rather than as a function of the obstacle's size.
  - HRTF quality is whatever the browser's PannerNode provides.
  - Loaded files loop with an audible seam; the crossfade used on the
    generated presets would be wrong for music.

Test Plan

  npm test          116 unit tests. Physics and room acoustics are pure
                    functions. The audio graph is tested against a mock
                    AudioContext that records connections, so the suite
                    can assert topology directly: that the reverb send
                    hangs off the delay rather than the direct gain, and
                    that listener forward/up are published and not just
                    position. Both were silently wrong before.
  npm run test:e2e  47 checks driving real Chrome; starts its own dev
                    server. Covers what a mock cannot: that the view is
                    actually sized, that orbiting changes the ear balance,
                    that walking away attenuates the direct path while the
                    room holds steady, that each demo does what its
                    caption claims, that the panel follows the demos
                    rather than silently reverting them, and that a real
                    WAV decoded by the browser spatialises like any other
                    source while a non-audio file fails visibly.
  npm run build     tsc plus a production bundle, split so three.js gets
                    its own long-lived chunk (470 kB three, 99 kB app).

Manual check: load the page in a browser with headphones, press
"Enable audio & start", then press 3 and walk away with W/S. The Direct
row should fall while Room (wet) stays flat, and the HUD chip should flip
to room-dominant as you cross the ring drawn on the floor.

Refs: ORIGINAL_REQUEST.md
Docs: README.md
This commit is contained in:
2026-07-26 14:50:23 +02:00
parent acf442ab41
commit 1a827b8b0d
46 changed files with 9468 additions and 7 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
*.local
.DS_Store
.vite/
coverage/
test-results/
playwright-report/
*.log
+52
View File
@@ -0,0 +1,52 @@
# Original User Request
## Initial Request — 2026-07-26T09:38:00Z
An interactive 3D web application using Three.js and Web Audio API that simulates realistic real-time spatial audio propagation (distance attenuation, directional sound cone, doppler effect, room acoustic reflections) with interactive 3D sound source controls and listener perspective.
Working directory: /pantheon/pfs/git/threejs_sound_sim
Integrity mode: demo
## Requirements
### R1. Interactive 3D Audio Environment & Navigation
Build a responsive 3D web interface using Vite, Three.js, and HTML5/CSS. Render a textured 3D room environment containing a movable 3D sound source object (with directional orientation indicators) and a listener/microphone object mapped to camera controls. Provide smooth orbit and keyboard/mouse controls to manipulate both listener and source positions in real time.
### R2. Real-Time Spatial Audio Physics Engine
Implement realistic spatial audio rendering using Web Audio API:
1. Distance Attenuation: Inverse-square / exponential distance attenuation models with configurable rolloff factors.
2. Directional Emission: Cone-based directional audio patterning (inner angle, outer angle, outer gain attenuation).
3. Doppler Pitch Shift: Dynamic frequency pitch calculation based on relative velocity vector between moving sound source and listener.
4. Room Acoustics & Occlusion: Environment reverberation simulation using ConvolverNode / impulse responses or synthetic feedback delay networks, plus acoustic attenuation when obstacles block direct line-of-sight.
### R3. Audio Dashboard & Visualizers
Provide a modern, glassmorphic UI overlay with:
1. Audio Source Controls: Preset selection (instrument sample, engine sound, synthesized tone generator), play/pause, volume master.
2. Physics & Room Controls: Sliders for source velocity, room dimensions/reverb preset, rolloff factor, and directional cone angles.
3. Spectrum & Waveform Analysis: Live canvas visualizers showing real-time frequency spectrum and oscilloscope waveform data from Web Audio AnalyserNode.
### R4. Verification & Build Suite
Provide programmatic test coverage:
1. Unit tests for spatial audio physics calculations (distance gain formulas, doppler shift equation output validation).
2. Automated build check ensuring npm run build generates an error-free production bundle.
## Acceptance Criteria
### 3D Visualization & Interaction
- [ ] 3D scene renders smoothly (60 FPS target) with room boundaries, sound source mesh, directional vector cone, and listener mesh.
- [ ] User can interactively position, drag, or rotate the sound source in 3D space.
- [ ] Camera controls allow orbiting, panning, and inspecting the acoustic environment from the listener's perspective.
### Realistic Spatial Audio Simulation
- [ ] Moving listener further from sound source reduces volume according to configured distance attenuation model.
- [ ] Dynamic relative motion between source and listener produces audible and measurable Doppler pitch shift.
- [ ] Rotating the directional sound source attenuates volume when the listener is outside the inner emission cone.
- [ ] Toggling room reverb presets dynamically changes spatial acoustic reflection parameters.
### User Interface & Analysis
- [ ] Control dashboard updates physics and audio parameters seamlessly without audio glitching.
- [ ] Frequency spectrum and oscilloscope canvases render live audio waveforms accurately.
### Programmatic Verification
- [ ] npm test passes all unit tests for spatial audio math and calculation modules.
- [ ] npm run build succeeds without build or bundling errors.
+149 -7
View File
@@ -1,9 +1,151 @@
# A sound simulator in threejs
# Resonance — a spatial audio simulator in Three.js
## Purpose and general information
Move a loudspeaker through a 3D room and hear exactly what distance, direction,
obstacles and the room itself do to the sound. **Your camera is the microphone**:
orbiting the view swings the sound around your head, walking away makes the room
take over, and stepping behind a pillar muffles the source.
This is a realistic sound simulator where a 3D-scene is rendered and a sound
source within that scene. The user can move that sound-source around, turn it
around, in all directions. The camera position is also the "microphone" from
which point the sound can be heard. The goal is to make an accurate simulation
of how it would sound in real time.
Every number on screen is the actual value driving the audio graph that frame.
```bash
npm install
npm run dev # http://localhost:5173
```
Headphones strongly recommended — the binaural simulation collapses on speakers.
**Bring your own audio.** Drop a file onto the 3D view, or use *Load a track from
your computer* in the Source panel — MP3, WAV, FLAC, OGG, M4A, whatever your
browser decodes. It becomes the loudspeaker: set Motion to Orbit and your song
circles your head, Dopplering as it goes, muffling when it passes behind the
pillar. Files are summed to mono, because a loudspeaker standing at one point in
a room radiates one signal; a stereo mix would leave half the image glued to your
ears no matter where the source went. Nothing is uploaded — decoding happens
locally in the page.
## What it models
| Effect | How |
| --- | --- |
| **Distance attenuation** | Inverse / exponential / linear laws. At rolloff 1.0 the inverse law is exact physics: 6 dB per doubling. |
| **Directivity** | Web Audio's inner/outer cone model. The lobe in the scene dims with the gain you are actually receiving. |
| **Propagation delay** | A delay line holding `distance / speedOfSound`. Sound genuinely arrives late. |
| **Doppler** | *Emergent.* Nothing computes a Doppler ratio to feed the audio — the delay line chasing a moving source resamples the signal, exactly as air does. The reported ratio is `1 dD/dt`, which is what a delay line actually produces. |
| **Room reverberation** | A synthetic impulse response built from the room's real geometry via Sabine's equation, `T60 = 0.161 V / Sα`. Early reflections land at the actual wall arrival times. |
| **Direct-to-reverberant ratio** | The reverb send is tapped *before* distance and directivity, because a room's reverberant field is roughly uniform. This is the effect that makes distance audible. |
| **Occlusion** | A bundle of rays across the head gives fractional occlusion, which sweeps a lowpass on the direct path only — reflected sound still gets through. |
| **Air absorption** | Distance-dependent treble loss. The default is realistic, which means subtle; the slider goes to 8× if you want to hear it. |
### The critical distance
The one idea the app is built around. The direct sound falls as `1/r`; the
reverberant field does not fall at all. They cross at the **critical distance**,
`rc = √(R / 16π)`, drawn as a ring on the floor:
- **Inside the ring** the source dominates. You can point at it with your eyes shut.
- **Outside it** the room dominates. It does not get quieter as you back away — and that constancy is a large part of how your ears judge distance.
Press <kbd>3</kbd> for a guided walk through it.
## Controls
| | | | |
| --- | --- | --- | --- |
| <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> | walk (you are the mic) | <kbd>Q</kbd> <kbd>E</kbd> | down / up |
| <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> | move the source | <kbd>PgUp</kbd> <kbd>PgDn</kbd> | source height |
| drag | orbit | <kbd>Shift</kbd> | move 3× faster |
| <kbd>G</kbd> / <kbd>R</kbd> | gizmo: move / turn | double-click | place the source |
| <kbd>K</kbd> | play / pause | <kbd>H</kbd> | hide panels |
| <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> | guided demos | <kbd>?</kbd> | all shortcuts |
Camera panning is deliberately disabled: it would move the orbit target without
moving the camera, quietly breaking the invariant that the camera *is* the ear.
## The signal path
The bottom-left panel is the whole point. It lists every stage between the
source and your ear with its loss in dB on one shared 60…0 dB scale, so the
bars are directly comparable and the stage losses sum exactly to the total:
```
Source Sawtooth 3.1 dB ██████████
Distance 12.24 m 21.8 dB ███████
Directivity 0.0° off-axis 0.0 dB ██████████
Occlusion clear 0.0 dB ██████████
Air tone only · LP 20.5 kHz —
──────────────────────────────────────────────
At your ear 35.7 ms flight 21.8 dB ████
Room (wet) t60 1.63 s 9.1 dB ████████
Direct / room room-dominant 12.7 dB
```
Air absorption has no broadband dB — it only shapes tone — so it deliberately
reports no number rather than an invented one.
## Architecture
```
src/
physics/ Pure functions, no Web Audio, no Three.js. Fully unit-tested.
vector · attenuation · cone · doppler · occlusion
audio/
AudioEngine Node graph + per-frame spatial update + telemetry
presets Band-limited, seam-free looping source material
reverb Sabine acoustics and impulse-response synthesis
userAudio Decoding and mono-summing files from disk
scene/
Stage Renderer, camera-as-listener, orbit + gizmo, occlusion rays
Room · SoundSource · Annotations · motion
ui/
panel · signalPath · readouts · meters · onboarding · controls · format
main.ts One rAF loop: stage → engine → readouts
```
The audio graph:
```
source ▶ sourceGain ▶ delay ─┬─▶ air ▶ occlusion ▶ direct ▶ panner(HRTF) ─┐
│ ├▶ master ▶ limiter ▶ analyser ▶ out
└─▶ reverbSend ▶ convolver ──────────────────┘
```
`AudioEngine.updateSpatial()` computes the full acoustic state whether or not an
`AudioContext` exists, so every readout is live and correct before the user has
unlocked audio.
Source presets are built additively from a bounded harmonic series rather than
sampled from ideal waveforms. That leaves an octave of clean headroom, so
Doppler can pitch them up without folding harmonics back down as aliasing.
## Tests
```bash
npm test # 116 unit tests: physics, room acoustics, audio graph, presets, file loading
npm run test:e2e # 47 checks driving real Chrome (starts its own dev server)
npm run build # tsc + production bundle
```
The unit suite runs in Node against a mock `AudioContext` that records the graph
topology, so it can assert things like "the reverb send is tapped before the
distance gain" and "the listener's orientation is published, not just its
position" — both of which were silently wrong before.
The browser suite covers what a mock cannot: that the view is actually sized,
that orbiting swings the sound between the ears, that walking away attenuates
the direct path while the reverberant field holds steady, that each demo does
what it claims, and that the panel controls follow the demos rather than
silently reverting them.
## Known limitations
- Reverb is a single static impulse response per room. It does not update with
listener position, so you cannot hear yourself walk into a corner.
- No precedence effect. Beyond the critical distance real ears still localise
well because the first wavefront wins; here localisation degrades with the
direct-to-reverberant ratio, so a very live room smears the image more than
it would in life.
- Occlusion tests one obstacle, and models diffraction as a fixed broadband loss
rather than as a frequency-dependent function of the obstacle's size.
- HRTF quality is whatever the browser's `PannerNode` provides.
- Dark theme only: the viewport is a lit 3D room, and a light chrome around it
would need a second set of scene materials to not look like a picture frame.
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en" style="background: #0e0f12">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="color-scheme" content="dark" />
<meta
name="description"
content="An interactive 3D spatial audio simulator: move a loudspeaker through a room and hear what distance, direction, obstacles and the room itself do to the sound."
/>
<title>Resonance · spatial audio studio</title>
</head>
<body>
<div id="app">
<header id="bar">
<p class="wordmark">Resonance <span>spatial audio studio</span></p>
<button id="play" class="btn btn-primary" type="button" aria-pressed="false">▶ Play</button>
<label class="visually-hidden" for="volume">Master volume</label>
<input id="volume" type="range" min="0" max="1" step="0.01" value="0.7" style="width: 116px" />
<output id="volume-value" for="volume" class="field-value">70 %</output>
<span class="bar-spacer"></span>
<p id="status" class="status-chip" role="status" data-state="idle">
<span class="dot"></span><span id="status-text">Audio not started</span>
</p>
<button id="help-btn" class="btn btn-icon" type="button" aria-label="Keyboard shortcuts">?</button>
</header>
<main id="viewport">
<div class="viewport-tools">
<button id="reset-view" class="btn" type="button">Reset view</button>
<button id="hide-panels" class="btn" type="button" aria-pressed="false">Hide panels</button>
</div>
<p class="legend">
<span><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> walk</span>
<span><kbd>↑↓←→</kbd> move source</span>
<span><kbd>drag</kbd> orbit</span>
<span><kbd>G</kbd>/<kbd>R</kbd> move / turn</span>
<span><kbd>?</kbd> all controls</span>
</p>
</main>
<aside id="panel" aria-label="Simulation parameters"></aside>
<footer id="strip">
<section class="module" id="module-path">
<h2 class="module-title">Signal path <em>why it sounds like this</em></h2>
</section>
<section class="module" id="module-spectrum">
<h2 class="module-title">Spectrum <em>log Hz</em></h2>
<canvas id="spectrum" role="img" aria-label="Live frequency spectrum"></canvas>
</section>
<section class="module" id="module-scope">
<h2 class="module-title">Scope <em>trigger ↗</em></h2>
<canvas id="scope" role="img" aria-label="Live output waveform"></canvas>
</section>
</footer>
</div>
<output id="live" class="visually-hidden" aria-live="polite"></output>
<noscript>
<p style="padding: 24px">This simulator needs JavaScript and the Web Audio API.</p>
</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1965
View File
@@ -0,0 +1,1965 @@
{
"name": "threejs-sound-sim",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "threejs-sound-sim",
"version": "2.0.0",
"dependencies": {
"three": "^0.160.0"
},
"devDependencies": {
"@types/three": "^0.160.0",
"playwright": "^1.62.0",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@sinclair/typebox": {
"version": "0.27.12",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.160.0.tgz",
"integrity": "sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/stats.js": "*",
"@types/webxr": "*",
"fflate": "~0.6.10",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
"integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "1.6.1",
"@vitest/utils": "1.6.1",
"chai": "^4.3.10"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
"integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "1.6.1",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
"integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
"integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^2.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"diff-sequences": "^29.6.3",
"estree-walker": "^3.0.3",
"loupe": "^2.3.7",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
"integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.3",
"deep-eql": "^4.1.3",
"get-func-name": "^2.0.2",
"loupe": "^2.3.6",
"pathval": "^1.1.1",
"type-detect": "^4.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
"integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.2"
},
"engines": {
"node": "*"
}
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
"integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-detect": "^4.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fflate": {
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz",
"integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
"integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.3",
"pkg-types": "^1.2.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/loupe": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
"integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-limit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
"integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
"integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/pkg-types/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.62.2",
"@rollup/rollup-android-arm64": "4.62.2",
"@rollup/rollup-darwin-arm64": "4.62.2",
"@rollup/rollup-darwin-x64": "4.62.2",
"@rollup/rollup-freebsd-arm64": "4.62.2",
"@rollup/rollup-freebsd-x64": "4.62.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
"@rollup/rollup-linux-arm-musleabihf": "4.62.2",
"@rollup/rollup-linux-arm64-gnu": "4.62.2",
"@rollup/rollup-linux-arm64-musl": "4.62.2",
"@rollup/rollup-linux-loong64-gnu": "4.62.2",
"@rollup/rollup-linux-loong64-musl": "4.62.2",
"@rollup/rollup-linux-ppc64-gnu": "4.62.2",
"@rollup/rollup-linux-ppc64-musl": "4.62.2",
"@rollup/rollup-linux-riscv64-gnu": "4.62.2",
"@rollup/rollup-linux-riscv64-musl": "4.62.2",
"@rollup/rollup-linux-s390x-gnu": "4.62.2",
"@rollup/rollup-linux-x64-gnu": "4.62.2",
"@rollup/rollup-linux-x64-musl": "4.62.2",
"@rollup/rollup-openbsd-x64": "4.62.2",
"@rollup/rollup-openharmony-arm64": "4.62.2",
"@rollup/rollup-win32-arm64-msvc": "4.62.2",
"@rollup/rollup-win32-ia32-msvc": "4.62.2",
"@rollup/rollup-win32-x64-gnu": "4.62.2",
"@rollup/rollup-win32-x64-msvc": "4.62.2",
"fsevents": "~2.3.2"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
"integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/three": {
"version": "0.160.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
"integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
"integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/type-detect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
"integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vite-node": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
"integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.4",
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
"@vitest/snapshot": "1.6.1",
"@vitest/spy": "1.6.1",
"@vitest/utils": "1.6.1",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
"execa": "^8.0.1",
"local-pkg": "^0.5.0",
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"tinybench": "^2.5.1",
"tinypool": "^0.8.3",
"vite": "^5.0.0",
"vite-node": "1.6.1",
"why-is-node-running": "^2.2.2"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.6.1",
"@vitest/ui": "1.6.1",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "threejs-sound-sim",
"private": true,
"version": "2.0.0",
"description": "An interactive 3D spatial audio simulator built with Three.js and the Web Audio API.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "node scripts/e2e.mjs",
"shot": "node scripts/shot.mjs"
},
"dependencies": {
"three": "^0.160.0"
},
"devDependencies": {
"@types/three": "^0.160.0",
"playwright": "^1.62.0",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.1"
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0e0f12" />
<circle cx="10.5" cy="16" r="3.6" fill="#6c8cff" />
<g stroke="#8ca4ff" stroke-width="2" fill="none" stroke-linecap="round">
<path d="M16.5 10.5a8 8 0 0 1 0 11" />
<path d="M20.5 7a13 13 0 0 1 0 18" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 345 B

+415
View File
@@ -0,0 +1,415 @@
/**
* Browser smoke test.
*
* Drives the real app in real Chrome, where a real WebGL context and a real
* AudioContext exist, and asserts on the acoustic behaviour the unit suite
* cannot reach: that the view is actually sized, that turning your head
* changes the binaural image, that walking away is audible, and that the
* demos do what they claim.
*
* npm run test:e2e (against an already-running dev server)
* npm run test:e2e -- <url>
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { chromium } from 'playwright';
/** A real 16-bit PCM WAV, so the browser's own decoder is exercised. */
function makeWavFile(seconds, frequency, sampleRate = 44100) {
const frames = Math.floor(seconds * sampleRate);
const dataBytes = frames * 4; // stereo, 16-bit
const buffer = Buffer.alloc(44 + dataBytes);
buffer.write('RIFF', 0);
buffer.writeUInt32LE(36 + dataBytes, 4);
buffer.write('WAVE', 8);
buffer.write('fmt ', 12);
buffer.writeUInt32LE(16, 16); // PCM chunk size
buffer.writeUInt16LE(1, 20); // PCM
buffer.writeUInt16LE(2, 22); // channels
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 4, 28); // byte rate
buffer.writeUInt16LE(4, 32); // block align
buffer.writeUInt16LE(16, 34); // bits per sample
buffer.write('data', 36);
buffer.writeUInt32LE(dataBytes, 40);
for (let i = 0; i < frames; i++) {
const sample = Math.round(Math.sin((2 * Math.PI * frequency * i) / sampleRate) * 20000);
buffer.writeInt16LE(sample, 44 + i * 4);
buffer.writeInt16LE(sample, 44 + i * 4 + 2);
}
return buffer;
}
let failures = 0;
let checks = 0;
let server = null;
/** Boots a dev server unless the caller pointed us at one already. */
async function resolveUrl() {
const explicit = process.argv[2];
if (explicit) return explicit;
const port = 5179;
const url = `http://localhost:${port}/`;
server = spawn('npx', ['vite', '--port', String(port), '--strictPort'], {
stdio: 'ignore',
detached: false,
});
for (let attempt = 0; attempt < 40; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
try {
const response = await fetch(url);
if (response.ok) return url;
} catch {
/* not up yet */
}
}
throw new Error('dev server did not start');
}
const url = await resolveUrl();
function check(name, condition, detail = '') {
checks++;
if (condition) {
console.log(`${name}`);
} else {
failures++;
console.log(`${name}${detail ? `${detail}` : ''}`);
}
}
const browser = await chromium.launch({
channel: 'chrome',
args: [
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--autoplay-policy=no-user-gesture-required',
],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
page.on('console', (m) => {
if (m.type() === 'error' && !m.text().includes('favicon')) pageErrors.push(m.text());
});
const telemetry = () => page.evaluate(() => window.__resonance.telemetry());
const settings = () => page.evaluate(() => window.__resonance.settings());
const settle = (ms = 900) => page.waitForTimeout(ms);
console.log(`\nResonance smoke test — ${url}\n`);
await page.goto(url, { waitUntil: 'networkidle' });
await settle(1200);
console.log('boot');
{
const box = await page.evaluate(() => {
const c = document.querySelector('#viewport canvas');
return { w: c.width, h: c.height, cssW: c.clientWidth, cssH: c.clientHeight };
});
check('the 3D view fills its pane', box.cssW > 800 && box.cssH > 400, JSON.stringify(box));
check('the drawing buffer matches the CSS box', box.w >= box.cssW && box.h >= box.cssH);
check('the first-run dialog is shown', await page.locator('#welcome[open]').count() === 1);
const t = await telemetry();
check('telemetry is live before audio is unlocked', t.distance > 0 && Number.isFinite(t.distanceGainDb));
check('audio is not yet running', t.running === false);
}
console.log('\nunlocking audio');
await page.locator('#welcome button', { hasText: 'Enable audio' }).click();
await settle(1500);
{
const t = await telemetry();
check('the context reports running', t.running === true);
check('the status chip agrees', (await page.locator('#status').getAttribute('data-state')) === 'running');
check('the dialog closed', await page.locator('#welcome[open]').count() === 0);
check('output has level', t.outputLevelDb > -80, `${t.outputLevelDb} dB`);
}
console.log('\nbinaural image (the camera is the microphone)');
{
// Dry the room right out and use broadband noise. Reverb is diffuse by
// construction, so a live room legitimately masks the binaural image — this
// check is about HRTF, not about the direct-to-reverberant balance.
await page.selectOption('#surface', 'anechoic');
await page.selectOption('#preset', 'pink');
await settle(900);
const cameraBefore = await page.evaluate(() => window.__resonance.camera());
// Drag from low on the left, well clear of the transform gizmo — a drag that
// starts on the gizmo moves the source instead of orbiting the camera.
// Sample the ear balance right through the sweep rather than only at the
// ends: a sweep that happens to land back on its starting bearing would
// otherwise look like nothing moved.
const box = await page.locator('#viewport canvas').boundingBox();
const startX = box.x + box.width * 0.18;
const startY = box.y + box.height * 0.78;
let min = Infinity;
let max = -Infinity;
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let i = 1; i <= 18; i++) {
await page.mouse.move(startX + i * 22, startY);
await page.waitForTimeout(90);
const t = await telemetry();
const balance = t.leftLevelDb - t.rightLevelDb;
if (Number.isFinite(balance)) {
min = Math.min(min, balance);
max = Math.max(max, balance);
}
}
await page.mouse.up();
await settle(800);
const cameraAfter = await page.evaluate(() => window.__resonance.camera());
const moved = Math.abs(cameraAfter.x - cameraBefore.x) + Math.abs(cameraAfter.z - cameraBefore.z);
check('dragging orbits the camera', moved > 1, `moved ${moved.toFixed(2)} m`);
check(
'orbiting swings the sound between the ears',
max - min > 4,
`LR spanned ${min.toFixed(1)}${max.toFixed(1)} dB`
);
await page.selectOption('#surface', 'studio');
await page.selectOption('#preset', 'sawtooth');
await settle(600);
}
console.log('\nwalking away');
{
const before = await telemetry();
await page.locator('#viewport canvas').click({ position: { x: 500, y: 300 } });
await page.keyboard.down('KeyS');
await settle(1100);
await page.keyboard.up('KeyS');
await settle(500);
const after = await telemetry();
check(
'holding S increases the distance',
after.distance > before.distance + 0.5,
`${before.distance.toFixed(2)} m → ${after.distance.toFixed(2)} m`
);
// Check the distance term specifically: walking also changes the off-axis
// angle, so the *total* gain can legitimately rise as you back away.
check(
'distance attenuation increases',
after.distanceGainDb < before.distanceGainDb - 0.5,
`${before.distanceGainDb.toFixed(1)} dB → ${after.distanceGainDb.toFixed(1)} dB`
);
check(
'the reverberant field does not follow it down',
Math.abs(after.reverbGainDb - before.reverbGainDb) < 0.5,
`room ${before.reverbGainDb.toFixed(1)} dB → ${after.reverbGainDb.toFixed(1)} dB`
);
}
console.log('\ndemo 1 — siren fly-by');
{
await page.keyboard.press('Digit1');
await settle(600);
let sawShift = false;
let extreme = 1;
for (let i = 0; i < 40; i++) {
const t = await telemetry();
if (Math.abs(t.dopplerCents) > 12) sawShift = true;
if (Math.abs(t.dopplerCents) > Math.abs(extreme)) extreme = t.dopplerCents;
await page.waitForTimeout(120);
}
check('a moving source produces a Doppler shift', sawShift, `peak ${extreme.toFixed(1)} cents`);
check('the preset switched to the engine', (await settings()).preset === 'engine');
// A demo that changes the engine without updating the panel leaves every
// control showing a stale value, and the next touch writes it back — silently
// undoing the demo the user just asked for.
check(
'the panel followed the demo',
(await page.locator('#preset').inputValue()) === 'engine',
`preset select shows "${await page.locator('#preset').inputValue()}"`
);
check(
'the motion control followed the demo',
(await page.locator('.segmented button[aria-checked="true"]').first().textContent())?.trim() ===
'Fly-by'
);
}
console.log('\ndemo 2 — behind the pillar');
{
await page.keyboard.press('Digit2');
await settle(1200);
const t = await telemetry();
check('the line of sight is blocked', t.occlusion > 0.5, `occlusion ${t.occlusion.toFixed(2)}`);
check('the direct path is lowpassed', t.occlusionCutoff < 4000, `${Math.round(t.occlusionCutoff)} Hz`);
check('the signal path row flags it', await page.locator('.path tr[data-flag="warn"]').count() > 0);
}
console.log('\ndemo 3 — past the critical distance');
{
await page.keyboard.press('Digit3');
await settle(1200);
const t = await telemetry();
check('the room got livelier', t.t60 > 1.5, `t60 ${t.t60.toFixed(2)} s`);
check('a critical distance is reported', t.criticalDistance > 0 && t.criticalDistance < 20);
check('the room controls followed the demo', (await page.locator('#surface').inputValue()) === 'hall');
check('the width slider followed the demo', (await page.locator('#room-w').inputValue()) === '34');
check(
'direct-to-reverb is derived from the two levels shown',
Math.abs(t.directToReverbDb - (t.directGainDb - t.reverbGainDb)) < 1e-6
);
}
console.log('\nUI wiring');
{
await page.selectOption('#surface', 'cathedral');
await settle(900);
const stone = await telemetry();
await page.selectOption('#surface', 'anechoic');
await settle(900);
const foam = await telemetry();
check('harder surfaces ring longer', stone.t60 > foam.t60, `${stone.t60.toFixed(2)} vs ${foam.t60.toFixed(2)} s`);
check('deader surfaces push the critical distance out', foam.criticalDistance > stone.criticalDistance);
await page.locator('#play').click();
await settle(500);
check('pause stops playback', (await page.evaluate(() => window.__resonance.playing())) === false);
await page.locator('#play').click();
await settle(500);
check('play resumes it', (await page.evaluate(() => window.__resonance.playing())) === true);
await page.locator('#hide-panels').click();
await settle(400);
check('panels can be hidden', await page.evaluate(() => document.body.classList.contains('panels-hidden')));
await page.locator('#hide-panels').click();
await settle(400);
const shown = await page.evaluate(() => {
const rows = [...document.querySelectorAll('.path tr')];
const row = rows.find((r) => r.querySelector('th')?.textContent === 'Distance');
return row?.querySelector('td.detail')?.textContent ?? '';
});
const t = await telemetry();
check(
'the signal path prints the distance it measured',
Math.abs(parseFloat(shown) - t.distance) < 0.2,
`panel "${shown}" vs telemetry ${t.distance.toFixed(2)}`
);
}
console.log('\nloading a file from disk');
{
const wav = makeWavFile(3, 220);
const path = `${tmpdir()}/resonance-e2e-tone.wav`;
writeFileSync(path, wav);
await page.setInputFiles('#audio-file', path);
await settle(2000);
const settingsAfter = await settings();
check('the loaded file becomes the source', settingsAfter.preset === 'file');
check(
'the dropdown names the track',
(await page.locator('#preset option[value="file"]').textContent())?.includes('resonance-e2e-tone.wav'),
await page.locator('#preset option[value="file"]').textContent()
);
check('the dropdown selects it', (await page.locator('#preset').inputValue()) === 'file');
check(
'the duration is reported',
/0:03/.test((await page.locator('#audio-file-status').textContent()) ?? ''),
await page.locator('#audio-file-status').textContent()
);
check('playback started', (await page.evaluate(() => window.__resonance.playing())) === true);
const t = await telemetry();
check('the file is producing output', t.outputLevelDb > -80, `${t.outputLevelDb.toFixed(1)} dBFS`);
const sourceRow = await page.evaluate(() => {
const rows = [...document.querySelectorAll('.path tr')];
const row = rows.find((r) => r.querySelector('th')?.textContent === 'Source');
return row?.querySelector('td.detail')?.textContent ?? '';
});
check(
'the signal path names the track, not "file"',
sourceRow.includes('resonance-e2e-tone'),
`shows "${sourceRow}"`
);
// The whole point: a file behaves like every other source in the simulation.
await page.locator('.segmented button', { hasText: 'Orbit' }).click();
await settle(1500);
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < 25; i++) {
const s = await telemetry();
const balance = s.leftLevelDb - s.rightLevelDb;
if (Number.isFinite(balance)) {
min = Math.min(min, balance);
max = Math.max(max, balance);
}
await page.waitForTimeout(150);
}
check(
'the file is spatialised like any other source',
max - min > 3,
`LR spanned ${min.toFixed(1)}${max.toFixed(1)} dB`
);
// A non-audio file must fail visibly, not silently or in the console.
const junk = `${tmpdir()}/resonance-e2e-junk.txt`;
writeFileSync(junk, 'this is not audio');
await page.setInputFiles('#audio-file', junk);
await settle(1500);
const status = (await page.locator('#audio-file-status').textContent()) ?? '';
check('a non-audio file reports a readable error', /Could not decode/.test(status), status);
check(
'a failed load leaves the previous track playing',
(await settings()).preset === 'file' &&
(await page.evaluate(() => window.__resonance.playing())) === true
);
await page.locator('.segmented button', { hasText: 'Parked' }).click();
await page.selectOption('#preset', 'sawtooth');
await settle(600);
}
console.log('\naccessibility basics');
{
const audit = await page.evaluate(() => {
const controls = [...document.querySelectorAll('#panel input, #panel select')];
const unlabelled = controls.filter(
(c) => !c.id || !document.querySelector(`label[for="${c.id}"]`)
).length;
return {
total: controls.length,
unlabelled,
headings: document.querySelectorAll('h1, h2').length,
liveRegions: document.querySelectorAll('[aria-live]').length,
canvasLabels: [...document.querySelectorAll('#strip canvas')].every((c) =>
c.getAttribute('aria-label')
),
};
});
check('every panel control has a real label', audit.unlabelled === 0, `${audit.unlabelled} of ${audit.total}`);
check('the page is structured with headings', audit.headings >= 4);
check('there is a polite live region', audit.liveRegions >= 1);
check('the meter canvases are described', audit.canvasLabels);
}
console.log('\nruntime health');
check('no uncaught page errors', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '));
await browser.close();
server?.kill();
console.log(`\n${checks - failures}/${checks} checks passed\n`);
process.exit(failures === 0 ? 0 : 1);
+51
View File
@@ -0,0 +1,51 @@
// Screenshot helper: node scripts/shot.mjs <url> <outfile> [width] [height] [action]
// action: "welcome" keeps the first-run dialog open, anything else starts audio first.
import { chromium } from 'playwright';
const [, , url = 'http://localhost:5178/', out = '/tmp/shot.png', w = '1440', h = '900', action = 'start'] =
process.argv;
const browser = await chromium.launch({
channel: 'chrome',
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage({
viewport: { width: +w, height: +h },
deviceScaleFactor: 1,
colorScheme: 'dark',
});
const logs = [];
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}\n${e.stack ?? ''}`));
page.on('requestfailed', (r) => logs.push(`[404?] ${r.url()} ${r.failure()?.errorText ?? ''}`));
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(1200);
if (action !== 'welcome') {
const start = page.locator('#welcome button', { hasText: 'Enable audio' });
if (await start.count()) await start.click();
await page.waitForTimeout(2600);
}
const probe = await page.evaluate(() => {
const c = document.querySelector('canvas');
const strip = document.getElementById('strip');
const path = document.querySelector('.path');
return {
canvas: c ? { w: c.width, h: c.height, cssW: c.clientWidth, cssH: c.clientHeight } : null,
stripH: strip?.clientHeight,
pathH: path?.scrollHeight,
pathClipped: path ? path.scrollHeight > path.parentElement.clientHeight : null,
status: document.getElementById('status-text')?.textContent,
hud: document.querySelector('.hud')?.innerText.replace(/\n/g, ' | '),
ear: document.querySelector('.path .total')?.innerText.replace(/\t/g, ' '),
};
});
await page.screenshot({ path: out });
console.log(JSON.stringify(probe, null, 2));
console.log('--- console ---');
console.log(logs.filter((l) => !l.includes('GL Driver')).join('\n') || '(clean)');
await browser.close();
+740
View File
@@ -0,0 +1,740 @@
import {
DOPPLER_MAX_RATIO,
DOPPLER_MIN_RATIO,
DistanceModel,
Vec3,
airAbsorptionCutoff,
clamp,
coneGain,
distance,
distanceGain,
gainToDb,
normalize,
occlusionResponse,
offAxisAngle,
ratioToCents,
smoothingAlpha,
} from '../physics';
import { PresetId, SourceId, createPresetBuffer } from './presets';
import { AudioFileError, decodeAudioFile } from './userAudio';
import {
RoomAcoustics,
RoomDimensions,
SurfaceId,
computeAcoustics,
generateImpulseResponse,
getSurface,
reverberantGain,
} from './reverb';
export interface EngineSettings {
preset: SourceId;
masterVolume: number;
surface: SurfaceId;
room: RoomDimensions;
distanceModel: DistanceModel;
rolloffFactor: number;
refDistance: number;
maxDistance: number;
coneInnerAngle: number;
coneOuterAngle: number;
coneOuterGain: number;
speedOfSound: number;
/** 1.0 is physically realistic; higher exaggerates high-frequency loss. */
airAbsorption: number;
/** Time-of-flight delay, which also produces Doppler as a side effect. */
propagationEnabled: boolean;
}
/**
* A settings patch. Room dimensions are individually optional so a single
* slider can send just its own axis without having to restate the other two.
*/
export type EngineSettingsPatch = Partial<Omit<EngineSettings, 'room'>> & {
room?: Partial<RoomDimensions>;
};
export const DEFAULT_SETTINGS: EngineSettings = {
preset: 'sawtooth',
masterVolume: 0.7,
// A treated room by default: live enough to hear the space, dead enough that
// the direct sound still dominates near the source, which is where the
// binaural image is worth listening to.
surface: 'studio',
// Large enough that the critical distance lands several metres out, which is
// what lets the opening view sit inside it while still framing the room.
room: { width: 26, height: 9, depth: 26 },
distanceModel: 'inverse',
rolloffFactor: 1,
refDistance: 1,
maxDistance: 80,
coneInnerAngle: 70,
coneOuterAngle: 160,
coneOuterGain: 0.08,
speedOfSound: 343,
airAbsorption: 1,
propagationEnabled: true,
};
export interface SpatialInput {
sourcePos: Vec3;
sourceForward: Vec3;
listenerPos: Vec3;
listenerForward: Vec3;
listenerUp: Vec3;
/** 0 = clear line of sight, 1 = fully blocked. */
occlusion: number;
}
/** Everything the UI needs to explain why the scene sounds the way it does. */
export interface Telemetry {
distance: number;
distanceGainDb: number;
offAxisAngle: number;
coneGainDb: number;
occlusion: number;
occlusionGainDb: number;
occlusionCutoff: number;
airCutoff: number;
/** Frequency ratio actually being produced by the moving delay line. */
dopplerRatio: number;
dopplerCents: number;
/** Closing speed along the source→listener axis, m/s. Positive = approaching. */
closingSpeed: number;
timeOfFlightMs: number;
directGainDb: number;
reverbGainDb: number;
/** Direct-to-reverberant ratio in dB. Negative means the room dominates. */
directToReverbDb: number;
criticalDistance: number;
t60: number;
reverbDominant: boolean;
outputLevelDb: number;
leftLevelDb: number;
rightLevelDb: number;
clipping: boolean;
running: boolean;
}
export const SILENT_TELEMETRY: Telemetry = {
distance: 0,
distanceGainDb: -120,
offAxisAngle: 0,
coneGainDb: 0,
occlusion: 0,
occlusionGainDb: 0,
occlusionCutoff: 22050,
airCutoff: 22050,
dopplerRatio: 1,
dopplerCents: 0,
closingSpeed: 0,
timeOfFlightMs: 0,
directGainDb: -120,
reverbGainDb: -120,
directToReverbDb: 0,
criticalDistance: 0,
t60: 0,
reverbDominant: false,
outputLevelDb: -120,
leftLevelDb: -120,
rightLevelDb: -120,
clipping: false,
running: false,
};
/**
* Schedules AudioParam changes only when the target has meaningfully moved.
* Calling setTargetAtTime every frame for every parameter would pile up tens of
* thousands of automation events per minute for no audible benefit.
*/
class SmoothParam {
private last = Number.NaN;
constructor(
private readonly param: AudioParam,
private readonly epsilon: number,
private readonly tau = 0.02
) {}
set(value: number, now: number): void {
if (Number.isFinite(this.last) && Math.abs(value - this.last) < this.epsilon) return;
this.last = value;
this.param.setTargetAtTime(value, now, this.tau);
}
}
/**
* Longest propagation delay we preallocate. The worst case is the diagonal of
* the largest room (60 m cube ≈ 104 m) at the slowest speed of sound the UI
* offers (80 m/s), so 1.5 s leaves headroom. Sizing this to the default 343 m/s
* would silently saturate the delay line — freezing time of flight and killing
* Doppler — the moment anyone dragged the speed slider down.
*/
const MAX_DELAY_SECONDS = 1.5;
/** How fast the delay line is allowed to chase geometry. Also caps Doppler. */
const PROPAGATION_TAU = 0.09;
export class AudioEngine {
private ctx: AudioContext | null = null;
private settings: EngineSettings = { ...DEFAULT_SETTINGS };
private source: AudioBufferSourceNode | null = null;
private sourceGain: GainNode | null = null;
private delay: DelayNode | null = null;
private airFilter: BiquadFilterNode | null = null;
private occlusionFilter: BiquadFilterNode | null = null;
private directGain: GainNode | null = null;
private panner: PannerNode | null = null;
private reverbSend: GainNode | null = null;
private convolver: ConvolverNode | null = null;
private masterGain: GainNode | null = null;
private limiter: DynamicsCompressorNode | null = null;
private analyser: AnalyserNode | null = null;
private leftAnalyser: AnalyserNode | null = null;
private rightAnalyser: AnalyserNode | null = null;
private smoothDirect: SmoothParam | null = null;
private smoothAir: SmoothParam | null = null;
private smoothOcclusion: SmoothParam | null = null;
private smoothReverb: SmoothParam | null = null;
private smoothDelay: SmoothParam | null = null;
private frequencyData = new Uint8Array(1024);
private waveformData = new Uint8Array(2048);
private earScratch = new Uint8Array(1024);
private playing = false;
private acoustics: RoomAcoustics;
private impulseDirty = false;
private lastImpulseAt = -Infinity;
/** Mirrors the delay line's smoothing in JS so Doppler can be reported exactly. */
private smoothedDelay = 0;
private previousDelay = 0;
private userBuffer: AudioBuffer | null = null;
private userName = '';
constructor(settings?: Partial<EngineSettings>) {
this.settings = { ...DEFAULT_SETTINGS, ...settings };
this.acoustics = computeAcoustics(this.settings.room, getSurface(this.settings.surface).absorption);
}
get context(): AudioContext | null {
return this.ctx;
}
get isPlaying(): boolean {
return this.playing;
}
get sampleRate(): number {
return this.ctx?.sampleRate ?? 48000;
}
getSettings(): EngineSettings {
return { ...this.settings, room: { ...this.settings.room } };
}
getAcoustics(): RoomAcoustics {
return { ...this.acoustics };
}
/**
* Builds the node graph. Must be called from a user gesture: browsers refuse
* to let an AudioContext produce sound otherwise.
*
* source ▶ sourceGain ▶ delay ─┬─▶ air ▶ occlusion ▶ direct ▶ panner(HRTF) ─┐
* │ ├▶ master ▶ limiter ▶ analyser ▶ out
* └─▶ reverbSend ▶ convolver ──────────────────┘
*
* Two deliberate choices carry most of the realism:
*
* 1. `delay` holds the time of flight, distance / speedOfSound. Automating it
* resamples the signal, so Doppler falls out of the geometry for free
* rather than being faked from a noisy velocity estimate.
* 2. The reverb send is tapped *before* distance, cone and occlusion, because
* a room's reverberant field is roughly uniform — it depends on how much
* power the source radiates, not on where the listener stands or what is
* in the way. This is what makes walking away sound like distance instead
* of like turning down a fader.
*/
async init(): Promise<void> {
if (this.ctx) return;
const Ctor =
(typeof window !== 'undefined' &&
(window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)) ||
(globalThis as unknown as { AudioContext?: typeof AudioContext }).AudioContext;
if (typeof Ctor !== 'function') throw new Error('Web Audio is not available in this browser.');
const ctx = new Ctor();
this.ctx = ctx;
const now = ctx.currentTime;
this.sourceGain = ctx.createGain();
this.sourceGain.gain.setValueAtTime(1, now);
this.delay = ctx.createDelay(MAX_DELAY_SECONDS);
this.delay.delayTime.setValueAtTime(0, now);
this.airFilter = ctx.createBiquadFilter();
this.airFilter.type = 'lowpass';
this.airFilter.Q.setValueAtTime(0.5, now);
this.airFilter.frequency.setValueAtTime(22050, now);
this.occlusionFilter = ctx.createBiquadFilter();
this.occlusionFilter.type = 'lowpass';
this.occlusionFilter.Q.setValueAtTime(0.7, now);
this.occlusionFilter.frequency.setValueAtTime(22050, now);
this.directGain = ctx.createGain();
this.directGain.gain.setValueAtTime(0, now);
this.panner = ctx.createPanner();
this.panner.panningModel = 'HRTF';
// Distance and directivity are computed by our own physics and applied to
// directGain, so the panner is left to do nothing but binaural placement.
this.panner.distanceModel = 'linear';
this.panner.rolloffFactor = 0;
this.panner.coneInnerAngle = 360;
this.panner.coneOuterAngle = 360;
this.panner.coneOuterGain = 1;
this.reverbSend = ctx.createGain();
this.reverbSend.gain.setValueAtTime(0, now);
this.convolver = ctx.createConvolver();
this.convolver.normalize = false;
this.rebuildImpulseResponse();
this.masterGain = ctx.createGain();
this.masterGain.gain.setValueAtTime(this.settings.masterVolume, now);
// Catches the peaks when a close, on-axis source stacks with a wet room.
this.limiter = ctx.createDynamicsCompressor();
this.limiter.threshold.setValueAtTime(-6, now);
this.limiter.knee.setValueAtTime(0, now);
this.limiter.ratio.setValueAtTime(20, now);
this.limiter.attack.setValueAtTime(0.003, now);
this.limiter.release.setValueAtTime(0.25, now);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 4096;
this.analyser.smoothingTimeConstant = 0.75;
this.analyser.minDecibels = -100;
this.analyser.maxDecibels = -10;
this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
this.waveformData = new Uint8Array(this.analyser.fftSize);
// Per-ear metering. The main analyser downmixes to mono, so without this
// split there is no way to show that the binaural image is doing anything.
const splitter = ctx.createChannelSplitter(2);
this.leftAnalyser = ctx.createAnalyser();
this.rightAnalyser = ctx.createAnalyser();
for (const ear of [this.leftAnalyser, this.rightAnalyser]) {
ear.fftSize = 1024;
ear.smoothingTimeConstant = 0.6;
}
this.earScratch = new Uint8Array(1024);
this.sourceGain.connect(this.delay);
this.delay.connect(this.airFilter);
this.airFilter.connect(this.occlusionFilter);
this.occlusionFilter.connect(this.directGain);
this.directGain.connect(this.panner);
this.panner.connect(this.masterGain);
this.delay.connect(this.reverbSend);
this.reverbSend.connect(this.convolver);
this.convolver.connect(this.masterGain);
this.masterGain.connect(this.limiter);
this.limiter.connect(this.analyser);
this.analyser.connect(ctx.destination);
this.limiter.connect(splitter);
splitter.connect(this.leftAnalyser, 0);
splitter.connect(this.rightAnalyser, 1);
this.smoothDirect = new SmoothParam(this.directGain.gain, 0.0008);
this.smoothAir = new SmoothParam(this.airFilter.frequency, 20, 0.05);
this.smoothOcclusion = new SmoothParam(this.occlusionFilter.frequency, 15, 0.04);
this.smoothReverb = new SmoothParam(this.reverbSend.gain, 0.002, 0.08);
// Tight tau: the JS mirror has already done the smoothing, this only
// bridges between frames.
this.smoothDelay = new SmoothParam(this.delay.delayTime, 0.00002, 0.012);
}
/** Resumes the context and starts the looping source. Safe to call twice. */
async start(): Promise<void> {
await this.init();
if (!this.ctx || !this.sourceGain) return;
if (this.ctx.state === 'suspended') await this.ctx.resume();
const now = this.ctx.currentTime;
this.sourceGain.gain.cancelScheduledValues(now);
this.sourceGain.gain.setValueAtTime(1, now);
if (!this.source) this.startSource(now);
this.playing = true;
}
stop(): void {
if (!this.ctx || !this.sourceGain) {
this.playing = false;
return;
}
const now = this.ctx.currentTime;
// Fade before stopping, otherwise the abrupt cut clicks.
this.sourceGain.gain.cancelScheduledValues(now);
this.sourceGain.gain.setValueAtTime(this.sourceGain.gain.value, now);
this.sourceGain.gain.linearRampToValueAtTime(0, now + 0.03);
this.stopSource(now + 0.04);
this.playing = false;
}
async toggle(): Promise<boolean> {
if (this.playing) this.stop();
else await this.start();
return this.playing;
}
setPreset(preset: SourceId): void {
if (preset === this.settings.preset) return;
// Selecting "file" with nothing loaded would start a silent source.
if (preset === 'file' && !this.userBuffer) return;
this.settings.preset = preset;
this.restartSource();
}
/** Fades out, swaps the buffer on the silent frame, fades back in. */
private restartSource(): void {
if (!this.ctx || !this.playing || !this.sourceGain) return;
const now = this.ctx.currentTime;
const swapAt = now + 0.03;
this.sourceGain.gain.cancelScheduledValues(now);
this.sourceGain.gain.setValueAtTime(this.sourceGain.gain.value, now);
this.sourceGain.gain.linearRampToValueAtTime(0, swapAt);
this.stopSource(swapAt);
this.startSource(swapAt);
this.sourceGain.gain.linearRampToValueAtTime(1, swapAt + 0.03);
}
/** The loaded file, if any: name and duration for the UI. */
getUserAudio(): { name: string; duration: number } | null {
if (!this.userBuffer) return null;
return { name: this.userName, duration: this.userBuffer.duration };
}
/**
* Decodes a file from disk and makes it the source.
*
* Creates the AudioContext if there isn't one yet — decoding needs a context,
* and a file picker or a drop is itself a user gesture, so this is a legal
* place to do it. Playback still waits for Play.
*/
async loadUserAudio(file: File): Promise<{ name: string; duration: number }> {
await this.init();
if (!this.ctx) throw new AudioFileError('Web Audio is not available in this browser.');
const buffer = await decodeAudioFile(this.ctx, file);
this.userBuffer = buffer;
this.userName = file.name;
// Force a swap even when 'file' was already selected, so loading a second
// track replaces the first instead of silently doing nothing.
const wasFile = this.settings.preset === 'file';
this.settings.preset = 'file';
if (wasFile) this.restartSource();
else this.setPreset('file');
return { name: this.userName, duration: buffer.duration };
}
setMasterVolume(volume: number): void {
this.settings.masterVolume = clamp(volume, 0, 1);
if (this.ctx && this.masterGain) {
this.masterGain.gain.setTargetAtTime(this.settings.masterVolume, this.ctx.currentTime, 0.015);
}
}
/**
* Applies a settings patch. Changes that alter the room's acoustics mark the
* impulse response dirty rather than regenerating it immediately, so dragging
* a room slider does not rebuild a multi-second convolution buffer per frame.
*/
update(patch: EngineSettingsPatch): void {
const room = { ...this.settings.room, ...patch.room };
const roomChanged =
room.width !== this.settings.room.width ||
room.height !== this.settings.room.height ||
room.depth !== this.settings.room.depth ||
(patch.surface !== undefined && patch.surface !== this.settings.surface);
// Preset and volume own their own transitions, so hand them over rather
// than letting the spread overwrite the value they compare against.
const { preset, masterVolume, ...rest } = patch;
this.settings = { ...this.settings, ...rest, room };
this.acoustics = computeAcoustics(room, getSurface(this.settings.surface).absorption);
if (masterVolume !== undefined) this.setMasterVolume(masterVolume);
if (preset !== undefined) this.setPreset(preset);
if (roomChanged) this.impulseDirty = true;
}
/**
* Runs one frame of the simulation: computes the acoustic state from the
* geometry, applies it to the graph, and returns the numbers for the UI.
*
* Works with no AudioContext, so every readout is live and correct before the
* user has unlocked audio.
*/
updateSpatial(input: SpatialInput, dt: number): Telemetry {
const s = this.settings;
const d = distance(input.sourcePos, input.listenerPos);
const gDistance = distanceGain(d, s.distanceModel, {
refDistance: s.refDistance,
maxDistance: s.maxDistance,
rolloffFactor: s.rolloffFactor,
});
const gCone = coneGain(input.sourcePos, input.sourceForward, input.listenerPos, {
innerAngle: s.coneInnerAngle,
outerAngle: s.coneOuterAngle,
outerGain: s.coneOuterGain,
});
const angle = offAxisAngle(input.sourcePos, input.sourceForward, input.listenerPos);
const occl = occlusionResponse(input.occlusion);
const airCutoff = airAbsorptionCutoff(d, s.airAbsorption);
// Chase the true time of flight. The lag is what creates Doppler: while the
// delay is still catching up, the signal is played back off-rate.
const targetDelay = s.propagationEnabled ? Math.min(d / s.speedOfSound, MAX_DELAY_SECONDS) : 0;
const step = dt > 0 ? dt : 1 / 60;
this.previousDelay = this.smoothedDelay;
this.smoothedDelay += (targetDelay - this.smoothedDelay) * smoothingAlpha(step, PROPAGATION_TAU);
// For y(t) = x(t D(t)) the instantaneous frequency ratio is exactly
// 1 D'(t). The textbook moving-source form 1/(1 v/c) agrees only to
// first order, and it has a pole that flips the sign of the shift when a
// source closes fast — reporting a two-octave drop as it rushes towards you.
const delayRate = (this.smoothedDelay - this.previousDelay) / step;
const ratio = clamp(1 - delayRate, DOPPLER_MIN_RATIO, DOPPLER_MAX_RATIO);
const gDirect = gDistance * gCone * occl.gain;
const gReverb = reverberantGain(this.acoustics, s.refDistance);
if (this.ctx && this.playing) {
this.applyToGraph(input, gDirect, gReverb, occl.cutoff, airCutoff);
}
const levels = this.measureLevels();
// Report the acoustic level whether or not audio is running: the room's
// behaviour is a property of the geometry, and blanking it would leave the
// direct-to-reverb row disagreeing with the row above it.
const reverbDb = gainToDb(gReverb);
return {
distance: d,
distanceGainDb: gainToDb(gDistance),
offAxisAngle: angle,
coneGainDb: gainToDb(gCone),
occlusion: clamp(input.occlusion, 0, 1),
occlusionGainDb: gainToDb(occl.gain),
occlusionCutoff: occl.cutoff,
airCutoff,
dopplerRatio: ratio,
dopplerCents: ratioToCents(ratio),
closingSpeed: -delayRate * s.speedOfSound,
timeOfFlightMs: this.smoothedDelay * 1000,
directGainDb: gainToDb(gDirect),
reverbGainDb: reverbDb,
directToReverbDb: gainToDb(gDirect) - reverbDb,
criticalDistance: this.acoustics.criticalDistance,
t60: this.acoustics.t60,
// Compare the levels themselves rather than distance against rc. With a
// non-inverse falloff law or a reference distance other than 1 m the two
// disagree, and this flag sits directly beside the ratio it describes.
reverbDominant: gDirect < gReverb,
outputLevelDb: levels.rms,
leftLevelDb: levels.left,
rightLevelDb: levels.right,
clipping: levels.clipping,
running: this.playing && this.ctx?.state === 'running',
};
}
getFrequencyData(): Uint8Array {
if (this.analyser) this.analyser.getByteFrequencyData(this.frequencyData);
else this.frequencyData.fill(0);
return this.frequencyData;
}
getWaveformData(): Uint8Array {
if (this.analyser) this.analyser.getByteTimeDomainData(this.waveformData);
else this.waveformData.fill(128);
return this.waveformData;
}
dispose(): void {
this.stop();
this.ctx?.close().catch(() => {
/* already closing */
});
this.ctx = null;
}
private applyToGraph(
input: SpatialInput,
gDirect: number,
gReverb: number,
occlusionCutoff: number,
airCutoff: number
): void {
const ctx = this.ctx;
if (!ctx || !this.panner) return;
const now = ctx.currentTime;
this.smoothDirect?.set(gDirect, now);
this.smoothReverb?.set(gReverb, now);
this.smoothOcclusion?.set(occlusionCutoff, now);
this.smoothAir?.set(airCutoff, now);
this.smoothDelay?.set(this.smoothedDelay, now);
setPosition(this.panner, input.sourcePos, now);
setOrientation(this.panner, normalize(input.sourceForward), now);
// Both the listener's position *and* its orientation must be published, or
// HRTF has no idea which way the ears are facing and the image stays frozen
// to the world axes no matter how the camera turns.
const listener = ctx.listener;
setPosition(listener, input.listenerPos, now);
const forward = normalize(input.listenerForward);
const up = normalize(input.listenerUp, { x: 0, y: 1, z: 0 });
if (listener.forwardX) {
// Ramp all six together so forward and up never drift out of square.
rampParam(listener.forwardX, forward.x, now);
rampParam(listener.forwardY, forward.y, now);
rampParam(listener.forwardZ, forward.z, now);
rampParam(listener.upX, up.x, now);
rampParam(listener.upY, up.y, now);
rampParam(listener.upZ, up.z, now);
} else if (typeof (listener as unknown as LegacyListener).setOrientation === 'function') {
(listener as unknown as LegacyListener).setOrientation(
forward.x,
forward.y,
forward.z,
up.x,
up.y,
up.z
);
}
if (this.impulseDirty && now - this.lastImpulseAt > 0.25) this.rebuildImpulseResponse();
}
private startSource(when: number): void {
if (!this.ctx || !this.sourceGain) return;
const source = this.ctx.createBufferSource();
source.buffer =
this.settings.preset === 'file' && this.userBuffer
? this.userBuffer
: createPresetBuffer(this.ctx, this.settings.preset as PresetId);
source.loop = true;
source.connect(this.sourceGain);
source.start(when);
this.source = source;
}
private stopSource(when: number): void {
const source = this.source;
if (!source) return;
this.source = null;
try {
source.stop(when);
} catch {
/* never started */
}
source.onended = () => source.disconnect();
}
private rebuildImpulseResponse(): void {
if (!this.ctx || !this.convolver) return;
this.convolver.buffer = generateImpulseResponse(
this.ctx,
this.settings.room,
getSurface(this.settings.surface).absorption,
this.settings.speedOfSound
);
this.impulseDirty = false;
this.lastImpulseAt = this.ctx.currentTime;
}
private measureLevels(): { rms: number; left: number; right: number; clipping: boolean } {
if (!this.analyser || !this.playing) {
return { rms: -120, left: -120, right: -120, clipping: false };
}
this.analyser.getByteTimeDomainData(this.waveformData);
let sum = 0;
let clipping = false;
for (let i = 0; i < this.waveformData.length; i++) {
const byte = this.waveformData[i];
const sample = (byte - 128) / 128;
sum += sample * sample;
// Test the raw bytes. The 0..255 range is asymmetric about 128, so full
// positive scale is only 127/128 = 0.992 — an amplitude threshold above
// that can never trip on a positive peak.
if (byte >= 254 || byte <= 1) clipping = true;
}
return {
rms: gainToDb(Math.sqrt(sum / this.waveformData.length)),
left: this.earLevel(this.leftAnalyser),
right: this.earLevel(this.rightAnalyser),
clipping,
};
}
private earLevel(analyser: AnalyserNode | null): number {
if (!analyser) return -120;
analyser.getByteTimeDomainData(this.earScratch);
let sum = 0;
for (let i = 0; i < this.earScratch.length; i++) {
const sample = (this.earScratch[i] - 128) / 128;
sum += sample * sample;
}
return gainToDb(Math.sqrt(sum / this.earScratch.length));
}
}
interface LegacyListener {
setPosition(x: number, y: number, z: number): void;
setOrientation(fx: number, fy: number, fz: number, ux: number, uy: number, uz: number): void;
}
function rampParam(param: AudioParam, value: number, now: number): void {
param.setTargetAtTime(value, now, 0.02);
}
function setPosition(node: PannerNode | AudioListener, p: Vec3, now: number): void {
if (node.positionX) {
rampParam(node.positionX, p.x, now);
rampParam(node.positionY, p.y, now);
rampParam(node.positionZ, p.z, now);
} else if (typeof (node as unknown as LegacyListener).setPosition === 'function') {
(node as unknown as LegacyListener).setPosition(p.x, p.y, p.z);
}
}
function setOrientation(panner: PannerNode, forward: Vec3, now: number): void {
if (panner.orientationX) {
rampParam(panner.orientationX, forward.x, now);
rampParam(panner.orientationY, forward.y, now);
rampParam(panner.orientationZ, forward.z, now);
} else if (
typeof (panner as unknown as { setOrientation?: LegacyListener['setOrientation'] })
.setOrientation === 'function'
) {
(panner as unknown as LegacyListener).setOrientation(forward.x, forward.y, forward.z, 0, 1, 0);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './AudioEngine';
export * from './presets';
export * from './reverb';
export * from './userAudio';
+282
View File
@@ -0,0 +1,282 @@
export type PresetId =
| 'sine'
| 'triangle'
| 'sawtooth'
| 'square'
| 'pluck'
| 'engine'
| 'beacon'
| 'pink';
/**
* What the source is playing. `'file'` is not a generated preset — it means
* "the buffer the user loaded from disk", so it lives outside PRESETS.
*/
export type SourceId = PresetId | 'file';
export interface PresetInfo {
id: PresetId;
label: string;
/** One line explaining what this source is good for hearing. */
hint: string;
}
export const PRESETS: PresetInfo[] = [
{ id: 'sawtooth', label: 'Sawtooth', hint: 'Rich harmonics — best for hearing the cone and occlusion filters' },
{ id: 'sine', label: 'Sine', hint: 'One pure frequency — the clearest way to hear Doppler pitch shift' },
{ id: 'triangle', label: 'Triangle', hint: 'Soft, few harmonics — easy on the ears while you explore' },
{ id: 'square', label: 'Square', hint: 'Hollow odd harmonics — cuts through reverb' },
{ id: 'beacon', label: 'Beacon', hint: 'Pulsed blips — sharp transients make direction easiest to pinpoint' },
{ id: 'pluck', label: 'Pluck', hint: 'Repeating string pluck — the decay tail reveals the room reverb' },
{ id: 'engine', label: 'Engine', hint: 'Procedural motor — the classic Doppler fly-by sound' },
{ id: 'pink', label: 'Pink noise', hint: 'All frequencies at once — the reference signal for spatial testing' },
];
const TAU = Math.PI * 2;
/**
* Wraps the tail of a buffer into its head with an equal-power crossfade so it
* loops without a click. The returned buffer is `fadeSeconds` shorter.
*/
function makeSeamless(ctx: BaseAudioContext, source: AudioBuffer, fadeSeconds: number): AudioBuffer {
const rate = source.sampleRate;
const fade = Math.min(Math.floor(fadeSeconds * rate), Math.floor(source.length / 2));
if (fade <= 1) return source;
const length = source.length - fade;
const out = ctx.createBuffer(source.numberOfChannels, length, rate);
for (let ch = 0; ch < source.numberOfChannels; ch++) {
const src = source.getChannelData(ch);
const dst = out.getChannelData(ch);
dst.set(src.subarray(0, length));
for (let i = 0; i < fade; i++) {
const t = (i + 1) / (fade + 1);
// The sample that plays after dst[length-1] is dst[0], and the natural
// successor of src[length-1] is src[length]. So the *tail* must dominate
// at i=0 and hand over to the head across the fade — not the other way
// round, which would leave a discontinuity at each end of the region.
// cos/sin keeps the crossfade equal-power.
dst[i] = src[length + i] * Math.cos((t * Math.PI) / 2) + dst[i] * Math.sin((t * Math.PI) / 2);
}
}
return out;
}
function normalize(buffer: AudioBuffer, peak: number): AudioBuffer {
let max = 0;
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
const data = buffer.getChannelData(ch);
for (let i = 0; i < data.length; i++) {
const abs = Math.abs(data[i]);
if (abs > max) max = abs;
}
}
if (max > 1e-9) {
const scale = peak / max;
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
const data = buffer.getChannelData(ch);
for (let i = 0; i < data.length; i++) data[i] *= scale;
}
}
return buffer;
}
/**
* Additive band-limited waveform. Summing a finite harmonic series instead of
* sampling an ideal sawtooth keeps the spectrum below Nyquist, so the source
* stays clean when Doppler shifts it upward. Headroom is reserved for a shift
* of `pitchHeadroom`x before harmonics start folding back as aliasing.
*/
function bandLimitedTone(
ctx: BaseAudioContext,
shape: 'sine' | 'triangle' | 'sawtooth' | 'square',
frequency: number,
duration: number,
pitchHeadroom = 2
): AudioBuffer {
const rate = ctx.sampleRate || 48000;
// Exact whole number of periods, so the loop point lands on a zero crossing.
const periods = Math.max(1, Math.round(duration * frequency));
const length = Math.max(1, Math.round((periods * rate) / frequency));
const nyquist = rate / 2 / pitchHeadroom;
const maxHarmonic = shape === 'sine' ? 1 : Math.max(1, Math.floor(nyquist / frequency));
const buffer = ctx.createBuffer(1, length, rate);
const data = buffer.getChannelData(0);
for (let h = 1; h <= maxHarmonic; h++) {
let amplitude = 0;
switch (shape) {
case 'sine':
amplitude = h === 1 ? 1 : 0;
break;
case 'sawtooth':
amplitude = 1 / h;
break;
case 'square':
amplitude = h % 2 === 1 ? 1 / h : 0;
break;
case 'triangle':
amplitude = h % 2 === 1 ? (((h - 1) / 2) % 2 === 0 ? 1 : -1) / (h * h) : 0;
break;
}
if (amplitude === 0) continue;
const step = (TAU * frequency * h) / rate;
for (let i = 0; i < length; i++) data[i] += amplitude * Math.sin(step * i);
}
return normalize(buffer, 0.9);
}
/** Paul Kellett's pink noise filter — a good approximation of -3 dB/octave. */
function pinkNoise(ctx: BaseAudioContext, duration: number): AudioBuffer {
const rate = ctx.sampleRate || 48000;
const length = Math.floor(rate * duration);
const buffer = ctx.createBuffer(2, length, rate);
for (let ch = 0; ch < 2; ch++) {
const data = buffer.getChannelData(ch);
let b0 = 0;
let b1 = 0;
let b2 = 0;
let b3 = 0;
let b4 = 0;
let b5 = 0;
let b6 = 0;
for (let i = 0; i < length; i++) {
const white = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.969 * b2 + white * 0.153852;
b3 = 0.8665 * b3 + white * 0.3104856;
b4 = 0.55 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.016898;
data[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
b6 = white * 0.115926;
}
}
return makeSeamless(ctx, normalize(buffer, 0.7), 0.05);
}
/** A four-stroke-ish motor: FM-wobbled harmonic stack plus firing-rate noise. */
function engine(ctx: BaseAudioContext, duration: number, baseFreq: number): AudioBuffer {
const rate = ctx.sampleRate || 48000;
const length = Math.floor(rate * duration);
const buffer = ctx.createBuffer(2, length, rate);
const left = buffer.getChannelData(0);
const right = buffer.getChannelData(1);
// Idle wobble, chosen to complete whole cycles over `duration` so it loops.
const wobbleRate = Math.round(7 * duration) / duration;
const wobbleDepth = 4;
let phase = 0;
let noiseL = 0;
let noiseR = 0;
for (let i = 0; i < length; i++) {
const t = i / rate;
const freq = baseFreq + wobbleDepth * Math.sin(TAU * wobbleRate * t);
phase += (TAU * freq) / rate;
const harmonics =
0.5 * Math.sin(phase) +
0.35 * Math.sin(2 * phase) +
0.22 * Math.sin(3 * phase) +
0.14 * Math.sin(4 * phase) +
0.08 * Math.sin(6 * phase);
// Exhaust noise gated by the firing pulses (two per revolution).
const firing = 0.5 + 0.5 * Math.sin(2 * phase);
// One-pole lowpass keeps the noise from sounding like hiss.
noiseL += 0.25 * ((Math.random() * 2 - 1) * firing - noiseL);
noiseR += 0.25 * ((Math.random() * 2 - 1) * firing - noiseR);
left[i] = harmonics * 0.75 + noiseL * 0.5;
right[i] = harmonics * 0.75 + noiseR * 0.5;
}
return makeSeamless(ctx, normalize(buffer, 0.85), 0.04);
}
/** A repeating plucked string. The decay tail is what makes reverb audible. */
function pluck(ctx: BaseAudioContext, frequency: number, interval: number, repeats: number): AudioBuffer {
const rate = ctx.sampleRate || 48000;
const noteSamples = Math.floor(rate * interval);
const length = noteSamples * repeats;
const buffer = ctx.createBuffer(1, length, rate);
const data = buffer.getChannelData(0);
const weights = [1, 0.6, 0.4, 0.25, 0.15, 0.1, 0.06, 0.03];
// Higher harmonics die first, as they do on a real string.
const decays = [1.6, 2.4, 3.2, 4.2, 5.4, 6.8, 8.4, 10.2];
// Let each note ring well past the next attack, and wrap the overhang back
// to the head of the buffer. Notes then sum instead of being chopped off, so
// there is no discontinuity at a note boundary or at the loop point.
const tailSamples = Math.min(length, noteSamples * 3);
for (let n = 0; n < repeats; n++) {
const offset = n * noteSamples;
for (let i = 0; i < tailSamples; i++) {
const t = i / rate;
const attack = 1 - Math.exp(-t / 0.004);
let sample = 0;
for (let h = 0; h < weights.length; h++) {
sample += weights[h] * Math.exp(-decays[h] * t) * Math.sin(TAU * (h + 1) * frequency * t);
}
data[(offset + i) % length] += sample * attack;
}
}
return normalize(buffer, 0.9);
}
/** Pulsed tone. Sharp onsets give the ear the timing cues it localises with. */
function beacon(ctx: BaseAudioContext, frequency: number, interval: number, repeats: number): AudioBuffer {
const rate = ctx.sampleRate || 48000;
const noteSamples = Math.floor(rate * interval);
const length = noteSamples * repeats;
const buffer = ctx.createBuffer(1, length, rate);
const data = buffer.getChannelData(0);
const blipSamples = Math.floor(rate * 0.12);
for (let n = 0; n < repeats; n++) {
const offset = n * noteSamples;
// Alternate between two pitches so the pattern reads as deliberate.
const f = n % 2 === 0 ? frequency : frequency * 1.5;
for (let i = 0; i < blipSamples; i++) {
const t = i / rate;
// Raised-cosine window: no clicks at either end of the blip.
const window = 0.5 - 0.5 * Math.cos((TAU * i) / blipSamples);
data[offset + i] = window * (Math.sin(TAU * f * t) * 0.7 + Math.sin(TAU * f * 2 * t) * 0.2);
}
}
return normalize(buffer, 0.9);
}
export function createPresetBuffer(ctx: BaseAudioContext, preset: PresetId): AudioBuffer {
switch (preset) {
case 'sine':
return bandLimitedTone(ctx, 'sine', 330, 1, 1);
case 'triangle':
return bandLimitedTone(ctx, 'triangle', 220, 1);
case 'square':
return bandLimitedTone(ctx, 'square', 165, 1);
case 'pluck':
return pluck(ctx, 196, 0.75, 4);
case 'engine':
return engine(ctx, 3, 48);
case 'beacon':
return beacon(ctx, 880, 0.6, 4);
case 'pink':
return pinkNoise(ctx, 3);
case 'sawtooth':
default:
return bandLimitedTone(ctx, 'sawtooth', 165, 1);
}
}
+156
View File
@@ -0,0 +1,156 @@
import { clamp } from '../physics';
export type SurfaceId = 'anechoic' | 'studio' | 'living' | 'hall' | 'cathedral';
export interface Surface {
id: SurfaceId;
label: string;
/** Average Sabine absorption coefficient of the room's surfaces, 0..1. */
absorption: number;
hint: string;
}
export const SURFACES: Surface[] = [
{ id: 'anechoic', label: 'Anechoic foam', absorption: 0.9, hint: 'Almost no reflections — pure direct sound' },
{ id: 'studio', label: 'Treated studio', absorption: 0.45, hint: 'Short, controlled decay' },
{ id: 'living', label: 'Carpet & drapes', absorption: 0.22, hint: 'A normal furnished room' },
{ id: 'hall', label: 'Wood hall', absorption: 0.09, hint: 'Long, musical reverberation' },
{ id: 'cathedral', label: 'Stone cathedral', absorption: 0.035, hint: 'Vast, washy, slow to decay' },
];
export function getSurface(id: SurfaceId): Surface {
return SURFACES.find((s) => s.id === id) ?? SURFACES[2];
}
export interface RoomDimensions {
width: number;
height: number;
depth: number;
}
export interface RoomAcoustics {
/** Reverberation time to -60 dB, in seconds. */
t60: number;
/** Room constant R = Sα / (1 α), in m². */
roomConstant: number;
/** Distance at which the reverberant field equals the direct field, in metres. */
criticalDistance: number;
/** Total surface area, m². */
surfaceArea: number;
/** Volume, m³. */
volume: number;
}
/**
* Derives the room's acoustic behaviour from its geometry and surface material
* using Sabine's equation, T60 = 0.161 V / (S α).
*
* This is what makes the room-size sliders audible: a bigger or harder room
* genuinely rings longer and pushes the critical distance closer to the source.
*/
export function computeAcoustics(dims: RoomDimensions, absorption: number): RoomAcoustics {
const { width: w, height: h, depth: d } = dims;
const volume = Math.max(1, w * h * d);
const surfaceArea = Math.max(1, 2 * (w * h + w * d + h * d));
const alpha = clamp(absorption, 0.01, 0.99);
const t60 = clamp((0.161 * volume) / (surfaceArea * alpha), 0.08, 8);
const roomConstant = (surfaceArea * alpha) / (1 - alpha);
// r_c = sqrt(R / 16π) for an omnidirectional source.
const criticalDistance = Math.sqrt(roomConstant / (16 * Math.PI));
return { t60, roomConstant, criticalDistance, surfaceArea, volume };
}
/**
* Level of the diffuse reverberant field relative to the direct sound measured
* at the reference distance.
*
* The reverberant field is roughly uniform throughout a room, so it is *not*
* distance-attenuated. Setting its level to refDistance / criticalDistance
* makes wet and dry balance exactly at the critical distance — walk further
* than that and the room takes over, which is the effect real rooms have.
*/
export function reverberantGain(acoustics: RoomAcoustics, refDistance = 1): number {
if (acoustics.criticalDistance < 1e-6) return 1;
return clamp(refDistance / acoustics.criticalDistance, 0, 1.4);
}
/**
* Synthesises a stereo impulse response for the given room.
*
* Structure follows a real room impulse: a direct-path spike, a sparse set of
* early reflections whose delays come from the actual wall distances, then an
* exponentially decaying diffuse tail that loses its high frequencies as it
* goes — because each bounce absorbs treble faster than bass.
*/
export function generateImpulseResponse(
ctx: BaseAudioContext,
dims: RoomDimensions,
absorption: number,
speedOfSound = 343
): AudioBuffer {
const acoustics = computeAcoustics(dims, absorption);
const rate = ctx.sampleRate || 48000;
const length = Math.max(1, Math.floor(rate * acoustics.t60));
const buffer = ctx.createBuffer(2, length, rate);
const left = buffer.getChannelData(0);
const right = buffer.getChannelData(1);
// -60 dB over t60 seconds.
const decay = 6.907755 / acoustics.t60;
const reflectivity = 1 - clamp(absorption, 0.01, 0.99);
// Diffuse tail: decaying noise, lowpassed harder as the tail progresses.
let lpL = 0;
let lpR = 0;
for (let i = 0; i < length; i++) {
const t = i / rate;
const envelope = Math.exp(-decay * t);
// Treble dies faster than broadband energy, as it does on every bounce.
const coefficient = clamp(Math.exp(-decay * t * 0.9), 0.02, 1);
// A one-pole filter fed white noise outputs RMS sqrt(a / (2 - a)), so
// narrowing the filter over time would quietly steepen the decay on top of
// the envelope and the tail would die well short of the T60 the UI reports.
// Dividing it back out leaves the envelope in sole charge of the level.
const compensation = Math.sqrt((2 - coefficient) / coefficient);
lpL += coefficient * ((Math.random() * 2 - 1) - lpL);
lpR += coefficient * ((Math.random() * 2 - 1) - lpR);
left[i] = lpL * envelope * compensation;
right[i] = lpR * envelope * compensation;
}
// Early reflections off the six surfaces, from a source near the middle.
const halfPaths = [dims.width, dims.depth, dims.height, dims.width * 1.5, dims.depth * 1.5];
halfPaths.forEach((pathLength, index) => {
const delay = pathLength / speedOfSound;
const sample = Math.floor(delay * rate);
if (sample <= 0 || sample >= length) return;
// Each reflection loses energy to the surface and to spreading.
const amplitude = (reflectivity / (1 + pathLength * 0.15)) * Math.exp(-decay * delay);
const pan = index % 2 === 0 ? 0.35 : -0.35;
left[sample] += amplitude * (1 - pan);
right[sample] += amplitude * (1 + pan);
});
// Normalise to unit energy per channel, not to unit peak.
//
// A convolution's output level follows the impulse response's total energy,
// so peak-normalising would make a long tail far louder than a short one and
// `reverberantGain` would stop meaning anything. With unit energy the
// convolver passes signal through at roughly its input level, which lets the
// send gain alone set the direct-to-reverberant balance.
let energy = 0;
for (let i = 0; i < length; i++) {
energy += left[i] * left[i] + right[i] * right[i];
}
if (energy > 1e-12) {
const scale = 1 / Math.sqrt(energy / 2);
for (let i = 0; i < length; i++) {
left[i] *= scale;
right[i] *= scale;
}
}
return buffer;
}
+85
View File
@@ -0,0 +1,85 @@
/** Anything larger than this is almost certainly not what the user meant. */
export const MAX_FILE_BYTES = 200 * 1024 * 1024;
export class AudioFileError extends Error {}
/**
* Collapses a buffer to a single channel.
*
* A loudspeaker standing at one point in a room radiates one signal. Feeding a
* stereo mix into the panner would leave part of the image fixed to the
* listener's ears no matter where the source is, which is exactly the illusion
* this app exists to avoid. Summing to mono makes the file behave like a real
* source in the space.
*/
export function downmixToMono(ctx: BaseAudioContext, buffer: AudioBuffer): AudioBuffer {
if (buffer.numberOfChannels === 1) return buffer;
const mono = ctx.createBuffer(1, buffer.length, buffer.sampleRate);
const out = mono.getChannelData(0);
const channels = buffer.numberOfChannels;
for (let channel = 0; channel < channels; channel++) {
const data = buffer.getChannelData(channel);
for (let i = 0; i < data.length; i++) out[i] += data[i];
}
// Average, then pull the peak back under unity if summing pushed it over.
let peak = 0;
for (let i = 0; i < out.length; i++) {
out[i] /= channels;
const abs = Math.abs(out[i]);
if (abs > peak) peak = abs;
}
if (peak > 0.98) {
const scale = 0.98 / peak;
for (let i = 0; i < out.length; i++) out[i] *= scale;
}
return mono;
}
/**
* Decodes a user-supplied audio file into a mono buffer.
*
* Format support is whatever the browser's decoder handles — typically MP3,
* WAV, FLAC, OGG, AAC/M4A. Errors are turned into messages worth showing a
* person rather than a bare DOMException.
*/
export async function decodeAudioFile(ctx: BaseAudioContext, file: File): Promise<AudioBuffer> {
if (file.size === 0) throw new AudioFileError('That file is empty.');
if (file.size > MAX_FILE_BYTES) {
throw new AudioFileError(
`That file is ${(file.size / 1024 / 1024).toFixed(0)} MB. Try something under ${
MAX_FILE_BYTES / 1024 / 1024
} MB.`
);
}
let bytes: ArrayBuffer;
try {
bytes = await file.arrayBuffer();
} catch {
throw new AudioFileError('Could not read that file from disk.');
}
let decoded: AudioBuffer;
try {
decoded = await ctx.decodeAudioData(bytes);
} catch {
throw new AudioFileError(
`Could not decode “${file.name}”. Try MP3, WAV, FLAC, OGG or M4A.`
);
}
if (decoded.length === 0) throw new AudioFileError('That file decoded to no audio.');
return downmixToMono(ctx, decoded);
}
/** "3:47" — for a duration readout. */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const total = Math.round(seconds);
const minutes = Math.floor(total / 60);
return `${minutes}:${String(total % 60).padStart(2, '0')}`;
}
+368
View File
@@ -0,0 +1,368 @@
import * as THREE from 'three';
import './styles/app.css';
import {
AudioEngine,
AudioFileError,
DEFAULT_SETTINGS,
EngineSettings,
EngineSettingsPatch,
PRESETS,
SILENT_TELEMETRY,
Telemetry,
formatDuration,
} from './audio';
import { MotionMode, Stage } from './scene';
import { Hud, OutputModule } from './ui/readouts';
import { Panel } from './ui/panel';
import { Scope, Spectrum } from './ui/meters';
import { SignalPath } from './ui/signalPath';
import { DemoSpec, Onboarding } from './ui/onboarding';
import { fmt } from './ui/format';
const need = <T extends Element>(selector: string): T => {
const node = document.querySelector<T>(selector);
if (!node) throw new Error(`Missing required element: ${selector}`);
return node;
};
class App {
private readonly engine = new AudioEngine();
private readonly stage: Stage;
private readonly panel: Panel;
private readonly hud = new Hud();
private readonly output = new OutputModule();
private readonly signalPath: SignalPath;
private readonly spectrum: Spectrum;
private readonly scope: Scope;
private readonly onboarding: Onboarding;
private readonly playButton = need<HTMLButtonElement>('#play');
private readonly statusChip = need<HTMLElement>('#status');
private readonly statusText = need<HTMLElement>('#status-text');
private readonly live = need<HTMLElement>('#live');
private settings: EngineSettings = { ...DEFAULT_SETTINGS };
private motion: { mode: MotionMode; speed: number } = { mode: 'static', speed: 8 };
private telemetry: Telemetry = SILENT_TELEMETRY;
private lastFrame = performance.now();
private lastAnnounced = '';
private frame = 0;
constructor() {
const viewport = need<HTMLElement>('#viewport');
this.stage = new Stage(viewport, this.settings.room);
this.stage.setConeAngles(this.settings.coneInnerAngle, this.settings.coneOuterAngle);
viewport.append(this.hud.root);
this.panel = new Panel(this.settings, {
onEngine: (patch) => this.applyEngine(patch),
onFile: (file) => void this.loadFile(file),
onMotion: (mode, speed) => this.setMotion(mode, speed),
onYaw: (degrees) => this.stage.setSourceYaw(degrees),
onAnnotations: (visible) => this.stage.setAnnotationsVisible(visible),
});
need('#panel').append(this.panel.root);
this.signalPath = new SignalPath(() => this.sourceLabel(), () => this.settings.masterVolume);
need('#module-path').append(this.signalPath.root);
need('#strip').append(this.output.root);
this.spectrum = new Spectrum(need<HTMLCanvasElement>('#spectrum'), () => this.engine.sampleRate);
this.scope = new Scope(need<HTMLCanvasElement>('#scope'));
this.onboarding = new Onboarding(this.buildDemos(), {
onEnableAudio: () => this.play(),
onExploreMuted: () => this.onboarding.toast('Exploring muted — press Play whenever you like'),
});
this.bindChrome();
this.stage.resetView();
this.onboarding.open();
// Read-only inspection hook for the browser smoke test (scripts/e2e.mjs).
// Nothing in the app reads it.
(window as unknown as Record<string, unknown>).__resonance = {
telemetry: () => this.telemetry,
settings: () => this.settings,
playing: () => this.engine.isPlaying,
camera: () => {
const p = this.stage.camera.position;
return { x: p.x, y: p.y, z: p.z, azimuth: (Math.atan2(p.z, p.x) * 180) / Math.PI };
},
};
// Start rendering immediately. The engine computes the full acoustic state
// without an AudioContext, so every readout is live and correct before the
// user has clicked anything.
requestAnimationFrame(this.tick);
}
private applyEngine(patch: EngineSettingsPatch): void {
this.engine.update(patch);
this.settings = this.engine.getSettings();
if (patch.room) this.stage.setRoomDims(this.settings.room);
if (patch.coneInnerAngle !== undefined || patch.coneOuterAngle !== undefined) {
this.stage.setConeAngles(this.settings.coneInnerAngle, this.settings.coneOuterAngle);
}
}
/** What the signal path calls the current source. */
private sourceLabel(): string {
if (this.settings.preset === 'file') {
const name = this.engine.getUserAudio()?.name ?? 'Your file';
// The column is narrow; a long filename would push the dB out of line.
return name.length > 22 ? `${name.slice(0, 21)}` : name;
}
return PRESETS.find((p) => p.id === this.settings.preset)?.label ?? this.settings.preset;
}
/**
* Decodes a file the user picked or dropped and makes it the source.
*
* Starts playback automatically: someone who just handed the app a song
* expects to hear it, and the pick itself is the gesture that unlocks audio.
*/
private async loadFile(file: File): Promise<void> {
this.panel.file.setStatus(`Decoding “${file.name}”…`, 'busy');
try {
const { name, duration } = await this.engine.loadUserAudio(file);
this.settings = this.engine.getSettings();
this.panel.setUserTrack(name, formatDuration(duration));
this.panel.file.setStatus(`Loaded “${name}” · ${formatDuration(duration)}`);
if (!this.engine.isPlaying) await this.play();
this.onboarding.toast(`Playing “${name}” — try Orbit motion to hear it circle you`);
} catch (error) {
const message =
error instanceof AudioFileError
? error.message
: `Could not load “${file.name}”.`;
this.panel.file.setStatus(message, 'error');
this.onboarding.toast(message);
}
}
private async play(): Promise<void> {
await this.engine.start();
this.settings = this.engine.getSettings();
this.syncTransport();
}
private async togglePlay(): Promise<void> {
if (this.engine.isPlaying) {
this.engine.stop();
this.syncTransport();
} else {
try {
await this.play();
} catch (error) {
this.setStatus('error', 'Web Audio blocked');
this.onboarding.toast(error instanceof Error ? error.message : 'Could not start audio');
}
}
}
private syncTransport(): void {
const playing = this.engine.isPlaying;
this.playButton.textContent = playing ? '⏸ Pause' : '▶ Play';
this.playButton.setAttribute('aria-pressed', String(playing));
}
private bindChrome(): void {
this.playButton.addEventListener('click', () => void this.togglePlay());
const volume = need<HTMLInputElement>('#volume');
const volumeValue = need<HTMLElement>('#volume-value');
volume.addEventListener('input', () => {
const value = Number(volume.value);
volumeValue.textContent = fmt.volume(value);
this.applyEngine({ masterVolume: value });
});
this.bindDropTarget();
need('#reset-view').addEventListener('click', () => this.stage.resetView());
need('#help-btn').addEventListener('click', () => this.onboarding.toggleHelp());
const hide = need<HTMLButtonElement>('#hide-panels');
hide.addEventListener('click', () => {
const hidden = document.body.classList.toggle('panels-hidden');
hide.setAttribute('aria-pressed', String(hidden));
hide.textContent = hidden ? 'Show panels' : 'Hide panels';
});
window.addEventListener('keydown', (event) => {
if (this.onboarding.isOpen) return;
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'select' || tag === 'textarea') return;
if (event.code === 'KeyK') void this.togglePlay();
else if (event.code === 'KeyH') hide.click();
else if (event.key === '?') this.onboarding.toggleHelp();
else if (event.code === 'Digit1') this.onboarding.runDemo(0);
else if (event.code === 'Digit2') this.onboarding.runDemo(1);
else if (event.code === 'Digit3') this.onboarding.runDemo(2);
else return;
event.preventDefault();
});
}
/** Drop an audio file anywhere on the 3D view to load it. */
private bindDropTarget(): void {
const viewport = need<HTMLElement>('#viewport');
let depth = 0;
const setActive = (active: boolean) => viewport.classList.toggle('drop-active', active);
// dragenter/dragleave fire for every child element crossed, so count them
// rather than toggling, or the highlight flickers as the pointer moves.
viewport.addEventListener('dragenter', (event) => {
event.preventDefault();
depth++;
setActive(true);
});
viewport.addEventListener('dragover', (event) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
viewport.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (depth === 0) setActive(false);
});
viewport.addEventListener('drop', (event) => {
event.preventDefault();
depth = 0;
setActive(false);
const file = event.dataTransfer?.files?.[0];
if (file) void this.loadFile(file);
});
}
/**
* Three guided scenarios, each isolating one thing the engine models.
* Every one ends by syncing the panel, so the controls agree with what the
* demo just set up.
*/
private buildDemos(): DemoSpec[] {
const withSync = (specs: DemoSpec[]): DemoSpec[] =>
specs.map((spec) => ({
...spec,
run: () => {
spec.run();
this.syncPanel();
},
}));
return withSync([
{
id: 'flyby',
label: 'Siren fly-by',
// Names the signal-path row rather than the Output module, which is
// hidden on narrow screens.
caption: 'Watch the Doppler shift in Output, and “At your ear” rise as it approaches.',
run: () => {
this.applyEngine({ preset: 'engine', propagationEnabled: true, speedOfSound: 343 });
this.setMotion('flyby', 32);
},
},
{
id: 'occlusion',
label: 'Behind the pillar',
caption: 'Watch the Occlusion row: the lowpass slides from 22 kHz down to 350 Hz.',
run: () => {
this.applyEngine({ preset: 'pink' });
this.setMotion('static', 8);
// Park the source directly behind the obstacle, as seen from the ear.
const listener = this.stage.camera.getWorldPosition(new THREE.Vector3());
const behind = this.stage.obstacle.position
.clone()
.sub(listener)
.setY(0)
.normalize()
.multiplyScalar(3.5)
.add(this.stage.obstacle.position);
behind.y = 1.6;
this.stage.setSourcePosition(behind);
},
},
{
id: 'critical',
label: 'Past the critical distance',
caption:
'Walk away with W/S: the direct sound falls, the room stays put. That is how you judge distance.',
run: () => {
this.applyEngine({
preset: 'pluck',
surface: 'hall',
room: { width: 34, height: 12, depth: 34 },
});
this.setMotion('static', 8);
this.stage.setSourcePosition(new THREE.Vector3(0, 1.6, 0));
this.stage.resetView();
},
},
]);
}
private setMotion(mode: MotionMode, speed: number): void {
this.motion = { mode, speed };
this.stage.setMotion(mode, speed);
}
/** Pushes engine and motion state back into the panel after a demo or reset. */
private syncPanel(): void {
this.panel.sync(this.settings, this.motion);
}
private setStatus(state: string, text: string): void {
if (this.statusChip.dataset.state !== state) this.statusChip.dataset.state = state;
if (this.statusText.textContent !== text) this.statusText.textContent = text;
}
private tick = (now: number): void => {
const dt = Math.min(0.1, Math.max(0.001, (now - this.lastFrame) / 1000));
this.lastFrame = now;
this.frame++;
const pose = this.stage.update(
dt,
this.telemetry.criticalDistance,
this.telemetry.reverbDominant,
Math.pow(10, this.telemetry.coneGainDb / 20)
);
this.telemetry = this.engine.updateSpatial(pose, dt);
this.stage.render();
// The 3D view runs every frame; the DOM does not need to.
if (this.frame % 6 === 0) this.renderReadouts();
if (this.frame % 2 === 0) {
const running = this.telemetry.running;
this.spectrum.draw(this.engine.getFrequencyData(), running);
this.scope.draw(this.engine.getWaveformData(), running);
}
requestAnimationFrame(this.tick);
};
private renderReadouts(): void {
this.hud.update(this.telemetry);
this.output.update(this.telemetry);
this.signalPath.update(this.telemetry);
this.panel.update(this.telemetry, this.stage.getSourceYaw());
const state = this.engine.context?.state;
if (!state) this.setStatus('idle', 'Audio not started');
else if (state === 'running' && this.engine.isPlaying)
this.setStatus('running', `Running · ${Math.round(this.engine.sampleRate / 1000)} kHz`);
else this.setStatus('suspended', 'Paused');
// Announce state transitions only. Continuously reading out telemetry
// would make a screen reader unusable.
const announcement = this.telemetry.occlusion > 0.5 ? 'Line of sight blocked' : '';
if (announcement !== this.lastAnnounced) {
this.lastAnnounced = announcement;
this.live.textContent = announcement;
}
}
}
new App();
+72
View File
@@ -0,0 +1,72 @@
import { clamp } from './vector';
export type DistanceModel = 'inverse' | 'exponential' | 'linear';
export interface DistanceParams {
/** Distance at which gain is unity, in metres. */
refDistance: number;
/** Distance beyond which the linear model reaches its floor, in metres. */
maxDistance: number;
/** How quickly loudness falls off. 1.0 is physically neutral for `inverse`. */
rolloffFactor: number;
}
/**
* Web Audio `inverse` model. At rolloffFactor 1 this is the physical inverse
* distance law for a point source: doubling the distance halves the amplitude
* (-6 dB).
*/
export function inverseDistanceGain(d: number, p: DistanceParams): number {
if (p.refDistance <= 0) return 0;
const clamped = Math.max(d, p.refDistance);
const denominator = p.refDistance + p.rolloffFactor * (clamped - p.refDistance);
return denominator <= 0 ? 0 : clamp(p.refDistance / denominator, 0, 1);
}
/** Web Audio `exponential` model: (d / refDistance) ^ -rolloffFactor. */
export function exponentialDistanceGain(d: number, p: DistanceParams): number {
if (p.refDistance <= 0) return 0;
const clamped = Math.max(d, p.refDistance);
return clamp(Math.pow(clamped / p.refDistance, -p.rolloffFactor), 0, 1);
}
/** Web Audio `linear` model: fades linearly to the floor at maxDistance. */
export function linearDistanceGain(d: number, p: DistanceParams): number {
if (p.refDistance <= 0) return 0;
if (p.maxDistance <= p.refDistance) return d >= p.maxDistance ? 0 : 1;
const clamped = clamp(d, p.refDistance, p.maxDistance);
const t = (clamped - p.refDistance) / (p.maxDistance - p.refDistance);
return clamp(1 - p.rolloffFactor * t, 0, 1);
}
export function distanceGain(d: number, model: DistanceModel, p: DistanceParams): number {
switch (model) {
case 'exponential':
return exponentialDistanceGain(d, p);
case 'linear':
return linearDistanceGain(d, p);
case 'inverse':
default:
return inverseDistanceGain(d, p);
}
}
/**
* Atmospheric absorption of high frequencies over distance, expressed as the
* cutoff of a one-pole lowpass.
*
* Real air at 20 °C / 50 % RH absorbs roughly 0.1 dB per metre at 8 kHz, which
* is barely audible across a room. `strength` scales that: 1.0 is realistic,
* higher values exaggerate the effect so it can be heard at room scale.
*/
export function airAbsorptionCutoff(
distanceMetres: number,
strength: number,
nyquist = 22050
): number {
if (strength <= 0 || distanceMetres <= 0) return nyquist;
// Halve the cutoff every `halfLife` metres; ~120 m at realistic strength.
const halfLife = 120 / Math.max(strength, 1e-3);
const cutoff = nyquist * Math.pow(0.5, distanceMetres / halfLife);
return clamp(cutoff, 200, nyquist);
}
+44
View File
@@ -0,0 +1,44 @@
import { Vec3, clamp, direction, dot, normalize } from './vector';
export interface ConeParams {
/** Full angle of the fully-loud cone, in degrees. */
innerAngle: number;
/** Full angle at which attenuation reaches `outerGain`, in degrees. */
outerAngle: number;
/** Gain applied outside the outer cone, 0..1. */
outerGain: number;
}
/**
* Angle in degrees between the source's forward axis and the listener,
* measured from the source. 0° means the listener is dead ahead.
*/
export function offAxisAngle(sourcePos: Vec3, sourceForward: Vec3, listenerPos: Vec3): number {
const toListener = direction(sourcePos, listenerPos);
if (!toListener) return 0;
const forward = normalize(sourceForward);
return (Math.acos(clamp(dot(forward, toListener), -1, 1)) * 180) / Math.PI;
}
/**
* Directivity gain, following the Web Audio PannerNode cone model: unity
* inside the inner cone, linearly interpolated across the transition band,
* `outerGain` beyond the outer cone.
*/
export function coneGain(
sourcePos: Vec3,
sourceForward: Vec3,
listenerPos: Vec3,
p: ConeParams
): number {
const angle = offAxisAngle(sourcePos, sourceForward, listenerPos);
const innerHalf = Math.max(0, p.innerAngle / 2);
const outerHalf = Math.max(innerHalf, p.outerAngle / 2);
const floor = clamp(p.outerGain, 0, 1);
if (angle <= innerHalf) return 1;
if (angle >= outerHalf) return floor;
const t = (angle - innerHalf) / (outerHalf - innerHalf);
return clamp(1 + t * (floor - 1), 0, 1);
}
+38
View File
@@ -0,0 +1,38 @@
import { Vec3, clamp, direction, dot } from './vector';
/** Playback-rate ratios beyond this are chipmunk/monster territory, not physics. */
export const DOPPLER_MIN_RATIO = 0.25;
export const DOPPLER_MAX_RATIO = 4;
/**
* Doppler frequency ratio (perceived / emitted) for a source and listener
* moving through a medium:
*
* f' / f = (c - v_listener) / (c - v_source)
*
* where both velocities are the components along the source→listener axis.
* A source approaching the listener raises the pitch; a listener fleeing the
* source lowers it.
*/
export function dopplerRatio(
sourcePos: Vec3,
sourceVel: Vec3,
listenerPos: Vec3,
listenerVel: Vec3,
speedOfSound = 343
): number {
const c = speedOfSound > 0 ? speedOfSound : 343;
const toListener = direction(sourcePos, listenerPos);
if (!toListener) return 1;
// Positive when the source chases the listener.
const vSource = dot(sourceVel, toListener);
// Positive when the listener flees the source.
const vListener = dot(listenerVel, toListener);
// Keep the source subsonic so the denominator never collapses.
const denominator = c - Math.min(vSource, 0.9 * c);
if (denominator <= 0) return DOPPLER_MAX_RATIO;
return clamp((c - vListener) / denominator, DOPPLER_MIN_RATIO, DOPPLER_MAX_RATIO);
}
+5
View File
@@ -0,0 +1,5 @@
export * from './vector';
export * from './attenuation';
export * from './cone';
export * from './doppler';
export * from './occlusion';
+32
View File
@@ -0,0 +1,32 @@
import { clamp } from './vector';
export interface OcclusionResponse {
/** Lowpass cutoff applied to the direct path, in Hz. */
cutoff: number;
/** Broadband gain applied to the direct path, 0..1. */
gain: number;
}
/** Cutoff of the direct path when fully blocked. */
const BLOCKED_CUTOFF = 350;
const OPEN_CUTOFF = 22050;
/** Broadband loss when fully blocked; the rest of the energy diffracts around. */
const BLOCKED_GAIN = 0.22;
/**
* Maps a fractional occlusion (0 = clear line of sight, 1 = fully blocked) to
* filter settings for the direct path.
*
* Interpolating the cutoff geometrically rather than linearly keeps the sweep
* perceptually even, since pitch perception is logarithmic. The reverberant
* path is deliberately left untouched: sound that has bounced off the walls
* still reaches the listener when the direct path is blocked, which is exactly
* why an occluded source sounds muffled and far away rather than silent.
*/
export function occlusionResponse(occlusion: number): OcclusionResponse {
const t = clamp(occlusion, 0, 1);
return {
cutoff: OPEN_CUTOFF * Math.pow(BLOCKED_CUTOFF / OPEN_CUTOFF, t),
gain: 1 + t * (BLOCKED_GAIN - 1),
};
}
+56
View File
@@ -0,0 +1,56 @@
export interface Vec3 {
x: number;
y: number;
z: number;
}
export function distance(a: Vec3, b: Vec3): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
export function dot(a: Vec3, b: Vec3): number {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
export function length(v: Vec3): number {
return Math.sqrt(dot(v, v));
}
/** Returns a unit vector, or `fallback` when `v` is degenerate. */
export function normalize(v: Vec3, fallback: Vec3 = { x: 0, y: 0, z: -1 }): Vec3 {
const len = length(v);
if (len < 1e-9) return { ...fallback };
return { x: v.x / len, y: v.y / len, z: v.z / len };
}
/** Unit vector pointing from `from` towards `to`, or null if they coincide. */
export function direction(from: Vec3, to: Vec3): Vec3 | null {
const d = { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z };
return length(d) < 1e-9 ? null : normalize(d);
}
export function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
/** Linear amplitude ratio to decibels. Silence reports -Infinity's practical stand-in, -120 dB. */
export function gainToDb(gain: number): number {
return gain <= 1e-6 ? -120 : 20 * Math.log10(gain);
}
/** Frequency ratio to cents (1200 cents = one octave). */
export function ratioToCents(ratio: number): number {
return ratio <= 0 ? 0 : 1200 * Math.log2(ratio);
}
/**
* Frame-rate independent exponential smoothing coefficient.
* `tau` is the time in seconds for the signal to cover ~63% of the gap.
*/
export function smoothingAlpha(dt: number, tau: number): number {
if (tau <= 0) return 1;
return 1 - Math.exp(-dt / tau);
}
+122
View File
@@ -0,0 +1,122 @@
import * as THREE from 'three';
import { PALETTE } from './palette';
/**
* In-world explanations of the acoustics: the line the direct sound travels
* along, the sphere inside which direct sound beats the room, and a marker on
* the floor showing where the listener is standing.
*/
export class Annotations {
readonly group = new THREE.Group();
private readonly ray: THREE.Line;
private readonly rayMaterial: THREE.LineBasicMaterial;
private readonly criticalRing: THREE.Mesh;
private readonly listenerRing: THREE.Mesh;
private readonly listenerStem: THREE.Line;
constructor() {
this.group.name = 'Annotations';
this.rayMaterial = new THREE.LineBasicMaterial({
color: PALETTE.linkStrong,
transparent: true,
opacity: 0.8,
});
this.ray = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
this.rayMaterial
);
this.ray.frustumCulled = false;
this.group.add(this.ray);
// Flat ring on the floor at the critical distance: step outside it and the
// reverberant field is louder than the direct sound.
// Deliberately hairline: this is an annotation the eye should be able to
// ignore, not a wall across the room.
this.criticalRing = new THREE.Mesh(
new THREE.RingGeometry(0.994, 1, 128),
new THREE.MeshBasicMaterial({
color: PALETTE.criticalField,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
depthWrite: false,
})
);
this.criticalRing.rotation.x = -Math.PI / 2;
this.group.add(this.criticalRing);
this.listenerRing = new THREE.Mesh(
new THREE.RingGeometry(0.34, 0.42, 48),
new THREE.MeshBasicMaterial({
color: PALETTE.listener,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthWrite: false,
})
);
this.listenerRing.rotation.x = -Math.PI / 2;
this.group.add(this.listenerRing);
this.listenerStem = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
new THREE.LineBasicMaterial({ color: PALETTE.listener, transparent: true, opacity: 0.35 })
);
this.listenerStem.frustumCulled = false;
this.group.add(this.listenerStem);
}
update(options: {
sourcePos: THREE.Vector3;
listenerPos: THREE.Vector3;
criticalDistance: number;
occlusion: number;
reverbDominant: boolean;
}): void {
const { sourcePos, listenerPos, criticalDistance, occlusion, reverbDominant } = options;
setLine(this.ray, sourcePos, listenerPos);
// Blocked line of sight turns the ray amber and fades it, matching the
// occlusion row in the panel.
this.rayMaterial.color.setHex(occlusion > 0.05 ? 0xf0a22e : PALETTE.linkStrong);
this.rayMaterial.opacity = 0.85 - 0.5 * occlusion;
const radius = Math.max(0.2, criticalDistance);
this.criticalRing.position.set(sourcePos.x, 0.02, sourcePos.z);
this.criticalRing.scale.setScalar(radius);
(this.criticalRing.material as THREE.MeshBasicMaterial).color.setHex(
reverbDominant ? PALETTE.criticalField : 0x3fcf8e
);
this.listenerRing.position.set(listenerPos.x, 0.02, listenerPos.z);
setLine(
this.listenerStem,
new THREE.Vector3(listenerPos.x, 0.02, listenerPos.z),
listenerPos
);
}
setVisible(visible: boolean): void {
this.group.visible = visible;
}
dispose(): void {
this.group.traverse((object) => {
const mesh = object as THREE.Mesh | THREE.Line;
if (mesh.geometry) mesh.geometry.dispose();
const material = (mesh as THREE.Mesh).material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else if (material) (material as THREE.Material).dispose();
});
}
}
function setLine(line: THREE.Line, from: THREE.Vector3, to: THREE.Vector3): void {
const positions = line.geometry.getAttribute('position') as THREE.BufferAttribute;
positions.setXYZ(0, from.x, from.y, from.z);
positions.setXYZ(1, to.x, to.y, to.z);
positions.needsUpdate = true;
line.geometry.computeBoundingSphere();
}
+125
View File
@@ -0,0 +1,125 @@
import * as THREE from 'three';
import { PALETTE } from './palette';
export interface RoomDims {
width: number;
height: number;
depth: number;
}
/**
* The bounding room: floor, walls seen from the inside, a grid for scale, and
* bright edges so the volume reads clearly from any camera angle.
*/
export class Room {
readonly group = new THREE.Group();
private dims: RoomDims;
private readonly shell: THREE.Mesh;
private readonly floor: THREE.Mesh;
private readonly edges: THREE.LineSegments;
private grid: THREE.GridHelper;
private readonly shellGeometry: THREE.BoxGeometry;
constructor(dims: RoomDims) {
this.dims = { ...dims };
this.group.name = 'Room';
this.shellGeometry = new THREE.BoxGeometry(1, 1, 1);
this.shell = new THREE.Mesh(
this.shellGeometry,
new THREE.MeshStandardMaterial({
color: PALETTE.wall,
side: THREE.BackSide,
roughness: 0.95,
metalness: 0,
})
);
this.shell.name = 'RoomShell';
this.shell.receiveShadow = true;
this.group.add(this.shell);
this.floor = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshStandardMaterial({ color: PALETTE.floor, roughness: 0.9, metalness: 0.05 })
);
this.floor.name = 'Floor';
this.floor.rotation.x = -Math.PI / 2;
this.floor.receiveShadow = true;
this.group.add(this.floor);
this.edges = new THREE.LineSegments(
new THREE.EdgesGeometry(this.shellGeometry),
new THREE.LineBasicMaterial({ color: PALETTE.edge, transparent: true, opacity: 0.55 })
);
this.edges.name = 'RoomEdges';
this.group.add(this.edges);
this.grid = this.buildGrid();
this.group.add(this.grid);
this.applyDims();
}
getDims(): RoomDims {
return { ...this.dims };
}
setDims(dims: RoomDims): void {
if (dims.width === this.dims.width && dims.height === this.dims.height && dims.depth === this.dims.depth) {
return;
}
this.dims = { ...dims };
this.applyDims();
// Only the grid needs rebuilding — everything else is a scaled unit mesh.
this.group.remove(this.grid);
this.grid.geometry.dispose();
(this.grid.material as THREE.Material).dispose();
this.grid = this.buildGrid();
this.group.add(this.grid);
}
/** Keeps a point inside the room, leaving `margin` metres of clearance. */
clamp(position: THREE.Vector3, margin = 0.6): THREE.Vector3 {
const halfW = Math.max(margin, this.dims.width / 2 - margin);
const halfD = Math.max(margin, this.dims.depth / 2 - margin);
position.x = THREE.MathUtils.clamp(position.x, -halfW, halfW);
position.y = THREE.MathUtils.clamp(position.y, margin, Math.max(margin, this.dims.height - margin));
position.z = THREE.MathUtils.clamp(position.z, -halfD, halfD);
return position;
}
dispose(): void {
this.shellGeometry.dispose();
(this.shell.material as THREE.Material).dispose();
this.floor.geometry.dispose();
(this.floor.material as THREE.Material).dispose();
this.edges.geometry.dispose();
(this.edges.material as THREE.Material).dispose();
this.grid.geometry.dispose();
(this.grid.material as THREE.Material).dispose();
}
private applyDims(): void {
const { width, height, depth } = this.dims;
this.shell.scale.set(width, height, depth);
this.shell.position.set(0, height / 2, 0);
this.edges.scale.copy(this.shell.scale);
this.edges.position.copy(this.shell.position);
this.floor.scale.set(width, depth, 1);
}
private buildGrid(): THREE.GridHelper {
const span = Math.max(this.dims.width, this.dims.depth);
// One grid line per metre, capped so huge rooms do not turn into moiré.
const divisions = Math.min(60, Math.max(4, Math.round(span)));
const grid = new THREE.GridHelper(span, divisions, PALETTE.gridMajor, PALETTE.gridMinor);
grid.name = 'FloorGrid';
grid.position.y = 0.008;
const material = grid.material as THREE.Material;
material.transparent = true;
material.opacity = 0.5;
return grid;
}
}
+202
View File
@@ -0,0 +1,202 @@
import * as THREE from 'three';
import { PALETTE } from './palette';
const LOBE_RADIUS = 1.8;
/**
* The loudspeaker, plus a directivity lobe that shows where it is actually
* radiating. The lobe is a spherical sector rather than a cone so it stays
* well-defined all the way out to a 360° omnidirectional pattern.
*/
export class SoundSource extends THREE.Group {
private readonly body: THREE.Mesh;
private readonly cabinet: THREE.Mesh;
private readonly arrow: THREE.ArrowHelper;
private innerLobe: THREE.Mesh;
private outerRim: THREE.LineSegments;
private innerAngle = 70;
private outerAngle = 160;
constructor() {
super();
this.name = 'SoundSource';
this.cabinet = new THREE.Mesh(
new THREE.BoxGeometry(0.62, 0.86, 0.5),
new THREE.MeshStandardMaterial({ color: 0x2b2016, roughness: 0.75, metalness: 0.15 })
);
this.cabinet.castShadow = true;
this.add(this.cabinet);
// The driver, facing local Z, which is the convention the physics uses.
this.body = new THREE.Mesh(
new THREE.CylinderGeometry(0.26, 0.3, 0.12, 32),
new THREE.MeshStandardMaterial({
color: PALETTE.source,
emissive: PALETTE.sourceEmissive,
emissiveIntensity: 0.5,
roughness: 0.35,
metalness: 0.6,
})
);
this.body.rotation.x = Math.PI / 2;
this.body.position.z = -0.27;
this.body.castShadow = true;
this.add(this.body);
this.arrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, -1),
new THREE.Vector3(0, 0, -0.35),
1.3,
PALETTE.source,
0.32,
0.18
);
this.add(this.arrow);
this.innerLobe = this.buildLobe();
this.add(this.innerLobe);
this.outerRim = this.buildRim();
this.add(this.outerRim);
}
getConeAngles(): { inner: number; outer: number } {
return { inner: this.innerAngle, outer: this.outerAngle };
}
setConeAngles(inner: number, outer: number): void {
const nextInner = THREE.MathUtils.clamp(inner, 0, 360);
const nextOuter = THREE.MathUtils.clamp(Math.max(outer, inner), 0, 360);
if (nextInner === this.innerAngle && nextOuter === this.outerAngle) return;
this.innerAngle = nextInner;
this.outerAngle = nextOuter;
this.remove(this.innerLobe);
disposeMesh(this.innerLobe);
this.innerLobe = this.buildLobe();
this.add(this.innerLobe);
this.remove(this.outerRim);
this.outerRim.geometry.dispose();
(this.outerRim.material as THREE.Material).dispose();
this.outerRim = this.buildRim();
this.add(this.outerRim);
}
/** Fades the lobe with the gain the listener is actually receiving. */
setReceivedGain(gain: number): void {
const material = this.innerLobe.material as THREE.MeshBasicMaterial;
material.opacity = 0.05 + 0.16 * THREE.MathUtils.clamp(gain, 0, 1);
}
getForward(): THREE.Vector3 {
return new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion).normalize();
}
/**
* Turns the speaker to face `target`.
*
* Object3D.lookAt points local +Z at the target for non-camera objects, but
* the acoustic forward axis is Z (where the driver and the arrow point), so
* aiming directly would face the speaker's back at the listener. Reflecting
* the target through the source position flips it the right way round.
*/
aimAt(target: THREE.Vector3): void {
this.lookAt(this.position.clone().multiplyScalar(2).sub(target));
}
dispose(): void {
disposeMesh(this.body);
disposeMesh(this.cabinet);
disposeMesh(this.innerLobe);
this.outerRim.geometry.dispose();
(this.outerRim.material as THREE.Material).dispose();
this.arrow.dispose();
}
/** A spherical sector of half-angle innerAngle/2, opening along local Z. */
private buildLobe(): THREE.Mesh {
const geometry = new THREE.SphereGeometry(
LOBE_RADIUS,
40,
20,
0,
Math.PI * 2,
0,
THREE.MathUtils.degToRad(Math.max(this.innerAngle, 1) / 2)
);
geometry.rotateX(-Math.PI / 2);
return new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({
color: PALETTE.coneInner,
transparent: true,
opacity: 0.18,
side: THREE.DoubleSide,
depthWrite: false,
})
);
}
/**
* A single ring at the outer angle, marking where directivity bottoms out.
*
* A full wireframe sphere here reads as a ball of noise around the speaker;
* one rim plus four meridians states the same angle and leaves the scene
* readable.
*/
private buildRim(): THREE.LineSegments {
const half = THREE.MathUtils.degToRad(Math.max(this.outerAngle, 1) / 2);
const radius = LOBE_RADIUS * 1.02;
const points: THREE.Vector3[] = [];
const ringRadius = Math.sin(half) * radius;
const ringZ = -Math.cos(half) * radius;
const segments = 64;
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2;
const b = ((i + 1) / segments) * Math.PI * 2;
points.push(
new THREE.Vector3(Math.cos(a) * ringRadius, Math.sin(a) * ringRadius, ringZ),
new THREE.Vector3(Math.cos(b) * ringRadius, Math.sin(b) * ringRadius, ringZ)
);
}
// Four meridians from the apex out to the rim, so the opening angle is
// legible even when the ring is edge-on to the camera.
for (let m = 0; m < 4; m++) {
const azimuth = (m / 4) * Math.PI * 2;
const steps = 10;
for (let i = 0; i < steps; i++) {
points.push(
meridianPoint(azimuth, (i / steps) * half, radius),
meridianPoint(azimuth, ((i + 1) / steps) * half, radius)
);
}
}
return new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: PALETTE.coneOuter,
transparent: true,
opacity: 0.5,
depthWrite: false,
})
);
}
}
/** Point on a sphere of `radius`, `polar` radians off the local Z axis. */
function meridianPoint(azimuth: number, polar: number, radius: number): THREE.Vector3 {
const ring = Math.sin(polar) * radius;
return new THREE.Vector3(Math.cos(azimuth) * ring, Math.sin(azimuth) * ring, -Math.cos(polar) * radius);
}
function disposeMesh(mesh: THREE.Mesh): void {
mesh.geometry.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material.dispose();
}
+516
View File
@@ -0,0 +1,516 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { Annotations } from './Annotations';
import { Room, RoomDims } from './Room';
import { SoundSource } from './SoundSource';
import { PALETTE } from './palette';
import { MotionMode, motionPosition } from './motion';
export type GizmoMode = 'translate' | 'rotate';
export interface StagePose {
sourcePos: THREE.Vector3;
sourceForward: THREE.Vector3;
listenerPos: THREE.Vector3;
listenerForward: THREE.Vector3;
listenerUp: THREE.Vector3;
/** 0 = clear line of sight, 1 = fully blocked. */
occlusion: number;
}
/**
* Probe offsets across the head, as [right, up] in metres. Applied in the frame
* perpendicular to the source→listener ray, not in world axes.
*/
const PROBE_OFFSETS: Array<[number, number]> = [
[0, 0],
[0.32, 0],
[-0.32, 0],
[0, 0.3],
[0, -0.3],
];
const WORLD_UP = new THREE.Vector3(0, 1, 0);
const WALK_SPEED = 4.2;
/**
* Owns the WebGL view. The camera *is* the microphone: its world position and
* orientation are what get published to the AudioContext listener, so orbiting
* the view genuinely swings the sound around the user's head.
*/
export class Stage {
readonly scene = new THREE.Scene();
readonly camera: THREE.PerspectiveCamera;
readonly renderer: THREE.WebGLRenderer;
readonly controls: OrbitControls;
readonly source = new SoundSource();
readonly room: Room;
readonly obstacle: THREE.Mesh;
private readonly annotations = new Annotations();
private readonly gizmo: TransformControls;
private readonly raycaster = new THREE.Raycaster();
private readonly resizeObserver: ResizeObserver;
private readonly keys = new Set<string>();
private readonly container: HTMLElement;
private motionMode: MotionMode = 'static';
private motionSpeed = 8;
private motionPhase = 0;
private dragging = false;
private readonly scratch = new THREE.Vector3();
private readonly probeTarget = new THREE.Vector3();
private readonly probeRight = new THREE.Vector3();
private readonly probeUp = new THREE.Vector3();
private readonly scratchAxis = new THREE.Vector3();
private readonly scratchDir = new THREE.Vector3();
private readonly obstacleBounds = new THREE.Box3();
private disposers: Array<() => void> = [];
constructor(container: HTMLElement, dims: RoomDims) {
this.container = container;
this.scene.background = new THREE.Color(PALETTE.background);
this.scene.fog = new THREE.Fog(PALETTE.fog, 30, 130);
const width = Math.max(1, container.clientWidth);
const height = Math.max(1, container.clientHeight);
this.camera = new THREE.PerspectiveCamera(58, width / height, 0.1, 400);
this.camera.position.set(6.5, 2.4, 8.5);
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
// Size the renderer up front. Skipping this leaves the canvas at its
// 300x150 default until the first window resize event that may never come.
this.renderer.setSize(width, height, false);
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.05;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.domElement.classList.add('viewport-canvas');
this.renderer.domElement.tabIndex = 0;
container.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.07;
// Panning would move the orbit target without moving the camera, which is
// the ear. Disabling it keeps "the camera is the microphone" an invariant.
this.controls.enablePan = false;
this.controls.minDistance = 0.8;
this.controls.maxDistance = 46;
this.controls.maxPolarAngle = Math.PI / 2 - 0.04;
this.controls.target.set(0, 1.5, 0);
this.controls.update();
this.room = new Room(dims);
this.scene.add(this.room.group);
this.source.position.set(-2.8, 1.6, -1.6);
this.scene.add(this.source);
this.obstacle = new THREE.Mesh(
new THREE.BoxGeometry(2.6, 3.2, 0.4),
new THREE.MeshStandardMaterial({ color: PALETTE.obstacle, roughness: 0.8, metalness: 0.1 })
);
this.obstacle.name = 'Obstacle';
// Well away from both the source and the arc the camera starts on, so the
// default scene has clear line of sight. Occlusion is something the user
// should discover by walking behind it, not the first thing the app reports.
this.obstacle.position.set(0.8, 1.6, 3.2);
this.obstacle.castShadow = true;
this.obstacle.receiveShadow = true;
this.scene.add(this.obstacle);
this.scene.add(this.annotations.group);
this.addLights();
this.gizmo = new TransformControls(this.camera, this.renderer.domElement);
this.gizmo.setSize(0.62);
this.gizmo.attach(this.source);
this.scene.add(this.gizmo);
this.bindEvents();
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(container);
}
setRoomDims(dims: RoomDims): void {
this.room.setDims(dims);
this.room.clamp(this.source.position);
this.room.clamp(this.controls.target);
this.room.clamp(this.obstacle.position, 0.3);
this.clampCamera();
}
setConeAngles(inner: number, outer: number): void {
this.source.setConeAngles(inner, outer);
}
setMotion(mode: MotionMode, speed: number): void {
this.motionMode = mode;
this.motionSpeed = speed;
const interactive = mode === 'static';
// Disabling the gizmo mid-drag means its 'dragging-changed' event never
// fires, so OrbitControls would stay disabled forever and the source would
// stay stuck to the pointer. Unwind the drag by hand first.
if (!interactive && this.dragging) {
this.dragging = false;
this.controls.enabled = true;
}
this.gizmo.enabled = interactive;
this.gizmo.visible = interactive;
}
setGizmoMode(mode: GizmoMode): void {
this.gizmo.setMode(mode);
}
getGizmoMode(): GizmoMode {
return this.gizmo.getMode() as GizmoMode;
}
setAnnotationsVisible(visible: boolean): void {
this.annotations.setVisible(visible);
}
setSourcePosition(position: THREE.Vector3): void {
this.source.position.copy(position);
this.room.clamp(this.source.position);
}
setSourceYaw(degrees: number): void {
this.source.rotation.set(0, THREE.MathUtils.degToRad(degrees), 0);
}
/**
* Yaw derived from the forward vector rather than from `rotation.y`.
*
* `aimAt` and the rotate gizmo both write a quaternion, and reading back the
* default XYZ Euler folds any aim beyond ±90° back into range — so the slider
* would show the wrong angle and, worse, writing that folded value back would
* spin the speaker the moment anyone touched it.
*/
getSourceYaw(): number {
const forward = this.source.getForward();
return THREE.MathUtils.radToDeg(Math.atan2(-forward.x, -forward.z));
}
/**
* Frames the scene from a three-quarter angle, close enough that the source
* is comfortably loud. The standoff is capped rather than scaled with the
* room, so enlarging the room does not park the listener against a wall.
*/
resetView(): void {
const { width, depth } = this.room.getDims();
// Orbit about the room centre, standing roughly 80° around from the
// source. The orbit axis has to be offset from the source: OrbitControls
// always points the camera at its target, so orbiting about the source
// itself would pin it dead ahead and the binaural image would never move.
const radius = THREE.MathUtils.clamp(Math.min(width, depth) * 0.24, 4, 8);
const azimuth = Math.atan2(this.source.position.z, this.source.position.x) + Math.PI * 0.45;
this.controls.target.set(0, 1.5, 0);
this.camera.position.set(Math.cos(azimuth) * radius, 2.4, Math.sin(azimuth) * radius);
this.clampCamera();
this.controls.update();
this.source.aimAt(this.camera.position);
}
/** Advances camera, source motion and annotations by one frame. */
update(dt: number, criticalDistance: number, reverbDominant: boolean, receivedGain: number): StagePose {
this.walk(dt);
if (this.motionMode !== 'static') {
// Integrate distance travelled rather than sampling f(time × speed), so
// moving the speed slider changes the pace without teleporting the source.
this.motionPhase += this.motionSpeed * dt;
const next = motionPosition(
this.motionMode,
this.motionPhase,
this.room.getDims(),
new THREE.Vector3(0, 0, 0),
this.scratch
);
if (next) {
this.source.position.copy(next);
// Keep the speaker aimed at the listener while it moves, so a fly-by
// demonstrates Doppler rather than directivity dropout.
this.source.aimAt(this.camera.position);
}
}
this.controls.update();
this.clampCamera();
this.scene.updateMatrixWorld(true);
const listenerPos = this.camera.getWorldPosition(new THREE.Vector3());
const sourcePos = this.source.getWorldPosition(new THREE.Vector3());
const occlusion = this.sampleOcclusion(sourcePos, listenerPos);
this.source.setReceivedGain(receivedGain);
this.annotations.update({
sourcePos,
listenerPos,
criticalDistance,
occlusion,
reverbDominant,
});
return {
sourcePos,
sourceForward: this.source.getForward(),
listenerPos,
listenerForward: this.camera.getWorldDirection(new THREE.Vector3()),
listenerUp: this.camera.up.clone().applyQuaternion(this.camera.quaternion).normalize(),
occlusion,
};
}
render(): void {
this.renderer.render(this.scene, this.camera);
}
dispose(): void {
this.resizeObserver.disconnect();
this.disposers.forEach((off) => off());
this.disposers = [];
this.gizmo.detach();
this.gizmo.dispose();
this.scene.remove(this.gizmo);
this.annotations.dispose();
this.room.dispose();
this.source.dispose();
this.obstacle.geometry.dispose();
(this.obstacle.material as THREE.Material).dispose();
this.renderer.dispose();
this.renderer.domElement.remove();
}
private addLights(): void {
// Enough ambient fill that the walls read as surfaces rather than as void;
// the room has to be legible before any of the overlays mean anything.
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
this.scene.add(new THREE.HemisphereLight(0xa8c0ff, 0x2a2118, 1.1));
const key = new THREE.DirectionalLight(0xfff2e0, 1.9);
key.position.set(7, 14, 6);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1;
key.shadow.camera.far = 60;
key.shadow.camera.left = -22;
key.shadow.camera.right = 22;
key.shadow.camera.top = 22;
key.shadow.camera.bottom = -22;
key.shadow.bias = -0.0006;
this.scene.add(key);
const fill = new THREE.DirectionalLight(0x6c8cff, 0.35);
fill.position.set(-8, 6, -7);
this.scene.add(fill);
}
/**
* Fraction of the head that the obstacle hides, sampled with a small bundle
* of rays. A single centre ray would snap between 0 and 1 as the listener
* crosses the shadow edge; spreading the probes makes the filter sweep in
* smoothly, the way walking behind a pillar actually sounds.
*/
private sampleOcclusion(sourcePos: THREE.Vector3, listenerPos: THREE.Vector3): number {
// A source buried in the obstacle casts no rays that hit it from the
// inside, and would otherwise be reported as a clear line of sight.
this.obstacleBounds.setFromObject(this.obstacle);
if (this.obstacleBounds.containsPoint(sourcePos)) return 1;
const axis = this.scratchAxis.subVectors(listenerPos, sourcePos);
const span = axis.length();
if (span < 0.05) return 0;
axis.divideScalar(span);
// Spread the probes across the plane perpendicular to the ray. Fixed
// world-axis offsets collapse onto the ray whenever the source happens to
// lie along that axis, and occlusion snaps between 0 and 1 as you cross the
// shadow edge instead of sweeping in.
this.probeRight.crossVectors(axis, WORLD_UP);
if (this.probeRight.lengthSq() < 1e-6) this.probeRight.set(1, 0, 0);
this.probeRight.normalize();
this.probeUp.crossVectors(this.probeRight, axis).normalize();
let blocked = 0;
for (const [right, up] of PROBE_OFFSETS) {
this.probeTarget
.copy(listenerPos)
.addScaledVector(this.probeRight, right)
.addScaledVector(this.probeUp, up);
const direction = this.scratchDir.subVectors(this.probeTarget, sourcePos);
const reach = direction.length();
if (reach < 0.05) continue;
direction.divideScalar(reach);
this.raycaster.set(sourcePos, direction);
this.raycaster.far = reach - 0.02;
if (this.raycaster.intersectObject(this.obstacle, false).length > 0) blocked++;
}
return blocked / PROBE_OFFSETS.length;
}
private walk(dt: number): void {
if (this.keys.size === 0) return;
const move = new THREE.Vector3();
if (this.keys.has('KeyW')) move.z -= 1;
if (this.keys.has('KeyS')) move.z += 1;
if (this.keys.has('KeyA')) move.x -= 1;
if (this.keys.has('KeyD')) move.x += 1;
if (this.keys.has('KeyE')) move.y += 1;
if (this.keys.has('KeyQ')) move.y -= 1;
const sourceMove = new THREE.Vector3();
if (this.keys.has('ArrowUp')) sourceMove.z -= 1;
if (this.keys.has('ArrowDown')) sourceMove.z += 1;
if (this.keys.has('ArrowLeft')) sourceMove.x -= 1;
if (this.keys.has('ArrowRight')) sourceMove.x += 1;
if (this.keys.has('PageUp')) sourceMove.y += 1;
if (this.keys.has('PageDown')) sourceMove.y -= 1;
const multiplier = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight') ? 3 : 1;
const step = WALK_SPEED * dt * multiplier;
// Both the listener and the source move in the camera's yaw frame, so
// "forward" means the same thing whichever one you are steering.
const yaw = new THREE.Euler(0, 0, 0, 'YXZ');
yaw.setFromQuaternion(this.camera.quaternion);
yaw.x = 0;
yaw.z = 0;
if (move.lengthSq() > 0) {
move.normalize().applyEuler(yaw).multiplyScalar(step);
this.camera.position.add(move);
this.controls.target.add(move);
this.clampCamera();
}
if (sourceMove.lengthSq() > 0 && this.motionMode === 'static') {
sourceMove.normalize().applyEuler(yaw).multiplyScalar(step);
this.source.position.add(sourceMove);
this.room.clamp(this.source.position);
}
}
private clampCamera(): void {
const before = this.camera.position.clone();
this.room.clamp(this.camera.position, 0.5);
const correction = this.camera.position.clone().sub(before);
// Move the orbit target with the camera so the view does not swing wildly
// when the listener is pushed off a wall.
if (correction.lengthSq() > 1e-8) this.controls.target.add(correction);
this.room.clamp(this.controls.target, 0.4);
}
private bindEvents(): void {
const canvas = this.renderer.domElement;
const onKeyDown = (event: KeyboardEvent) => {
if (!isSceneKeyTarget(event.target)) return;
if (event.code === 'KeyG') this.gizmo.setMode('translate');
if (event.code === 'KeyR') this.gizmo.setMode('rotate');
if (NAV_KEYS.has(event.code)) {
event.preventDefault();
this.keys.add(event.code);
}
};
const onKeyUp = (event: KeyboardEvent) => this.keys.delete(event.code);
// Holding a key while the tab loses focus would otherwise send the listener
// drifting forever, since the keyup never arrives.
const onBlur = () => this.keys.clear();
const onDoubleClick = (event: MouseEvent) => {
if (this.motionMode !== 'static') return;
const rect = canvas.getBoundingClientRect();
const ndc = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
this.raycaster.setFromCamera(ndc, this.camera);
this.raycaster.far = Infinity;
const hit = new THREE.Vector3();
if (this.raycaster.ray.intersectPlane(GROUND_PLANE, hit)) {
hit.y = this.source.position.y;
this.setSourcePosition(hit);
}
};
const onDraggingChanged = (event: { value: boolean }) => {
this.dragging = event.value;
this.controls.enabled = !event.value;
};
const onGizmoChange = () => {
if (this.dragging) this.room.clamp(this.source.position);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onBlur);
canvas.addEventListener('dblclick', onDoubleClick);
this.gizmo.addEventListener('dragging-changed', onDraggingChanged as never);
this.gizmo.addEventListener('change', onGizmoChange);
this.disposers.push(
() => window.removeEventListener('keydown', onKeyDown),
() => window.removeEventListener('keyup', onKeyUp),
() => window.removeEventListener('blur', onBlur),
() => document.removeEventListener('visibilitychange', onBlur),
() => canvas.removeEventListener('dblclick', onDoubleClick),
() => this.gizmo.removeEventListener('dragging-changed', onDraggingChanged as never),
() => this.gizmo.removeEventListener('change', onGizmoChange)
);
}
private resize(): void {
const width = Math.max(1, this.container.clientWidth);
const height = Math.max(1, this.container.clientHeight);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
}
const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const NAV_KEYS = new Set([
'KeyW',
'KeyA',
'KeyS',
'KeyD',
'KeyQ',
'KeyE',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'PageUp',
'PageDown',
'ShiftLeft',
'ShiftRight',
]);
/**
* Whether a keystroke belongs to the scene rather than to a control.
*
* Claiming the arrow keys globally makes radiogroups, selects and dialogs
* unusable by keyboard, and lets WASD walk the listener around behind an open
* modal. Scene keys are only accepted when focus is on nothing in particular —
* the body or the canvas.
*/
function isSceneKeyTarget(target: EventTarget | null): boolean {
if (document.querySelector('dialog[open]')) return false;
const element = target as HTMLElement | null;
if (!element || element === document.body) return true;
if (element.isContentEditable) return false;
return !element.closest('input, select, textarea, button, a, summary, dialog, [role="radio"]');
}
+6
View File
@@ -0,0 +1,6 @@
export * from './Stage';
export * from './Room';
export * from './SoundSource';
export * from './Annotations';
export * from './motion';
export * from './palette';
+75
View File
@@ -0,0 +1,75 @@
import * as THREE from 'three';
export type MotionMode = 'static' | 'orbit' | 'flyby' | 'pendulum';
export interface MotionOption {
id: MotionMode;
label: string;
hint: string;
}
export const MOTION_MODES: MotionOption[] = [
{ id: 'static', label: 'Parked', hint: 'You place the source yourself' },
{ id: 'orbit', label: 'Orbit', hint: 'Circles you — the clearest way to hear the binaural image move' },
{ id: 'flyby', label: 'Fly-by', hint: 'Sweeps past and back: the classic Doppler swoop, in both directions' },
{ id: 'pendulum', label: 'Pendulum', hint: 'Swings side to side through the room' },
];
/**
* Position of an automatically-moving source after travelling `phase` metres
* along its path. Returns null for 'static' — "leave it where the user put it".
*
* The parameter is distance travelled, not elapsed time, and the caller
* integrates it as `phase += speed * dt`. Driving the path from `time * speed`
* instead would teleport the source the instant anyone touched the speed
* slider, because every point on the path would be re-evaluated at a new
* parameter. Integrating keeps position continuous under any speed change.
*/
export function motionPosition(
mode: MotionMode,
phase: number,
room: { width: number; height: number; depth: number },
centre: THREE.Vector3,
out = new THREE.Vector3()
): THREE.Vector3 | null {
const halfW = Math.max(1.5, room.width / 2 - 1.5);
const halfD = Math.max(1.5, room.depth / 2 - 1.5);
const height = THREE.MathUtils.clamp(1.6, 0.8, room.height - 0.8);
switch (mode) {
case 'orbit': {
const radius = Math.min(halfW, halfD) * 0.8;
const angle = phase / Math.max(radius, 0.5);
return out.set(
centre.x + Math.cos(angle) * radius,
height,
centre.z + Math.sin(angle) * radius
);
}
case 'flyby': {
// Ping-pong along X. A sawtooth would snap the source back across the
// room at the end of every pass, which reads as a violent pitch dive and
// an impossible closing speed.
return out.set(
-halfW + pingPong(phase, halfW * 2),
height,
centre.z - Math.min(halfD, 3)
);
}
case 'pendulum': {
const span = halfD * 0.85;
// Sinusoidal swing, so the turnaround has no velocity discontinuity.
return out.set(centre.x, height, Math.sin(phase / Math.max(span, 0.5)) * span);
}
case 'static':
default:
return null;
}
}
/** Triangle wave: 0 → span → 0, continuous in position at every turn. */
function pingPong(value: number, span: number): number {
if (span <= 0) return 0;
const wrapped = ((value % (span * 2)) + span * 2) % (span * 2);
return wrapped <= span ? wrapped : span * 2 - wrapped;
}
+23
View File
@@ -0,0 +1,23 @@
/** Scene colours. Kept in sync with the CSS custom properties in app.css. */
export const PALETTE = {
background: 0x0e0f12,
fog: 0x0e0f12,
floor: 0x232a36,
gridMajor: 0x4a5a70,
gridMinor: 0x2f3948,
wall: 0x1c222c,
edge: 0x59677e,
source: 0xffb545,
sourceEmissive: 0xd97706,
coneInner: 0xffb545,
coneOuter: 0x7c6a4a,
listener: 0x4fd1c5,
obstacle: 0x4a5568,
linkStrong: 0x4fd1c5,
linkWeak: 0x6b7280,
criticalField: 0x8b5cf6,
} as const;
+971
View File
@@ -0,0 +1,971 @@
@import './tokens.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--ink-1);
font-family: var(--sans);
font-size: 14px;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button,
input,
select {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent-hi);
outline-offset: 2px;
border-radius: inherit;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ── Shell ────────────────────────────────────────────────────────────── */
#app {
display: grid;
height: 100dvh;
grid-template-columns: 1fr var(--panel-w);
grid-template-rows: var(--bar-h) 1fr var(--strip-h);
grid-template-areas:
'bar bar'
'view panel'
'strip strip';
}
body.panels-hidden #app {
grid-template-columns: 1fr 0;
grid-template-rows: var(--bar-h) 1fr 0;
}
body.panels-hidden #panel,
body.panels-hidden #strip {
display: none;
}
/* ── Top bar ──────────────────────────────────────────────────────────── */
#bar {
grid-area: bar;
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
background: var(--surface-1);
border-bottom: 1px solid var(--line);
}
.wordmark {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.005em;
}
.wordmark span {
font-size: 12px;
font-weight: 400;
color: var(--ink-3);
}
.bar-spacer {
flex: 1;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
font-size: 12px;
color: var(--ink-2);
white-space: nowrap;
}
.status-chip .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ink-3);
}
.status-chip[data-state='running'] .dot {
background: var(--ok);
}
.status-chip[data-state='suspended'] .dot {
background: var(--warn);
}
.status-chip[data-state='error'] .dot {
background: var(--alert);
}
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: var(--r-input);
background: var(--surface-2);
color: var(--ink-1);
font-size: 13px;
cursor: pointer;
transition: background 120ms var(--ease), border-color 120ms var(--ease);
}
.btn:hover {
background: var(--surface-3);
}
.btn:active {
transform: translateY(0.5px);
}
.btn-primary {
min-height: 36px;
padding: 0 16px;
border-color: transparent;
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-hi);
}
.btn-primary:active {
background: var(--accent-press);
}
.btn-icon {
width: 36px;
min-height: 36px;
padding: 0;
}
.btn-lg {
min-height: 44px;
padding: 0 20px;
font-size: 15px;
}
@media (pointer: coarse) {
.btn,
.btn-icon {
min-height: 44px;
}
}
/* ── Viewport ─────────────────────────────────────────────────────────── */
#viewport {
grid-area: view;
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.viewport-canvas {
display: block;
width: 100%;
height: 100%;
}
.viewport-canvas:focus-visible {
outline: 2px solid var(--accent-hi);
outline-offset: -2px;
}
.hud {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
min-width: 208px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-card);
background: var(--surface-2);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
pointer-events: none;
}
.hud-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.hud-value {
font-family: var(--mono);
font-size: 24px;
line-height: 1.1;
font-weight: 450;
}
.hud-label {
margin-top: 3px;
font-size: 11.5px;
color: var(--ink-3);
}
.hud-flag {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 9px;
padding-top: 9px;
border-top: 1px solid var(--line);
font-size: 12px;
color: var(--ok);
}
.hud-flag[data-dominant='room'] {
color: var(--warn);
}
.hud-flag[data-blocked='true'] {
color: var(--alert);
}
.viewport-tools {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
}
/* Drop target for audio files */
#viewport.drop-active::after {
content: 'Drop to load this track';
position: absolute;
inset: 12px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--accent-hi);
border-radius: var(--r-card);
background: rgb(108 140 255 / 0.12);
color: var(--ink-1);
font-size: 17px;
font-weight: 600;
pointer-events: none;
z-index: 3;
}
.file-btn {
width: 100%;
justify-content: flex-start;
}
.field-hint[data-tone='error'] {
color: var(--alert);
}
.field-hint[data-tone='busy'] {
color: var(--accent-hi);
}
.legend {
position: absolute;
left: 16px;
bottom: 14px;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
max-width: calc(100% - 32px);
font-size: 11.5px;
color: var(--ink-3);
pointer-events: none;
}
.legend kbd {
padding: 1px 5px;
border: 1px solid var(--line-strong);
border-radius: var(--r-tick);
background: var(--surface-2);
font-family: var(--mono);
font-size: 10.5px;
color: var(--ink-2);
}
/* ── Side panel ───────────────────────────────────────────────────────── */
#panel {
grid-area: panel;
overflow-y: auto;
padding: 20px;
background: var(--surface-1);
border-left: 1px solid var(--line);
scrollbar-width: thin;
}
.section {
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: var(--r-card);
background: var(--surface-2);
}
.section > summary {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
list-style: none;
border-radius: var(--r-card);
}
.section > summary::-webkit-details-marker {
display: none;
}
.section > summary::before {
content: '';
width: 0;
height: 0;
border-left: 5px solid var(--ink-3);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 240ms var(--ease);
}
.section[open] > summary::before {
transform: rotate(90deg);
}
.section-body {
padding: 4px 16px 16px;
}
/* ── Controls ─────────────────────────────────────────────────────────── */
.field {
margin-bottom: 14px;
}
.field:last-child {
margin-bottom: 0;
}
.field-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.field-head label {
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
}
.field-value {
font-family: var(--mono);
font-size: 13px;
font-weight: 500;
color: var(--ink-1);
}
.field-hint {
margin-top: 5px;
font-size: 12px;
line-height: 1.42;
color: var(--ink-3);
}
input[type='range'] {
-webkit-appearance: none;
appearance: none;
display: block;
width: 100%;
height: 20px;
padding: 8px 0;
background: transparent;
cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
background: var(--line-strong);
}
input[type='range']::-moz-range-track {
height: 4px;
border-radius: 2px;
background: var(--line-strong);
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
margin-top: -7px;
border: 1px solid var(--bg);
border-radius: 999px;
background: var(--ink-1);
transition: background 120ms var(--ease);
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
border: 1px solid var(--bg);
border-radius: 999px;
background: var(--ink-1);
}
input[type='range']:hover::-webkit-slider-thumb {
background: var(--accent-hi);
}
select {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: var(--r-input);
background: var(--surface-3);
cursor: pointer;
}
.segmented {
display: flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--line-strong);
border-radius: var(--r-input);
background: var(--surface-3);
}
.segmented button {
flex: 1;
min-height: 26px;
padding: 0 8px;
border: 0;
border-radius: var(--r-tick);
background: transparent;
color: var(--ink-2);
font-size: 12.5px;
cursor: pointer;
transition: background 180ms var(--ease);
}
.segmented button[aria-checked='true'] {
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
}
.switch input {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
/* ── Instrument strip ─────────────────────────────────────────────────── */
#strip {
grid-area: strip;
display: grid;
grid-template-columns: minmax(340px, 1.5fr) minmax(240px, 1fr) minmax(190px, 0.8fr) 200px;
gap: 1px;
min-height: 0;
background: var(--line);
border-top: 1px solid var(--line);
}
.module {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 10px 14px 12px;
background: var(--surface-1);
}
.module-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--ink-3);
}
.module-title em {
font-style: normal;
letter-spacing: 0;
text-transform: none;
font-weight: 400;
}
.module canvas {
display: block;
width: 100%;
flex: 1;
min-height: 0;
border-radius: var(--r-card);
background: var(--well);
}
/* Signal path table */
/*
* Scrollable as a last resort. At very short window heights the eight stages
* cannot all fit, and silently cropping the bottom rows would hide exactly the
* totals the table exists to show.
*/
#module-path {
overflow-y: auto;
scrollbar-width: thin;
}
.path {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.path th {
font-weight: 500;
text-align: left;
color: var(--ink-2);
padding: 1.5px 0;
white-space: nowrap;
}
.path td {
padding: 1.5px 0 1.5px 10px;
font-family: var(--mono);
color: var(--ink-1);
white-space: nowrap;
}
.path td.detail {
color: var(--ink-3);
text-align: right;
width: 1%;
}
.path td.db {
text-align: right;
width: 1%;
}
.path td.bar {
width: 34%;
padding-left: 12px;
}
.path tr[data-flag='warn'] th,
.path tr[data-flag='warn'] td.db {
color: var(--warn);
}
.path tr.total th,
.path tr.total td {
padding-top: 6px;
border-top: 1px solid var(--line);
font-weight: 600;
}
.meter {
position: relative;
height: 7px;
border-radius: 2px;
background: var(--well);
overflow: hidden;
}
.meter i {
position: absolute;
inset: 0 auto 0 0;
display: block;
border-radius: 2px;
background: var(--seg-survive);
}
.meter i[data-seg='distance'] {
background: var(--seg-distance);
}
.meter i[data-seg='directivity'] {
background: var(--seg-directivity);
}
.meter i[data-seg='occlusion'] {
background: var(--seg-occlusion);
}
.meter i[data-seg='room'] {
background: var(--warn);
}
/* Output module */
.ears {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 5px 8px;
margin-top: 8px;
font-size: 12px;
}
.ears span {
color: var(--ink-2);
}
.ears .db {
font-family: var(--mono);
color: var(--ink-1);
text-align: right;
}
.readout {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 1.5px 0;
font-size: 12px;
color: var(--ink-2);
}
.readout b {
font-family: var(--mono);
font-weight: 500;
color: var(--ink-1);
}
.clip-led {
padding: 1px 6px;
border-radius: var(--r-tick);
background: var(--well);
color: var(--ink-3);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
}
.clip-led[data-on='true'] {
background: var(--alert);
color: var(--on-accent);
}
/* ── Dialog ───────────────────────────────────────────────────────────── */
dialog {
width: min(560px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--r-dialog);
background: var(--surface-3);
color: var(--ink-1);
box-shadow: 0 32px 64px -24px rgb(0 0 0 / 0.8);
}
dialog::backdrop {
background: rgb(6 7 9 / 0.68);
}
.dialog-body {
padding: 28px;
}
.dialog-body h2 {
margin: 0 0 10px;
font-family: var(--serif);
font-size: 34px;
line-height: 1.12;
font-weight: 400;
letter-spacing: -0.02em;
}
.dialog-body p {
margin: 0 0 16px;
font-size: 15px;
line-height: 1.6;
color: var(--ink-2);
}
.dialog-note {
display: flex;
gap: 9px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: var(--r-card);
background: var(--surface-2);
font-size: 13px;
line-height: 1.5;
color: var(--ink-2);
}
.dialog-actions {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0 8px;
}
.key-table {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 7px 12px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--ink-2);
}
.key-table kbd {
padding: 1px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--r-tick);
background: var(--surface-2);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--strip-h) + 20px);
transform: translateX(-50%);
padding: 10px 16px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--surface-3);
font-size: 13px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
z-index: 20;
}
/* ── Responsive ───────────────────────────────────────────────────────── */
/*
* The strip keeps its full height at every width. Dropping the scope buys the
* space instead: the signal path is the product, and a clipped gain budget is
* worse than a missing oscilloscope.
*/
@media (max-width: 1280px) {
:root {
--panel-w: 330px;
}
#strip {
grid-template-columns: minmax(300px, 1.4fr) minmax(200px, 1fr) 180px;
}
#module-scope {
display: none;
}
}
/*
* Stacked layout. The viewport keeps a hard minimum and the panel is capped in
* vh: without both, a 1024x768 window gives the 3D view about 150px, which is
* useless in an app whose subject is the geometry.
*/
@media (max-width: 1024px) {
#app {
grid-template-columns: 1fr;
grid-template-rows: var(--bar-h) minmax(260px, 1fr) auto var(--strip-h);
grid-template-areas:
'bar'
'view'
'panel'
'strip';
}
#panel {
max-height: 26vh;
padding: 14px;
border-left: 0;
border-top: 1px solid var(--line);
}
/* Flow the sections into columns rather than stretching one 900px slider. */
#panel > div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 14px;
align-items: start;
}
#panel .section {
margin-bottom: 0;
}
/* Output stays: the fly-by demo tells the user to watch it. */
#strip {
grid-template-columns: minmax(260px, 1.4fr) minmax(150px, 1fr) 170px;
}
.legend {
display: none;
}
.wordmark span {
display: none;
}
}
/*
* Phone widths. The meter strip cannot fit three modules side by side without
* pushing the shell wider than the viewport, so it drops to the signal path
* alone — everything else has a home in the panel or the HUD.
*/
@media (max-width: 640px) {
:root {
--bar-h: 52px;
}
#bar {
gap: 8px;
padding: 0 10px;
}
#volume,
#volume-value,
.status-chip {
display: none;
}
#strip {
grid-template-columns: 1fr;
}
#module-spectrum,
#module-output {
display: none;
}
.viewport-tools {
top: 10px;
right: 10px;
}
.hud {
top: 10px;
left: 10px;
min-width: 0;
padding: 9px 11px;
}
.hud-value {
font-size: 19px;
}
}
/*
* Short windows: the strip gives up height first, and the signal path tightens
* its rows rather than dropping any of them. Every stage has to stay visible
* for the budget to add up.
*/
@media (max-height: 760px) {
:root {
--strip-h: 172px;
}
.path {
font-size: 11px;
}
.path th,
.path td {
padding-top: 0;
padding-bottom: 0;
line-height: 1.42;
}
.path tr.total th,
.path tr.total td {
padding-top: 4px;
}
.module-title {
margin-bottom: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
* Design tokens.
*
* One accent hue for interaction, three semantic hues for state, everything
* else achromatic — so the most colourful thing on screen is always live
* acoustic data rather than chrome.
*/
:root {
color-scheme: dark;
--bg: #0e0f12;
--surface-1: #17191e;
--surface-2: #1d2027;
--surface-3: #232833;
--well: #101216;
--line: #2a2f3a;
--line-strong: #697285;
--ink-1: #e9e7e2;
--ink-2: #b3b8c4;
--ink-3: #8e95a3;
--accent: #6c8cff;
--accent-hi: #8ca4ff;
--accent-press: #4e6fe8;
--on-accent: #0e0f12;
--ok: #3fcf8e;
--warn: #f0a22e;
--alert: #ff5d5d;
/* Gain-budget segments: loss reads as desaturation, survival as chroma. */
--seg-survive: #8ca4ff;
--seg-distance: #3e4a6b;
--seg-directivity: #4a4560;
--seg-occlusion: #6e5326;
--sans: ui-sans-serif, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--serif: ui-serif, 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
--mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
--r-tick: 3px;
--r-input: 5px;
--r-card: 8px;
--r-dialog: 12px;
--ease: cubic-bezier(0.2, 0, 0, 1);
--panel-w: 380px;
--bar-h: 56px;
--strip-h: 218px;
}
/*
* Dark only, deliberately. The viewport is a lit 3D room rendered against a
* near-black background; wrapping it in a light chrome puts a white picture
* frame around a dark photograph, and re-lighting the scene for a light theme
* would mean a second set of materials for no real gain.
*/
@media (prefers-contrast: more) {
:root {
--line: var(--line-strong);
--ink-3: var(--ink-2);
}
}
+282
View File
@@ -0,0 +1,282 @@
type Props = Record<string, string | number | boolean | undefined>;
/** Terse element builder. Keys starting with `aria`/`data` become attributes. */
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Props = {},
children: Array<Node | string> = []
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value === undefined || value === false) continue;
if (key === 'class') node.className = String(value);
else if (key === 'text') node.textContent = String(value);
else if (key === 'html') node.innerHTML = String(value);
else if (key in node && !key.startsWith('aria') && !key.startsWith('data'))
(node as unknown as Record<string, unknown>)[key] = value;
else node.setAttribute(key, String(value));
}
for (const child of children) node.append(child);
return node;
}
export interface Field<T> {
root: HTMLElement;
set(value: T): void;
}
export interface SliderSpec {
id: string;
label: string;
min: number;
max: number;
step: number;
value: number;
hint?: string;
format: (value: number) => string;
onInput: (value: number) => void;
}
/**
* A labelled slider with a live value readout. Native `input[type=range]` keeps
* keyboard support, screen-reader semantics and touch behaviour for free.
*/
export function slider(spec: SliderSpec): Field<number> {
// <output> is an implicit aria-live=polite region. Left on, every slider
// readout would be announced continuously as the simulation runs, which makes
// the app unusable with a screen reader. The value stays reachable through
// the slider's own accessible value.
const value = el('output', {
class: 'field-value',
for: spec.id,
text: spec.format(spec.value),
'aria-live': 'off',
});
const input = el('input', {
id: spec.id,
type: 'range',
min: spec.min,
max: spec.max,
step: spec.step,
value: spec.value,
});
const head = el('div', { class: 'field-head' }, [
el('label', { for: spec.id, text: spec.label }),
value,
]);
const children: Node[] = [head, input];
if (spec.hint) {
const hint = el('p', { class: 'field-hint', id: `${spec.id}-hint`, text: spec.hint });
input.setAttribute('aria-describedby', hint.id);
children.push(hint);
}
input.addEventListener('input', () => {
const next = Number(input.value);
value.textContent = spec.format(next);
spec.onInput(next);
});
// Double-clicking the label restores the value the app shipped with.
head.addEventListener('dblclick', () => {
input.value = String(spec.value);
input.dispatchEvent(new Event('input'));
});
return {
root: el('div', { class: 'field' }, children),
set(next: number) {
input.value = String(next);
value.textContent = spec.format(next);
},
};
}
export interface SelectSpec<T extends string> {
id: string;
label: string;
value: T;
options: Array<{ id: T; label: string; hint?: string }>;
onChange: (value: T) => void;
}
export interface SelectField<T extends string> extends Field<T> {
/** Adds an option, or relabels it if the id is already present. */
upsert(option: { id: T; label: string; hint?: string }): void;
}
/** A labelled select whose hint line tracks the chosen option. */
export function select<T extends string>(spec: SelectSpec<T>): SelectField<T> {
const options = [...spec.options];
const node = el('select', { id: spec.id });
for (const option of options) {
node.append(el('option', { value: option.id, text: option.label, selected: option.id === spec.value }));
}
const hint = el('p', { class: 'field-hint', id: `${spec.id}-hint` });
node.setAttribute('aria-describedby', hint.id);
const syncHint = (value: string) => {
hint.textContent = options.find((o) => o.id === value)?.hint ?? '';
};
syncHint(spec.value);
node.addEventListener('change', () => {
syncHint(node.value);
spec.onChange(node.value as T);
});
return {
root: el('div', { class: 'field' }, [
el('div', { class: 'field-head' }, [el('label', { for: spec.id, text: spec.label })]),
node,
hint,
]),
set(next: T) {
node.value = next;
syncHint(next);
},
upsert(option) {
const existing = options.find((o) => o.id === option.id);
if (existing) {
Object.assign(existing, option);
const el = node.querySelector<HTMLOptionElement>(`option[value="${option.id}"]`);
if (el) el.textContent = option.label;
} else {
options.push(option);
node.append(el('option', { value: option.id, text: option.label }));
}
if (node.value === option.id) syncHint(option.id);
},
};
}
export interface SegmentedSpec<T extends string> {
label: string;
value: T;
options: Array<{ id: T; label: string }>;
onChange: (value: T) => void;
}
/** A radiogroup styled as a segmented control. */
export function segmented<T extends string>(spec: SegmentedSpec<T>): Field<T> {
const group = el('div', { class: 'segmented', role: 'radiogroup', 'aria-label': spec.label });
const buttons = spec.options.map((option) => {
const button = el('button', {
type: 'button',
role: 'radio',
text: option.label,
'aria-checked': String(option.id === spec.value),
});
button.addEventListener('click', () => {
apply(option.id);
spec.onChange(option.id);
});
group.append(button);
return { id: option.id, button };
});
function apply(value: T) {
for (const entry of buttons) {
entry.button.setAttribute('aria-checked', String(entry.id === value));
}
}
return {
root: el('div', { class: 'field' }, [
el('div', { class: 'field-head' }, [el('label', { text: spec.label })]),
group,
]),
set: apply,
};
}
export interface SwitchSpec {
id: string;
label: string;
checked: boolean;
hint?: string;
onChange: (checked: boolean) => void;
}
export function toggle(spec: SwitchSpec): Field<boolean> {
const input = el('input', { id: spec.id, type: 'checkbox', checked: spec.checked });
input.addEventListener('change', () => spec.onChange(input.checked));
const children: Node[] = [
el('label', { class: 'switch', for: spec.id }, [
el('span', { text: spec.label }),
input,
]),
];
if (spec.hint) children.push(el('p', { class: 'field-hint', text: spec.hint }));
return {
root: el('div', { class: 'field' }, children),
set(next: boolean) {
input.checked = next;
},
};
}
export interface FilePickerSpec {
id: string;
label: string;
accept: string;
hint: string;
onPick: (file: File) => void;
}
export interface FilePicker {
root: HTMLElement;
/** Shows progress, the loaded track, or an error, in place of the hint. */
setStatus(text: string, tone?: 'idle' | 'busy' | 'error'): void;
}
/**
* A file input styled as a button. The native input stays in the DOM and keeps
* its label association, so it remains keyboard-operable and announced; the
* button simply forwards its click.
*/
export function filePicker(spec: FilePickerSpec): FilePicker {
const input = el('input', {
id: spec.id,
type: 'file',
accept: spec.accept,
class: 'visually-hidden',
});
const button = el('button', { class: 'btn file-btn', type: 'button' }, [
el('span', { text: '⤒' }),
el('span', { text: spec.label }),
]);
const status = el('p', { class: 'field-hint', id: `${spec.id}-status`, text: spec.hint });
input.setAttribute('aria-describedby', status.id);
button.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files?.[0];
if (file) spec.onPick(file);
// Clear, so picking the same file twice still fires a change event.
input.value = '';
});
return {
root: el('div', { class: 'field' }, [
el('label', { for: spec.id, class: 'visually-hidden', text: spec.label }),
input,
button,
status,
]),
setStatus(text, tone = 'idle') {
status.textContent = text;
status.dataset.tone = tone;
},
};
}
/** A collapsible panel section. */
export function section(title: string, open: boolean, fields: HTMLElement[]): HTMLElement {
const details = el('details', { class: 'section', open });
details.append(el('summary', { text: title }));
details.append(el('div', { class: 'section-body' }, fields));
return details;
}
+56
View File
@@ -0,0 +1,56 @@
/** U+2212. A hyphen is not a minus sign, and it reads short next to digits. */
const MINUS = '';
/** U+2009, so "12.4 m" cannot wrap between the number and its unit. */
const THIN = ' ';
const DASH = '—';
/**
* Fixed-decimal rendering with a true minus sign.
*
* The sign is decided from the *rendered* text rather than the raw value, so a
* Doppler ratio a hair under 1 reads "0.0", never "0.0".
*/
function fixed(value: number, decimals: number): string {
const text = Math.abs(value).toFixed(decimals);
return value < 0 && Number(text) !== 0 ? MINUS + text : text;
}
/** Wraps a formatter so any non-finite input renders as a bare em dash. */
function guard(format: (value: number) => string): (value: number) => string {
return (value: number) => (Number.isFinite(value) ? format(value) : DASH);
}
export const fmt = {
metres: guard((v) => `${fixed(v, 2)}${THIN}m`),
db: guard((v) => (v <= -119 ? `${MINUS}${THIN}dB` : `${fixed(v, 1)}${THIN}dB`)),
degrees: guard((v) => `${fixed(v, 1)}°`),
ratio: guard((v) => `×${v.toFixed(4)}`),
cents: guard((v) => {
const text = Math.abs(v).toFixed(1);
if (Number(text) === 0) return `0.0${THIN}¢`;
return `${v > 0 ? '+' : MINUS}${text}${THIN}¢`;
}),
seconds: guard((v) => `${fixed(v, 2)}${THIN}s`),
ms: guard((v) => `${fixed(v, 1)}${THIN}ms`),
percent: guard((v) => `${Math.round(v * 100)}${THIN}%`),
speed: guard((v) => `${fixed(v, 1)}${THIN}m/s`),
/** Three significant figures, switching to a k suffix past 1 kHz. */
hz: guard((v) => {
if (v >= 1000) {
const k = v / 1000;
return `${k >= 10 ? k.toFixed(1) : k.toFixed(2)}${THIN}kHz`;
}
return `${Math.round(v)}${THIN}Hz`;
}),
volume: guard((v) => `${Math.round(v * 100)}${THIN}%`),
};
+222
View File
@@ -0,0 +1,222 @@
import { clamp } from '../physics';
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Keeps the backing store matched to the CSS box so nothing renders blurry. */
class HiDpiCanvas {
readonly ctx: CanvasRenderingContext2D;
width = 0;
height = 0;
constructor(readonly canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvas context unavailable');
this.ctx = ctx;
}
/** Returns false when the canvas has no area worth drawing into. */
sync(): boolean {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.round(this.canvas.clientWidth * dpr);
const height = Math.round(this.canvas.clientHeight * dpr);
if (width === 0 || height === 0) return false;
if (width !== this.canvas.width || height !== this.canvas.height) {
this.canvas.width = width;
this.canvas.height = height;
}
this.width = width;
this.height = height;
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
return true;
}
emptyState(message: string): void {
const { ctx } = this;
ctx.clearRect(0, 0, this.width, this.height);
ctx.fillStyle = cssVar('--ink-3', '#8e95a3');
ctx.font = `${Math.round(this.height * 0.11)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(message, this.width / 2, this.height / 2);
}
}
const OCTAVE_LABELS = [31.25, 125, 500, 2000, 8000];
/**
* Log-frequency spectrum with a decaying peak-hold trace.
*
* A linear FFT axis wastes nine tenths of its width on the top three octaves,
* where almost nothing interesting happens; mapping to log frequency puts the
* bass and mids where the eye can actually read them.
*/
export class Spectrum {
private readonly surface: HiDpiCanvas;
private peaks: Float32Array | null = null;
constructor(canvas: HTMLCanvasElement, private readonly sampleRate: () => number) {
this.surface = new HiDpiCanvas(canvas);
}
draw(data: Uint8Array, running: boolean): void {
if (!this.surface.sync()) return;
if (!running) {
this.peaks = null;
this.surface.emptyState('Audio suspended — press Play');
return;
}
const { ctx, width, height } = this.surface;
ctx.clearRect(0, 0, width, height);
const nyquist = this.sampleRate() / 2;
const minHz = 20;
const maxHz = Math.min(20000, nyquist);
const logMin = Math.log10(minHz);
const logSpan = Math.log10(maxHz) - logMin;
const columns = Math.max(24, Math.floor(width / 3));
if (!this.peaks || this.peaks.length !== columns) this.peaks = new Float32Array(columns);
const peaks = this.peaks;
this.drawGrid(ctx, width, height, logMin, logSpan);
const gradient = ctx.createLinearGradient(0, height, 0, 0);
gradient.addColorStop(0, cssVar('--accent', '#6c8cff'));
gradient.addColorStop(1, cssVar('--accent-hi', '#8ca4ff'));
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i < columns; i++) {
// Each column covers one slice of the log axis; take the loudest bin in it.
const fromHz = Math.pow(10, logMin + (i / columns) * logSpan);
const toHz = Math.pow(10, logMin + ((i + 1) / columns) * logSpan);
const fromBin = Math.floor((fromHz / nyquist) * data.length);
const toBin = Math.max(fromBin + 1, Math.ceil((toHz / nyquist) * data.length));
let peak = 0;
for (let bin = fromBin; bin < toBin && bin < data.length; bin++) {
if (data[bin] > peak) peak = data[bin];
}
const level = peak / 255;
peaks[i] = Math.max(level, peaks[i] - 0.012);
const x = (i / columns) * width;
ctx.lineTo(x, height - level * height);
}
ctx.lineTo(width, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
for (let i = 0; i < columns; i++) {
const x = (i / columns) * width;
const y = height - peaks[i] * height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = cssVar('--ink-2', '#b3b8c4');
ctx.lineWidth = Math.max(1, height / 110);
ctx.stroke();
}
private drawGrid(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
logMin: number,
logSpan: number
): void {
ctx.strokeStyle = cssVar('--line', '#2a2f3a');
ctx.lineWidth = 1;
ctx.fillStyle = cssVar('--ink-3', '#8e95a3');
ctx.font = `${Math.round(height * 0.11)}px ui-sans-serif, system-ui, sans-serif`;
// Labels sit along the top: the spectrum grows from the bottom, so anything
// printed down there ends up buried under the loudest part of the signal.
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
for (const hz of OCTAVE_LABELS) {
const x = ((Math.log10(hz) - logMin) / logSpan) * width;
if (x < 0 || x > width) continue;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.fillText(hz >= 1000 ? `${hz / 1000}k` : String(Math.round(hz)), x + 3, 2);
}
// getByteFrequencyData spans minDecibels..maxDecibels, i.e. 100..10 dB.
for (const fraction of [0.25, 0.5, 0.75]) {
const y = height * fraction;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
}
/** Oscilloscope with rising-edge triggering, so the trace stands still. */
export class Scope {
private readonly surface: HiDpiCanvas;
constructor(canvas: HTMLCanvasElement) {
this.surface = new HiDpiCanvas(canvas);
}
draw(data: Uint8Array, running: boolean): void {
if (!this.surface.sync()) return;
if (!running) {
this.surface.emptyState('No signal');
return;
}
const { ctx, width, height } = this.surface;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = cssVar('--line', '#2a2f3a');
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const y = (height / 4) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Find the first upward zero crossing so successive frames line up.
const span = Math.floor(data.length / 2);
let start = 0;
for (let i = 1; i < span; i++) {
if (data[i - 1] < 128 && data[i] >= 128) {
start = i;
break;
}
}
ctx.beginPath();
for (let i = 0; i < span; i++) {
const sample = (data[start + i] - 128) / 128;
const x = (i / span) * width;
const y = height / 2 - sample * (height / 2) * 0.92;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = cssVar('--ok', '#3fcf8e');
ctx.lineWidth = Math.max(1.2, height / 90);
ctx.lineJoin = 'round';
ctx.stroke();
}
}
/** Maps a dB reading onto a 0..1 meter fill over a fixed 60..0 dB window. */
export function dbToFill(db: number, floor = -60): number {
return clamp((db - floor) / -floor, 0, 1);
}
+190
View File
@@ -0,0 +1,190 @@
import { el } from './controls';
const SEEN_KEY = 'resonance.seen';
export const KEY_HELP: Array<[string, string]> = [
['W A S D', 'walk (you are the microphone)'],
['Q / E', 'move down / up'],
['↑ ↓ ← →', 'move the source'],
['Page ↑ / ↓', 'source height'],
['Shift', 'move 3× faster'],
['drag', 'orbit — swings the sound around your head'],
['G / R', 'gizmo: move / turn the source'],
['double-click', 'place the source on the floor'],
['K', 'play / pause'],
['H', 'hide the panels'],
['?', 'this list'],
];
export interface DemoSpec {
id: string;
label: string;
caption: string;
run: () => void;
}
function keyTable(): HTMLElement {
const grid = el('div', { class: 'key-table' });
for (const [keys, description] of KEY_HELP) {
grid.append(el('kbd', { text: keys }), el('span', { text: description }));
}
return grid;
}
export interface OnboardingHandlers {
onEnableAudio: () => Promise<void>;
onExploreMuted: () => void;
}
/** First-run explainer, the shortcut sheet, and transient captions. */
export class Onboarding {
private readonly welcome: HTMLDialogElement;
private readonly help: HTMLDialogElement;
private readonly startButton: HTMLButtonElement;
private readonly startError = el('p', { class: 'field-hint', role: 'alert' });
private toastTimer = 0;
constructor(
private readonly demos: DemoSpec[],
private readonly handlers: OnboardingHandlers
) {
this.startButton = el('button', {
class: 'btn btn-primary btn-lg',
type: 'button',
text: '▶ Enable audio & start',
autofocus: true,
});
const muted = el('button', { class: 'btn', type: 'button', text: 'Explore muted' });
const demoRow = el('div', { class: 'demo-row' });
demos.forEach((demo, index) => {
const button = el('button', {
class: 'btn',
type: 'button',
text: `${index + 1} ${demo.label}`,
});
button.addEventListener('click', async () => {
await this.enableAudio();
demo.run();
this.toast(demo.caption);
});
demoRow.append(button);
});
this.welcome = el('dialog', { id: 'welcome' }, [
el('div', { class: 'dialog-body' }, [
el('h2', { text: 'Hear a room.' }),
el('p', {
text:
'Move a loudspeaker through a 3D space and hear exactly what distance, direction, ' +
'obstacles and the room itself do to the sound. Your camera is the microphone.',
}),
el('div', { class: 'dialog-note' }, [
el('span', { text: '🎧' }),
el('span', {
text:
'Headphones strongly recommended — the binaural simulation collapses on laptop speakers.',
}),
]),
el('div', { class: 'dialog-actions' }, [this.startButton, muted]),
el('p', { class: 'field-hint', text: 'Browsers need one click before sound can play.' }),
this.startError,
el('p', { class: 'field-hint', text: 'Or start with a demo:' }),
demoRow,
keyTable(),
]),
]) as HTMLDialogElement;
this.help = el('dialog', { id: 'help' }, [
el('div', { class: 'dialog-body' }, [
el('h2', { text: 'Controls' }),
keyTable(),
el('div', { class: 'dialog-actions' }, [
el('button', { class: 'btn btn-primary', type: 'button', text: 'Close', value: 'close' }),
]),
]),
]) as HTMLDialogElement;
this.startButton.addEventListener('click', () => this.enableAudio());
muted.addEventListener('click', () => {
handlers.onExploreMuted();
this.dismiss();
});
this.help.querySelector('button')?.addEventListener('click', () => this.help.close());
// Esc on the welcome card means "explore muted" rather than a dead end.
this.welcome.addEventListener('cancel', () => handlers.onExploreMuted());
this.welcome.addEventListener('close', () => markSeen());
document.body.append(this.welcome, this.help);
}
/** Shows the card on a first visit, or a quiet hint on a return visit. */
open(): void {
if (hasSeen()) {
this.toast('Press ? for controls');
return;
}
this.welcome.showModal();
}
toggleHelp(): void {
if (this.help.open) this.help.close();
else this.help.showModal();
}
get isOpen(): boolean {
return this.welcome.open || this.help.open;
}
runDemo(index: number): void {
const demo = this.demos[index];
if (!demo) return;
demo.run();
this.toast(demo.caption);
}
toast(message: string): void {
document.querySelector('.toast')?.remove();
window.clearTimeout(this.toastTimer);
const node = el('div', { class: 'toast', role: 'status', text: message });
document.body.append(node);
this.toastTimer = window.setTimeout(() => node.remove(), 5200);
}
private async enableAudio(): Promise<void> {
this.startButton.disabled = true;
this.startButton.textContent = 'Starting…';
try {
await this.handlers.onEnableAudio();
this.dismiss();
} catch (error) {
// Report inline: a console error is invisible to the person who is stuck.
this.startError.textContent = `Your browser blocked Web Audio — the 3D view still works. (${
error instanceof Error ? error.message : String(error)
})`;
this.startButton.disabled = false;
this.startButton.textContent = 'Retry';
}
}
private dismiss(): void {
if (this.welcome.open) this.welcome.close();
}
}
function hasSeen(): boolean {
try {
return localStorage.getItem(SEEN_KEY) === '1';
} catch {
return false;
}
}
function markSeen(): void {
try {
localStorage.setItem(SEEN_KEY, '1');
} catch {
/* private browsing */
}
}
+299
View File
@@ -0,0 +1,299 @@
import { DistanceModel } from '../physics';
import {
EngineSettings,
EngineSettingsPatch,
PRESETS,
SURFACES,
SourceId,
SurfaceId,
Telemetry,
} from '../audio';
import { MOTION_MODES, MotionMode } from '../scene';
import { el, filePicker, section, segmented, select, slider, toggle } from './controls';
import { fmt } from './format';
export interface PanelHandlers {
onEngine(patch: EngineSettingsPatch): void;
onFile(file: File): void;
onMotion(mode: MotionMode, speed: number): void;
onYaw(degrees: number): void;
onAnnotations(visible: boolean): void;
}
/**
* The parameter panel. Every control writes straight through to the engine, and
* every section carries a plain-language hint, because a slider labelled
* "rolloff factor" teaches nobody anything on its own.
*/
export class Panel {
readonly root: HTMLElement;
private readonly yaw: ReturnType<typeof slider>;
private readonly motionSpeed: ReturnType<typeof slider>;
private readonly motion: ReturnType<typeof segmented<MotionMode>>;
private readonly preset: ReturnType<typeof select<SourceId>>;
readonly file: ReturnType<typeof filePicker>;
private readonly surface: ReturnType<typeof select<SurfaceId>>;
private readonly model: ReturnType<typeof segmented<DistanceModel>>;
private readonly dimensions: Record<'width' | 'height' | 'depth', ReturnType<typeof slider>>;
private readonly roomSummary = el('p', { class: 'field-hint' });
private motionMode: MotionMode = 'static';
private speed = 8;
constructor(settings: EngineSettings, h: PanelHandlers) {
this.preset = select<SourceId>({
id: 'preset',
label: 'Sound',
value: settings.preset,
options: PRESETS.map((p) => ({ id: p.id, label: p.label, hint: p.hint })),
onChange: (value) => h.onEngine({ preset: value }),
});
this.file = filePicker({
id: 'audio-file',
label: 'Load a track from your computer',
accept: 'audio/*,.mp3,.wav,.flac,.ogg,.m4a,.aac,.opus',
hint: 'Or drop a file onto the 3D view. MP3, WAV, FLAC, OGG, M4A. Summed to mono, because a loudspeaker at one point in a room radiates one signal.',
onPick: (file) => h.onFile(file),
});
this.motion = segmented<MotionMode>({
label: 'Motion',
value: 'static',
options: MOTION_MODES.map((m) => ({ id: m.id, label: m.label })),
onChange: (value) => {
this.motionMode = value;
h.onMotion(value, this.speed);
},
});
this.motionSpeed = slider({
id: 'motion-speed',
label: 'Motion speed',
min: 1,
max: 45,
step: 0.5,
value: this.speed,
format: fmt.speed,
hint: 'Doppler needs movement. Above roughly 10 m/s the pitch shift becomes obvious.',
onInput: (value) => {
this.speed = value;
h.onMotion(this.motionMode, value);
},
});
this.yaw = slider({
id: 'source-yaw',
label: 'Source aim',
min: -180,
max: 180,
step: 1,
value: 0,
format: fmt.degrees,
onInput: (value) => h.onYaw(value),
});
this.surface = select<SurfaceId>({
id: 'surface',
label: 'Surfaces',
value: settings.surface,
options: SURFACES.map((s) => ({ id: s.id, label: s.label, hint: s.hint })),
onChange: (value) => h.onEngine({ surface: value }),
});
const dimension = (
id: string,
label: string,
key: 'width' | 'height' | 'depth',
value: number
) =>
slider({
id,
label,
min: 4,
max: 60,
step: 1,
value,
format: fmt.metres,
onInput: (next) => h.onEngine({ room: { [key]: next } }),
});
this.dimensions = {
width: dimension('room-w', 'Width', 'width', settings.room.width),
height: dimension('room-h', 'Height', 'height', settings.room.height),
depth: dimension('room-d', 'Depth', 'depth', settings.room.depth),
};
this.model = segmented<DistanceModel>({
label: 'Falloff law',
value: settings.distanceModel,
options: [
{ id: 'inverse', label: 'Inverse' },
{ id: 'exponential', label: 'Exponential' },
{ id: 'linear', label: 'Linear' },
],
onChange: (value) => h.onEngine({ distanceModel: value }),
});
this.root = el('div', {}, [
el('h1', { class: 'visually-hidden', text: 'Simulation parameters' }),
section('Source', true, [
this.preset.root,
this.file.root,
this.motion.root,
this.motionSpeed.root,
this.yaw.root,
]),
section('Room', true, [
this.surface.root,
this.dimensions.width.root,
this.dimensions.height.root,
this.dimensions.depth.root,
this.roomSummary,
]),
section('Distance', false, [
this.model.root,
slider({
id: 'rolloff',
label: 'Rolloff',
min: 0,
max: 5,
step: 0.1,
value: settings.rolloffFactor,
format: (v) => `×${v.toFixed(1)}`,
hint: '1.0 is the physical inverse-distance law: every doubling of distance costs 6 dB.',
onInput: (value) => h.onEngine({ rolloffFactor: value }),
}).root,
slider({
id: 'ref-distance',
label: 'Reference distance',
min: 0.25,
max: 10,
step: 0.25,
value: settings.refDistance,
format: fmt.metres,
hint: 'The distance at which the source plays at full level.',
onInput: (value) => h.onEngine({ refDistance: value }),
}).root,
]),
section('Directivity', false, [
slider({
id: 'cone-inner',
label: 'Inner angle',
min: 0,
max: 360,
step: 1,
value: settings.coneInnerAngle,
format: fmt.degrees,
hint: 'Inside this cone the source is at full level. 360° is omnidirectional.',
onInput: (value) => h.onEngine({ coneInnerAngle: value }),
}).root,
slider({
id: 'cone-outer',
label: 'Outer angle',
min: 0,
max: 360,
step: 1,
value: settings.coneOuterAngle,
format: fmt.degrees,
onInput: (value) => h.onEngine({ coneOuterAngle: value }),
}).root,
slider({
id: 'cone-gain',
label: 'Behind-the-speaker level',
min: 0,
max: 1,
step: 0.01,
value: settings.coneOuterGain,
format: (v) => fmt.db(20 * Math.log10(Math.max(v, 1e-4))),
onInput: (value) => h.onEngine({ coneOuterGain: value }),
}).root,
]),
section('Advanced', false, [
toggle({
id: 'propagation',
label: 'Propagation delay',
checked: settings.propagationEnabled,
hint: 'Sound takes time to arrive. Chasing that delay is what produces Doppler.',
onChange: (checked) => h.onEngine({ propagationEnabled: checked }),
}).root,
slider({
id: 'speed-of-sound',
label: 'Speed of sound',
min: 80,
max: 900,
step: 1,
value: settings.speedOfSound,
format: (v) => `${Math.round(v)} m/s`,
hint: '343 m/s is dry air at 20 °C. Lower it to exaggerate Doppler.',
onInput: (value) => h.onEngine({ speedOfSound: value }),
}).root,
slider({
id: 'air',
label: 'Air absorption',
min: 0,
max: 8,
step: 0.1,
value: settings.airAbsorption,
format: (v) => (v === 0 ? 'off' : `×${v.toFixed(1)}`),
hint: 'Air swallows treble over distance. 1.0 is realistic — barely audible indoors.',
onInput: (value) => h.onEngine({ airAbsorption: value }),
}).root,
toggle({
id: 'annotations',
label: 'Show acoustic overlays',
checked: true,
hint: 'The ray to your ear and the critical-distance ring on the floor.',
onChange: (checked) => h.onAnnotations(checked),
}).root,
]),
]);
}
/** Registers a loaded track in the Sound dropdown and selects it. */
setUserTrack(name: string, duration: string): void {
this.preset.upsert({
id: 'file',
label: `${name}`,
hint: `Your file · ${duration}. It loops, and Doppler pitches it as the source moves.`,
});
this.preset.set('file');
}
/**
* Pushes engine state back into the controls.
*
* Anything that changes settings without going through a control — the demos,
* a reset — must call this. Otherwise the panel keeps displaying the old
* value, and the next touch of that control writes the stale value back,
* silently undoing the change the user just asked for.
*/
sync(settings: EngineSettings, motion: { mode: MotionMode; speed: number }): void {
this.preset.set(settings.preset);
this.surface.set(settings.surface);
this.model.set(settings.distanceModel);
this.dimensions.width.set(settings.room.width);
this.dimensions.height.set(settings.room.height);
this.dimensions.depth.set(settings.room.depth);
this.motionMode = motion.mode;
this.speed = motion.speed;
this.motion.set(motion.mode);
this.motionSpeed.set(motion.speed);
}
/** Reflects derived room acoustics and gizmo-driven rotation back into the UI. */
update(t: Telemetry, sourceYaw: number): void {
const summary = `Reverberation ${fmt.seconds(t.t60)} · critical distance ${fmt.metres(
t.criticalDistance
)}. Past that, the room is louder than the source.`;
if (this.roomSummary.textContent !== summary) this.roomSummary.textContent = summary;
this.yaw.set(Math.round(sourceYaw));
}
}
+89
View File
@@ -0,0 +1,89 @@
import { Telemetry } from '../audio';
import { el } from './controls';
import { fmt } from './format';
import { dbToFill } from './meters';
/**
* The one floating element over the 3D view: what the listener is hearing right
* now, placed where the eye already is rather than across the screen in a panel.
*/
export class Hud {
readonly root: HTMLElement;
private readonly distance = el('span', { class: 'hud-value' });
private readonly level = el('span', { class: 'hud-value' });
private readonly flag = el('p', { class: 'hud-flag' });
constructor() {
this.root = el('div', { class: 'hud', role: 'group', 'aria-label': 'At your ear' }, [
el('div', { class: 'hud-row' }, [this.distance, this.level]),
el('div', { class: 'hud-row' }, [
el('span', { class: 'hud-label', text: 'distance' }),
el('span', { class: 'hud-label', text: 'at your ear' }),
]),
this.flag,
]);
}
update(t: Telemetry): void {
this.distance.textContent = fmt.metres(t.distance);
this.level.textContent = fmt.db(t.directGainDb);
const blocked = t.occlusion > 0.15;
this.flag.dataset.dominant = t.reverbDominant ? 'room' : 'direct';
this.flag.dataset.blocked = String(blocked);
this.flag.textContent = blocked
? `Line of sight ${fmt.percent(t.occlusion)} blocked`
: t.reverbDominant
? `Room-dominant (rc ${fmt.metres(t.criticalDistance)})`
: `Direct-dominant (rc ${fmt.metres(t.criticalDistance)})`;
}
}
/** Output metering: per-ear levels, Doppler, time of flight, clip indicator. */
export class OutputModule {
readonly root: HTMLElement;
private readonly leftFill = el('i');
private readonly rightFill = el('i');
private readonly leftDb = el('span', { class: 'db' });
private readonly rightDb = el('span', { class: 'db' });
private readonly rms = el('b');
private readonly ratio = el('b');
private readonly cents = el('b');
private readonly speed = el('b');
private readonly clip = el('span', { class: 'clip-led', text: 'CLIP' });
constructor() {
this.root = el('section', { class: 'module', id: 'module-output' }, [
el('h2', { class: 'module-title' }, [
document.createTextNode('Output'),
this.clip,
]),
el('div', { class: 'readout' }, [el('span', { text: 'rms' }), this.rms]),
el('div', { class: 'ears' }, [
el('span', { text: 'L' }),
el('div', { class: 'meter' }, [this.leftFill]),
this.leftDb,
el('span', { text: 'R' }),
el('div', { class: 'meter' }, [this.rightFill]),
this.rightDb,
]),
el('div', { class: 'readout' }, [el('span', { text: 'doppler' }), this.ratio]),
el('div', { class: 'readout' }, [el('span', { text: 'shift' }), this.cents]),
el('div', { class: 'readout' }, [el('span', { text: 'closing' }), this.speed]),
]);
}
update(t: Telemetry): void {
this.rms.textContent = fmt.db(t.outputLevelDb);
this.leftDb.textContent = fmt.db(t.leftLevelDb);
this.rightDb.textContent = fmt.db(t.rightLevelDb);
this.leftFill.style.width = `${(dbToFill(t.leftLevelDb) * 100).toFixed(1)}%`;
this.rightFill.style.width = `${(dbToFill(t.rightLevelDb) * 100).toFixed(1)}%`;
this.ratio.textContent = fmt.ratio(t.dopplerRatio);
this.cents.textContent = fmt.cents(t.dopplerCents);
this.speed.textContent = fmt.speed(t.closingSpeed);
this.clip.dataset.on = String(t.clipping);
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Telemetry } from '../audio';
import { el } from './controls';
import { fmt } from './format';
import { dbToFill } from './meters';
interface Row {
tr: HTMLTableRowElement;
detail: HTMLTableCellElement;
db: HTMLTableCellElement;
fill: HTMLElement | null;
}
function row(label: string, segment: string | null, isTotal = false): Row {
const detail = el('td', { class: 'detail' });
const db = el('td', { class: 'db' });
const cells: HTMLElement[] = [el('th', { scope: 'row', text: label }), detail, db];
let fill: HTMLElement | null = null;
if (segment) {
fill = el('i', { 'data-seg': segment });
cells.push(el('td', { class: 'bar' }, [el('div', { class: 'meter' }, [fill])]));
} else {
cells.push(el('td', { class: 'bar' }));
}
const tr = el('tr', { class: isTotal ? 'total' : '' }, cells) as HTMLTableRowElement;
return { tr, detail, db, fill };
}
/**
* The gain budget: every stage between the source and the ear, with its loss in
* dB on one shared 60…0 dB scale so the bars are directly comparable.
*
* This is the answer to "why does it sound like that". It is a real DOM table
* rather than a canvas so the most important telemetry is not trapped in pixels
* where a screen reader cannot reach it.
*/
export class SignalPath {
readonly root: HTMLElement;
private readonly source = row('Source', 'survive');
private readonly distance = row('Distance', 'distance');
private readonly directivity = row('Directivity', 'directivity');
private readonly occlusion = row('Occlusion', 'occlusion');
private readonly air = row('Air', null);
private readonly direct = row('At your ear', 'survive', true);
private readonly room = row('Room (wet)', 'room');
private readonly ratio = row('Direct / room', null);
private cache = new Map<HTMLElement, string>();
constructor(private readonly presetLabel: () => string, private readonly volume: () => number) {
const body = el('tbody', {}, [
this.source.tr,
this.distance.tr,
this.directivity.tr,
this.occlusion.tr,
this.air.tr,
this.direct.tr,
this.room.tr,
this.ratio.tr,
]);
this.root = el('table', { class: 'path' }, [
el('caption', { class: 'visually-hidden', text: 'Signal path: gain at each stage' }),
body,
]);
}
update(t: Telemetry): void {
this.text(this.source.detail, this.presetLabel());
this.text(this.source.db, fmt.db(20 * Math.log10(Math.max(this.volume(), 1e-4))));
this.bar(this.source.fill, 1);
this.text(this.distance.detail, fmt.metres(t.distance));
this.text(this.distance.db, fmt.db(t.distanceGainDb));
this.bar(this.distance.fill, dbToFill(t.distanceGainDb));
this.text(this.directivity.detail, `${fmt.degrees(t.offAxisAngle)} off-axis`);
this.text(this.directivity.db, fmt.db(t.coneGainDb));
this.bar(this.directivity.fill, dbToFill(t.coneGainDb));
const blocked = t.occlusion > 0.01;
this.text(
this.occlusion.detail,
blocked ? `${fmt.percent(t.occlusion)} blocked · LP ${fmt.hz(t.occlusionCutoff)}` : 'clear'
);
this.text(this.occlusion.db, fmt.db(t.occlusionGainDb));
this.bar(this.occlusion.fill, dbToFill(t.occlusionGainDb));
this.occlusion.tr.dataset.flag = blocked ? 'warn' : '';
// Air absorption only shapes tone; it has no broadband dB to report, so
// printing one would break the guarantee that the stages sum to the total.
this.text(this.air.detail, `tone only · LP ${fmt.hz(t.airCutoff)}`);
this.text(this.air.db, '—');
this.text(this.direct.detail, `${fmt.ms(t.timeOfFlightMs)} flight`);
this.text(this.direct.db, fmt.db(t.directGainDb));
this.bar(this.direct.fill, dbToFill(t.directGainDb));
this.text(this.room.detail, `t60 ${fmt.seconds(t.t60)}`);
this.text(this.room.db, fmt.db(t.reverbGainDb));
this.bar(this.room.fill, dbToFill(t.reverbGainDb));
this.text(
this.ratio.detail,
t.reverbDominant ? 'room-dominant' : `direct-dominant · rc ${fmt.metres(t.criticalDistance)}`
);
this.text(this.ratio.db, fmt.db(t.directToReverbDb));
this.ratio.tr.dataset.flag = t.reverbDominant ? 'warn' : '';
}
/** Writes only when the rendered string actually changed. */
private text(node: HTMLElement, value: string): void {
if (this.cache.get(node) === value) return;
this.cache.set(node, value);
node.textContent = value;
}
private bar(node: HTMLElement | null, fraction: number): void {
if (!node) return;
node.style.width = `${(fraction * 100).toFixed(1)}%`;
}
}
+440
View File
@@ -0,0 +1,440 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AudioEngine, DEFAULT_SETTINGS, SpatialInput } from '../src/audio/AudioEngine';
import { MockAudioContext, MockParam, installMockAudio } from './mockAudioContext';
let audio: ReturnType<typeof installMockAudio>;
beforeEach(() => {
audio = installMockAudio();
});
afterEach(() => {
audio.restore();
});
const ctxOf = () => audio.instances[0] as MockAudioContext;
function poseAt(x: number, z = 0, extra: Partial<SpatialInput> = {}): SpatialInput {
return {
sourcePos: { x: 0, y: 1.5, z: 0 },
sourceForward: { x: 0, y: 0, z: -1 },
listenerPos: { x, y: 1.5, z },
listenerForward: { x: 0, y: 0, z: -1 },
listenerUp: { x: 0, y: 1, z: 0 },
occlusion: 0,
...extra,
};
}
/** Settles the delay-line smoothing so telemetry reflects a steady state. */
function settle(engine: AudioEngine, pose: SpatialInput, frames = 400) {
let telemetry = engine.updateSpatial(pose, 1 / 60);
for (let i = 0; i < frames; i++) telemetry = engine.updateSpatial(pose, 1 / 60);
return telemetry;
}
describe('graph topology', () => {
it('wires the documented signal path', async () => {
const engine = new AudioEngine();
await engine.start();
const edges = ctxOf().edges();
expect(edges).toContain('gain->delay'); // sourceGain -> delay
expect(edges).toContain('delay->biquad'); // -> air absorption
expect(edges).toContain('biquad->biquad'); // air -> occlusion
expect(edges).toContain('biquad->gain'); // occlusion -> directGain
expect(edges).toContain('gain->panner');
expect(edges).toContain('gain->convolver'); // reverbSend -> convolver
expect(edges).toContain('convolver->gain'); // wet -> master
expect(edges).toContain('gain->compressor'); // master -> limiter
expect(edges).toContain('compressor->analyser');
expect(edges).toContain('analyser->destination');
});
it('taps the reverb send before distance, cone and occlusion', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
// The wet send must hang off the delay, not off the direct gain: a room's
// reverberant field does not get quieter because you walked away.
const feedsConvolver = ctx.connections.find((c) => c.to.kind === 'convolver');
const sendSource = ctx.connections.find((c) => c.to === feedsConvolver?.from);
expect(sendSource?.from.kind).toBe('delay');
});
it('neuters the panner so only our own physics sets level', async () => {
const engine = new AudioEngine();
await engine.start();
const panner = ctxOf().nodeOf<never>('panner') as unknown as Record<string, unknown>;
expect(panner.panningModel).toBe('HRTF');
expect(panner.rolloffFactor).toBe(0);
expect(panner.coneOuterGain).toBe(1);
});
});
describe('listener orientation', () => {
it('publishes forward and up, not just position', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial(
{
...poseAt(5),
listenerForward: { x: 1, y: 0, z: 0 },
listenerUp: { x: 0, y: 1, z: 0 },
},
1 / 60
);
expect(listener.forwardX.value).toBeCloseTo(1, 6);
expect(listener.forwardZ.value).toBeCloseTo(0, 6);
expect(listener.upY.value).toBeCloseTo(1, 6);
expect(listener.forwardX.events.length).toBeGreaterThan(0);
});
it('tracks a turning head', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -1 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: 1 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(1, 6);
});
it('normalises whatever it is handed', async () => {
const engine = new AudioEngine();
await engine.start();
const listener = ctxOf().listener;
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -9 } }, 1 / 60);
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
});
it('keeps the panner on the source', async () => {
const engine = new AudioEngine();
await engine.start();
const panner = ctxOf().nodeOf('panner') as unknown as Record<string, MockParam>;
engine.updateSpatial(
{ ...poseAt(5), sourcePos: { x: -3, y: 2, z: 7 } },
1 / 60
);
expect(panner.positionX.value).toBeCloseTo(-3, 6);
expect(panner.positionZ.value).toBeCloseTo(7, 6);
});
});
describe('gain staging', () => {
it('drives the direct gain from distance, cone and occlusion together', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
const directGain = ctx.feederOf('gain', 'panner');
const telemetry = engine.updateSpatial(poseAt(4), 1 / 60);
const expected = Math.pow(10, telemetry.directGainDb / 20);
expect(directGain.gain.value).toBeCloseTo(expected, 5);
});
it('keeps the reverb send level independent of listener distance', async () => {
const engine = new AudioEngine();
await engine.start();
const send = ctxOf().feederOf('gain', 'convolver');
engine.updateSpatial(poseAt(2), 1 / 60);
const near = send.gain.value;
engine.updateSpatial(poseAt(40), 1 / 60);
const far = send.gain.value;
expect(near).toBeGreaterThan(0);
expect(far).toBeCloseTo(near, 6);
});
it('applies master volume on its own stage, not folded into the physics', async () => {
const engine = new AudioEngine();
await engine.start();
const ctx = ctxOf();
const directGain = ctx.feederOf('gain', 'panner');
const master = ctx.feederOf('gain', 'compressor');
const before = (engine.updateSpatial(poseAt(4), 1 / 60), directGain.gain.value);
engine.setMasterVolume(0.25);
engine.updateSpatial(poseAt(4), 1 / 60);
expect(master.gain.value).toBeCloseTo(0.25, 6);
// Changing the volume must not move the acoustic gain, or the signal-path
// readout would stop describing the physics.
expect(directGain.gain.value).toBeCloseTo(before, 6);
});
it('lowers the direct level as the listener walks away', async () => {
const engine = new AudioEngine();
await engine.start();
const near = engine.updateSpatial(poseAt(2), 1 / 60).directGainDb;
const far = engine.updateSpatial(poseAt(20), 1 / 60).directGainDb;
expect(far).toBeLessThan(near - 15);
});
it('attenuates and muffles a blocked path', async () => {
const engine = new AudioEngine();
await engine.start();
const clear = engine.updateSpatial(poseAt(6, 0, { occlusion: 0 }), 1 / 60);
const blocked = engine.updateSpatial(poseAt(6, 0, { occlusion: 1 }), 1 / 60);
expect(blocked.directGainDb).toBeLessThan(clear.directGainDb);
expect(blocked.occlusionCutoff).toBeLessThan(clear.occlusionCutoff / 10);
});
it('attenuates when the source is aimed away', async () => {
const engine = new AudioEngine();
await engine.start();
const onAxis = engine.updateSpatial(
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: -1 } },
1 / 60
);
const offAxis = engine.updateSpatial(
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: 1 } },
1 / 60
);
expect(offAxis.coneGainDb).toBeLessThan(onAxis.coneGainDb - 10);
});
});
describe('propagation and doppler', () => {
it('settles the delay line on the true time of flight', async () => {
const engine = new AudioEngine();
await engine.start();
const telemetry = settle(engine, poseAt(34.3));
// 34.3 m at 343 m/s is 100 ms.
expect(telemetry.timeOfFlightMs).toBeCloseTo(100, 0);
});
it('reports no shift once the geometry is still', async () => {
const engine = new AudioEngine();
await engine.start();
const telemetry = settle(engine, poseAt(10));
expect(telemetry.dopplerRatio).toBeCloseTo(1, 3);
expect(Math.abs(telemetry.dopplerCents)).toBeLessThan(2);
});
it('raises pitch as the gap closes and lowers it as the gap opens', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(30));
// Sweep the listener inwards: the delay shortens, so pitch must rise.
let approaching = 1;
for (let d = 30; d > 10; d -= 0.5) {
approaching = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
}
expect(approaching).toBeGreaterThan(1.0005);
let receding = 1;
for (let d = 10; d < 30; d += 0.5) {
receding = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
}
expect(receding).toBeLessThan(0.9995);
});
it('stays silent about doppler when propagation is switched off', async () => {
const engine = new AudioEngine({ propagationEnabled: false });
await engine.start();
settle(engine, poseAt(30));
for (let d = 30; d > 10; d -= 0.5) engine.updateSpatial(poseAt(d), 1 / 60);
const telemetry = engine.updateSpatial(poseAt(10), 1 / 60);
expect(telemetry.timeOfFlightMs).toBeCloseTo(0, 3);
expect(telemetry.dopplerRatio).toBeCloseTo(1, 6);
});
it('reports the shift a delay line actually produces, 1 dD/dt', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(30));
// Close at a steady rate and compare the reported ratio against 1 D'
// reconstructed from the reported time of flight.
let previousFlight = engine.updateSpatial(poseAt(30), 1 / 60).timeOfFlightMs;
let checked = 0;
for (let d = 29.5; d > 12; d -= 0.5) {
const t = engine.updateSpatial(poseAt(d), 1 / 60);
const delayRate = (t.timeOfFlightMs - previousFlight) / 1000 / (1 / 60);
previousFlight = t.timeOfFlightMs;
expect(t.dopplerRatio).toBeCloseTo(1 - delayRate, 6);
checked++;
}
expect(checked).toBeGreaterThan(10);
});
it('keeps the shift on the correct side of unity even when closing hard', async () => {
// The textbook 1/(1 v/c) form has a pole that flips sign here, reporting
// a two-octave drop for a source rushing towards the listener.
const engine = new AudioEngine({ speedOfSound: 80 });
await engine.start();
settle(engine, poseAt(40));
for (let d = 39; d > 2; d -= 3) {
const t = engine.updateSpatial(poseAt(d), 1 / 60);
expect(t.dopplerRatio).toBeGreaterThanOrEqual(1);
expect(t.closingSpeed).toBeGreaterThan(0);
}
});
it('does not saturate the delay line at the slowest speed of sound', async () => {
const engine = new AudioEngine({ speedOfSound: 80, room: { width: 60, height: 60, depth: 60 } });
await engine.start();
// Room diagonal is ~104 m; at 80 m/s that is 1.3 s of flight.
const telemetry = settle(engine, poseAt(100), 2000);
expect(telemetry.timeOfFlightMs).toBeCloseTo(1250, -1);
});
it('never lets a violent jump produce an absurd pitch', async () => {
const engine = new AudioEngine();
await engine.start();
settle(engine, poseAt(60));
// Teleport the listener: a naive velocity estimate would chirp an octave.
const telemetry = engine.updateSpatial(poseAt(0.5), 1 / 60);
expect(telemetry.dopplerRatio).toBeLessThanOrEqual(4);
expect(telemetry.dopplerRatio).toBeGreaterThanOrEqual(0.25);
expect(Number.isFinite(telemetry.dopplerCents)).toBe(true);
});
});
describe('lifecycle', () => {
it('reports live telemetry before any AudioContext exists', () => {
const engine = new AudioEngine();
const telemetry = engine.updateSpatial(poseAt(8), 1 / 60);
expect(engine.context).toBeNull();
expect(telemetry.running).toBe(false);
expect(telemetry.distance).toBeCloseTo(8, 6);
expect(telemetry.distanceGainDb).toBeLessThan(0);
expect(telemetry.criticalDistance).toBeGreaterThan(0);
expect(audio.instances).toHaveLength(0);
});
it('resumes a suspended context on start', async () => {
const engine = new AudioEngine();
await engine.start();
expect(ctxOf().state).toBe('running');
expect(engine.isPlaying).toBe(true);
});
it('is idempotent across repeated start and stop', async () => {
const engine = new AudioEngine();
await engine.start();
await engine.start();
expect(ctxOf().started).toHaveLength(1);
engine.stop();
engine.stop();
expect(engine.isPlaying).toBe(false);
await engine.start();
expect(engine.isPlaying).toBe(true);
expect(ctxOf().started).toHaveLength(2);
});
it('swaps presets without tearing down the graph', async () => {
const engine = new AudioEngine();
await engine.start();
const edgeCount = ctxOf().connections.length;
engine.update({ preset: 'engine' });
engine.update({ preset: 'beacon' });
expect(engine.getSettings().preset).toBe('beacon');
// A new source node reconnects; the rest of the graph must be untouched.
expect(ctxOf().connections.length).toBeLessThanOrEqual(edgeCount + 4);
});
it('rebuilds room acoustics when the geometry changes', async () => {
const engine = new AudioEngine();
await engine.start();
const before = engine.getAcoustics();
engine.update({ room: { width: 40, height: 16, depth: 40 }, surface: 'cathedral' });
const after = engine.getAcoustics();
expect(after.t60).toBeGreaterThan(before.t60);
expect(after.criticalDistance).toBeLessThan(before.criticalDistance);
});
it('merges partial room patches instead of dropping dimensions', () => {
const engine = new AudioEngine();
engine.update({ room: { width: 30 } as never });
const room = engine.getSettings().room;
expect(room.width).toBe(30);
expect(room.height).toBe(DEFAULT_SETTINGS.room.height);
expect(room.depth).toBe(DEFAULT_SETTINGS.room.depth);
});
it('clamps master volume', () => {
const engine = new AudioEngine();
engine.setMasterVolume(5);
expect(engine.getSettings().masterVolume).toBe(1);
engine.setMasterVolume(-2);
expect(engine.getSettings().masterVolume).toBe(0);
});
it('produces finite telemetry for degenerate geometry', () => {
const engine = new AudioEngine();
const coincident = engine.updateSpatial(
{
sourcePos: { x: 1, y: 1, z: 1 },
sourceForward: { x: 0, y: 0, z: 0 },
listenerPos: { x: 1, y: 1, z: 1 },
listenerForward: { x: 0, y: 0, z: 0 },
listenerUp: { x: 0, y: 0, z: 0 },
occlusion: 0,
},
0
);
for (const value of Object.values(coincident)) {
if (typeof value === 'number') expect(Number.isFinite(value)).toBe(true);
}
});
});
describe('telemetry consistency', () => {
it('sums the stage losses into the total, exactly', async () => {
const engine = new AudioEngine();
await engine.start();
const t = engine.updateSpatial(
poseAt(0, -7, { occlusion: 0.6, sourceForward: { x: 1, y: 0, z: 0 } }),
1 / 60
);
expect(t.distanceGainDb + t.coneGainDb + t.occlusionGainDb).toBeCloseTo(t.directGainDb, 6);
});
it('derives the direct-to-reverb ratio from the two levels it reports', async () => {
const engine = new AudioEngine();
await engine.start();
const t = engine.updateSpatial(poseAt(9), 1 / 60);
expect(t.directToReverbDb).toBeCloseTo(t.directGainDb - t.reverbGainDb, 6);
});
it('flags room dominance at the critical distance when the listener is on-axis', async () => {
const engine = new AudioEngine();
await engine.start();
const rc = engine.getAcoustics().criticalDistance;
// poseAt(0, -d) puts the listener dead ahead of a source facing Z, so the
// cone contributes no attenuation and distance alone decides.
expect(engine.updateSpatial(poseAt(0, -rc * 0.5), 1 / 60).reverbDominant).toBe(false);
expect(engine.updateSpatial(poseAt(0, -rc * 2), 1 / 60).reverbDominant).toBe(true);
});
it('counts directivity towards room dominance, not just distance', async () => {
const engine = new AudioEngine();
await engine.start();
const rc = engine.getAcoustics().criticalDistance;
// Well inside the critical distance, but aimed away: the direct sound loses
// to the reverberant field even though the source is close.
const behind = engine.updateSpatial(poseAt(0, rc * 0.5), 1 / 60);
expect(behind.distance).toBeLessThan(rc);
expect(behind.reverbDominant).toBe(true);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { fmt } from '../src/ui/format';
const stripThin = (s: string) => s.replace(//g, ' ');
describe('value formatting', () => {
it('uses a real minus sign, not a hyphen', () => {
expect(fmt.db(-12.3)).toContain('');
expect(fmt.db(-12.3)).not.toContain('-');
});
it('never renders a negative zero', () => {
// A Doppler ratio a hair under 1 must read "0.0 ¢", not "0.0 ¢".
expect(stripThin(fmt.cents(-0.0001))).toBe('0.0 ¢');
expect(stripThin(fmt.cents(0))).toBe('0.0 ¢');
expect(stripThin(fmt.speed(-0.004))).toBe('0.0 m/s');
expect(stripThin(fmt.db(-0.02))).toBe('0.0 dB');
});
it('signs real values', () => {
expect(stripThin(fmt.cents(38.2))).toBe('+38.2 ¢');
expect(stripThin(fmt.cents(-38.2))).toBe('38.2 ¢');
});
it('renders silence as −∞ rather than a huge number', () => {
expect(stripThin(fmt.db(-120))).toBe('−∞ dB');
expect(stripThin(fmt.db(-500))).toBe('−∞ dB');
});
it('survives non-finite input', () => {
for (const formatter of [fmt.metres, fmt.db, fmt.degrees, fmt.seconds, fmt.ms, fmt.speed]) {
expect(formatter(Number.NaN)).toBe('—');
expect(formatter(Number.POSITIVE_INFINITY)).toBe('—');
}
expect(fmt.hz(Number.NaN)).toBe('—');
expect(fmt.cents(Number.NaN)).toBe('—');
});
it('switches Hz to kHz with three significant figures', () => {
expect(stripThin(fmt.hz(350))).toBe('350 Hz');
expect(stripThin(fmt.hz(1700))).toBe('1.70 kHz');
expect(stripThin(fmt.hz(22050))).toBe('22.1 kHz');
});
it('keeps decimals fixed so numbers do not jitter in width', () => {
expect(stripThin(fmt.metres(5))).toBe('5.00 m');
expect(stripThin(fmt.metres(12.345))).toBe('12.35 m');
expect(stripThin(fmt.ratio(1))).toBe('×1.0000');
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* A minimal Web Audio stand-in: enough of the API for AudioEngine to build its
* graph, with the connections recorded so tests can assert on the topology
* rather than on implementation details.
*/
export interface Connection {
from: MockNode;
to: MockNode;
}
let nodeCounter = 0;
export class MockParam {
value: number;
readonly events: Array<{ type: string; value: number; time: number }> = [];
constructor(value = 0) {
this.value = value;
}
setValueAtTime(value: number, time: number): this {
this.value = value;
this.events.push({ type: 'setValueAtTime', value, time });
return this;
}
setTargetAtTime(value: number, time: number): this {
this.value = value;
this.events.push({ type: 'setTargetAtTime', value, time });
return this;
}
linearRampToValueAtTime(value: number, time: number): this {
this.value = value;
this.events.push({ type: 'linearRamp', value, time });
return this;
}
cancelScheduledValues(): this {
return this;
}
}
export class MockNode {
readonly id: string;
readonly outputs: MockNode[] = [];
constructor(readonly kind: string, readonly ctx: MockAudioContext) {
this.id = `${kind}#${nodeCounter++}`;
ctx.nodes.push(this);
}
connect(target: MockNode): MockNode {
this.outputs.push(target);
this.ctx.connections.push({ from: this, to: target });
return target;
}
disconnect(): void {
this.outputs.length = 0;
}
}
class MockAudioParamNode extends MockNode {
readonly gain = new MockParam(1);
}
export class MockAudioContext {
readonly connections: Connection[] = [];
readonly nodes: MockNode[] = [];
readonly sampleRate = 48000;
currentTime = 0;
state: AudioContextState = 'suspended';
readonly destination = new MockNode('destination', this);
readonly listener = {
positionX: new MockParam(),
positionY: new MockParam(),
positionZ: new MockParam(),
forwardX: new MockParam(0),
forwardY: new MockParam(0),
forwardZ: new MockParam(-1),
upX: new MockParam(0),
upY: new MockParam(1),
upZ: new MockParam(0),
};
readonly started: MockNode[] = [];
async resume(): Promise<void> {
this.state = 'running';
}
async close(): Promise<void> {
this.state = 'closed';
}
createGain() {
return new MockAudioParamNode('gain', this);
}
createDelay(maxDelay = 1) {
const node = new MockNode('delay', this) as MockNode & {
delayTime: MockParam;
maxDelayTime: number;
};
node.delayTime = new MockParam(0);
node.maxDelayTime = maxDelay;
return node;
}
createBiquadFilter() {
const node = new MockNode('biquad', this) as MockNode & {
type: string;
frequency: MockParam;
Q: MockParam;
};
node.type = 'lowpass';
node.frequency = new MockParam(350);
node.Q = new MockParam(1);
return node;
}
createPanner() {
const node = new MockNode('panner', this) as MockNode & Record<string, unknown>;
node.panningModel = 'equalpower';
node.distanceModel = 'inverse';
node.rolloffFactor = 1;
node.coneInnerAngle = 360;
node.coneOuterAngle = 360;
node.coneOuterGain = 0;
node.positionX = new MockParam();
node.positionY = new MockParam();
node.positionZ = new MockParam();
node.orientationX = new MockParam(1);
node.orientationY = new MockParam(0);
node.orientationZ = new MockParam(0);
return node;
}
createConvolver() {
const node = new MockNode('convolver', this) as MockNode & {
buffer: AudioBuffer | null;
normalize: boolean;
};
node.buffer = null;
node.normalize = true;
return node;
}
createDynamicsCompressor() {
const node = new MockNode('compressor', this) as MockNode & Record<string, MockParam>;
for (const key of ['threshold', 'knee', 'ratio', 'attack', 'release']) {
node[key] = new MockParam(0);
}
return node;
}
createAnalyser() {
const node = new MockNode('analyser', this) as MockNode & Record<string, unknown>;
node.fftSize = 2048;
node.frequencyBinCount = 1024;
node.smoothingTimeConstant = 0.8;
node.minDecibels = -100;
node.maxDecibels = -30;
node.getByteFrequencyData = (array: Uint8Array) => array.fill(0);
node.getByteTimeDomainData = (array: Uint8Array) => array.fill(128);
return node;
}
createChannelSplitter() {
return new MockNode('splitter', this);
}
createBufferSource() {
const ctx = this;
const node = new MockNode('bufferSource', this) as MockNode & Record<string, unknown>;
node.buffer = null;
node.loop = false;
node.playbackRate = new MockParam(1);
node.start = () => {
ctx.started.push(node);
};
node.stop = () => {};
return node;
}
/**
* Stands in for the browser decoder. A payload whose first byte is 0 is
* treated as undecodable, which is how tests exercise the failure path.
*/
async decodeAudioData(bytes: ArrayBuffer): Promise<AudioBuffer> {
const view = new Uint8Array(bytes);
if (view.length === 0 || view[0] === 0) throw new Error('EncodingError');
// Two channels of a recognisable ramp, so downmixing can be checked.
const length = 4096;
const buffer = this.createBuffer(2, length, this.sampleRate);
const left = buffer.getChannelData(0);
const right = buffer.getChannelData(1);
for (let i = 0; i < length; i++) {
left[i] = Math.sin((i / length) * Math.PI * 2) * 0.8;
right[i] = -left[i] * 0.5;
}
return buffer;
}
createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer {
const data = Array.from({ length: channels }, () => new Float32Array(length));
return {
numberOfChannels: channels,
length,
sampleRate,
duration: length / sampleRate,
getChannelData: (channel: number) => data[channel],
copyFromChannel: () => {},
copyToChannel: () => {},
} as unknown as AudioBuffer;
}
/** Every edge in the graph, as "from->to" strings. */
edges(): string[] {
return this.connections.map((c) => `${c.from.kind}->${c.to.kind}`);
}
/** The single node of `kind` that feeds a node of `intoKind`. */
feederOf(kind: string, intoKind: string): MockNode & { gain: MockParam } {
const edge = this.connections.find((c) => c.from.kind === kind && c.to.kind === intoKind);
if (!edge) throw new Error(`No ${kind} -> ${intoKind} edge in the graph`);
return edge.from as MockNode & { gain: MockParam };
}
nodeOf<T extends MockNode>(kind: string): T {
const node = this.nodes.find((n) => n.kind === kind);
if (!node) throw new Error(`No ${kind} node in the graph`);
return node as T;
}
}
/** Installs the mock as the global AudioContext and returns a reset function. */
export function installMockAudio(): { restore: () => void; instances: MockAudioContext[] } {
const instances: MockAudioContext[] = [];
const previous = (globalThis as Record<string, unknown>).AudioContext;
(globalThis as Record<string, unknown>).AudioContext = function MockCtor() {
const ctx = new MockAudioContext();
instances.push(ctx);
return ctx;
} as unknown as typeof AudioContext;
return {
instances,
restore() {
(globalThis as Record<string, unknown>).AudioContext = previous;
},
};
}
+260
View File
@@ -0,0 +1,260 @@
import { describe, expect, it } from 'vitest';
import {
airAbsorptionCutoff,
coneGain,
distanceGain,
dopplerRatio,
gainToDb,
inverseDistanceGain,
linearDistanceGain,
occlusionResponse,
offAxisAngle,
ratioToCents,
smoothingAlpha,
} from '../src/physics';
const at = (x: number, y = 0, z = 0) => ({ x, y, z });
const params = { refDistance: 1, maxDistance: 100, rolloffFactor: 1 };
describe('distance attenuation', () => {
it('holds unity gain inside the reference distance', () => {
expect(inverseDistanceGain(0, params)).toBe(1);
expect(inverseDistanceGain(0.5, params)).toBe(1);
expect(inverseDistanceGain(1, params)).toBe(1);
});
it('loses 6 dB per doubling of distance, the inverse-distance law', () => {
const near = gainToDb(inverseDistanceGain(4, params));
const far = gainToDb(inverseDistanceGain(8, params));
expect(near - far).toBeCloseTo(6.02, 1);
});
it('never attenuates when rolloff is zero', () => {
const flat = { ...params, rolloffFactor: 0 };
expect(inverseDistanceGain(50, flat)).toBe(1);
});
it('reaches silence at maxDistance under the linear model', () => {
expect(linearDistanceGain(100, params)).toBe(0);
expect(linearDistanceGain(1000, params)).toBe(0);
});
it('degrades gracefully when maxDistance is below refDistance', () => {
const inverted = { refDistance: 10, maxDistance: 2, rolloffFactor: 1 };
expect(linearDistanceGain(1, inverted)).toBe(1);
expect(linearDistanceGain(5, inverted)).toBe(0);
});
it('stays within [0, 1] for every model across a wide sweep', () => {
for (const model of ['inverse', 'exponential', 'linear'] as const) {
for (const d of [0, 0.001, 1, 7, 99, 1e4]) {
for (const rolloff of [0, 1, 5]) {
const gain = distanceGain(d, model, { ...params, rolloffFactor: rolloff });
expect(gain).toBeGreaterThanOrEqual(0);
expect(gain).toBeLessThanOrEqual(1);
expect(Number.isFinite(gain)).toBe(true);
}
}
}
});
it('decreases monotonically with distance', () => {
for (const model of ['inverse', 'exponential', 'linear'] as const) {
let previous = Infinity;
for (let d = 0; d <= 60; d += 2) {
const gain = distanceGain(d, model, params);
expect(gain).toBeLessThanOrEqual(previous + 1e-12);
previous = gain;
}
}
});
});
describe('air absorption', () => {
it('is transparent at zero strength or zero distance', () => {
expect(airAbsorptionCutoff(50, 0)).toBe(22050);
expect(airAbsorptionCutoff(0, 4)).toBe(22050);
});
it('rolls the cutoff down as distance grows', () => {
const near = airAbsorptionCutoff(5, 1);
const far = airAbsorptionCutoff(80, 1);
expect(far).toBeLessThan(near);
expect(far).toBeGreaterThan(200);
});
it('is barely audible at realistic strength across a room', () => {
// Real air costs very little treble over 20 m; the default must reflect that.
expect(airAbsorptionCutoff(20, 1)).toBeGreaterThan(15000);
});
it('never falls below the floor even at extreme strength', () => {
expect(airAbsorptionCutoff(1000, 8)).toBeGreaterThanOrEqual(200);
});
});
describe('directivity cone', () => {
const cone = { innerAngle: 60, outerAngle: 180, outerGain: 0.1 };
const source = at(0, 0, 0);
const forward = at(0, 0, -1);
it('is at full level dead ahead', () => {
expect(coneGain(source, forward, at(0, 0, -5), cone)).toBe(1);
});
it('is at full level anywhere inside the inner cone', () => {
// 25° off-axis, inside the 30° inner half-angle.
const listener = at(Math.sin(0.436) * 5, 0, -Math.cos(0.436) * 5);
expect(coneGain(source, forward, listener, cone)).toBe(1);
});
it('falls to the floor gain directly behind the source', () => {
expect(coneGain(source, forward, at(0, 0, 5), cone)).toBeCloseTo(0.1, 6);
});
it('interpolates across the transition band', () => {
// 60° off-axis: past the 30° inner half-angle, short of the 90° outer one.
const listener = at(Math.sin(Math.PI / 3) * 5, 0, -Math.cos(Math.PI / 3) * 5);
const gain = coneGain(source, forward, listener, cone);
expect(gain).toBeGreaterThan(0.1);
expect(gain).toBeLessThan(1);
});
it('reaches the floor exactly at the outer half-angle', () => {
expect(coneGain(source, forward, at(5, 0, 0), cone)).toBeCloseTo(0.1, 6);
});
it('falls monotonically as the listener swings off-axis', () => {
let previous = Infinity;
for (let deg = 0; deg <= 180; deg += 10) {
const rad = (deg * Math.PI) / 180;
const listener = at(Math.sin(rad) * 5, 0, -Math.cos(rad) * 5);
const gain = coneGain(source, forward, listener, cone);
expect(gain).toBeLessThanOrEqual(previous + 1e-9);
previous = gain;
}
});
it('is omnidirectional at 360 degrees', () => {
const omni = { innerAngle: 360, outerAngle: 360, outerGain: 0 };
for (const listener of [at(5), at(-5), at(0, 5), at(0, 0, 5)]) {
expect(coneGain(source, forward, listener, omni)).toBe(1);
}
});
it('treats a coincident listener as on-axis rather than dividing by zero', () => {
expect(coneGain(source, forward, source, cone)).toBe(1);
});
it('survives a degenerate forward vector', () => {
const gain = coneGain(source, at(0, 0, 0), at(0, 0, -5), cone);
expect(Number.isFinite(gain)).toBe(true);
});
it('reports the off-axis angle in degrees', () => {
expect(offAxisAngle(source, forward, at(0, 0, -5))).toBeCloseTo(0, 6);
expect(offAxisAngle(source, forward, at(5, 0, 0))).toBeCloseTo(90, 6);
expect(offAxisAngle(source, forward, at(0, 0, 5))).toBeCloseTo(180, 6);
});
});
describe('doppler', () => {
const still = at(0, 0, 0);
it('is unity when nothing moves', () => {
expect(dopplerRatio(at(0), still, at(10), still)).toBe(1);
});
it('raises pitch when the source approaches', () => {
// Source at origin, listener at +10 x, source moving towards it.
expect(dopplerRatio(at(0), at(30), at(10), still)).toBeGreaterThan(1);
});
it('lowers pitch when the source recedes', () => {
expect(dopplerRatio(at(0), at(-30), at(10), still)).toBeLessThan(1);
});
it('lowers pitch when the listener flees', () => {
expect(dopplerRatio(at(0), still, at(10), at(30))).toBeLessThan(1);
});
it('matches the textbook ratio for an approaching source', () => {
// f'/f = c / (c - v) = 343 / (343 - 34.3) = 1.1111…
expect(dopplerRatio(at(0), at(34.3), at(10), still, 343)).toBeCloseTo(1.1111, 3);
});
it('ignores motion perpendicular to the line of sight', () => {
expect(dopplerRatio(at(0), at(0, 0, 40), at(10), still)).toBeCloseTo(1, 6);
});
it('stays finite and bounded at and beyond the speed of sound', () => {
for (const v of [343, 400, 5000]) {
const ratio = dopplerRatio(at(0), at(v), at(10), still, 343);
expect(Number.isFinite(ratio)).toBe(true);
expect(ratio).toBeLessThanOrEqual(4);
expect(ratio).toBeGreaterThanOrEqual(0.25);
}
});
it('falls back to a sane medium when given a nonsense speed of sound', () => {
expect(dopplerRatio(at(0), still, at(10), still, 0)).toBe(1);
});
it('returns unity for a coincident source and listener', () => {
expect(dopplerRatio(at(5), at(10), at(5), still)).toBe(1);
});
});
describe('occlusion', () => {
it('is transparent with a clear line of sight', () => {
const clear = occlusionResponse(0);
expect(clear.gain).toBe(1);
expect(clear.cutoff).toBeCloseTo(22050, 0);
});
it('muffles but does not silence a fully blocked path', () => {
const blocked = occlusionResponse(1);
expect(blocked.cutoff).toBeCloseTo(350, 0);
expect(blocked.gain).toBeGreaterThan(0);
expect(blocked.gain).toBeLessThan(0.3);
});
it('sweeps the cutoff monotonically and geometrically', () => {
let previous = Infinity;
for (let t = 0; t <= 1; t += 0.1) {
const { cutoff } = occlusionResponse(t);
expect(cutoff).toBeLessThan(previous);
previous = cutoff;
}
// Geometric interpolation puts the halfway point at the geometric mean,
// which is what keeps the sweep sounding even.
expect(occlusionResponse(0.5).cutoff).toBeCloseTo(Math.sqrt(350 * 22050), 0);
});
it('clamps out-of-range input', () => {
expect(occlusionResponse(-3).gain).toBe(1);
expect(occlusionResponse(9).gain).toBeCloseTo(occlusionResponse(1).gain, 12);
});
});
describe('unit helpers', () => {
it('converts gain to decibels', () => {
expect(gainToDb(1)).toBeCloseTo(0, 9);
expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
expect(gainToDb(0)).toBe(-120);
});
it('converts frequency ratios to cents', () => {
expect(ratioToCents(1)).toBeCloseTo(0, 9);
expect(ratioToCents(2)).toBeCloseTo(1200, 6);
expect(ratioToCents(0.5)).toBeCloseTo(-1200, 6);
});
it('produces frame-rate independent smoothing', () => {
// Two 8 ms steps must land where one 16 ms step lands.
const single = smoothingAlpha(0.016, 0.1);
const a = smoothingAlpha(0.008, 0.1);
const twice = 1 - (1 - a) * (1 - a);
expect(twice).toBeCloseTo(single, 9);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { PRESETS, createPresetBuffer } from '../src/audio/presets';
import { MockAudioContext } from './mockAudioContext';
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
describe('sound presets', () => {
it('offers a labelled, explained option for every id', () => {
for (const preset of PRESETS) {
expect(preset.label.length).toBeGreaterThan(0);
expect(preset.hint.length).toBeGreaterThan(0);
}
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(PRESETS.length);
});
it('renders every preset as finite, audible, unclipped audio', () => {
for (const preset of PRESETS) {
const buffer = createPresetBuffer(ctx, preset.id);
expect(buffer.length).toBeGreaterThan(1000);
const data = buffer.getChannelData(0);
let peak = 0;
let energy = 0;
for (let i = 0; i < data.length; i++) {
expect(Number.isFinite(data[i])).toBe(true);
peak = Math.max(peak, Math.abs(data[i]));
energy += data[i] * data[i];
}
expect(peak).toBeGreaterThan(0.05);
expect(peak).toBeLessThanOrEqual(1);
expect(energy).toBeGreaterThan(0);
}
});
it('falls back to a known preset for an unrecognised id', () => {
expect(createPresetBuffer(ctx, 'nonsense' as never).length).toBeGreaterThan(0);
});
it('loops without a discontinuity', () => {
for (const preset of PRESETS) {
const data = createPresetBuffer(ctx, preset.id).getChannelData(0);
// Measure against the 99.9th percentile of the internal sample-to-sample
// steps, never the maximum. A bug that injects one discontinuity would
// raise the maximum and so raise the bar it is being measured against —
// it would manufacture its own alibi.
const steps = new Float64Array(data.length - 1);
for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
steps.sort();
const typical = steps[Math.floor(steps.length * 0.999)];
const seam = Math.abs(data[data.length - 1] - data[0]);
expect(seam).toBeLessThanOrEqual(typical * 2 + 0.02);
}
});
it('introduces no discontinuity of its own at the crossfade boundary', () => {
// The wrap-around crossfade must not swap a seam at index 0 for a seam at
// the end of the fade region.
for (const preset of ['pink', 'engine'] as const) {
const data = createPresetBuffer(ctx, preset).getChannelData(0);
const steps = new Float64Array(data.length - 1);
for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
const sorted = Float64Array.from(steps).sort();
const typical = sorted[Math.floor(sorted.length * 0.999)];
let worst = 0;
for (const step of steps) worst = Math.max(worst, step);
expect(worst).toBeLessThanOrEqual(typical * 3);
}
});
it('band-limits the harmonic waveforms so Doppler cannot alias them', () => {
// A naive sawtooth carries energy right up to Nyquist; pitching it up folds
// the top harmonics back down as inharmonic noise. Building the wave from a
// bounded harmonic series leaves an octave of clean headroom instead.
const rate = 48000;
const fundamental = 165;
const data = createPresetBuffer(ctx, 'sawtooth').getChannelData(0);
const inBand = magnitudeAt(data, fundamental * 20, rate);
const nearLimit = magnitudeAt(data, fundamental * 70, rate);
const aboveLimit = magnitudeAt(data, fundamental * 90, rate);
expect(inBand).toBeGreaterThan(0);
// Harmonics run out below rate/4, leaving room for a 2x Doppler shift.
expect(nearLimit).toBeGreaterThan(aboveLimit * 20);
expect(aboveLimit).toBeLessThan(inBand * 0.02);
});
});
/** Single-bin DFT magnitude, for probing one frequency without a full FFT. */
function magnitudeAt(data: Float32Array, frequency: number, sampleRate: number): number {
const omega = (2 * Math.PI * frequency) / sampleRate;
let real = 0;
let imaginary = 0;
for (let i = 0; i < data.length; i++) {
real += data[i] * Math.cos(omega * i);
imaginary += data[i] * Math.sin(omega * i);
}
return Math.hypot(real, imaginary) / data.length;
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import {
SURFACES,
computeAcoustics,
generateImpulseResponse,
getSurface,
reverberantGain,
} from '../src/audio/reverb';
import { MockAudioContext } from './mockAudioContext';
const room = { width: 20, height: 8, depth: 20 };
describe('room acoustics', () => {
it('matches Sabine for a worked example', () => {
// V = 3200 m³, S = 1440 m², α = 0.22 → T60 = 0.161·3200 / (1440·0.22)
const { t60, volume, surfaceArea } = computeAcoustics(room, 0.22);
expect(volume).toBe(3200);
expect(surfaceArea).toBe(1440);
expect(t60).toBeCloseTo((0.161 * 3200) / (1440 * 0.22), 4);
});
it('rings longer in a bigger room', () => {
const small = computeAcoustics({ width: 6, height: 3, depth: 6 }, 0.22);
const large = computeAcoustics({ width: 40, height: 15, depth: 40 }, 0.22);
expect(large.t60).toBeGreaterThan(small.t60);
});
it('rings longer on harder surfaces', () => {
const soft = computeAcoustics(room, 0.9);
const hard = computeAcoustics(room, 0.035);
expect(hard.t60).toBeGreaterThan(soft.t60);
});
it('clamps T60 to a usable range for absurd geometry', () => {
const huge = computeAcoustics({ width: 500, height: 200, depth: 500 }, 0.01);
expect(huge.t60).toBeLessThanOrEqual(8);
expect(huge.t60).toBeGreaterThan(0);
});
it('puts the critical distance closer in a live room than a dead one', () => {
const dead = computeAcoustics(room, 0.9);
const live = computeAcoustics(room, 0.035);
expect(live.criticalDistance).toBeLessThan(dead.criticalDistance);
});
it('produces a physically sensible critical distance for a normal room', () => {
// A furnished 20x8x20 room should cross over a couple of metres out.
const { criticalDistance } = computeAcoustics(room, 0.22);
expect(criticalDistance).toBeGreaterThan(1);
expect(criticalDistance).toBeLessThan(6);
});
});
describe('reverberant field level', () => {
it('balances wet against dry exactly at the critical distance', () => {
const acoustics = computeAcoustics(room, 0.22);
// Dry follows 1/r with refDistance 1, so at r = rc it equals 1/rc.
expect(reverberantGain(acoustics, 1)).toBeCloseTo(1 / acoustics.criticalDistance, 6);
});
it('is louder in a more reflective room', () => {
const dead = reverberantGain(computeAcoustics(room, 0.9));
const live = reverberantGain(computeAcoustics(room, 0.035));
expect(live).toBeGreaterThan(dead);
});
it('stays bounded', () => {
for (const surface of SURFACES) {
const gain = reverberantGain(computeAcoustics(room, surface.absorption));
expect(gain).toBeGreaterThanOrEqual(0);
expect(gain).toBeLessThanOrEqual(1.4);
}
});
});
describe('impulse response', () => {
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
it('is stereo, finite and non-silent', () => {
const ir = generateImpulseResponse(ctx, room, 0.22);
expect(ir.numberOfChannels).toBe(2);
expect(ir.length).toBeGreaterThan(1000);
const left = ir.getChannelData(0);
let energy = 0;
for (let i = 0; i < left.length; i++) {
expect(Number.isFinite(left[i])).toBe(true);
expect(Math.abs(left[i])).toBeLessThanOrEqual(1);
energy += left[i] * left[i];
}
expect(energy).toBeGreaterThan(0);
});
it('decays, rather than sustaining or growing', () => {
const ir = generateImpulseResponse(ctx, room, 0.22);
const data = ir.getChannelData(0);
const rms = (from: number, to: number) => {
let sum = 0;
for (let i = from; i < to; i++) sum += data[i] * data[i];
return Math.sqrt(sum / (to - from));
};
const head = rms(0, Math.floor(data.length * 0.1));
const tail = rms(Math.floor(data.length * 0.85), data.length);
expect(tail).toBeLessThan(head * 0.2);
});
it('gets longer on harder surfaces', () => {
const soft = generateImpulseResponse(ctx, room, 0.9);
const hard = generateImpulseResponse(ctx, room, 0.035);
expect(hard.length).toBeGreaterThan(soft.length);
});
it('places early reflections at the geometric arrival times', () => {
const ir = generateImpulseResponse(ctx, room, 0.09, 343);
const data = ir.getChannelData(0);
// A 20 m wall path arrives at 20/343 s.
const expected = Math.floor((20 / 343) * ir.sampleRate);
const neighbourhood = Math.max(
...Array.from({ length: 5 }, (_, i) => Math.abs(data[expected - 2 + i]))
);
const background = Math.abs(data[expected + 2000]);
expect(neighbourhood).toBeGreaterThan(background);
});
});
describe('surfaces', () => {
it('exposes an absorption coefficient for every preset', () => {
for (const surface of SURFACES) {
expect(surface.absorption).toBeGreaterThan(0);
expect(surface.absorption).toBeLessThan(1);
expect(surface.hint.length).toBeGreaterThan(0);
}
});
it('falls back to a normal room for an unknown id', () => {
expect(getSurface('nonsense' as never).id).toBe('living');
});
});
+185
View File
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AudioEngine } from '../src/audio/AudioEngine';
import {
AudioFileError,
MAX_FILE_BYTES,
decodeAudioFile,
downmixToMono,
formatDuration,
} from '../src/audio/userAudio';
import { MockAudioContext, installMockAudio } from './mockAudioContext';
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
/** A File whose bytes are controllable; first byte 0 means "undecodable". */
function fakeFile(name: string, bytes: number[], size?: number): File {
const data = new Uint8Array(bytes);
return {
name,
size: size ?? data.length,
arrayBuffer: async () => data.buffer,
} as unknown as File;
}
describe('downmix to mono', () => {
it('averages the channels', () => {
const stereo = ctx.createBuffer(2, 4, 48000);
stereo.getChannelData(0).set([1, 0, 0.5, -1]);
stereo.getChannelData(1).set([0, 1, 0.5, -1]);
const mono = downmixToMono(ctx, stereo);
expect(mono.numberOfChannels).toBe(1);
// Averages to [0.5, 0.5, 0.5, -1], then the 1 peak is pulled to 0.98.
const data = mono.getChannelData(0);
expect(data[0]).toBeCloseTo(0.49, 5);
expect(data[1]).toBeCloseTo(0.49, 5);
expect(data[2]).toBeCloseTo(0.49, 5);
expect(data[3]).toBeCloseTo(-0.98, 5);
});
it('leaves a mono buffer untouched', () => {
const source = ctx.createBuffer(1, 4, 48000);
expect(downmixToMono(ctx, source)).toBe(source);
});
it('pulls the peak back under unity', () => {
const stereo = ctx.createBuffer(2, 2, 48000);
stereo.getChannelData(0).set([1, 1]);
stereo.getChannelData(1).set([1, 1]);
const mono = downmixToMono(ctx, stereo);
// Float32 rounding can land a hair above the target, hence the epsilon.
for (const sample of mono.getChannelData(0)) expect(Math.abs(sample)).toBeLessThan(0.981);
});
it('preserves length and sample rate', () => {
const stereo = ctx.createBuffer(2, 777, 44100);
const mono = downmixToMono(ctx, stereo);
expect(mono.length).toBe(777);
expect(mono.sampleRate).toBe(44100);
});
});
describe('decoding a file', () => {
it('returns a mono buffer for good audio', async () => {
const buffer = await decodeAudioFile(ctx, fakeFile('song.mp3', [1, 2, 3, 4]));
expect(buffer.numberOfChannels).toBe(1);
expect(buffer.length).toBeGreaterThan(0);
});
it('rejects an empty file with a readable message', async () => {
await expect(decodeAudioFile(ctx, fakeFile('empty.mp3', []))).rejects.toBeInstanceOf(
AudioFileError
);
});
it('rejects an absurdly large file before reading it', async () => {
const huge = fakeFile('huge.wav', [1], MAX_FILE_BYTES + 1);
await expect(decodeAudioFile(ctx, huge)).rejects.toThrow(/MB/);
});
it('turns a decoder failure into advice, not a DOMException', async () => {
const bad = fakeFile('notes.txt', [0, 0, 0]);
await expect(decodeAudioFile(ctx, bad)).rejects.toThrow(/MP3, WAV, FLAC/);
await expect(decodeAudioFile(ctx, bad)).rejects.toBeInstanceOf(AudioFileError);
});
it('names the offending file in the error', async () => {
await expect(decodeAudioFile(ctx, fakeFile('holiday.docx', [0]))).rejects.toThrow(
/holiday\.docx/
);
});
});
describe('duration formatting', () => {
it('renders minutes and seconds', () => {
expect(formatDuration(0)).toBe('0:00');
expect(formatDuration(9)).toBe('0:09');
expect(formatDuration(227)).toBe('3:47');
expect(formatDuration(3600)).toBe('60:00');
});
it('survives nonsense', () => {
expect(formatDuration(Number.NaN)).toBe('—');
expect(formatDuration(-5)).toBe('—');
});
});
describe('engine integration', () => {
let audio: ReturnType<typeof installMockAudio>;
beforeEach(() => {
audio = installMockAudio();
});
afterEach(() => {
audio.restore();
});
it('loads a file, selects it, and reports it', async () => {
const engine = new AudioEngine();
const info = await engine.loadUserAudio(fakeFile('track.flac', [1, 2, 3]));
expect(info.name).toBe('track.flac');
expect(info.duration).toBeGreaterThan(0);
expect(engine.getSettings().preset).toBe('file');
expect(engine.getUserAudio()?.name).toBe('track.flac');
});
it('creates the AudioContext on demand so a picker click can decode', async () => {
const engine = new AudioEngine();
expect(engine.context).toBeNull();
await engine.loadUserAudio(fakeFile('track.wav', [1]));
expect(engine.context).not.toBeNull();
});
it('plays the loaded buffer rather than a generated preset', async () => {
const engine = new AudioEngine();
await engine.loadUserAudio(fakeFile('track.wav', [1]));
await engine.start();
const ctxInstance = audio.instances[0];
const started = ctxInstance.started.at(-1) as unknown as { buffer: AudioBuffer };
expect(started.buffer).toBe(
(engine as unknown as { userBuffer: AudioBuffer }).userBuffer
);
expect(started.buffer.numberOfChannels).toBe(1);
});
it('swaps to a second file while the first is playing', async () => {
const engine = new AudioEngine();
await engine.loadUserAudio(fakeFile('one.wav', [1]));
await engine.start();
const startsAfterFirst = audio.instances[0].started.length;
await engine.loadUserAudio(fakeFile('two.wav', [2]));
expect(engine.getUserAudio()?.name).toBe('two.wav');
// A second load must actually restart the source, not silently no-op
// because the selected source id was already 'file'.
expect(audio.instances[0].started.length).toBeGreaterThan(startsAfterFirst);
});
it('leaves the source alone when a bad file is offered', async () => {
const engine = new AudioEngine();
await engine.start();
await expect(engine.loadUserAudio(fakeFile('bad.txt', [0]))).rejects.toBeInstanceOf(
AudioFileError
);
expect(engine.getSettings().preset).toBe('sawtooth');
expect(engine.getUserAudio()).toBeNull();
});
it('ignores a request to select the file source when nothing is loaded', () => {
const engine = new AudioEngine();
engine.update({ preset: 'file' });
expect(engine.getSettings().preset).toBe('sawtooth');
});
it('can switch back to a generated preset after loading a file', async () => {
const engine = new AudioEngine();
await engine.loadUserAudio(fakeFile('track.wav', [1]));
engine.update({ preset: 'pink' });
expect(engine.getSettings().preset).toBe('pink');
// The file stays loaded, so the dropdown entry remains valid.
expect(engine.getUserAudio()?.name).toBe('track.wav');
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting / Strict */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"]
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// Three.js is most of the bundle and changes far less often than the
// app, so give it its own long-lived chunk.
manualChunks: { three: ['three'] },
},
},
},
test: {
// The unit suite covers pure physics and the audio graph, neither of which
// needs a DOM. Browser behaviour is covered by scripts/e2e.mjs against real
// Chrome, where a real WebGL context and a real AudioContext exist.
environment: 'node',
globals: true,
include: ['test/**/*.test.ts'],
},
});