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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './AudioEngine';
|
||||
export * from './presets';
|
||||
export * from './reverb';
|
||||
export * from './userAudio';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './vector';
|
||||
export * from './attenuation';
|
||||
export * from './cone';
|
||||
export * from './doppler';
|
||||
export * from './occlusion';
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './Stage';
|
||||
export * from './Room';
|
||||
export * from './SoundSource';
|
||||
export * from './Annotations';
|
||||
export * from './motion';
|
||||
export * from './palette';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}%`),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user