not-tonight/src/audio/TechnoEngine.ts
type-two 1a66c602fd LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine
The Phase-1 debt was 'verified by ear: NOTHING'. I still can't hear, so this
lane builds the instrument that lets someone who can tell me what's wrong, and
makes their answer a diff instead of a conversation.

- TechnoEngine drives every voice from the live TrackMix and the arc, instead of
  module consts. Behaviour-identical at DEFAULT_MIX (verified constant by
  constant). Voices whose effective gain rounds to silence are skipped, not
  clamped — at 9PM the bass builds zero nodes rather than ~8/sec of silence.
  setMixValue quantises integer paths, so what formatMix dumps is what played.
- MixDeskScene: solo/mute per voice with a per-voice listening prompt, every
  mix param on log-mapped sliders, an arc scrubber (audition 1AM at 9PM), and
  DUMP MIX -> a pasteable TrackMix literal. Restores the arc pin and solo state
  it found on close, rather than forcing a default — the demo deliberately pins
  PEAK and the desk was silently un-tuning it.
- Ambience: rain outside, crowd murmur inside, kebab-shop fluoro hum. On the SFX
  bus, not the music lowpass — that filter models the PA being behind a wall,
  and rain is not behind anything. Headcount comes off door:verdict admits and
  floor:ejection, NOT door:clicker: the clicker is the player's claimed count and
  its divergence from reality is the mechanic, so the room would sound like the
  lie. Verified live: 120 + 2 admits - 1 ejection = 121, clicker ignored.
- JuiceDemoScene pins the arc at PEAK on entry. GameClock runs 360 game-min in
  13 real min, so an unpinned demo opens on kick+hat and anyone judging the bass
  would conclude it's broken.
- Phone thread scrolls with visible-window culling and a binary-searched range,
  a history cap, and a 'new below' cue when a text lands off-screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:47:53 +10:00

671 lines
24 KiB
TypeScript

