From 1a66c602fd51b328506bf403f06a259d51adb1d0 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 21:47:53 +1000 Subject: [PATCH] LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audio/Ambience.ts | 445 +++++++++++++++++++++++ src/audio/TechnoEngine.ts | 323 ++++++++++++++--- src/ui/JuiceDemoScene.ts | 248 ++++++++++++- src/ui/MixDeskScene.ts | 724 ++++++++++++++++++++++++++++++++++++++ src/ui/Phone.ts | 402 ++++++++++++++++++--- tests/arc.test.ts | 277 +++++++++++++++ tests/mix.test.ts | 271 ++++++++++++++ 7 files changed, 2568 insertions(+), 122 deletions(-) create mode 100644 src/audio/Ambience.ts create mode 100644 src/ui/MixDeskScene.ts create mode 100644 tests/arc.test.ts create mode 100644 tests/mix.test.ts diff --git a/src/audio/Ambience.ts b/src/audio/Ambience.ts new file mode 100644 index 0000000..f73807c --- /dev/null +++ b/src/audio/Ambience.ts @@ -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; + + 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; +} diff --git a/src/audio/TechnoEngine.ts b/src/audio/TechnoEngine.ts index fa90800..4c55cc3 100644 --- a/src/audio/TechnoEngine.ts +++ b/src/audio/TechnoEngine.ts @@ -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 = 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(); + 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 | 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 { + return this.mixState; + } + + /** + * Set one knob by dotted path: `'master'`, `'levels.'`, + * `'.'`, `'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; + 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. diff --git a/src/ui/JuiceDemoScene.ts b/src/ui/JuiceDemoScene.ts index 48c4ff2..1138a85 100644 --- a/src/ui/JuiceDemoScene.ts +++ b/src/ui/JuiceDemoScene.ts @@ -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 = [ ['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> = { 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 = { ...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(); diff --git a/src/ui/MixDeskScene.ts b/src/ui/MixDeskScene.ts new file mode 100644 index 0000000..6f6b8b6 --- /dev/null +++ b/src/ui/MixDeskScene.ts @@ -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; + 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 = { + 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(); +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 | 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 + : 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(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; + } +} diff --git a/src/ui/Phone.ts b/src/ui/Phone.ts index 378a543..1a89108 100644 --- a/src/ui/Phone.ts +++ b/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; } } diff --git a/tests/arc.test.ts b/tests/arc.test.ts new file mode 100644 index 0000000..20df9a9 --- /dev/null +++ b/tests/arc.test.ts @@ -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)); + }); +}); diff --git a/tests/mix.test.ts b/tests/mix.test.ts new file mode 100644 index 0000000..e3f8fe0 --- /dev/null +++ b/tests/mix.test.ts @@ -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 => ({ ...cloneMix(DEFAULT_MIX), ...over }); + +const enabled = (...on: VoiceName[]): Record => ({ + 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>; + 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 }; + 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/); + }); +});