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>
This commit is contained in:
parent
d112bda270
commit
1a66c602fd
445
src/audio/Ambience.ts
Normal file
445
src/audio/Ambience.ts
Normal file
@ -0,0 +1,445 @@
|
||||
// The beds under the night: rain outside, bodies inside, and the kebab shop's
|
||||
// fluoro tube humming to itself while both happen.
|
||||
//
|
||||
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
|
||||
// The music lowpass models "the track heard through a wall" — that is a
|
||||
// statement about the PA being on the other side of a door. Rain is not on the
|
||||
// other side of anything; it is falling on your head, so filtering it with the
|
||||
// music would put the weather indoors with the speakers. What makes rain
|
||||
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
|
||||
// The crowd bed is the same argument run backwards: it is the room you are
|
||||
// standing in when you are inside, and a wall's worth of muffling belongs on
|
||||
// the PA, not on the people. Location is a mix decision here, not a filter one.
|
||||
|
||||
import type { EventBus } from '../core/EventBus';
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import type { Sfx } from './Sfx';
|
||||
|
||||
export interface AmbienceOptions {
|
||||
rain?: boolean;
|
||||
crowd?: boolean;
|
||||
kebab?: boolean;
|
||||
}
|
||||
|
||||
type Bed = 'rain' | 'crowd' | 'kebab';
|
||||
type Location = 'door' | 'floor';
|
||||
|
||||
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
|
||||
const NOISE_SECONDS = 4;
|
||||
|
||||
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
|
||||
* stepping through the door reads as one movement rather than two events. */
|
||||
const LOC_FADE_S = 0.7;
|
||||
|
||||
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
|
||||
const COUNT_FADE_S = 0.4;
|
||||
|
||||
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
|
||||
const TOGGLE_FADE_S = 0.25;
|
||||
|
||||
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
|
||||
const CROWD_PEAK = 0.09;
|
||||
|
||||
/**
|
||||
* Saturation constant for the headcount curve. At k=14 the first dozen people
|
||||
* buy most of the loudness and the hundredth buys almost none — which is how a
|
||||
* room actually behaves, and the opposite of what a linear map does.
|
||||
*/
|
||||
const CROWD_K = 14;
|
||||
|
||||
/** Crowd bed at the door: the room leaking past you, not silence. */
|
||||
const CROWD_DOOR_DUCK = 0.15;
|
||||
|
||||
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
|
||||
const KEBAB_LEVEL = 0.014;
|
||||
|
||||
export class Ambience {
|
||||
private readonly bus: EventBus;
|
||||
private readonly ctx: AudioContext;
|
||||
private readonly dest: AudioNode;
|
||||
private readonly sfx: Sfx;
|
||||
|
||||
private readonly offs: Array<() => void> = [];
|
||||
|
||||
private readonly enabled: Record<Bed, boolean>;
|
||||
|
||||
private loc: Location = 'door';
|
||||
private running = false;
|
||||
private count = 0;
|
||||
|
||||
private crowd: CrowdBed | null = null;
|
||||
private kebab: KebabBed | null = null;
|
||||
|
||||
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
|
||||
private noise: AudioBuffer | null = null;
|
||||
|
||||
constructor(
|
||||
bus: EventBus,
|
||||
ctx: AudioContext,
|
||||
destination: AudioNode,
|
||||
sfx: Sfx,
|
||||
opts: AmbienceOptions = {},
|
||||
) {
|
||||
this.bus = bus;
|
||||
this.ctx = ctx;
|
||||
this.dest = destination;
|
||||
this.sfx = sfx;
|
||||
this.enabled = {
|
||||
rain: opts.rain ?? true,
|
||||
crowd: opts.crowd ?? true,
|
||||
// The kebab shop is the moral centre of the thing (design doc §5). It gets
|
||||
// a sound whether or not anyone is listening for it.
|
||||
kebab: opts.kebab ?? true,
|
||||
};
|
||||
|
||||
this.offs.push(
|
||||
this.bus.on('audio:location', ({ location }) => {
|
||||
if (location === this.loc) return; // door -> door must not restack a fade
|
||||
this.loc = location;
|
||||
this.applyLocation();
|
||||
}),
|
||||
);
|
||||
|
||||
// Headcount is derived, not published: NightState.capacity.inside is in no
|
||||
// event payload and we may not add one. So we count the truth log — an
|
||||
// admission puts a body in the room, an ejection takes one out.
|
||||
//
|
||||
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
|
||||
// count, and the gap between it and reality is a game mechanic. Driving the
|
||||
// crowd bed off it would make the room sound like the lie instead of the
|
||||
// room, and would hand the player a way to hear their own miscount.
|
||||
this.offs.push(
|
||||
this.bus.on('door:verdict', ({ verdict }) => {
|
||||
if (verdict === 'admit') this.setInsideCount(this.count + 1);
|
||||
}),
|
||||
);
|
||||
this.offs.push(
|
||||
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
get insideCount(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
/** Lets a demo drive the room without staging a hundred real admissions. */
|
||||
setInsideCount(n: number): void {
|
||||
const next = Math.max(0, Math.floor(n));
|
||||
if (next === this.count) return;
|
||||
this.count = next;
|
||||
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
this.noise ??= noiseBuffer(this.ctx);
|
||||
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
|
||||
this.kebab = new KebabBed(this.ctx, this.dest);
|
||||
|
||||
// Snap rather than fade on the first frame: there is no previous location to
|
||||
// cross from, and a 700ms swell out of nothing sounds like a mistake.
|
||||
this.crowd.setLevel(this.crowdTarget(), 0);
|
||||
this.crowd.setLocation(this.crowdDuck(), 0);
|
||||
this.kebab.setLevel(this.kebabTarget(), 0);
|
||||
this.kebab.setLocation(this.kebabDuck(), 0);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
this.crowd?.stop();
|
||||
this.crowd = null;
|
||||
this.kebab?.stop();
|
||||
this.kebab = null;
|
||||
this.sfx.stopRain();
|
||||
}
|
||||
|
||||
setEnabled(bed: Bed, on: boolean): void {
|
||||
if (this.enabled[bed] === on) return;
|
||||
this.enabled[bed] = on;
|
||||
if (!this.running) return;
|
||||
|
||||
switch (bed) {
|
||||
case 'rain':
|
||||
return this.applyRain();
|
||||
case 'crowd':
|
||||
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
|
||||
case 'kebab':
|
||||
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
for (const off of this.offs) off();
|
||||
this.offs.length = 0;
|
||||
}
|
||||
|
||||
// --- policy ---------------------------------------------------------------
|
||||
|
||||
private applyLocation(): void {
|
||||
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
|
||||
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
|
||||
* bus; a second one would just be the first one twice as loud. Both calls are
|
||||
* idempotent, so repeated door -> door is a no-op there as well as here.
|
||||
*
|
||||
* The `running` guard is what the crowd and kebab beds get for free from their
|
||||
* null refs. Without it a location change after stop() — the subscription
|
||||
* outlives stop(), only destroy() drains it — would start rain under a room
|
||||
* with no crowd and no kebab in it, and nothing would take it back down.
|
||||
*/
|
||||
private applyRain(): void {
|
||||
if (!this.running) return;
|
||||
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
|
||||
else this.sfx.stopRain();
|
||||
}
|
||||
|
||||
private crowdTarget(): number {
|
||||
if (!this.enabled.crowd) return 0;
|
||||
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
|
||||
}
|
||||
|
||||
private crowdDuck(): number {
|
||||
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
|
||||
}
|
||||
|
||||
private kebabTarget(): number {
|
||||
return this.enabled.kebab ? KEBAB_LEVEL : 0;
|
||||
}
|
||||
|
||||
private kebabDuck(): number {
|
||||
return this.loc === 'door' ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
|
||||
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
|
||||
* band between them is where a room full of people sits.
|
||||
*/
|
||||
class CrowdBed {
|
||||
private readonly src: AudioBufferSourceNode;
|
||||
private readonly lfos: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
|
||||
this.ctx = ctx;
|
||||
|
||||
this.src = ctx.createBufferSource();
|
||||
this.src.buffer = buffer;
|
||||
this.src.loop = true;
|
||||
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
|
||||
this.src.playbackRate.value = 0.85;
|
||||
|
||||
const band = ctx.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = 500;
|
||||
band.Q.value = 0.75; // ~300-800Hz at -3dB
|
||||
|
||||
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 1100;
|
||||
|
||||
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
|
||||
// gain of 0 would push it negative, and an empty room has to be silent.
|
||||
const wobble = ctx.createGain();
|
||||
wobble.gain.value = 1;
|
||||
this.wobbler(wobble, 0.19, 0.12);
|
||||
this.wobbler(wobble, 0.07, 0.07);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(band, lp, wobble, this.level, this.loc);
|
||||
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
this.src.start();
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
|
||||
}
|
||||
|
||||
private wobbler(target: GainNode, hz: number, depth: number): void {
|
||||
const lfo = this.ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = hz;
|
||||
const amount = this.ctx.createGain();
|
||||
amount.gain.value = depth;
|
||||
lfo.connect(amount).connect(target.gain);
|
||||
lfo.start();
|
||||
this.lfos.push(lfo);
|
||||
this.chain.push(amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
|
||||
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
|
||||
* work. Mono and completely dry — it is six metres away through an open
|
||||
* shopfront, and reverb would put it in a cathedral.
|
||||
*/
|
||||
class KebabBed {
|
||||
private readonly oscs: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode) {
|
||||
this.ctx = ctx;
|
||||
|
||||
const sum = ctx.createGain();
|
||||
sum.gain.value = 1;
|
||||
|
||||
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
|
||||
// as a clean test tone.
|
||||
this.partial(100, 1, sum);
|
||||
this.partial(50, 0.45, sum);
|
||||
this.partial(200, 0.18, sum);
|
||||
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 400;
|
||||
|
||||
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
|
||||
// difference between "hum" and "sine wave someone left running".
|
||||
const flicker = ctx.createGain();
|
||||
flicker.gain.value = 1;
|
||||
const lfo = ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.6;
|
||||
const depth = ctx.createGain();
|
||||
depth.gain.value = 0.08;
|
||||
lfo.connect(depth).connect(flicker.gain);
|
||||
lfo.start();
|
||||
this.oscs.push(lfo);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
|
||||
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
|
||||
}
|
||||
|
||||
private partial(hz: number, gain: number, sum: GainNode): void {
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = hz;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.value = gain;
|
||||
osc.connect(g).connect(sum);
|
||||
osc.start();
|
||||
this.oscs.push(osc);
|
||||
this.chain.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
// --- plumbing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Linear throughout, unlike the engine's sweeps: these targets legitimately
|
||||
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
|
||||
* Re-anchoring at the live value first keeps an interrupted fade continuous —
|
||||
* players do walk in and straight back out.
|
||||
*
|
||||
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
|
||||
* `cancelScheduledValues(now)` removes every event at or after `now`, including
|
||||
* the in-flight ramp's end event, and the spec does not say what the getter
|
||||
* reads afterwards: Blink returns the last rendered value, but a UA computing it
|
||||
* from the surviving timeline returns the ramp's origin. Reading first pins the
|
||||
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
|
||||
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
|
||||
*
|
||||
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
|
||||
* which is the same thing done properly — but Firefox has never shipped it, so
|
||||
* feature-detect and fall back.
|
||||
*/
|
||||
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
|
||||
const now = ctx.currentTime;
|
||||
const from = param.value;
|
||||
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
|
||||
else param.cancelScheduledValues(now);
|
||||
param.setValueAtTime(from, now);
|
||||
if (durS <= 0) param.setValueAtTime(to, now);
|
||||
else param.linearRampToValueAtTime(to, now + durS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
|
||||
* running noise bed is audible as a click, and leaving the nodes attached means
|
||||
* a six-hour night accumulates every bed it ever started.
|
||||
*/
|
||||
function stopBed(
|
||||
ctx: AudioContext,
|
||||
out: GainNode,
|
||||
src: AudioBufferSourceNode | null,
|
||||
oscs: readonly OscillatorNode[],
|
||||
chain: readonly AudioNode[],
|
||||
): void {
|
||||
const fade = 0.25;
|
||||
const at = ctx.currentTime + fade;
|
||||
ramp(ctx, out.gain, 0, fade);
|
||||
|
||||
const tail = src ?? oscs[0];
|
||||
if (tail) {
|
||||
tail.onended = () => {
|
||||
for (const n of chain) n.disconnect();
|
||||
src?.disconnect();
|
||||
for (const o of oscs) o.disconnect();
|
||||
};
|
||||
}
|
||||
src?.stop(at);
|
||||
for (const o of oscs) o.stop(at);
|
||||
}
|
||||
|
||||
/** Deterministic white noise — house rule forbids Math.random, and a fixed seed
|
||||
* means the room sounds the same on every run. */
|
||||
function noiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
|
||||
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
@ -1,11 +1,22 @@
|
||||
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,
|
||||
@ -29,28 +40,33 @@ const TIMER_MS = 25;
|
||||
*/
|
||||
const START_LEAD_MS = 60;
|
||||
|
||||
const MASTER_GAIN = 0.8;
|
||||
|
||||
// Resonance: a hair of peak at the cutoff is what reads as "bass through a wall"
|
||||
// rather than "someone turned the treble down". Flat once you're on the floor.
|
||||
const DOOR_Q = 6;
|
||||
const FLOOR_Q = 0.7;
|
||||
|
||||
const KICK_PEAK = 0.9;
|
||||
const HAT_PEAK = 0.25;
|
||||
const BASS_PEAK = 0.11;
|
||||
const PAD_PEAK = 0.05;
|
||||
|
||||
const SILENT = 0.0001; // exponential ramps cannot reach 0
|
||||
|
||||
const BASS_ROOT_HZ = 55; // A1
|
||||
const BASS_STEPS = 32; // 2 bars of 16ths
|
||||
const PAD_BARS = 8;
|
||||
|
||||
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
|
||||
const BASS_SWEEP_S = 0.03;
|
||||
|
||||
/**
|
||||
* Semitones from A1, 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.
|
||||
* 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,
|
||||
@ -70,6 +86,29 @@ const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorTy
|
||||
{ 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;
|
||||
@ -85,6 +124,10 @@ export interface TechnoEngineOptions {
|
||||
* 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;
|
||||
@ -105,6 +148,12 @@ export class TechnoEngine {
|
||||
*/
|
||||
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';
|
||||
@ -122,13 +171,13 @@ export class TechnoEngine {
|
||||
this.grid = { bpm: this.bpm, originMs: 0 };
|
||||
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = MASTER_GAIN;
|
||||
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 = DOOR_Q;
|
||||
this.filter.Q.value = this.qFor(this.loc);
|
||||
this.filter.connect(this.master);
|
||||
|
||||
this.musicBus = this.ctx.createGain();
|
||||
@ -144,7 +193,14 @@ export class TechnoEngine {
|
||||
|
||||
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 {
|
||||
@ -163,6 +219,76 @@ export class TechnoEngine {
|
||||
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.
|
||||
@ -228,7 +354,7 @@ export class TechnoEngine {
|
||||
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, location === 'floor' ? FLOOR_Q : DOOR_Q, 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. */
|
||||
@ -245,6 +371,7 @@ export class TechnoEngine {
|
||||
this.stop();
|
||||
for (const off of this.unsubs) off();
|
||||
this.unsubs.length = 0;
|
||||
if (activeEngine === this) activeEngine = null;
|
||||
void this.ctx.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
@ -290,104 +417,148 @@ export class TechnoEngine {
|
||||
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;
|
||||
|
||||
this.kick(timeMs / 1000);
|
||||
// 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 (offbeat !== undefined) this.hat(offbeat / 1000);
|
||||
if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);
|
||||
|
||||
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, BASS_ROOT_HZ * Math.pow(2, semi / 12));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
|
||||
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): void {
|
||||
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(150, at);
|
||||
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06);
|
||||
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(KICK_PEAK, at + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
|
||||
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 + 0.36);
|
||||
osc.stop(at + decayS + 0.02);
|
||||
this.hold(osc, [gain]);
|
||||
}
|
||||
|
||||
private hat(at: number): void {
|
||||
private hat(at: number, peak: number): void {
|
||||
const h = this.mixState.hat;
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = this.noiseBuffer;
|
||||
// Cheap variety: start the read head somewhere different each hit so the
|
||||
// one shared buffer doesn't ring identically 400 times a minute.
|
||||
const offset = (at * 7.3) % (this.noiseBuffer.duration - 0.1);
|
||||
|
||||
const hp = this.ctx.createBiquadFilter();
|
||||
hp.type = 'highpass';
|
||||
hp.frequency.value = 7000;
|
||||
hp.frequency.value = h.hpHz;
|
||||
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(HAT_PEAK, at);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
|
||||
gain.gain.setValueAtTime(peak, at);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
|
||||
|
||||
src.connect(hp).connect(gain).connect(this.musicBus);
|
||||
src.start(at, offset, 0.06);
|
||||
// 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): void {
|
||||
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 = 8;
|
||||
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(180, at);
|
||||
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03);
|
||||
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11);
|
||||
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(BASS_PEAK, at + 0.006);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
|
||||
|
||||
lp.connect(gain).connect(this.musicBus);
|
||||
|
||||
for (const detune of [-8, 8]) {
|
||||
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 + 0.13);
|
||||
this.hold(osc, detune > 0 ? [lp, gain] : []);
|
||||
}
|
||||
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): void {
|
||||
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000;
|
||||
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 = 600;
|
||||
lp.frequency.value = this.mixState.pad.lpHz;
|
||||
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
|
||||
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
|
||||
gain.gain.linearRampToValueAtTime(0, at + cycle);
|
||||
|
||||
lp.connect(gain).connect(this.musicBus);
|
||||
@ -406,6 +577,44 @@ export class TechnoEngine {
|
||||
|
||||
// --- 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.
|
||||
|
||||
@ -3,6 +3,9 @@ import { EventBus } from '../core/EventBus';
|
||||
import { GameClock } from '../core/GameClock';
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import { Meters, freshNightState } from '../core/meters';
|
||||
import { Ambience } from '../audio/Ambience';
|
||||
import { arcLabel } from '../audio/arc';
|
||||
import { VOICE_NAMES } from '../audio/mix';
|
||||
import { Sfx, type SfxName } from '../audio/Sfx';
|
||||
import { TechnoEngine } from '../audio/TechnoEngine';
|
||||
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
||||
@ -10,6 +13,7 @@ import type { DressCodeRule } from '../data/types';
|
||||
import { Clicker } from './Clicker';
|
||||
import { DialogueBox } from './DialogueBox';
|
||||
import { MeterHud } from './MeterHud';
|
||||
import { MixDeskScene, type MixDeskData } from './MixDeskScene';
|
||||
import { Phone } from './Phone';
|
||||
import { stamp, type StampHandle } from './Stamp';
|
||||
import { UI, chunkyButton, font } from './style';
|
||||
@ -56,6 +60,37 @@ const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
|
||||
['typeTick', 'TYPE TICK'],
|
||||
];
|
||||
|
||||
/** Structural copy of Ambience's private `Bed` union — it doesn't export one. */
|
||||
type BedName = 'rain' | 'crowd' | 'kebab';
|
||||
|
||||
/** Beds default to a quiet street: room and kebab shop on, weather off, so RAIN
|
||||
* still reads as a thing you switch on the way Phase 1 had it. */
|
||||
const BED_DEFAULTS: Readonly<Record<BedName, boolean>> = { rain: false, crowd: true, kebab: true };
|
||||
|
||||
const BEDS: readonly BedName[] = ['rain', 'crowd', 'kebab'];
|
||||
|
||||
/** Inside-count nudges. ±10 exists because the crowd bed's curve saturates and
|
||||
* you cannot hear that by clicking +1 eleven times. */
|
||||
const COUNT_STEPS: readonly number[] = [-10, -1, 1, 10];
|
||||
|
||||
const MIX_BTN_X = 176;
|
||||
const MIX_BTN_Y = 84;
|
||||
|
||||
const ROOM_X = 100;
|
||||
const NIGHT_X = 396;
|
||||
|
||||
/**
|
||||
* Midnight — the PEAK keyframe, where every voice scales to 1. The scene pins here
|
||||
* on open because it is a tuning rig, not a night: unpinned it starts at 21:00
|
||||
* EMPTY, where bass and pad scale to 0, and GameClock's 360 game-minutes over 13
|
||||
* real minutes means nobody hears a bassline for the first ~2 minutes. That reads
|
||||
* as a broken engine. The button beside the readout hands the arc back.
|
||||
*/
|
||||
const PIN_MINUTE = 180;
|
||||
|
||||
const PIN_LABEL = 'PINNED @ PEAK · TAP=FOLLOW';
|
||||
const FOLLOW_LABEL = 'FOLLOWING CLOCK · TAP=PIN';
|
||||
|
||||
interface PendingBeat {
|
||||
beatIndex: number;
|
||||
audioTimeMs: number;
|
||||
@ -64,6 +99,21 @@ interface PendingBeat {
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
const pad2 = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/** arcMinute is minutes since 21:00, and may be pinned away from the game clock. */
|
||||
const wallClock = (min: number): string => {
|
||||
const t = (21 * 60 + Math.round(min)) % (24 * 60);
|
||||
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
|
||||
};
|
||||
|
||||
/** Ten cells of ASCII. Monospace is already the house font; a real bar would be
|
||||
* four game objects per voice refreshed every frame for the same information. */
|
||||
const meterBar = (v: number): string => {
|
||||
const n = clamp(Math.round(v * 10), 0, 10);
|
||||
return '#'.repeat(n) + '-'.repeat(10 - n);
|
||||
};
|
||||
|
||||
const hzFromT = (t: number): number => MIN_HZ * Math.pow(HZ_RATIO, clamp(t, 0, 1));
|
||||
const tFromHz = (hz: number): number =>
|
||||
clamp(Math.log(clamp(hz, MIN_HZ, MAX_HZ) / MIN_HZ) / Math.log(HZ_RATIO), 0, 1);
|
||||
@ -86,6 +136,7 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
|
||||
private engine!: TechnoEngine;
|
||||
private sfx!: Sfx;
|
||||
private ambience!: Ambience;
|
||||
|
||||
private hud!: MeterHud;
|
||||
private phone!: Phone;
|
||||
@ -95,15 +146,19 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
|
||||
private statusText!: Phaser.GameObjects.Text;
|
||||
private hzText!: Phaser.GameObjects.Text;
|
||||
private arcText!: Phaser.GameObjects.Text;
|
||||
private insideText!: Phaser.GameObjects.Text;
|
||||
private beatPad!: Phaser.GameObjects.Rectangle;
|
||||
private beatRing!: Phaser.GameObjects.Rectangle;
|
||||
private handle!: Phaser.GameObjects.Rectangle;
|
||||
private locButton!: Phaser.GameObjects.Container;
|
||||
private rainButton!: Phaser.GameObjects.Container;
|
||||
private arcPinButton!: Phaser.GameObjects.Container;
|
||||
private patronRoot!: Phaser.GameObjects.Container;
|
||||
|
||||
private readonly stampMarks: StampHandle[] = [];
|
||||
private readonly pendingBeats: PendingBeat[] = [];
|
||||
private readonly bedButtons: Array<{ bed: BedName; btn: Phaser.GameObjects.Container }> = [];
|
||||
private readonly bedsOn: Record<BedName, boolean> = { ...BED_DEFAULTS };
|
||||
private beatFlash = 0;
|
||||
private beatFlashBar = false;
|
||||
private lastBeat = -1;
|
||||
@ -111,9 +166,21 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
|
||||
private overrideHz: number | null = null;
|
||||
private dragging = false;
|
||||
private raining = false;
|
||||
private dazzaIndex = 0;
|
||||
private audioStarting = false;
|
||||
/** Last pin state pushed to the button label; null forces a sync on the first frame. */
|
||||
private arcPinShown: boolean | null = null;
|
||||
|
||||
/**
|
||||
* The desk duck-types anything with startRain/stopRain and drives it from its
|
||||
* header. Handing it this adapter rather than the Ambience keeps a single owner
|
||||
* of rain policy — two objects both calling Sfx.startRain is how you get a bed
|
||||
* that will not switch off — and keeps the demo's own RAIN button in step.
|
||||
*/
|
||||
private readonly deskRain = {
|
||||
startRain: (): void => this.setBed('rain', true),
|
||||
stopRain: (): void => this.setBed('rain', false),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('JuiceDemo');
|
||||
@ -135,14 +202,16 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
|
||||
this.overrideHz = null;
|
||||
this.dragging = false;
|
||||
this.raining = false;
|
||||
this.dazzaIndex = 0;
|
||||
this.audioStarting = false;
|
||||
this.arcPinShown = null;
|
||||
|
||||
this.dialogue = null;
|
||||
this.dialogueKill = false;
|
||||
// Shutdown already destroyed the objects these handles point at.
|
||||
this.stampMarks.length = 0;
|
||||
this.bedButtons.length = 0;
|
||||
Object.assign(this.bedsOn, BED_DEFAULTS);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@ -156,7 +225,20 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
// The graph is built in the constructor, so Sfx can be wired now and simply
|
||||
// stays silent until the context resumes. No post-unlock re-wiring needed.
|
||||
this.engine = new TechnoEngine(this.bus);
|
||||
// Every run, not just the first: a restart builds a fresh engine whose arc
|
||||
// defaults to following a clock that has just been reset to 21:00.
|
||||
this.engine.setArcMinute(PIN_MINUTE);
|
||||
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
|
||||
// Same lifetime as Sfx, and on the same bus: the beds are dry ambience, not
|
||||
// music, so the door's lowpass has no business touching them.
|
||||
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx, {
|
||||
...this.bedsOn,
|
||||
});
|
||||
|
||||
// Registering the desk here is the only route to it: main.ts is
|
||||
// integration-owned, so nothing else can put it in the scene manager.
|
||||
// Re-adding a live key throws, and Phaser keeps scenes across a restart.
|
||||
if (!this.scene.get('MixDesk')) this.scene.add('MixDesk', MixDeskScene, false);
|
||||
|
||||
this.buildBackdrop();
|
||||
this.buildBeatIndicator();
|
||||
@ -164,6 +246,9 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
this.buildStatus();
|
||||
this.buildSfxColumn();
|
||||
this.buildActionColumn();
|
||||
this.buildMixDeskButton();
|
||||
this.buildRoomPanel();
|
||||
this.buildNightPanel();
|
||||
this.buildAudioStrip();
|
||||
|
||||
this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
|
||||
@ -186,6 +271,7 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
this.buildUnlockOverlay();
|
||||
|
||||
this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
|
||||
this.input.keyboard?.on('keydown-M', () => this.openMixDesk());
|
||||
|
||||
this.events.once('shutdown', () => this.teardown());
|
||||
}
|
||||
@ -204,7 +290,7 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
.text(
|
||||
TRACK_X0,
|
||||
336,
|
||||
'sounds down the left · verdicts down the right · drag MUFFLE to open the door on the bass',
|
||||
'sounds left · verdicts right · M for the mix desk · drag MUFFLE to open the door',
|
||||
font(7, UI.paper),
|
||||
)
|
||||
.setAlpha(0.45);
|
||||
@ -253,13 +339,89 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
onClick: () => this.sfx.play(name),
|
||||
});
|
||||
});
|
||||
this.rainButton = chunkyButton(this, 50, 80 + SFX_BUTTONS.length * 16, 'RAIN: OFF', {
|
||||
w: 76,
|
||||
h: 13,
|
||||
fontSize: 7,
|
||||
fill: UI.grime,
|
||||
onClick: () => this.toggleRain(),
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's headline: everything else on this screen is a widget, this is the
|
||||
* thing you sit at for ten minutes and leave with a diff. Sized to say so.
|
||||
*/
|
||||
private buildMixDeskButton(): void {
|
||||
chunkyButton(this, MIX_BTN_X, MIX_BTN_Y, 'MIX DESK [M]', {
|
||||
w: 152,
|
||||
h: 26,
|
||||
fontSize: 12,
|
||||
fill: UI.neonPink,
|
||||
onClick: () => this.openMixDesk(),
|
||||
});
|
||||
this.add
|
||||
.text(MIX_BTN_X, MIX_BTN_Y + 20, 'the ear pass lives here', font(7, UI.paper))
|
||||
.setOrigin(0.5, 0)
|
||||
.setAlpha(0.45);
|
||||
}
|
||||
|
||||
/** Ambience beds plus a way to fill the room without admitting ninety people. */
|
||||
private buildRoomPanel(): void {
|
||||
this.add.text(ROOM_X, 116, 'THE ROOM', font(7, UI.kebabAmber)).setAlpha(0.8);
|
||||
|
||||
BEDS.forEach((bed, i) => {
|
||||
const btn = chunkyButton(this, ROOM_X + 38, 130 + i * 16, this.bedLabel(bed), {
|
||||
w: 84,
|
||||
h: 13,
|
||||
fontSize: 7,
|
||||
fill: UI.grime,
|
||||
onClick: () => this.setBed(bed, !this.bedsOn[bed]),
|
||||
});
|
||||
this.bedButtons.push({ bed, btn });
|
||||
});
|
||||
|
||||
// Rain and kebab are door-only and the crowd all but vanishes out there, so
|
||||
// an ON bed can still be silent. Say so before someone files it as a bug.
|
||||
this.add
|
||||
.text(ROOM_X, 172, 'rain + kebab outside · crowd inside', font(7, UI.paper))
|
||||
.setAlpha(0.35);
|
||||
|
||||
this.insideText = this.add.text(ROOM_X, 186, '', font(8, UI.neonGreen));
|
||||
|
||||
COUNT_STEPS.forEach((step, i) => {
|
||||
chunkyButton(this, ROOM_X + 17 + i * 36, 206, step > 0 ? `+${step}` : String(step), {
|
||||
w: 34,
|
||||
h: 13,
|
||||
fontSize: 7,
|
||||
onClick: () => this.ambience.setInsideCount(this.ambience.insideCount + step),
|
||||
});
|
||||
});
|
||||
|
||||
this.add
|
||||
.text(ROOM_X, 218, 'the crowd bed swells as it fills', font(7, UI.paper))
|
||||
.setAlpha(0.35);
|
||||
}
|
||||
|
||||
/** What the night is doing to the mix, which is otherwise entirely invisible. */
|
||||
private buildNightPanel(): void {
|
||||
this.add.text(NIGHT_X, 116, 'THE NIGHT', font(7, UI.kebabAmber)).setAlpha(0.8);
|
||||
this.arcText = this.add.text(NIGHT_X, 130, '', font(7, UI.neonGreen)).setLineSpacing(2);
|
||||
|
||||
// Width 128 keeps this clear of THE JOB's column, which starts at x 528.
|
||||
this.arcPinButton = chunkyButton(this, NIGHT_X + 64, 198, PIN_LABEL, {
|
||||
w: 128,
|
||||
h: 14,
|
||||
fontSize: 7,
|
||||
fill: UI.kebabAmber,
|
||||
textColour: UI.ink,
|
||||
onClick: () => this.toggleArcPin(),
|
||||
});
|
||||
|
||||
this.add
|
||||
.text(NIGHT_X, 208, 'pinned at PEAK: everything on', font(7, UI.paper))
|
||||
.setAlpha(0.35);
|
||||
this.add
|
||||
.text(NIGHT_X, 218, 'scrub the hour in the mix desk', font(7, UI.paper))
|
||||
.setAlpha(0.35);
|
||||
}
|
||||
|
||||
/** Reads the engine rather than a local flag: the mix desk pins and unpins too. */
|
||||
private toggleArcPin(): void {
|
||||
this.engine.setArcMinute(this.engine.arcPinned ? null : PIN_MINUTE);
|
||||
}
|
||||
|
||||
private buildActionColumn(): void {
|
||||
@ -291,7 +453,9 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private buildAudioStrip(): void {
|
||||
this.hzText = this.add.text(TRACK_X0, 296, '', font(8, UI.neonGreen));
|
||||
// Right-aligned to the far end of the track: at TRACK_X0 it printed straight
|
||||
// through the MUFFLE label, which is the same overlap defect Phase 1 caught.
|
||||
this.hzText = this.add.text(TRACK_X1, 296, '', font(8, UI.neonGreen)).setOrigin(1, 0);
|
||||
|
||||
const track = this.add
|
||||
.rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel)
|
||||
@ -363,6 +527,9 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
.then(() => {
|
||||
if (stale()) return;
|
||||
engine.start();
|
||||
// Beds need a running context to build their sources on; before this
|
||||
// they would be four seconds of noise nobody ever hears.
|
||||
this.ambience.start();
|
||||
root.destroy(true);
|
||||
})
|
||||
.catch(() => {
|
||||
@ -442,11 +609,30 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
this.bus.emit('dazza:text', { text: line.text, rule });
|
||||
}
|
||||
|
||||
private toggleRain(): void {
|
||||
this.raining = !this.raining;
|
||||
if (this.raining) this.sfx.startRain();
|
||||
else this.sfx.stopRain();
|
||||
labelOf(this.rainButton)?.setText(this.raining ? 'RAIN: ON' : 'RAIN: OFF');
|
||||
private bedLabel(bed: BedName): string {
|
||||
return `${bed.toUpperCase().padEnd(5)} ${this.bedsOn[bed] ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
|
||||
/** chunkyButton restores its own fill on release, so latched state has to live
|
||||
* in the label. Same trick the Phase-1 rain button used. */
|
||||
private setBed(bed: BedName, on: boolean): void {
|
||||
this.bedsOn[bed] = on;
|
||||
this.ambience.setEnabled(bed, on);
|
||||
const entry = this.bedButtons.find((b) => b.bed === bed);
|
||||
if (entry) labelOf(entry.btn)?.setText(this.bedLabel(bed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Launched, not started: the desk is an overlay and the music has to keep
|
||||
* running underneath it while you turn things. The engine goes in the payload
|
||||
* rather than leaning on getActiveEngine() so a restart cannot hand the desk
|
||||
* the previous run's closed context.
|
||||
*/
|
||||
private openMixDesk(): void {
|
||||
if (this.scene.isActive('MixDesk')) return;
|
||||
const data: MixDeskData = { engine: this.engine, ambience: this.deskRain };
|
||||
this.scene.launch('MixDesk', data);
|
||||
this.scene.bringToTop('MixDesk');
|
||||
}
|
||||
|
||||
private setCutoffFromX(x: number): void {
|
||||
@ -481,6 +667,7 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
this.renderBeat(delta);
|
||||
this.syncSlider();
|
||||
this.renderStatus();
|
||||
this.renderNight();
|
||||
|
||||
this.hud.update(delta);
|
||||
this.phone.update(delta);
|
||||
@ -545,7 +732,32 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arc is a silent multiplier on every voice, so without a readout "the bass
|
||||
* is missing" at 9 PM looks like a broken engine rather than an empty room.
|
||||
*/
|
||||
private renderNight(): void {
|
||||
const min = this.engine.arcMinute;
|
||||
const pinned = this.engine.arcPinned;
|
||||
// The desk drops the pin on its way out, so the label is derived, not latched.
|
||||
if (pinned !== this.arcPinShown) {
|
||||
this.arcPinShown = pinned;
|
||||
labelOf(this.arcPinButton)?.setText(pinned ? PIN_LABEL : FOLLOW_LABEL);
|
||||
}
|
||||
const scales = this.engine.arcScales;
|
||||
const lines = [
|
||||
`${wallClock(min)} · ${arcLabel(min)}${pinned ? ' · PINNED' : ''}`,
|
||||
...VOICE_NAMES.map((v) => `${v.padEnd(4)} |${meterBar(scales[v])}| ${scales[v].toFixed(2)}`),
|
||||
];
|
||||
this.arcText.setText(lines.join('\n'));
|
||||
this.insideText.setText(`INSIDE: ${this.ambience.insideCount}`);
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
// Before the engine goes: the desk holds a reference to it and writes knobs
|
||||
// every frame, and a closed AudioContext throws when you touch its params.
|
||||
if (this.scene.isActive('MixDesk')) this.scene.stop('MixDesk');
|
||||
this.input.keyboard?.off('keydown-M');
|
||||
this.input.keyboard?.off('keydown-SPACE');
|
||||
for (const m of this.stampMarks) m.decal.destroy();
|
||||
this.stampMarks.length = 0;
|
||||
@ -554,7 +766,9 @@ export class JuiceDemoScene extends Phaser.Scene {
|
||||
this.phone.destroy();
|
||||
this.clicker.destroy();
|
||||
this.hud.destroy();
|
||||
// Sfx first: engine.destroy() closes the context its nodes hang off.
|
||||
// Ambience before Sfx (it calls stopRain on the way out), Sfx before the
|
||||
// engine, which closes the context all of their nodes hang off.
|
||||
this.ambience.destroy();
|
||||
this.sfx.destroy();
|
||||
this.engine.destroy();
|
||||
this.meters.destroy();
|
||||
|
||||
724
src/ui/MixDeskScene.ts
Normal file
724
src/ui/MixDeskScene.ts
Normal file
@ -0,0 +1,724 @@
|
||||
import Phaser from 'phaser';
|
||||
import { NIGHT_END_MIN, arcLabel } from '../audio/arc';
|
||||
import { getActiveEngine, type TechnoEngine } from '../audio/TechnoEngine';
|
||||
import {
|
||||
MIX_RANGES,
|
||||
VOICE_NAMES,
|
||||
clampTo,
|
||||
formatMix,
|
||||
soloedVoice,
|
||||
voiceGain,
|
||||
type VoiceName,
|
||||
} from '../audio/mix';
|
||||
import { UI, chunkyButton, font, panel } from './style';
|
||||
|
||||
// The ear pass, as a scene.
|
||||
//
|
||||
// Every number in the track was chosen by an agent that cannot hear. This desk
|
||||
// exists so a human can sit down for ten minutes, solo one voice, drag the knob
|
||||
// that is wrong, and leave with a diff. That last part is the whole point: a
|
||||
// tuning session that does not end in a pasteable TrackMix literal was a
|
||||
// conversation, not a change. Hence DUMP MIX, and hence the readouts showing
|
||||
// *effective* gain rather than the raw level — the arc is a multiplier, and a
|
||||
// kick that reads as too quiet at 9 PM may be perfectly set for midnight.
|
||||
|
||||
const CANVAS_W = 640;
|
||||
const CANVAS_H = 360;
|
||||
|
||||
/** Hint grey. UI.dim is tuned for the game's panels and vanishes at 6px on this backdrop. */
|
||||
const HINT = 0x8a8aa0;
|
||||
|
||||
const PARAM_ROWS_MAX = 6;
|
||||
|
||||
export interface MixDeskData {
|
||||
engine?: TechnoEngine;
|
||||
/** Duck-typed: Ambience is a sibling lane's file and the desk must not hard-depend on it. */
|
||||
ambience?: unknown;
|
||||
}
|
||||
|
||||
interface RainSource {
|
||||
startRain(): void;
|
||||
stopRain(): void;
|
||||
}
|
||||
|
||||
const isRainSource = (o: unknown): o is RainSource => {
|
||||
if (typeof o !== 'object' || o === null) return false;
|
||||
const r = o as Partial<RainSource>;
|
||||
return typeof r.startRain === 'function' && typeof r.stopRain === 'function';
|
||||
};
|
||||
|
||||
interface ParamSpec {
|
||||
/** Dotted path understood by TechnoEngine.setMixValue. */
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
readonly hint: string;
|
||||
readonly range: keyof typeof MIX_RANGES;
|
||||
/** Engine quantises this one, so the desk must too — see SliderSpec.integer. */
|
||||
readonly integer?: true;
|
||||
}
|
||||
|
||||
interface ParamGroup {
|
||||
readonly name: string;
|
||||
readonly rows: readonly ParamSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The knob table. Rows are data so the layout code is written once — twenty
|
||||
* hand-rolled rows is twenty places for a path typo to hide.
|
||||
*/
|
||||
const GROUPS: readonly ParamGroup[] = [
|
||||
{
|
||||
name: 'LEVELS',
|
||||
rows: [
|
||||
{ path: 'master', label: 'master', hint: 'post-filter output. mind your ears.', range: 'master' },
|
||||
{ path: 'levels.kick', label: 'kick level', hint: 'raw gain, before the arc scales it.', range: 'level' },
|
||||
{ path: 'levels.hat', label: 'hat level', hint: 'the tick. easy to over-serve.', range: 'level' },
|
||||
{ path: 'levels.bass', label: 'bass level', hint: 'the door filter eats most of this.', range: 'level' },
|
||||
{ path: 'levels.pad', label: 'pad level', hint: 'glue. hear it as a part, too loud.', range: 'level' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'KICK',
|
||||
rows: [
|
||||
{ path: 'kick.startHz', label: 'kick startHz', hint: 'beater pitch at the transient. click.', range: 'kick.startHz' },
|
||||
{ path: 'kick.endHz', label: 'kick endHz', hint: 'where the drop lands. the body.', range: 'kick.endHz' },
|
||||
{ path: 'kick.dropS', label: 'kick dropS', hint: 'length of the fall. longer = bigger box.', range: 'kick.dropS' },
|
||||
{ path: 'kick.attackS', label: 'kick attackS', hint: 'slower attack rounds off the front.', range: 'kick.attackS' },
|
||||
{ path: 'kick.decayS', label: 'kick decayS', hint: 'tail. too long and it smears the bass.', range: 'kick.decayS' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'HAT + PAD',
|
||||
rows: [
|
||||
{ path: 'hat.hpHz', label: 'hat hpHz', hint: 'highpass corner. lower = more body.', range: 'hat.hpHz' },
|
||||
{ path: 'hat.decayS', label: 'hat decayS', hint: 'tick versus sizzle lives right here.', range: 'hat.decayS' },
|
||||
{ path: 'pad.lpHz', label: 'pad lpHz', hint: 'brightness of the wash behind it all.', range: 'pad.lpHz' },
|
||||
{ path: 'pad.bars', label: 'pad bars', hint: 'bars per fade. longer = unnoticed.', range: 'pad.bars', integer: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'BASS',
|
||||
rows: [
|
||||
{ path: 'bass.rootHz', label: 'bass rootHz', hint: 'A1 by default. move it, move the lot.', range: 'bass.rootHz' },
|
||||
{ path: 'bass.filterLoHz', label: 'bass filterLo', hint: 'floor of the per-note filter sweep.', range: 'bass.filterLoHz' },
|
||||
{ path: 'bass.filterHiHz', label: 'bass filterHi', hint: 'peak of the sweep. this is the wow.', range: 'bass.filterHiHz' },
|
||||
{ path: 'bass.q', label: 'bass q', hint: 'resonance on the sweep. past 12 it whistles.', range: 'bass.q' },
|
||||
{ path: 'bass.decayS', label: 'bass decayS', hint: 'note length. short rolls, long drones.', range: 'bass.decayS' },
|
||||
{ path: 'bass.detuneCents', label: 'bass detune', hint: 'cents between the saws. the character.', range: 'bass.detuneCents' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ROOM',
|
||||
rows: [
|
||||
{ path: 'doorQ', label: 'door Q', hint: 'resonant peak outside — the wall cue.', range: 'doorQ' },
|
||||
{ path: 'floorQ', label: 'floor Q', hint: 'inside. flat is right; you are in the room.', range: 'floorQ' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* What to actually listen for. The difference between "sounds fine" and a useful
|
||||
* answer is usually just knowing which question was being asked.
|
||||
*/
|
||||
const LISTEN_FOR: Record<VoiceName, string> = {
|
||||
kick: 'KICK — does it read as a kick through a wall, or just a thud? If it thuds, more startHz and a shorter dropS.',
|
||||
hat: 'HAT — should tick, not hiss. Sizzling means hpHz is too low or decayS too long.',
|
||||
bass: 'BASS — should roll under the kick, not fight it. If the two blur together, cut kick decayS first.',
|
||||
pad: 'PAD — glue, not a part. If you can name the chord, the level is too high.',
|
||||
};
|
||||
|
||||
const NO_SOLO = 'Nothing soloed — SOLO a voice to judge it on its own, then unsolo to hear it in the room.';
|
||||
|
||||
/** Readable at a glance across four orders of magnitude (0.008 s up to 14000 Hz). */
|
||||
const fmt = (v: number): string => {
|
||||
const a = Math.abs(v);
|
||||
if (a >= 100) return v.toFixed(0);
|
||||
if (a >= 10) return v.toFixed(1);
|
||||
if (a >= 1) return v.toFixed(2);
|
||||
return v.toFixed(3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Text.setColor is unguarded: it goes through TextStyle.setFill to updateText(),
|
||||
* which resizes and clears the backing canvas, re-measures, re-fills and re-uploads
|
||||
* the GL texture — no no-op check, unlike setText. refreshAll() runs every frame, so
|
||||
* an unconditional setColor is hundreds of texture uploads a second on the same main
|
||||
* thread as the audio scheduler, and the rig degrades the sound it exists to judge.
|
||||
* Keyed weakly: entries die with the Text, so a scene restart starts cold.
|
||||
*/
|
||||
const lastColour = new WeakMap<Phaser.GameObjects.Text, string>();
|
||||
const setColour = (t: Phaser.GameObjects.Text | null | undefined, css: string): void => {
|
||||
if (!t || lastColour.get(t) === css) return;
|
||||
lastColour.set(t, css);
|
||||
t.setColor(css);
|
||||
};
|
||||
|
||||
const pad2 = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/** clockMin is minutes since 21:00. */
|
||||
const wallClock = (clockMin: number): string => {
|
||||
const t = (21 * 60 + Math.round(clockMin)) % (24 * 60);
|
||||
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
|
||||
};
|
||||
|
||||
interface SliderSpec {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
range: readonly [number, number];
|
||||
/** Frequencies get a log taper: linear Hz spends most of its travel where nothing changes. */
|
||||
log?: boolean;
|
||||
/**
|
||||
* Snap to whole numbers on write. Without it a slider can hold a value the engine
|
||||
* never uses (pad.bars rounds internally) and DUMP MIX exports that phantom — a
|
||||
* mix literal that does not describe what was heard is worse than no dump at all.
|
||||
*/
|
||||
integer?: boolean;
|
||||
read: () => number;
|
||||
write: (v: number) => void;
|
||||
format?: (v: number) => string;
|
||||
}
|
||||
|
||||
interface Refreshable {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface ToggleSpec {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h?: number;
|
||||
label: string;
|
||||
on: () => boolean;
|
||||
click: () => void;
|
||||
onFill?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning desk for the techno engine, run as an overlay above whatever scene owns
|
||||
* the audio. It never pauses that scene — the music has to keep playing while
|
||||
* you turn things.
|
||||
*/
|
||||
export class MixDeskScene extends Phaser.Scene {
|
||||
private engine: TechnoEngine | null = null;
|
||||
/** Arc pin as we found it, restored on close. null = was following the clock. */
|
||||
private entryArc: number | null = null;
|
||||
private entryEnabled: Record<VoiceName, boolean> | null = null;
|
||||
private rain: RainSource | null = null;
|
||||
private raining = false;
|
||||
|
||||
/** Everything alive for the whole scene; page-scoped objects live in `paramOwned`. */
|
||||
private owned: Phaser.GameObjects.GameObject[] = [];
|
||||
private paramOwned: Phaser.GameObjects.GameObject[] = [];
|
||||
|
||||
private refreshers: Refreshable[] = [];
|
||||
private paramRefreshers: Refreshable[] = [];
|
||||
|
||||
private page = 0;
|
||||
private groupTitle: Phaser.GameObjects.Text | null = null;
|
||||
private pageLabel: Phaser.GameObjects.Text | null = null;
|
||||
private listenText: Phaser.GameObjects.Text | null = null;
|
||||
private statusText: Phaser.GameObjects.Text | null = null;
|
||||
private arcText: Phaser.GameObjects.Text | null = null;
|
||||
private scaleText: Phaser.GameObjects.Text | null = null;
|
||||
private toastText: Phaser.GameObjects.Text | null = null;
|
||||
private toastTween: Phaser.Tweens.Tween | null = null;
|
||||
|
||||
private readonly onEsc = (): void => {
|
||||
this.scene.stop();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('MixDesk');
|
||||
}
|
||||
|
||||
init(data: MixDeskData): void {
|
||||
// A desk that only works in the demo is useless on a real night, where the
|
||||
// scene holding the engine is not one we are allowed to import.
|
||||
this.engine = data.engine ?? getActiveEngine();
|
||||
this.rain = isRainSource(data.ambience) ? data.ambience : null;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Phaser reuses the scene instance across restarts and only re-runs create();
|
||||
// field initialisers fired once, ever.
|
||||
this.owned = [];
|
||||
this.paramOwned = [];
|
||||
this.refreshers = [];
|
||||
this.paramRefreshers = [];
|
||||
this.page = 0;
|
||||
this.raining = false;
|
||||
this.toastTween = null;
|
||||
|
||||
// Snapshot the monitoring state we are about to borrow. Closing the desk
|
||||
// restores this rather than forcing a default: JuiceDemoScene deliberately
|
||||
// pins the arc at PEAK so every voice is audible the moment you open it, and
|
||||
// a desk that resets that on the way out silently un-tunes the very scene it
|
||||
// was opened from.
|
||||
const eng = this.engine;
|
||||
this.entryArc = eng && eng.arcPinned ? eng.arcMinute : null;
|
||||
this.entryEnabled = eng
|
||||
? Object.fromEntries(VOICE_NAMES.map((v) => [v, eng.mix.enabled[v]])) as Record<VoiceName, boolean>
|
||||
: null;
|
||||
|
||||
// Interactive so clicks land on the desk instead of leaking through to the
|
||||
// scene underneath, which is still running and still listening.
|
||||
this.own(
|
||||
this.add.rectangle(CANVAS_W / 2, CANVAS_H / 2, CANVAS_W, CANVAS_H, UI.ink, 0.93).setInteractive(),
|
||||
);
|
||||
|
||||
this.own(this.add.text(10, 4, 'MIX DESK', font(10, UI.neonPink)));
|
||||
this.own(chunkyButton(this, 600, 11, 'CLOSE ESC', { w: 54, h: 13, onClick: () => this.scene.stop() }));
|
||||
this.input.keyboard?.on('keydown-ESC', this.onEsc);
|
||||
this.events.once('shutdown', () => this.teardown());
|
||||
|
||||
const engine = this.engine;
|
||||
if (!engine) {
|
||||
this.own(
|
||||
this.add
|
||||
.text(
|
||||
CANVAS_W / 2,
|
||||
CANVAS_H / 2,
|
||||
'NO AUDIO ENGINE\n\nLaunch this scene with { engine } in its scene data,\nor start the audio first so getActiveEngine() has something to hand back.',
|
||||
{ ...font(9, UI.paper), align: 'center' },
|
||||
)
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rain) {
|
||||
const rain = this.toggle({
|
||||
x: 540,
|
||||
y: 11,
|
||||
w: 44,
|
||||
h: 13,
|
||||
label: 'RAIN',
|
||||
on: () => this.raining,
|
||||
click: () => this.setRain(!this.raining),
|
||||
});
|
||||
this.own(rain.node);
|
||||
this.refreshers.push(rain);
|
||||
}
|
||||
|
||||
this.statusText = this.own(this.add.text(90, 6, '', font(7, HINT)));
|
||||
|
||||
this.buildVoiceStrip(engine);
|
||||
this.buildParamRegion(engine);
|
||||
this.buildArcRegion(engine);
|
||||
this.buildExportBar(engine);
|
||||
|
||||
this.refreshAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- regions
|
||||
|
||||
private buildVoiceStrip(engine: TechnoEngine): void {
|
||||
this.own(panel(this, CANVAS_W / 2, 49, 632, 58));
|
||||
|
||||
VOICE_NAMES.forEach((v, i) => {
|
||||
const y = 32 + i * 13;
|
||||
this.own(this.add.text(10, y, v.toUpperCase(), font(8, UI.paper)).setOrigin(0, 0.5));
|
||||
|
||||
const mute = this.toggle({
|
||||
x: 46,
|
||||
y,
|
||||
w: 32,
|
||||
h: 11,
|
||||
label: 'MUTE',
|
||||
on: () => !engine.mix.enabled[v],
|
||||
click: () => engine.setVoiceEnabled(v, !engine.mix.enabled[v]),
|
||||
onFill: UI.danger,
|
||||
});
|
||||
const solo = this.toggle({
|
||||
x: 82,
|
||||
y,
|
||||
w: 32,
|
||||
h: 11,
|
||||
label: 'SOLO',
|
||||
on: () => soloedVoice(engine.mix) === v,
|
||||
click: () => this.solo(engine, v),
|
||||
onFill: UI.kebabAmber,
|
||||
});
|
||||
this.own(mute.node);
|
||||
this.own(solo.node);
|
||||
|
||||
const level = this.slider(
|
||||
{
|
||||
x: 108,
|
||||
y,
|
||||
w: 110,
|
||||
range: MIX_RANGES.level,
|
||||
read: () => engine.getMixValue(`levels.${v}`),
|
||||
write: (val) => engine.setMixValue(`levels.${v}`, val),
|
||||
},
|
||||
this.owned,
|
||||
);
|
||||
|
||||
// Effective gain, not the slider value: the arc is a multiplier, and hiding
|
||||
// it is how you end up "fixing" a level that the arc had already dropped.
|
||||
const eff = this.own(this.add.text(272, y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
|
||||
|
||||
this.refreshers.push({
|
||||
refresh: () => {
|
||||
mute.refresh();
|
||||
solo.refresh();
|
||||
level.refresh();
|
||||
const g = voiceGain(engine.mix, v, engine.arcScales[v]);
|
||||
eff.setText(`= ${fmt(g)}`);
|
||||
setColour(eff, g > 0.0005 ? '#9fe8a0' : '#c03434');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.listenText = this.own(
|
||||
this.add.text(336, 49, '', { ...font(7, UI.kebabAmber), wordWrap: { width: 292 } }).setOrigin(0, 0.5),
|
||||
);
|
||||
}
|
||||
|
||||
/** Solo toggles: on, this voice alone; off, everyone back. */
|
||||
private solo(engine: TechnoEngine, v: VoiceName): void {
|
||||
const already = soloedVoice(engine.mix) === v;
|
||||
for (const other of VOICE_NAMES) engine.setVoiceEnabled(other, already || other === v);
|
||||
}
|
||||
|
||||
private buildParamRegion(engine: TechnoEngine): void {
|
||||
this.own(panel(this, CANVAS_W / 2, 163, 632, 162));
|
||||
this.groupTitle = this.own(this.add.text(14, 92, '', font(9, UI.neonPink)).setOrigin(0, 0.5));
|
||||
this.pageLabel = this.own(this.add.text(160, 92, '', font(7, HINT)).setOrigin(0, 0.5));
|
||||
this.own(
|
||||
chunkyButton(this, 600, 92, 'PAGE >', {
|
||||
w: 54,
|
||||
h: 13,
|
||||
onClick: () => {
|
||||
this.page = (this.page + 1) % GROUPS.length;
|
||||
this.buildParamPage(engine);
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.buildParamPage(engine);
|
||||
}
|
||||
|
||||
/** Rebuilt wholesale per page — cheaper to reason about than repointing closures. */
|
||||
private buildParamPage(engine: TechnoEngine): void {
|
||||
for (const o of this.paramOwned) o.destroy();
|
||||
this.paramOwned = [];
|
||||
this.paramRefreshers = [];
|
||||
|
||||
const group = GROUPS[this.page] ?? GROUPS[0];
|
||||
if (!group) return;
|
||||
|
||||
this.groupTitle?.setText(group.name);
|
||||
this.pageLabel?.setText(`page ${this.page + 1} of ${GROUPS.length}`);
|
||||
|
||||
group.rows.slice(0, PARAM_ROWS_MAX).forEach((spec, i) => {
|
||||
const y = 106 + i * 22;
|
||||
this.paramOwned.push(this.add.text(14, y - 5, spec.label, font(8, UI.paper)).setOrigin(0, 0.5));
|
||||
this.paramOwned.push(this.add.text(14, y + 5, spec.hint, font(6, HINT)).setOrigin(0, 0.5));
|
||||
|
||||
const handle = this.slider(
|
||||
{
|
||||
x: 200,
|
||||
y,
|
||||
w: 320,
|
||||
range: MIX_RANGES[spec.range],
|
||||
log: spec.path.endsWith('Hz'),
|
||||
integer: spec.integer === true,
|
||||
read: () => engine.getMixValue(spec.path),
|
||||
write: (v) => engine.setMixValue(spec.path, v),
|
||||
},
|
||||
this.paramOwned,
|
||||
);
|
||||
this.paramRefreshers.push(handle);
|
||||
});
|
||||
}
|
||||
|
||||
private buildArcRegion(engine: TechnoEngine): void {
|
||||
this.own(panel(this, CANVAS_W / 2, 275, 632, 54));
|
||||
this.own(this.add.text(14, 262, 'ARC', font(8, UI.paper)).setOrigin(0, 0.5));
|
||||
|
||||
// Pinning is the only way the night's shape gets checked at all — otherwise
|
||||
// auditioning 1 AM means waiting four hours for it.
|
||||
const arc = this.slider(
|
||||
{
|
||||
x: 48,
|
||||
y: 262,
|
||||
w: 260,
|
||||
range: [0, NIGHT_END_MIN],
|
||||
integer: true,
|
||||
read: () => engine.arcMinute,
|
||||
write: (v) => engine.setArcMinute(v),
|
||||
format: wallClock,
|
||||
},
|
||||
this.owned,
|
||||
);
|
||||
this.refreshers.push(arc);
|
||||
|
||||
this.arcText = this.own(this.add.text(370, 262, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
|
||||
const follow = this.toggle({
|
||||
x: 580,
|
||||
y: 262,
|
||||
w: 100,
|
||||
h: 14,
|
||||
label: 'FOLLOW CLOCK',
|
||||
on: () => !engine.arcPinned,
|
||||
click: () => engine.setArcMinute(null),
|
||||
onFill: UI.ok,
|
||||
});
|
||||
this.own(follow.node);
|
||||
this.refreshers.push(follow);
|
||||
|
||||
this.scaleText = this.own(this.add.text(14, 284, '', font(8, UI.paper)).setOrigin(0, 0.5));
|
||||
this.own(
|
||||
this.add
|
||||
.text(370, 284, 'closing the desk puts the arc back how you found it', font(6, HINT))
|
||||
.setOrigin(0, 0.5),
|
||||
);
|
||||
}
|
||||
|
||||
private buildExportBar(engine: TechnoEngine): void {
|
||||
this.own(panel(this, CANVAS_W / 2, 329, 632, 46));
|
||||
this.own(
|
||||
chunkyButton(this, 60, 320, 'DUMP MIX', {
|
||||
w: 100,
|
||||
h: 16,
|
||||
fill: UI.neonPink,
|
||||
fontSize: 9,
|
||||
onClick: () => this.dump(engine),
|
||||
}),
|
||||
);
|
||||
this.own(
|
||||
chunkyButton(this, 155, 320, 'RESET', {
|
||||
w: 70,
|
||||
h: 16,
|
||||
fontSize: 9,
|
||||
onClick: () => {
|
||||
engine.resetMix();
|
||||
this.refreshAll();
|
||||
this.toast('back to DEFAULT_MIX');
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.toastText = this.own(this.add.text(200, 320, '', font(8, UI.neonGreen)).setOrigin(0, 0.5).setAlpha(0));
|
||||
this.own(
|
||||
this.add.text(10, 340, 'DUMP writes a TrackMix literal — paste it over DEFAULT_MIX in src/audio/mix.ts', font(6, HINT)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- widgets
|
||||
|
||||
/**
|
||||
* The one slider. Track, fill, handle, readout; drag the handle or click the
|
||||
* track. Reads its value from the engine every refresh rather than caching, so
|
||||
* a knob moved elsewhere (the arc, a reset) shows up here without plumbing.
|
||||
*/
|
||||
private slider(spec: SliderSpec, sink: Phaser.GameObjects.GameObject[]): Refreshable {
|
||||
const lo = spec.range[0];
|
||||
const hi = spec.range[1];
|
||||
const logOk = spec.log === true && lo > 0 && hi > lo;
|
||||
const int = spec.integer === true;
|
||||
const format = spec.format ?? (int ? (v: number): string => v.toFixed(0) : fmt);
|
||||
const quantise = (v: number): number => (int ? Math.round(v) : v);
|
||||
|
||||
const toT = (v: number): number => {
|
||||
const c = clampTo(v, spec.range);
|
||||
if (logOk) return Math.log(c / lo) / Math.log(hi / lo);
|
||||
return hi === lo ? 0 : (c - lo) / (hi - lo);
|
||||
};
|
||||
const toV = (t: number): number => {
|
||||
const k = Math.max(0, Math.min(1, t));
|
||||
return logOk ? lo * Math.pow(hi / lo, k) : lo + (hi - lo) * k;
|
||||
};
|
||||
|
||||
const groove = this.add
|
||||
.rectangle(spec.x, spec.y, spec.w, 5, UI.grime)
|
||||
.setOrigin(0, 0.5)
|
||||
.setStrokeStyle(1, UI.ink);
|
||||
const fill = this.add.rectangle(spec.x, spec.y, 1, 3, UI.neonPink).setOrigin(0, 0.5);
|
||||
const knob = this.add
|
||||
.rectangle(spec.x, spec.y, 5, 13, UI.paper)
|
||||
.setOrigin(0.5)
|
||||
.setStrokeStyle(1, UI.ink);
|
||||
const readout = this.add.text(spec.x + spec.w + 8, spec.y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5);
|
||||
sink.push(groove, fill, knob, readout);
|
||||
|
||||
let lastW = -1;
|
||||
const refresh = (): void => {
|
||||
const v = spec.read();
|
||||
const t = toT(v);
|
||||
knob.x = spec.x + t * spec.w;
|
||||
// setSize rebuilds the shape's path data; refresh() is per-frame, so diff it.
|
||||
const w = Math.max(1, t * spec.w);
|
||||
if (w !== lastW) {
|
||||
lastW = w;
|
||||
fill.setSize(w, 3);
|
||||
}
|
||||
readout.setText(format(v));
|
||||
};
|
||||
const setFromX = (px: number): void => {
|
||||
// Quantise after clamping — every integer param's range has whole bounds.
|
||||
spec.write(quantise(clampTo(toV((px - spec.x) / spec.w), spec.range)));
|
||||
refresh();
|
||||
};
|
||||
|
||||
groove.setInteractive({ useHandCursor: true });
|
||||
groove.on('pointerdown', (p: Phaser.Input.Pointer) => setFromX(p.x));
|
||||
knob.setInteractive({ draggable: true, useHandCursor: true });
|
||||
knob.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => setFromX(dragX));
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that shows a state. chunkyButton restores its own fill on release,
|
||||
* so a latching control has to draw itself.
|
||||
*/
|
||||
private toggle(spec: ToggleSpec): Refreshable & { node: Phaser.GameObjects.Container } {
|
||||
const h = spec.h ?? 13;
|
||||
const onFill = spec.onFill ?? UI.neonGreen;
|
||||
const node = this.add.container(spec.x, spec.y);
|
||||
const face = this.add.rectangle(0, 0, spec.w, h, UI.panelLip).setStrokeStyle(1, UI.ink);
|
||||
const text = this.add.text(0, 0, spec.label, font(7, UI.paper)).setOrigin(0.5);
|
||||
node.add([face, text]);
|
||||
|
||||
// refreshAll() runs every frame; both setters below are unguarded in Phaser.
|
||||
let lastOn: boolean | null = null;
|
||||
const refresh = (): void => {
|
||||
const on = spec.on();
|
||||
if (on === lastOn) return;
|
||||
lastOn = on;
|
||||
face.setFillStyle(on ? onFill : UI.panelLip);
|
||||
setColour(text, on ? '#0b0b12' : '#dad4c4');
|
||||
};
|
||||
|
||||
// Same anti-phantom-click arming as chunkyButton: Phaser fires pointerup on
|
||||
// any release over an object, including one whose press landed elsewhere.
|
||||
let armed = false;
|
||||
face.setInteractive({ useHandCursor: true });
|
||||
face.on('pointerdown', () => {
|
||||
armed = true;
|
||||
});
|
||||
face.on('pointerout', () => {
|
||||
armed = false;
|
||||
});
|
||||
face.on('pointerup', () => {
|
||||
if (!armed) return;
|
||||
armed = false;
|
||||
spec.click();
|
||||
this.refreshAll();
|
||||
});
|
||||
|
||||
refresh();
|
||||
return { refresh, node };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
private setRain(on: boolean): void {
|
||||
if (!this.rain) return;
|
||||
this.raining = on;
|
||||
if (on) this.rain.startRain();
|
||||
else this.rain.stopRain();
|
||||
}
|
||||
|
||||
private dump(engine: TechnoEngine): void {
|
||||
const text = formatMix(engine.mix);
|
||||
// The console copy is the one that always survives; the clipboard is a
|
||||
// convenience that browsers refuse without focus or permission.
|
||||
console.log(`[MixDesk] TrackMix @ ${wallClock(engine.arcMinute)}\n${text}`);
|
||||
const clip = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
||||
if (!clip) {
|
||||
this.toast('dumped to console (no clipboard API)');
|
||||
return;
|
||||
}
|
||||
clip.writeText(text).then(
|
||||
() => this.toast('copied to clipboard + console'),
|
||||
() => this.toast('clipboard refused — it is in the console'),
|
||||
);
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
const t = this.toastText;
|
||||
if (!t) return;
|
||||
this.toastTween?.remove();
|
||||
t.setText(msg).setAlpha(1);
|
||||
this.toastTween = this.tweens.add({ targets: t, alpha: 0, delay: 2200, duration: 500 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refresh
|
||||
|
||||
private refreshAll(): void {
|
||||
const engine = this.engine;
|
||||
if (!engine) return;
|
||||
|
||||
for (const r of this.refreshers) r.refresh();
|
||||
for (const r of this.paramRefreshers) r.refresh();
|
||||
|
||||
const solo = soloedVoice(engine.mix);
|
||||
this.listenText?.setText(solo ? LISTEN_FOR[solo] : NO_SOLO);
|
||||
setColour(this.listenText, solo ? '#d8a020' : '#8a8aa0');
|
||||
|
||||
const min = engine.arcMinute;
|
||||
this.arcText?.setText(`${arcLabel(min)}${engine.arcPinned ? ' [PINNED]' : ''}`);
|
||||
setColour(this.arcText, engine.arcPinned ? '#d8a020' : '#9fe8a0');
|
||||
|
||||
const s = engine.arcScales;
|
||||
this.scaleText?.setText(
|
||||
`arc scales ${VOICE_NAMES.map((v) => `${v} ${s[v].toFixed(2)}`).join(' ')}`,
|
||||
);
|
||||
|
||||
const running = engine.ctx.state === 'running';
|
||||
this.statusText?.setText(
|
||||
running ? `audio running · ${engine.bpm} bpm` : `AUDIO ${engine.ctx.state.toUpperCase()} — start the audio before trusting your ears`,
|
||||
);
|
||||
setColour(this.statusText, running ? '#8a8aa0' : '#c03434');
|
||||
}
|
||||
|
||||
override update(): void {
|
||||
// Cheap enough at a dozen sliders, and it keeps the desk honest about state
|
||||
// it does not own: the clock moves the arc, and the arc moves every readout.
|
||||
this.refreshAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- teardown
|
||||
|
||||
private own<T extends Phaser.GameObjects.GameObject>(o: T): T {
|
||||
this.owned.push(o);
|
||||
return o;
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.input.keyboard?.off('keydown-ESC', this.onEsc);
|
||||
this.toastTween?.remove();
|
||||
this.toastTween = null;
|
||||
this.tweens.killAll();
|
||||
|
||||
// Solo and arc-pin are monitoring state, not mix state — formatMix even hard-codes
|
||||
// `enabled` back to all-true. Leaving either latched after the desk closes would
|
||||
// silently break a real night with no UI left to undo it, so hand back exactly
|
||||
// what we borrowed. On a real night nothing is pinned or muted, so this restores
|
||||
// to all-true / follow-clock anyway.
|
||||
const engine = this.engine;
|
||||
if (engine) {
|
||||
for (const v of VOICE_NAMES) engine.setVoiceEnabled(v, this.entryEnabled?.[v] ?? true);
|
||||
engine.setArcMinute(this.entryArc);
|
||||
}
|
||||
if (this.raining) this.setRain(false);
|
||||
|
||||
for (const o of this.paramOwned) o.destroy();
|
||||
for (const o of this.owned) o.destroy();
|
||||
this.paramOwned = [];
|
||||
this.owned = [];
|
||||
this.refreshers = [];
|
||||
this.paramRefreshers = [];
|
||||
this.groupTitle = null;
|
||||
this.pageLabel = null;
|
||||
this.listenText = null;
|
||||
this.statusText = null;
|
||||
this.arcText = null;
|
||||
this.scaleText = null;
|
||||
this.toastText = null;
|
||||
this.engine = null;
|
||||
this.rain = null;
|
||||
}
|
||||
}
|
||||
402
src/ui/Phone.ts
402
src/ui/Phone.ts
@ -17,10 +17,38 @@ const PANEL_W = 150;
|
||||
const PANEL_H = 180;
|
||||
const PAD = 6;
|
||||
const BUBBLE_MAX_W = 104;
|
||||
const BUBBLE_PAD = 4;
|
||||
const BUBBLE_GAP = 4;
|
||||
/** Vertical room the clock stamp adds under a bubble. */
|
||||
const STAMP_H = 8;
|
||||
const HEADER_H = 13;
|
||||
const DOTS_H = 12;
|
||||
|
||||
/**
|
||||
* Bubbles are built only for the visible window plus this many either side.
|
||||
* Scrolling is then independent of thread length; opening the panel still costs
|
||||
* one arithmetic pass over the (bounded) history to lay out offsets. Overscan
|
||||
* hides the pop at the edges.
|
||||
*/
|
||||
const OVERSCAN = 2;
|
||||
/** Even culled, an unbounded array is a leak. Oldest lines fall off the top. */
|
||||
const MAX_MESSAGES = 240;
|
||||
|
||||
/** Panel interior the thread scrolls inside, in threadRoot-local coordinates. */
|
||||
const INTERIOR_TOP = -PANEL_H / 2 + HEADER_H + 3;
|
||||
const INTERIOR_BOTTOM = PANEL_H / 2 - PAD;
|
||||
const CUE_H = 5;
|
||||
const NEW_BELOW_H = 10;
|
||||
|
||||
const WHEEL_SCALE = 0.4;
|
||||
const WHEEL_CLAMP = 100;
|
||||
const DRAG_SLOP = 3;
|
||||
/**
|
||||
* How close to the bottom still counts as "reading the latest". Must exceed
|
||||
* DOTS_H, or reserving room for the typing dots would itself unstick the view.
|
||||
*/
|
||||
const STICK_SLOP = DOTS_H + 4;
|
||||
|
||||
const TOAST_W = 112;
|
||||
const TOAST_H = 26;
|
||||
const TOAST_MS = 3500;
|
||||
@ -38,6 +66,10 @@ interface ThreadMessage {
|
||||
carriedRule: boolean;
|
||||
/** clock-min the text landed, if a clock:tick has been seen yet */
|
||||
clockMin?: number;
|
||||
/** Bubble box, measured once on arrival by the off-screen ruler. Layout needs
|
||||
* every message's size but must not build a GameObject to learn it. */
|
||||
bw?: number;
|
||||
bh?: number;
|
||||
}
|
||||
|
||||
export interface PhoneSfx {
|
||||
@ -77,6 +109,30 @@ export class Phone {
|
||||
private readonly threadBody: Phaser.GameObjects.Container;
|
||||
private readonly dots: Phaser.GameObjects.Text;
|
||||
|
||||
private readonly maskShape: Phaser.GameObjects.Graphics;
|
||||
private readonly bodyMask: Phaser.Display.Masks.GeometryMask;
|
||||
private readonly scrollCue: Phaser.GameObjects.Container;
|
||||
private readonly newBelow: Phaser.GameObjects.Container;
|
||||
/** Off-display-list Text used purely to size bubbles. One texture, forever. */
|
||||
private readonly ruler: Phaser.GameObjects.Text;
|
||||
|
||||
/** Distance from the top of the content to the top of the interior. */
|
||||
private scrollY = 0;
|
||||
private contentH = 0;
|
||||
private maskH = 0;
|
||||
private dragging = false;
|
||||
private dragLastY = 0;
|
||||
private dragMoved = false;
|
||||
|
||||
/** Content-space Y of each message top, parallel to `messages`. */
|
||||
private readonly offsets: number[] = [];
|
||||
/** Message index range currently realised as GameObjects; -1 = nothing built. */
|
||||
private builtFrom = -1;
|
||||
private builtTo = -1;
|
||||
/** Whether the last layout kept the view pinned to the newest message. */
|
||||
private lastStick = true;
|
||||
private unreadBelow = false;
|
||||
|
||||
private toast: Phaser.GameObjects.Container | undefined;
|
||||
private toastMs = 0;
|
||||
private toastTween: Phaser.Tweens.Tween | undefined;
|
||||
@ -118,7 +174,21 @@ export class Phone {
|
||||
this.glowTween = scene.tweens.add({ targets: glow, alpha: 0.2, duration: 1800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
|
||||
body.setInteractive({ useHandCursor: true });
|
||||
body.on('pointerup', () => this.toggle());
|
||||
// Arm on our own down (and only if the pointer never turned into a scroll
|
||||
// drag) so a release that started on the thread can't phantom-toggle.
|
||||
let armed = false;
|
||||
body.on('pointerdown', () => {
|
||||
armed = true;
|
||||
this.dragMoved = false;
|
||||
});
|
||||
body.on('pointerout', () => {
|
||||
armed = false;
|
||||
});
|
||||
body.on('pointerup', () => {
|
||||
const fire = armed && !this.dragMoved;
|
||||
armed = false;
|
||||
if (fire) this.toggle();
|
||||
});
|
||||
|
||||
const anchor = this.threadAnchor();
|
||||
this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false);
|
||||
@ -131,7 +201,86 @@ export class Phone {
|
||||
.text(-PANEL_W / 2 + PAD + 4, PANEL_H / 2 - PAD - 9, '', font(8, UI.paper))
|
||||
.setAlpha(0.6)
|
||||
.setVisible(false);
|
||||
this.threadRoot.add([chrome, title, rule, this.threadBody, this.dots]);
|
||||
// The mask graphic lives off the display list and tracks threadRoot in world
|
||||
// space — a geometry mask is not transformed by its parent container.
|
||||
this.maskShape = scene.make.graphics({}, false);
|
||||
this.bodyMask = this.maskShape.createGeometryMask();
|
||||
this.threadBody.setMask(this.bodyMask);
|
||||
|
||||
this.ruler = scene.make.text(
|
||||
{ style: { ...font(7, UI.ink), wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true } } },
|
||||
false,
|
||||
);
|
||||
|
||||
// Sits outside the mask so it draws over the clipped edge: without it there
|
||||
// is nothing to tell the player history exists at all.
|
||||
this.scrollCue = scene.add.container(0, 0).setVisible(false);
|
||||
this.scrollCue.add([
|
||||
scene.add.rectangle(0, INTERIOR_TOP + CUE_H / 2, PANEL_W - 2, CUE_H, UI.panel, 0.72),
|
||||
scene.add.triangle(PANEL_W / 2 - PAD - 2, INTERIOR_TOP + 3, 0, 4, 3, 0, 6, 4, UI.paper).setAlpha(0.5),
|
||||
]);
|
||||
|
||||
// fillAlpha 0 (not alpha 0 — that clears the render flag and kills hit testing).
|
||||
const dragPad = scene.add
|
||||
.rectangle(0, (INTERIOR_TOP + INTERIOR_BOTTOM) / 2, PANEL_W - 2, INTERIOR_BOTTOM - INTERIOR_TOP, 0xffffff, 0)
|
||||
.setInteractive();
|
||||
dragPad.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
||||
if (!this.open_) return;
|
||||
this.dragging = true;
|
||||
this.dragLastY = p.worldY;
|
||||
this.dragMoved = false;
|
||||
});
|
||||
|
||||
// Below the fold needs its own signal: the scroll cue only ever says "history
|
||||
// above", and a rule-bearing text landing off-screen is a sacking offence.
|
||||
// Added after dragPad so it wins the topOnly hit test and the bar stays
|
||||
// clickable instead of starting a scroll drag.
|
||||
this.newBelow = scene.add.container(0, 0).setVisible(false);
|
||||
const nbBg = scene.add.rectangle(0, 0, PANEL_W - 2, NEW_BELOW_H, UI.neonPink, 0.92).setStrokeStyle(1, UI.ink, 0.5);
|
||||
const nbLabel = scene.add.text(0, 0, 'NEW BELOW v', font(6, UI.paper)).setOrigin(0.5);
|
||||
this.newBelow.add([nbBg, nbLabel]);
|
||||
nbBg.setInteractive({ useHandCursor: true });
|
||||
let nbArmed = false;
|
||||
nbBg.on('pointerdown', () => {
|
||||
nbArmed = true;
|
||||
});
|
||||
nbBg.on('pointerout', () => {
|
||||
nbArmed = false;
|
||||
});
|
||||
nbBg.on('pointerup', () => {
|
||||
const fire = nbArmed;
|
||||
nbArmed = false;
|
||||
if (fire) this.scrollToBottom();
|
||||
});
|
||||
|
||||
this.threadRoot.add([chrome, title, rule, this.threadBody, this.scrollCue, this.dots, dragPad, this.newBelow]);
|
||||
|
||||
const onWheel = (p: Phaser.Input.Pointer, _over: unknown[], _dx: number, dy: number): void => {
|
||||
if (!this.open_ || !this.pointerOverPanel(p)) return;
|
||||
this.scrollBy(Phaser.Math.Clamp(dy, -WHEEL_CLAMP, WHEEL_CLAMP) * WHEEL_SCALE);
|
||||
};
|
||||
// Tracked on the plugin rather than the pad so a drag survives the pointer
|
||||
// leaving the panel mid-flick.
|
||||
const onMove = (p: Phaser.Input.Pointer): void => {
|
||||
if (!this.dragging) return;
|
||||
const dy = p.worldY - this.dragLastY;
|
||||
this.dragLastY = p.worldY;
|
||||
if (Math.abs(dy) >= DRAG_SLOP) this.dragMoved = true;
|
||||
this.scrollBy(-dy);
|
||||
};
|
||||
const endDrag = (): void => {
|
||||
this.dragging = false;
|
||||
};
|
||||
scene.input.on('wheel', onWheel);
|
||||
scene.input.on('pointermove', onMove);
|
||||
scene.input.on('pointerup', endDrag);
|
||||
scene.input.on('pointerupoutside', endDrag);
|
||||
this.offs.push(() => {
|
||||
scene.input.off('wheel', onWheel);
|
||||
scene.input.off('pointermove', onMove);
|
||||
scene.input.off('pointerup', endDrag);
|
||||
scene.input.off('pointerupoutside', endDrag);
|
||||
});
|
||||
|
||||
this.offs.push(bus.on('dazza:text', ({ text, rule: dressRule }) => this.receive(text, dressRule !== undefined)));
|
||||
this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; }));
|
||||
@ -145,7 +294,7 @@ export class Phone {
|
||||
if (this.open_) return;
|
||||
this.open_ = true;
|
||||
this.openMs = 0;
|
||||
this.unreadDot.setVisible(false);
|
||||
this.clearUnreadBelow();
|
||||
this.screen.setFillStyle(UI.panel);
|
||||
this.dismissToast();
|
||||
|
||||
@ -159,7 +308,7 @@ export class Phone {
|
||||
duration: 160,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
this.layoutThread();
|
||||
this.layoutThread(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@ -191,6 +340,8 @@ export class Phone {
|
||||
|
||||
update(deltaMs: number): void {
|
||||
if (this.open_) this.openMs += deltaMs;
|
||||
// The panel slides on open/close, so the mask has to follow it every frame.
|
||||
if (this.threadRoot.visible) this.syncMask();
|
||||
|
||||
if (this.pending) {
|
||||
this.pendingMs += deltaMs;
|
||||
@ -198,10 +349,11 @@ export class Phone {
|
||||
const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3;
|
||||
this.dots.setText('.'.repeat(step + 1));
|
||||
if (this.pendingMs >= TYPING_MS) {
|
||||
this.messages.push(this.pending);
|
||||
this.pushMessage(this.pending);
|
||||
this.pending = undefined;
|
||||
this.dots.setVisible(false);
|
||||
this.layoutThread();
|
||||
this.flagIfLandedOffscreen();
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,6 +372,11 @@ export class Phone {
|
||||
// anything still running writing to a dead GameObject every frame.
|
||||
this.glowTween.remove();
|
||||
this.dismissToast();
|
||||
// The mask pair is off the display list; destroying threadRoot won't take it.
|
||||
this.threadBody.clearMask();
|
||||
this.bodyMask.destroy();
|
||||
this.maskShape.destroy();
|
||||
this.ruler.destroy();
|
||||
this.threadRoot.destroy(true);
|
||||
this.root.destroy(true);
|
||||
}
|
||||
@ -232,12 +389,13 @@ export class Phone {
|
||||
if (this.open_) {
|
||||
// Land any earlier pending line first so nothing gets swallowed by a
|
||||
// second buzz arriving mid-typing.
|
||||
if (this.pending) this.messages.push(this.pending);
|
||||
if (this.pending) this.pushMessage(this.pending);
|
||||
this.pending = msg;
|
||||
this.pendingMs = 0;
|
||||
this.dotsMs = 0;
|
||||
this.dots.setText('.').setVisible(true);
|
||||
this.layoutThread();
|
||||
this.flagIfLandedOffscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -245,11 +403,57 @@ export class Phone {
|
||||
}
|
||||
|
||||
private deliverClosed(msg: ThreadMessage): void {
|
||||
this.messages.push(msg);
|
||||
this.pushMessage(msg);
|
||||
if (msg.carriedRule) this.unreadDot.setVisible(true);
|
||||
this.showToast(msg.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append, trimming the oldest lines past the cap. Trimming shifts every
|
||||
* remaining message up by the shed height, so scrollY moves with it or the
|
||||
* player's place in the thread jumps.
|
||||
*/
|
||||
private pushMessage(msg: ThreadMessage): void {
|
||||
this.measure(msg);
|
||||
this.messages.push(msg);
|
||||
const over = this.messages.length - MAX_MESSAGES;
|
||||
if (over <= 0) return;
|
||||
|
||||
let shed = 0;
|
||||
for (let i = 0; i < over; i++) {
|
||||
const dropped = this.messages[i];
|
||||
if (dropped) shed += this.heightOf(dropped) + BUBBLE_GAP;
|
||||
}
|
||||
this.messages.splice(0, over);
|
||||
this.scrollY = Math.max(0, this.scrollY - shed);
|
||||
// Indices shifted under the built range; force the next pass to rebuild.
|
||||
this.builtFrom = -1;
|
||||
this.builtTo = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that arrived while the player was reading history is silent and
|
||||
* invisible — the sticky-bottom rule is right, but it needs an alarm.
|
||||
*/
|
||||
private flagIfLandedOffscreen(): void {
|
||||
if (this.lastStick) return;
|
||||
this.unreadBelow = true;
|
||||
this.unreadDot.setVisible(true);
|
||||
this.newBelow.setVisible(true);
|
||||
}
|
||||
|
||||
private clearUnreadBelow(): void {
|
||||
this.unreadBelow = false;
|
||||
this.unreadDot.setVisible(false);
|
||||
this.newBelow.setVisible(false);
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
this.scrollY = this.maxScroll;
|
||||
this.clearUnreadBelow();
|
||||
this.applyScroll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing mid-typing has to land the held message now. Left in `pending` it
|
||||
* would arrive behind anything received meanwhile — out of order, and with no
|
||||
@ -320,76 +524,178 @@ export class Phone {
|
||||
this.toastMs = 0;
|
||||
}
|
||||
|
||||
private layoutThread(): void {
|
||||
this.threadBody.removeAll(true);
|
||||
if (!this.open_) return;
|
||||
/** Visible thread height; the typing dots eat into it while Dazza is typing. */
|
||||
private get interiorH(): number {
|
||||
return INTERIOR_BOTTOM - INTERIOR_TOP - (this.pending ? DOTS_H : 0);
|
||||
}
|
||||
|
||||
const top = -PANEL_H / 2 + HEADER_H + 3;
|
||||
const floor = PANEL_H / 2 - PAD - (this.pending ? DOTS_H : 0);
|
||||
let cursor = floor;
|
||||
private get maxScroll(): number {
|
||||
return Math.max(0, this.contentH - this.interiorH);
|
||||
}
|
||||
|
||||
// Newest at the bottom: build upward and stop as soon as one won't fit. The
|
||||
// newest is clipped to the interior instead of dropped — a text long enough
|
||||
// to overflow the panel on its own would otherwise blank the whole thread.
|
||||
let placed = 0;
|
||||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||||
private pointerOverPanel(p: Phaser.Input.Pointer): boolean {
|
||||
return (
|
||||
Math.abs(p.worldX - this.threadRoot.x) <= PANEL_W / 2 &&
|
||||
Math.abs(p.worldY - this.threadRoot.y) <= PANEL_H / 2
|
||||
);
|
||||
}
|
||||
|
||||
private scrollBy(dy: number): void {
|
||||
const next = Phaser.Math.Clamp(this.scrollY + dy, 0, this.maxScroll);
|
||||
if (next === this.scrollY) return;
|
||||
this.scrollY = next;
|
||||
this.applyScroll();
|
||||
}
|
||||
|
||||
private applyScroll(): void {
|
||||
this.rebuildVisible(false);
|
||||
// Short threads hang off the bottom of the panel, the way a chat app does.
|
||||
const slack = Math.max(0, this.interiorH - this.contentH);
|
||||
this.threadBody.setY(INTERIOR_TOP + slack - this.scrollY);
|
||||
this.scrollCue.setVisible(this.scrollY > 0.5);
|
||||
// Reaching the bottom is the read receipt.
|
||||
if (this.maxScroll - this.scrollY <= STICK_SLOP) this.clearUnreadBelow();
|
||||
this.newBelow.setY(INTERIOR_TOP + this.interiorH - NEW_BELOW_H / 2).setVisible(this.unreadBelow);
|
||||
this.redrawMask();
|
||||
this.syncMask();
|
||||
}
|
||||
|
||||
private redrawMask(): void {
|
||||
const h = this.interiorH;
|
||||
if (h === this.maskH) return;
|
||||
this.maskH = h;
|
||||
this.maskShape.clear().fillStyle(0xffffff).fillRect(-PANEL_W / 2 + 1, INTERIOR_TOP, PANEL_W - 2, h);
|
||||
}
|
||||
|
||||
private syncMask(): void {
|
||||
this.maskShape.setPosition(this.threadRoot.x, this.threadRoot.y);
|
||||
}
|
||||
|
||||
/** Size a message without building it. Measured once, then cached forever. */
|
||||
private measure(msg: ThreadMessage): void {
|
||||
if (msg.bw !== undefined && msg.bh !== undefined) return;
|
||||
this.ruler.setText(msg.text);
|
||||
msg.bw = Math.min(BUBBLE_MAX_W, Math.ceil(this.ruler.width) + BUBBLE_PAD * 2);
|
||||
msg.bh = Math.ceil(this.ruler.height) + BUBBLE_PAD * 2;
|
||||
}
|
||||
|
||||
private heightOf(msg: ThreadMessage): number {
|
||||
this.measure(msg);
|
||||
return (msg.bh ?? 0) + (msg.clockMin !== undefined ? STAMP_H : 0);
|
||||
}
|
||||
|
||||
/** Recompute content-space offsets. Arithmetic only — no GameObjects. */
|
||||
private remeasure(): void {
|
||||
this.offsets.length = this.messages.length;
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < this.messages.length; i++) {
|
||||
const msg = this.messages[i];
|
||||
if (!msg) continue;
|
||||
const bubble = this.buildBubble(msg, placed === 0 ? floor - top : Number.POSITIVE_INFINITY);
|
||||
if (placed > 0 && cursor - bubble.height < top) {
|
||||
bubble.container.destroy(true);
|
||||
break;
|
||||
}
|
||||
placed++;
|
||||
bubble.container.setY(cursor - bubble.height);
|
||||
this.threadBody.add(bubble.container);
|
||||
cursor -= bubble.height + BUBBLE_GAP;
|
||||
this.offsets[i] = cursor;
|
||||
cursor += this.heightOf(msg) + BUBBLE_GAP;
|
||||
}
|
||||
this.contentH = Math.max(0, cursor - BUBBLE_GAP);
|
||||
}
|
||||
|
||||
/** Inclusive message range intersecting the interior, plus overscan. */
|
||||
private visibleRange(): { from: number; to: number } {
|
||||
const n = this.messages.length;
|
||||
if (n === 0) return { from: 0, to: -1 };
|
||||
const top = this.scrollY;
|
||||
const bottom = top + this.interiorH;
|
||||
|
||||
// Offsets ascend and heights are positive, so "ends above the viewport" is
|
||||
// monotonic — binary search it. The linear scan this replaces ran from index 0
|
||||
// on every pointermove of a drag, which was the last place thread length still
|
||||
// leaked into frame cost.
|
||||
let lo = 0;
|
||||
let hi = n - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const msg = this.messages[mid];
|
||||
if (msg && (this.offsets[mid] ?? 0) + this.heightOf(msg) <= top) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const from = lo;
|
||||
|
||||
let to = from;
|
||||
while (to < n - 1 && (this.offsets[to + 1] ?? 0) < bottom) to++;
|
||||
|
||||
return { from: Math.max(0, from - OVERSCAN), to: Math.min(n - 1, to + OVERSCAN) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Realise only the bubbles the player can see. Scrolling a pixel does not
|
||||
* rebuild; crossing into a new message does.
|
||||
*/
|
||||
private rebuildVisible(force: boolean): void {
|
||||
const { from, to } = this.visibleRange();
|
||||
if (!force && from === this.builtFrom && to === this.builtTo) return;
|
||||
this.threadBody.removeAll(true);
|
||||
this.builtFrom = from;
|
||||
this.builtTo = to;
|
||||
for (let i = from; i <= to; i++) {
|
||||
const msg = this.messages[i];
|
||||
if (!msg) continue;
|
||||
this.threadBody.add(this.buildBubble(msg).setY(this.offsets[i] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-lay the thread top-down, oldest first. A new message only yanks the view
|
||||
* to the bottom if the player was already reading there — scrolling back for
|
||||
* the dress-code rule and being thrown forward mid-sentence is unforgivable.
|
||||
*/
|
||||
private layoutThread(toBottom = false): void {
|
||||
const stick = toBottom || this.maxScroll - this.scrollY <= STICK_SLOP;
|
||||
this.lastStick = stick;
|
||||
if (!this.open_) {
|
||||
this.threadBody.removeAll(true);
|
||||
this.builtFrom = -1;
|
||||
this.builtTo = -1;
|
||||
this.contentH = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.remeasure();
|
||||
this.scrollY = stick ? this.maxScroll : Phaser.Math.Clamp(this.scrollY, 0, this.maxScroll);
|
||||
this.rebuildVisible(true);
|
||||
this.applyScroll();
|
||||
}
|
||||
|
||||
/** Container origin is the bubble's top-left; caller only sets Y. */
|
||||
private buildBubble(
|
||||
msg: ThreadMessage,
|
||||
maxHeight = Number.POSITIVE_INFINITY,
|
||||
): { container: Phaser.GameObjects.Container; height: number } {
|
||||
private buildBubble(msg: ThreadMessage): Phaser.GameObjects.Container {
|
||||
const incoming = msg.from === 'dazza';
|
||||
const pad = 4;
|
||||
this.measure(msg);
|
||||
const w = msg.bw ?? 0;
|
||||
const h = msg.bh ?? 0;
|
||||
const c = this.scene.add.container(0, 0);
|
||||
|
||||
const text = this.scene.add.text(pad, pad, msg.text, {
|
||||
const text = this.scene.add.text(BUBBLE_PAD, BUBBLE_PAD, msg.text, {
|
||||
...font(7, incoming ? UI.ink : UI.paper),
|
||||
wordWrap: { width: BUBBLE_MAX_W - pad * 2, useAdvancedWrap: true },
|
||||
wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true },
|
||||
});
|
||||
const w = Math.min(BUBBLE_MAX_W, Math.ceil(text.width) + pad * 2);
|
||||
const stampH = msg.clockMin !== undefined ? 8 : 0;
|
||||
let h = Math.ceil(text.height) + pad * 2;
|
||||
if (h + stampH > maxHeight) {
|
||||
// Fixed height crops the text canvas: the overflow is cut, not reflowed.
|
||||
const fit = Math.max(pad * 2 + 1, Math.floor(maxHeight) - stampH);
|
||||
text.setFixedSize(0, fit - pad * 2);
|
||||
h = fit;
|
||||
}
|
||||
const bg = this.scene.add
|
||||
.rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink)
|
||||
.setOrigin(0, 0)
|
||||
.setStrokeStyle(1, UI.ink, 0.4);
|
||||
c.add([bg, text]);
|
||||
|
||||
let height = h;
|
||||
if (msg.carriedRule) {
|
||||
// Rule-bearing texts stay flagged forever — this is the one you get sacked
|
||||
// for missing.
|
||||
const badge = this.scene.add.text(w + 3, 0, 'NEW', font(6, UI.neonPink));
|
||||
// for missing. Mirrored on right-aligned bubbles, which sit flush against
|
||||
// the panel edge and would otherwise push the badge outside it.
|
||||
const badge = this.scene.add.text(incoming ? w + 3 : -3, 0, 'NEW', font(6, UI.neonPink));
|
||||
if (!incoming) badge.setOrigin(1, 0);
|
||||
c.add(badge);
|
||||
}
|
||||
if (msg.clockMin !== undefined) {
|
||||
const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper));
|
||||
stamp.setAlpha(0.4);
|
||||
c.add(stamp);
|
||||
height += 8;
|
||||
}
|
||||
|
||||
c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w);
|
||||
return { container: c, height };
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
277
tests/arc.test.ts
Normal file
277
tests/arc.test.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ARC, arcAt, arcIntensity, arcLabel, NIGHT_END_MIN, type ArcScales } from '../src/audio/arc';
|
||||
import { VOICE_NAMES } from '../src/audio/mix';
|
||||
|
||||
const first = ARC[0]!;
|
||||
const last = ARC[ARC.length - 1]!;
|
||||
|
||||
const mean = (a: number, b: number): number => (a + b) / 2;
|
||||
|
||||
const PLATEAU_MIN = 180;
|
||||
const KICK_FULL_MIN = 120;
|
||||
|
||||
// The plateau is read straight off the keyframe, never sampled out of arcAt —
|
||||
// a bound derived from the function under test cannot catch that function
|
||||
// drifting. Kick is full by 11 PM by design; these three climb to midnight.
|
||||
const plateau: ArcScales = ARC.find((k) => k.at === PLATEAU_MIN)!.scales;
|
||||
const LATE_BUILDERS = ['hat', 'bass', 'pad'] as const;
|
||||
|
||||
describe('ARC keyframes', () => {
|
||||
// Every loop below iterates one of these two; a zero-length either would turn
|
||||
// the whole suite green by iterating nothing.
|
||||
it('has the keyframes and voices the rest of this suite loops over', () => {
|
||||
expect(ARC.length).toBe(7);
|
||||
expect(VOICE_NAMES.length).toBe(4);
|
||||
expect([...VOICE_NAMES].sort()).toEqual(['bass', 'hat', 'kick', 'pad']);
|
||||
});
|
||||
|
||||
it('runs strictly ascending from minute 0 to the end of the night', () => {
|
||||
expect(first.at).toBe(0);
|
||||
expect(last.at).toBe(NIGHT_END_MIN);
|
||||
expect(NIGHT_END_MIN).toBe(360);
|
||||
for (let i = 1; i < ARC.length; i++) {
|
||||
expect(ARC[i]!.at).toBeGreaterThan(ARC[i - 1]!.at);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every voice scale inside 0..1 and carries a label', () => {
|
||||
for (const k of ARC) {
|
||||
expect(k.label.length).toBeGreaterThan(0);
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(k.scales[v]).toBeGreaterThanOrEqual(0);
|
||||
expect(k.scales[v]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcAt', () => {
|
||||
it('returns the exact keyframe scales at each keyframe minute', () => {
|
||||
for (const k of ARC) {
|
||||
expect(arcAt(k.at)).toEqual(k.scales);
|
||||
}
|
||||
});
|
||||
|
||||
// Every branch has to copy. Handing back a live keyframe means one caller
|
||||
// doing `arcAt(-1).bass = 0` silently rewrites ARC for the whole session, and
|
||||
// every subsequent 9 PM opens wrong.
|
||||
it('returns a fresh object on every branch, including both clamps', () => {
|
||||
// Clamp low.
|
||||
const low = arcAt(-1);
|
||||
expect(low).not.toBe(first.scales);
|
||||
low.bass = 0.123;
|
||||
expect(first.scales.bass).toBe(0);
|
||||
expect(arcAt(-1).bass).toBe(0);
|
||||
|
||||
// Clamp high.
|
||||
const high = arcAt(NIGHT_END_MIN + 1);
|
||||
expect(high).not.toBe(last.scales);
|
||||
high.pad = 0.123;
|
||||
expect(last.scales.pad).toBe(0.6);
|
||||
expect(arcAt(NIGHT_END_MIN + 1).pad).toBe(0.6);
|
||||
|
||||
// The edge minutes themselves take the clamp branches, not interpolation.
|
||||
const atFirst = arcAt(first.at);
|
||||
expect(atFirst).not.toBe(first.scales);
|
||||
atFirst.kick = 0.111;
|
||||
expect(arcAt(first.at).kick).toBe(0.8);
|
||||
|
||||
const atLast = arcAt(last.at);
|
||||
expect(atLast).not.toBe(last.scales);
|
||||
atLast.kick = 0.111;
|
||||
expect(arcAt(last.at).kick).toBe(0.45);
|
||||
|
||||
// A non-finite minute funnels into the clamp-low branch.
|
||||
const stalled = arcAt(NaN);
|
||||
expect(stalled).not.toBe(first.scales);
|
||||
stalled.bass = 0.9;
|
||||
expect(arcAt(NaN).bass).toBe(0);
|
||||
|
||||
// Interpolation branch.
|
||||
const k = ARC[3]!;
|
||||
const s = arcAt(k.at);
|
||||
expect(s).not.toBe(k.scales);
|
||||
s.kick = 0;
|
||||
expect(k.scales.kick).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps outside the night instead of extrapolating', () => {
|
||||
expect(arcAt(-1)).toEqual(first.scales);
|
||||
expect(arcAt(-10_000)).toEqual(first.scales);
|
||||
expect(arcAt(NIGHT_END_MIN + 1)).toEqual(last.scales);
|
||||
expect(arcAt(10_000)).toEqual(last.scales);
|
||||
});
|
||||
|
||||
it('never produces a negative or >1 gain at any minute, including past the end', () => {
|
||||
for (let t = -120; t <= NIGHT_END_MIN + 120; t++) {
|
||||
const s = arcAt(t);
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(s[v]).toBeGreaterThanOrEqual(0);
|
||||
expect(s[v]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolates linearly between keyframes', () => {
|
||||
// 30 is the midpoint of the 0 -> 60 span.
|
||||
const a = ARC[0]!.scales;
|
||||
const b = ARC[1]!.scales;
|
||||
const mid = arcAt(30);
|
||||
for (const v of VOICE_NAMES) expect(mid[v]).toBeCloseTo(mean(a[v], b[v]), 10);
|
||||
|
||||
// 90 is the midpoint of 60 -> 120.
|
||||
const c = ARC[2]!.scales;
|
||||
const mid2 = arcAt(90);
|
||||
for (const v of VOICE_NAMES) expect(mid2[v]).toBeCloseTo(mean(b[v], c[v]), 10);
|
||||
|
||||
// A quarter of the way across the 330 -> 360 close-down.
|
||||
const d = ARC[5]!.scales;
|
||||
const e = ARC[6]!.scales;
|
||||
const q = arcAt(337.5);
|
||||
for (const v of VOICE_NAMES) expect(q[v]).toBeCloseTo(d[v] + (e[v] - d[v]) * 0.25, 10);
|
||||
});
|
||||
|
||||
it('survives a non-finite clock minute without emitting NaN', () => {
|
||||
for (const bad of [NaN, Infinity, -Infinity]) {
|
||||
const s: ArcScales = arcAt(bad);
|
||||
for (const v of VOICE_NAMES) expect(Number.isFinite(s[v])).toBe(true);
|
||||
}
|
||||
// A stalled clock reads as the start of the night, not as silence.
|
||||
expect(arcAt(NaN)).toEqual(first.scales);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shape of the night', () => {
|
||||
it('opens with no bassline — the room is empty', () => {
|
||||
expect(arcAt(0).bass).toBe(0);
|
||||
expect(arcAt(0).pad).toBe(0);
|
||||
expect(arcAt(0).kick).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('brings the bass up once there is a crowd to carry it', () => {
|
||||
expect(arcAt(60).bass).toBeGreaterThan(arcAt(0).bass);
|
||||
expect(arcAt(120).bass).toBeGreaterThan(arcAt(60).bass);
|
||||
});
|
||||
|
||||
it('builds every voice through the first half of the night', () => {
|
||||
for (const v of VOICE_NAMES) {
|
||||
let prev = -Infinity;
|
||||
for (let t = 0; t <= 180; t += 5) {
|
||||
const s = arcAt(t)[v];
|
||||
expect(s).toBeGreaterThanOrEqual(prev);
|
||||
prev = s;
|
||||
}
|
||||
expect(arcAt(180)[v]).toBeGreaterThan(arcAt(0)[v]);
|
||||
}
|
||||
});
|
||||
|
||||
// The build has to be a real build: strictly quieter than the plateau on the
|
||||
// way up, so a keyframe that arrives at full volume early — flattening the
|
||||
// climb the whole arc exists for — is a failure, not a pass.
|
||||
it('stays strictly under the midnight plateau on the way up', () => {
|
||||
for (const k of ARC) {
|
||||
if (k.at >= KICK_FULL_MIN) continue;
|
||||
for (const v of VOICE_NAMES) expect(k.scales[v]).toBeLessThan(plateau[v]);
|
||||
}
|
||||
|
||||
const eleven = ARC.find((k) => k.at === KICK_FULL_MIN)!;
|
||||
expect(eleven.scales.kick).toBe(plateau.kick);
|
||||
for (const v of LATE_BUILDERS) expect(eleven.scales[v]).toBeLessThan(plateau[v]);
|
||||
|
||||
for (let t = 0; t < KICK_FULL_MIN; t++) {
|
||||
for (const v of VOICE_NAMES) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
||||
}
|
||||
for (let t = KICK_FULL_MIN; t < PLATEAU_MIN; t++) {
|
||||
for (const v of LATE_BUILDERS) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds every voice at its loudest across the midnight..2 AM window', () => {
|
||||
for (const v of VOICE_NAMES) {
|
||||
for (let t = PLATEAU_MIN; t <= 300; t += 10) expect(arcAt(t)[v]).toBeCloseTo(plateau[v], 10);
|
||||
for (let t = 301; t <= NIGHT_END_MIN; t++) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
||||
}
|
||||
});
|
||||
|
||||
it('strips every voice back by lights up', () => {
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(plateau[v]);
|
||||
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(arcAt(330)[v]);
|
||||
}
|
||||
// Low end goes first — that is what actually empties a floor.
|
||||
expect(arcAt(330).bass).toBeLessThan(arcAt(330).pad);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcLabel', () => {
|
||||
it('names each keyframe at its minute and holds it until the next', () => {
|
||||
for (let i = 0; i < ARC.length; i++) {
|
||||
const k = ARC[i]!;
|
||||
expect(arcLabel(k.at)).toBe(k.label);
|
||||
const next = ARC[i + 1];
|
||||
if (next && next.at > k.at + 1) expect(arcLabel(k.at + 1)).toBe(k.label);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the expected labels across the night', () => {
|
||||
expect(arcLabel(0)).toBe('EMPTY');
|
||||
expect(arcLabel(59)).toBe('EMPTY');
|
||||
expect(arcLabel(60)).toBe('FILLING');
|
||||
expect(arcLabel(119)).toBe('FILLING');
|
||||
expect(arcLabel(120)).toBe('BUILDING');
|
||||
expect(arcLabel(180)).toBe('PEAK');
|
||||
expect(arcLabel(299)).toBe('PEAK');
|
||||
expect(arcLabel(300)).toBe('PEAK');
|
||||
expect(arcLabel(330)).toBe('LAST DRINKS');
|
||||
expect(arcLabel(359)).toBe('LAST DRINKS');
|
||||
expect(arcLabel(NIGHT_END_MIN)).toBe('LIGHTS UP');
|
||||
});
|
||||
|
||||
it('holds the edges before doors and after close', () => {
|
||||
expect(arcLabel(-30)).toBe(first.label);
|
||||
expect(arcLabel(NIGHT_END_MIN + 30)).toBe(last.label);
|
||||
expect(arcLabel(NaN)).toBe(first.label);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcIntensity', () => {
|
||||
it('stays within 0..1 across and beyond the night', () => {
|
||||
for (let t = -60; t <= NIGHT_END_MIN + 60; t++) {
|
||||
const i = arcIntensity(t);
|
||||
expect(i).toBeGreaterThanOrEqual(0);
|
||||
expect(i).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('is the mean of the four voice scalars', () => {
|
||||
for (const t of [0, 45, 120, 240, 345, 360]) {
|
||||
const s = arcAt(t);
|
||||
expect(arcIntensity(t)).toBeCloseTo((s.kick + s.hat + s.bass + s.pad) / 4, 10);
|
||||
}
|
||||
});
|
||||
|
||||
it('rises from doors to peak', () => {
|
||||
let prev = -Infinity;
|
||||
for (let t = 0; t <= 180; t += 10) {
|
||||
const i = arcIntensity(t);
|
||||
expect(i).toBeGreaterThan(prev);
|
||||
prev = i;
|
||||
}
|
||||
expect(arcIntensity(180)).toBe(1);
|
||||
expect(arcIntensity(0)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('holds flat across the peak plateau', () => {
|
||||
for (let t = 180; t <= 300; t += 20) expect(arcIntensity(t)).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it('falls from the plateau to lights up', () => {
|
||||
let prev = Infinity;
|
||||
for (let t = 300; t <= NIGHT_END_MIN; t += 10) {
|
||||
const i = arcIntensity(t);
|
||||
expect(i).toBeLessThan(prev);
|
||||
prev = i;
|
||||
}
|
||||
expect(arcIntensity(NIGHT_END_MIN)).toBeLessThan(arcIntensity(300));
|
||||
});
|
||||
});
|
||||
271
tests/mix.test.ts
Normal file
271
tests/mix.test.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clampTo,
|
||||
cloneMix,
|
||||
DEFAULT_MIX,
|
||||
formatMix,
|
||||
MIX_RANGES,
|
||||
soloedVoice,
|
||||
voiceGain,
|
||||
VOICE_NAMES,
|
||||
type TrackMix,
|
||||
type VoiceName,
|
||||
} from '../src/audio/mix';
|
||||
|
||||
const mix = (over: Partial<TrackMix> = {}): TrackMix => ({ ...cloneMix(DEFAULT_MIX), ...over });
|
||||
|
||||
const enabled = (...on: VoiceName[]): Record<VoiceName, boolean> => ({
|
||||
kick: on.includes('kick'),
|
||||
hat: on.includes('hat'),
|
||||
bass: on.includes('bass'),
|
||||
pad: on.includes('pad'),
|
||||
});
|
||||
|
||||
describe('DEFAULT_MIX', () => {
|
||||
// Tripwire. A retune is a deliberate, reviewed diff — if this goes red because
|
||||
// someone pasted an ear-pass dump, update the literals in the same commit.
|
||||
it('holds the Phase-1 hardcoded values exactly', () => {
|
||||
expect(DEFAULT_MIX).toEqual({
|
||||
master: 0.8,
|
||||
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
|
||||
hat: { hpHz: 7000, decayS: 0.04 },
|
||||
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
|
||||
pad: { lpHz: 600, bars: 8 },
|
||||
doorQ: 6,
|
||||
floorQ: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('ships every voice unmuted and every level inside its range', () => {
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(DEFAULT_MIX.enabled[v]).toBe(true);
|
||||
expect(DEFAULT_MIX.levels[v]).toBe(clampTo(DEFAULT_MIX.levels[v], MIX_RANGES.level));
|
||||
}
|
||||
expect(DEFAULT_MIX.master).toBe(clampTo(DEFAULT_MIX.master, MIX_RANGES.master));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneMix', () => {
|
||||
it('deep-copies every nested branch', () => {
|
||||
const src = cloneMix(DEFAULT_MIX);
|
||||
const copy = cloneMix(src);
|
||||
|
||||
expect(copy).toEqual(src);
|
||||
expect(copy.levels).not.toBe(src.levels);
|
||||
expect(copy.enabled).not.toBe(src.enabled);
|
||||
expect(copy.kick).not.toBe(src.kick);
|
||||
expect(copy.hat).not.toBe(src.hat);
|
||||
expect(copy.bass).not.toBe(src.bass);
|
||||
expect(copy.pad).not.toBe(src.pad);
|
||||
});
|
||||
|
||||
it('leaves the source untouched when every branch of the clone is mutated', () => {
|
||||
const src = cloneMix(DEFAULT_MIX);
|
||||
const copy = cloneMix(src);
|
||||
|
||||
copy.master = 0.1;
|
||||
copy.levels.kick = 0.01;
|
||||
copy.levels.hat = 0.02;
|
||||
copy.levels.bass = 0.03;
|
||||
copy.levels.pad = 0.04;
|
||||
copy.enabled.kick = false;
|
||||
copy.enabled.hat = false;
|
||||
copy.enabled.bass = false;
|
||||
copy.enabled.pad = false;
|
||||
copy.kick.startHz = 999;
|
||||
copy.hat.hpHz = 999;
|
||||
copy.bass.rootHz = 999;
|
||||
copy.pad.lpHz = 999;
|
||||
copy.doorQ = 99;
|
||||
copy.floorQ = 99;
|
||||
|
||||
expect(src.master).toBe(0.8);
|
||||
expect(src.levels).toEqual({ kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 });
|
||||
expect(src.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
|
||||
expect(src.kick.startHz).toBe(150);
|
||||
expect(src.hat.hpHz).toBe(7000);
|
||||
expect(src.bass.rootHz).toBe(55);
|
||||
expect(src.pad.lpHz).toBe(600);
|
||||
expect(src.doorQ).toBe(6);
|
||||
expect(src.floorQ).toBe(0.7);
|
||||
});
|
||||
|
||||
it('does not let a clone of DEFAULT_MIX mutate the module-level default', () => {
|
||||
const c = cloneMix(DEFAULT_MIX);
|
||||
c.levels.kick = 0;
|
||||
c.enabled.pad = false;
|
||||
expect(DEFAULT_MIX.levels.kick).toBe(0.9);
|
||||
expect(DEFAULT_MIX.enabled.pad).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampTo', () => {
|
||||
const range: readonly [number, number] = [0.5, 20];
|
||||
|
||||
it('passes values inside the range through unchanged', () => {
|
||||
expect(clampTo(0.5, range)).toBe(0.5);
|
||||
expect(clampTo(6, range)).toBe(6);
|
||||
expect(clampTo(20, range)).toBe(20);
|
||||
});
|
||||
|
||||
it('clamps both ends', () => {
|
||||
expect(clampTo(-100, range)).toBe(0.5);
|
||||
expect(clampTo(1e9, range)).toBe(20);
|
||||
expect(clampTo(20.0001, range)).toBe(20);
|
||||
expect(clampTo(0.4999, range)).toBe(0.5);
|
||||
});
|
||||
|
||||
// A NaN gain reaching WebAudio silently kills a voice for the rest of the
|
||||
// night, so a bad input must land on the minimum, never propagate. Infinity
|
||||
// takes the same branch: non-finite reads as a broken value, not a loud one.
|
||||
it('maps every non-finite input to the range minimum, not NaN and not the max', () => {
|
||||
for (const bad of [NaN, Infinity, -Infinity]) {
|
||||
expect(clampTo(bad, range)).toBe(0.5);
|
||||
expect(clampTo(bad, MIX_RANGES.master)).toBe(0);
|
||||
expect(Number.isNaN(clampTo(bad, range))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every declared range within its own bounds', () => {
|
||||
for (const [lo, hi] of Object.values(MIX_RANGES)) {
|
||||
expect(lo).toBeLessThan(hi);
|
||||
expect(clampTo(lo - 1, [lo, hi])).toBe(lo);
|
||||
expect(clampTo(hi + 1, [lo, hi])).toBe(hi);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voiceGain', () => {
|
||||
it('multiplies level by the arc scale', () => {
|
||||
const m = mix();
|
||||
expect(voiceGain(m, 'kick', 1)).toBeCloseTo(0.9, 10);
|
||||
expect(voiceGain(m, 'kick', 0.5)).toBeCloseTo(0.45, 10);
|
||||
expect(voiceGain(m, 'bass', 0.5)).toBeCloseTo(0.055, 10);
|
||||
});
|
||||
|
||||
it('defaults the arc scale to 1', () => {
|
||||
expect(voiceGain(mix(), 'hat')).toBe(mix().levels.hat);
|
||||
});
|
||||
|
||||
it('returns 0 for a disabled voice regardless of level or arc scale', () => {
|
||||
const m = mix({ enabled: enabled('hat'), levels: { kick: 1.5, hat: 0.25, bass: 1, pad: 1 } });
|
||||
expect(voiceGain(m, 'kick', 1)).toBe(0);
|
||||
expect(voiceGain(m, 'kick', 10)).toBe(0);
|
||||
expect(voiceGain(m, 'bass', 1)).toBe(0);
|
||||
expect(voiceGain(m, 'pad', 0.5)).toBe(0);
|
||||
expect(voiceGain(m, 'hat', 1)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('yields exactly 0 at a 0 arc scale', () => {
|
||||
for (const v of VOICE_NAMES) expect(voiceGain(mix(), v, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('never returns a negative gain', () => {
|
||||
const m = mix();
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(voiceGain(m, v, -1)).toBe(0);
|
||||
expect(voiceGain(m, v, -1e6)).toBe(0);
|
||||
expect(voiceGain(m, v, 2)).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect(voiceGain(mix({ levels: { kick: -1, hat: 0, bass: 0, pad: 0 } }), 'kick')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('soloedVoice', () => {
|
||||
it('is null when every voice is on', () => {
|
||||
expect(soloedVoice(mix())).toBeNull();
|
||||
});
|
||||
|
||||
it('is null when no voice is on', () => {
|
||||
expect(soloedVoice(mix({ enabled: enabled() }))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null with two voices on', () => {
|
||||
expect(soloedVoice(mix({ enabled: enabled('kick', 'bass') }))).toBeNull();
|
||||
expect(soloedVoice(mix({ enabled: enabled('hat', 'pad') }))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null with three voices on', () => {
|
||||
expect(soloedVoice(mix({ enabled: enabled('kick', 'hat', 'bass') }))).toBeNull();
|
||||
});
|
||||
|
||||
it('names the voice when exactly one is on', () => {
|
||||
for (const v of VOICE_NAMES) {
|
||||
expect(soloedVoice(mix({ enabled: enabled(v) }))).toBe(v);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMix', () => {
|
||||
// The dump is how a tuning session becomes a diff; parse it back and compare
|
||||
// field by field so a silently dropped knob goes red.
|
||||
const parseDump = (s: string): unknown =>
|
||||
JSON.parse(
|
||||
s
|
||||
.replace(/([{,]\s*)([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":')
|
||||
.replace(/,(\s*})/g, '$1'),
|
||||
);
|
||||
|
||||
const tuned: TrackMix = {
|
||||
master: 0.95,
|
||||
levels: { kick: 1.05, hat: 0.3125, bass: 0.123456789, pad: 0.07 },
|
||||
enabled: enabled('bass'),
|
||||
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
|
||||
hat: { hpHz: 8200, decayS: 0.055 },
|
||||
bass: { rootHz: 41, filterLoHz: 220, filterHiHz: 1400, q: 11.5, decayS: 0.19, detuneCents: 14 },
|
||||
pad: { lpHz: 780, bars: 4 },
|
||||
doorQ: 7.25,
|
||||
floorQ: 0.85,
|
||||
};
|
||||
|
||||
it('round-trips every numeric field, rounded to 4 decimal places', () => {
|
||||
expect(parseDump(formatMix(tuned))).toEqual({
|
||||
master: 0.95,
|
||||
levels: { kick: 1.05, hat: 0.3125, bass: 0.1235, pad: 0.07 },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
|
||||
hat: { hpHz: 8200, decayS: 0.055 },
|
||||
bass: {
|
||||
rootHz: 41,
|
||||
filterLoHz: 220,
|
||||
filterHiHz: 1400,
|
||||
q: 11.5,
|
||||
decayS: 0.19,
|
||||
detuneCents: 14,
|
||||
},
|
||||
pad: { lpHz: 780, bars: 4 },
|
||||
doorQ: 7.25,
|
||||
floorQ: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips DEFAULT_MIX back to itself', () => {
|
||||
expect(parseDump(formatMix(DEFAULT_MIX))).toEqual(DEFAULT_MIX);
|
||||
});
|
||||
|
||||
it('emits every key of every branch', () => {
|
||||
const out = parseDump(formatMix(tuned)) as Record<string, Record<string, unknown>>;
|
||||
expect(Object.keys(out).sort()).toEqual(Object.keys(tuned).sort());
|
||||
for (const branch of ['levels', 'enabled', 'kick', 'hat', 'bass', 'pad'] as const) {
|
||||
expect(Object.keys(out[branch] ?? {}).sort()).toEqual(Object.keys(tuned[branch]).sort());
|
||||
}
|
||||
});
|
||||
|
||||
// Mutes are an ear-pass tool, not a shipped state: the dump deliberately
|
||||
// un-mutes everything so a solo pass cannot ship as silence.
|
||||
it('always emits enabled: all true, even from a soloed mix', () => {
|
||||
expect(soloedVoice(tuned)).toBe('bass');
|
||||
const out = parseDump(formatMix(tuned)) as { enabled: Record<string, boolean> };
|
||||
expect(out.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
|
||||
});
|
||||
|
||||
it('emits integers without a decimal point', () => {
|
||||
const dump = formatMix(DEFAULT_MIX);
|
||||
expect(dump).toContain('startHz: 150');
|
||||
expect(dump).toContain('hpHz: 7000');
|
||||
expect(dump).toContain('doorQ: 6');
|
||||
expect(dump).not.toMatch(/\d\.0000/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user