import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import { type ArcScales, arcAt } from './arc';
import {
type AudioLocation,
cutoffFor,
gainFor,
sweepMsFor,
} from './filterCurve';
import {
DEFAULT_MIX,
MIX_RANGES,
type TrackMix,
VOICE_NAMES,
type VoiceName,
clampTo,
cloneMix,
voiceGain,
} from './mix';
import {
type BeatGrid,
barOf,
beatIntervalMs,
beatTimeMs,
dueBeats,
isDownbeat,
patternStep,
subdivisions,
} from './scheduling';
const DEFAULT_BPM = 128;
const DEFAULT_LOOKAHEAD_MS = 100;
const TIMER_MS = 25;
/**
* Beat 0 sits this far past the resume instant. Scheduling into the past is not
* an error — the node just fires immediately — but then the beat *sounds* later
* than the `audioTimeMs` we promised for it. The toilet rhythm game scores
* against that number, so the first beat has to be as honest as the rest.
*/
const START_LEAD_MS = 60;
const SILENT = 0.0001; // exponential ramps cannot reach 0
const BASS_STEPS = 32; // 2 bars of 16ths
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
const BASS_SWEEP_S = 0.03;
/**
* The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
* note *starts*, and resting exactly there makes the release sound like a gate.
* Expressed as a ratio so retuning the floor carries the rest point with it.
*/
const BASS_TAIL_RATIO = 200 / 180;
/** Fraction of the pad cycle spent fading in; the rest fades out. */
const PAD_RISE = 0.4;
/** A slider drag on `master` should not zipper, but should not lag either. */
const KNOB_RAMP_S = 0.03;
/** Mix paths the engine rounds at use-time, so they must be stored rounded too. */
const INTEGER_PATHS = new Set(['pad.bars']);
/**
* Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat
* — the kick owns that slot, and leaving it empty is what makes the bassline
* roll instead of thud.
*/
const BASS_PATTERN: readonly (number | null)[] = [
null, null, 0, 0,
null, 0, null, 0,
null, null, 3, 3,
null, 0, null, 12,
null, null, 0, 0,
null, 0, null, 0,
null, null, 5, 3,
null, 0, 2, null,
];
/** A minor, voiced wide and low. Detuning the pair is the whole character. */
const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorType }> = [
{ hz: 110, detune: -7, type: 'sawtooth' },
{ hz: 110, detune: 7, type: 'sawtooth' },
{ hz: 164.81, detune: 0, type: 'triangle' },
];
/** Widened once so a dotted path can be looked up without a cast at each site. */
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;
const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;
interface MixLens {
read: () => number;
write: (v: number) => void;
range: readonly [number, number];
}
/**
* The engine currently owning the AudioContext, or null.
*
* The tuning desk has to reach the engine during a real night, when the scene
* that constructed it belongs to another lane and there is no route to it. A
* module variable is the smallest thing that solves that — deliberately not a
* window global, which would make it part of the page's public surface.
*/
let activeEngine: TechnoEngine | null = null;
export const getActiveEngine = (): TechnoEngine | null => activeEngine;
export interface TechnoEngineOptions {
bpm?: number;
lookaheadMs?: number;
}
/**
* The looping techno track and the beat authority in one. Replaces
* core/StubBeatClock — same `beat:tick` contract, but the beats now come off the
* AudioContext clock instead of a frame counter, so they are actually true.
*
* Everything is synthesized; there are no samples to load and nothing to
* preload. All scheduling happens on a 25ms timer looking `lookaheadMs` into the
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
* look-ahead alone does not save us; what does is `resync()`, which drops the
* span that elapsed while we were asleep instead of dumping it into the graph.
*
* Every level and shape comes off a live `TrackMix` scaled by the night arc, so
* the track can be retuned and auditioned mid-playback. Nothing is read at
* construction that is not re-read on the next beat.
*/
export class TechnoEngine {
readonly ctx: AudioContext;
readonly master: GainNode;
readonly musicBus: GainNode;
readonly sfxBus: GainNode;
readonly bpm: number;
private readonly filter: BiquadFilterNode;
private readonly noiseBuffer: AudioBuffer;
private readonly lookaheadMs: number;
private readonly grid: BeatGrid;
private readonly unsubs: Array<() => void> = [];
/**
* Long-lived handles so stop() can silence what is already on the schedule.
* Keyed by source, valued by the rest of that voice's chain, because stop()
* pre-empts `onended` and would otherwise strand those nodes on the bus.
*/
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
private mixState: TrackMix = cloneMix(DEFAULT_MIX);
private clockMinute = 0;
private pinnedMinute: number | null = null;
/** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
private scales: ArcScales = arcAt(0);
private timer: ReturnType<typeof setInterval> | null = null;
private nextBeat = 0;
private loc: AudioLocation = 'door';
private override: number | null = null;
private unlockedFlag = false;
private unlocking: Promise<void> | null = null;
constructor(
private readonly bus: EventBus,
opts: TechnoEngineOptions = {},
) {
this.bpm = opts.bpm ?? DEFAULT_BPM;
this.lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS;
this.ctx = new AudioContext();
this.grid = { bpm: this.bpm, originMs: 0 };
this.master = this.ctx.createGain();
this.master.gain.value = this.mixState.master;
this.master.connect(this.ctx.destination);
this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass';
this.filter.frequency.value = cutoffFor(this.loc);
this.filter.Q.value = this.qFor(this.loc);
this.filter.connect(this.master);
this.musicBus = this.ctx.createGain();
this.musicBus.gain.value = gainFor(this.loc);
this.musicBus.connect(this.filter);
// Deliberately NOT through the filter: a stamp must sound like a stamp even
// when the music is buried behind a door.
this.sfxBus = this.ctx.createGain();
this.sfxBus.connect(this.master);
this.noiseBuffer = this.buildNoise();
this.unsubs.push(
this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
this.bus.on('clock:tick', ({ clockMin }) => {
this.clockMinute = clockMin;
if (this.pinnedMinute === null) this.scales = arcAt(clockMin);
}),
);
// eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point
activeEngine = this;
}
get running(): boolean {
return this.timer !== null;
}
get unlocked(): boolean {
return this.unlockedFlag;
}
get cutoffHz(): number {
return this.filter.frequency.value;
}
get location(): AudioLocation {
return this.loc;
}
// --- mix ------------------------------------------------------------------
get mix(): Readonly<TrackMix> {
return this.mixState;
}
/**
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
* `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
*
* An unknown path is inert rather than fatal — the tuning desk is generated
* from a table, and a typo there should cost one dead slider, not the night.
*/
setMixValue(path: string, value: number): void {
const lens = this.lens(path);
if (!lens) return;
// Quantise here rather than at the slider: what we store is what formatMix
// dumps, so a fractional bar count would export a value nobody ever heard.
// The engine owns the invariant because it is the thing that rounds at use.
const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
lens.write(v);
// Levels and shapes land on the next scheduled note (<500ms, inaudible as
// lag). These two are continuous, so they have to move now.
if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
if (path === this.loc + 'Q' && this.override === null) {
this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
}
}
/** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
getMixValue(path: string): number {
return this.lens(path)?.read() ?? Number.NaN;
}
setVoiceEnabled(voice: VoiceName, on: boolean): void {
this.mixState.enabled[voice] = on;
}
resetMix(): void {
this.mixState = cloneMix(DEFAULT_MIX);
this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
if (this.override === null) {
this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
}
}
// --- night arc ------------------------------------------------------------
/**
* Pin the arc to a minute for the ear pass — this is how 1 AM gets auditioned
* at 9 PM. null hands the arc back to `clock:tick`.
*/
setArcMinute(min: number | null): void {
this.pinnedMinute = min;
this.scales = arcAt(this.arcMinute);
}
get arcMinute(): number {
return this.pinnedMinute ?? this.clockMinute;
}
get arcScales(): ArcScales {
return { ...this.scales };
}
get arcPinned(): boolean {
return this.pinnedMinute !== null;
}
/**
* Resume after a real user gesture. Idempotent, and safe to call concurrently
* — overlapping callers await the same resume rather than racing the origin.
*/
async unlock(): Promise<void> {
if (this.unlockedFlag) return;
if (this.unlocking) return this.unlocking;
this.unlocking = (async () => {
if (this.ctx.state === 'suspended') await this.ctx.resume();
const atMs = this.ctx.currentTime * 1000;
// Re-origin the grid: beat 0 is now, not whenever this object was built.
this.grid.originMs = atMs + START_LEAD_MS;
this.nextBeat = 0;
this.unlockedFlag = true;
// Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume
// instant — consumers may treat beat 0 as landing exactly at atMs.
this.bus.emit('audio:unlocked', { atMs: this.grid.originMs });
})();
try {
await this.unlocking;
} finally {
this.unlocking = null;
}
}
/**
* Start the scheduler. Starting before `unlock()` is legal but silent: the
* timer spins and emits nothing until the gesture lands. Callers therefore
* never have to sequence start/unlock.
*
* Restarting after a stop needs no re-anchoring here: nothing is scheduled
* outside `tick()`, and its first wake resyncs past the gap.
*/
start(): void {
if (this.timer !== null) return;
this.timer = setInterval(() => this.tick(), TIMER_MS);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
for (const [src, chain] of [...this.voices]) {
src.onended = null;
try {
src.stop();
} catch {
/* already finished — nothing to silence */
}
this.release(src, chain);
}
this.voices.clear();
}
/** Ramp to the location's cutoff/gain. A pinned override wins over both. */
setLocation(location: AudioLocation, immediate = false): void {
this.loc = location;
if (this.override !== null) return;
const durS = immediate ? 0 : sweepMsFor(location) / 1000;
this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
this.ramp(this.musicBus.gain, gainFor(location), durS, false);
this.ramp(this.filter.Q, this.qFor(location), durS, false);
}
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
setCutoffOverride(hz: number | null): void {
this.override = hz;
if (hz === null) {
this.setLocation(this.loc);
return;
}
this.ramp(this.filter.frequency, this.clampHz(hz), 0, true);
}
destroy(): void {
this.stop();
for (const off of this.unsubs) off();
this.unsubs.length = 0;
if (activeEngine === this) activeEngine = null;
void this.ctx.close().catch(() => undefined);
}
// --- scheduling -----------------------------------------------------------
private tick(): void {
if (!this.unlockedFlag) return;
const nowMs = this.ctx.currentTime * 1000;
this.resync(nowMs);
const due = dueBeats(this.grid, this.nextBeat, nowMs, this.lookaheadMs);
if (due.length === 0) return;
for (const { beatIndex, timeMs } of due) {
this.scheduleBeat(beatIndex, timeMs);
// The SCHEDULED time, not the current one. Consumers time their input
// against this, so a convenient lie here is a broken rhythm game.
this.bus.emit('beat:tick', { beatIndex, audioTimeMs: timeMs });
if (isDownbeat(beatIndex)) {
this.bus.emit('beat:bar', { barIndex: barOf(beatIndex), beatIndex, audioTimeMs: timeMs });
}
}
const last = due[due.length - 1];
if (last) this.nextBeat = last.beatIndex + 1;
}
/**
* Skip a stale span rather than catch up on it. A throttled timer (hidden tab)
* or a long stop/start gap leaves the cursor minutes behind the audio clock;
* playing those beats would start every voice on the same sample — a clipped
* blast, not music — and `beat:tick` would hand the rhythm game timestamps it
* could only score as misses. The grace is one beat: ordinary wakeup jitter
* must not trigger a jump.
*
* Landing on a downbeat rather than the nearest beat keeps bar phase, so the
* resync reads as a cut instead of the track lurching sideways.
*/
private resync(nowMs: number): void {
const interval = beatIntervalMs(this.bpm);
if (beatTimeMs(this.grid, this.nextBeat) >= nowMs - interval) return;
const firstFuture = Math.ceil((nowMs - this.grid.originMs) / interval);
this.nextBeat = Math.ceil(firstFuture / 4) * 4;
}
/**
* Effective gain for a voice right now: level, mute and arc folded together.
* Shared with the tuning desk via mix.ts so the number on screen is the number
* the scheduler uses.
*/
private gainOf(voice: VoiceName): number {
return voiceGain(this.mixState, voice, this.scales[voice]);
}
private scheduleBeat(beatIndex: number, timeMs: number): void {
const sixteenths = subdivisions(this.grid, beatIndex, 4);
const mix = this.mixState;
// Silent voices are skipped, not scheduled quietly: an exponential ramp to
// zero throws, and at 9 PM the arc mutes the bass outright — no reason to
// build and tear down eight nodes a second for something nobody can hear.
const kickGain = this.gainOf('kick');
if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain);
const hatGain = this.gainOf('hat');
const offbeat = sixteenths[2]; // the 8th between beats
if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);
const bassGain = this.gainOf('bass');
if (bassGain > SILENT) {
for (let s = 0; s < sixteenths.length; s++) {
const at = sixteenths[s];
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
if (at === undefined || semi === undefined || semi === null) continue;
this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
}
}
const padGain = this.gainOf('pad');
const padBars = this.padBars();
if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) {
this.pad(timeMs / 1000, padGain, padBars);
}
}
/** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */
private padBars(): number {
return Math.max(1, Math.round(this.mixState.pad.bars));
}
// --- voices ---------------------------------------------------------------
private kick(at: number, peak: number): void {
const k = this.mixState.kick;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
// The pitch drop is the beater; the tail is the cone. This is the one voice
// that has to survive a 250Hz lowpass, so it gets a long body.
osc.frequency.setValueAtTime(k.startHz, at);
osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS);
// A decay tuned shorter than the attack would schedule the ramps out of
// order; the desk allows that combination, WebAudio does not.
const decayS = Math.max(k.attackS + 0.005, k.decayS);
gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
osc.connect(gain).connect(this.musicBus);
osc.start(at);
osc.stop(at + decayS + 0.02);
this.hold(osc, [gain]);
}
private hat(at: number, peak: number): void {
const h = this.mixState.hat;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer;
const hp = this.ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = h.hpHz;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(peak, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
src.connect(hp).connect(gain).connect(this.musicBus);
// Read far enough to cover the envelope; a longer decay must not be cut off
// by the buffer running out under it.
const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration);
// Cheap variety: start the read head somewhere different each hit so the one
// shared buffer doesn't ring identically 400 times a minute. The span is the
// buffer minus the read length, so offset + readS lands inside the buffer for
// any tuning of hat.decayS; a read as long as the buffer leaves no span.
const span = this.noiseBuffer.duration - readS;
const offset = span > 0 ? (at * 7.3) % span : 0;
src.start(at, offset, readS);
this.hold(src, [hp, gain]);
}
private bass(at: number, hz: number, peak: number): void {
const b = this.mixState.bass;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.Q.value = b.q;
const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS);
// Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
lp.frequency.setValueAtTime(b.filterLoHz, at);
lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
lp.connect(gain).connect(this.musicBus);
const spread = [-b.detuneCents, b.detuneCents];
spread.forEach((detune, i) => {
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = hz;
osc.detune.value = detune;
osc.connect(lp);
osc.start(at);
osc.stop(at + decayS + 0.01);
// Hang the shared chain off the LAST saw by index, not by sign: at zero
// detune both are 0 and a sign test would strand lp/gain on the bus.
this.hold(osc, i === spread.length - 1 ? [lp, gain] : []);
});
}
private pad(at: number, peak: number, bars: number): void {
const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = this.mixState.pad.lpHz;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus);
PAD_VOICES.forEach((v, i) => {
const osc = this.ctx.createOscillator();
osc.type = v.type;
osc.frequency.value = v.hz;
osc.detune.value = v.detune;
osc.connect(lp);
osc.start(at);
osc.stop(at + cycle);
this.hold(osc, i === PAD_VOICES.length - 1 ? [lp, gain] : []);
});
}
// --- plumbing -------------------------------------------------------------
private qFor(location: AudioLocation): number {
return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
}
/** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
private lens(path: string): MixLens | null {
const m = this.mixState;
if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
const range = RANGES[path];
if (!range) return null;
const key = path;
return { read: () => m[key], write: (v) => { m[key] = v; }, range };
}
const dot = path.indexOf('.');
if (dot <= 0) return null;
const head = path.slice(0, dot);
const tail = path.slice(dot + 1);
if (head === 'levels') {
// MIX_RANGES keys the shared bound as 'level' (singular) — one range for
// all four faders — while the path is per-voice.
if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
const v = tail as VoiceName;
return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
}
if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
// The four param groups have no index signature by design — the widening is
// confined here, behind the hasOwn check below.
const group = m[head as VoiceName] as unknown as Record<string, number>;
if (!Object.hasOwn(group, tail)) return null;
const range = RANGES[path];
if (!range) return null;
return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
}
/**
* One second of deterministic white noise, built once and re-read per hat.
* Allocating a buffer per hit would mean ~8 buffers a second for six hours.
*/
private buildNoise(): AudioBuffer {
const len = Math.floor(this.ctx.sampleRate);
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const data = buf.getChannelData(0);
const rng = new SeededRNG(0x1a7).stream('hat-noise');
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
return buf;
}
/** Keep the node reachable for stop(); let it free itself if it runs out. */
private hold(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
this.voices.set(src, chain);
src.onended = () => this.release(src, chain);
}
private release(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
this.voices.delete(src);
src.disconnect();
for (const n of chain) n.disconnect();
}
/**
* Re-anchor at the live value before ramping so a sweep interrupted mid-flight
* (door -> floor -> door in two seconds, which players absolutely do) continues
* from where it actually is instead of snapping.
*
* Exponential ramps trace exactly `cutoffAt()`'s curve — v0*(v1/v0)^t — so we
* get cutoffCurve()'s shape without setValueCurveAtTime's rule that a new curve
* may not overlap a running one. That overlap throws, and re-entrant location
* changes are the normal case here, not the edge case.
*/
private ramp(param: AudioParam, to: number, durS: number, exponential: boolean): void {
const now = this.ctx.currentTime;
const from = param.value;
param.cancelScheduledValues(now);
param.setValueAtTime(from, now);
if (durS <= 0) {
param.setValueAtTime(to, now);
} else if (exponential && from > 0 && to > 0) {
param.exponentialRampToValueAtTime(to, now + durS);
} else {
param.linearRampToValueAtTime(to, now + durS);
}
}
private clampHz(hz: number): number {
return Math.max(20, Math.min(hz, this.ctx.sampleRate / 2));
}
}