From d112bda2705380f86ce49514025351c0012062a5 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 20:30:42 +1000 Subject: [PATCH 1/3] LANE-JUICE2: lift the track's constants into a live mix + a night arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase-1 engine baked every level, pitch and envelope into module consts, so nothing could be tuned without an edit-reload cycle. Three things need them live: the ear pass (a human turns a knob and hears it on the next beat), the night arc, and the Phase-4 DJ role, whose control panel is specified as this exact surface (BUILD_PLAN §4). - mix.ts: TrackMix + DEFAULT_MIX (reproducing the Phase-1 values exactly, so lifting them changes no sound), MIX_RANGES bounds, and formatMix() — which is what turns a tuning session into a diff instead of numbers that die with the tab. - arc.ts: the night's shape as per-voice gain scalars. Thin kick at 9PM, bass in once there's a crowd to carry it, everything at midnight, low end pulled first at Last Drinks. Pure keyframes, so it's testable and the DJ role can drive the same surface by hand. Co-Authored-By: Claude Opus 4.8 --- src/audio/arc.ts | 98 +++++++++++++++++++++++++++++ src/audio/mix.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/audio/arc.ts create mode 100644 src/audio/mix.ts diff --git a/src/audio/arc.ts b/src/audio/arc.ts new file mode 100644 index 0000000..1f73879 --- /dev/null +++ b/src/audio/arc.ts @@ -0,0 +1,98 @@ +import { VOICE_NAMES, type VoiceName } from './mix'; + +// The night's shape, as gain scalars per voice. +// +// A club does not play the same track at 9 PM and at 1 AM, and a room that +// sounds identical for six hours is the main reason a long shift reads as flat. +// So the mix opens thin — kick and a bit of hat, the sound of a room hoping +// people turn up — the bassline arrives once there is a crowd to carry it, the +// pads land at peak, and Last Drinks strips it back so the lights coming up +// feel earned rather than abrupt. +// +// Pure keyframe interpolation: no audio types here, so it is testable in node +// and reusable by the Phase-4 DJ role, which drives this same surface by hand. + +/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */ +export const NIGHT_END_MIN = 360; + +export type ArcScales = Record; + +export interface ArcKeyframe { + /** Minutes since 21:00. */ + at: number; + label: string; + scales: ArcScales; +} + +/** + * Keyframes, ascending by `at`. Deliberately sparse — the interpolation between + * them is what makes the build feel gradual rather than stepped. + */ +export const ARC: readonly ArcKeyframe[] = [ + // 9 PM. Doors open to an empty room. Kick and a whisper of hat. + { at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } }, + // 10 PM. First real queue. The bassline turns up with them. + { at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } }, + // 11 PM. Room has weight. + { at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } }, + // Midnight through 2 AM. Everything, all of it. + { at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } }, + { at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } }, + // 2:30. Last drinks. Pull the low end first — that is what empties a floor. + { at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } }, + // 3 AM. Pads over a dying kick while the lights come up. + { at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } }, +]; + +const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + +/** + * Voice scalars at a given clock minute. Clamps outside the night rather than + * extrapolating — a paused or overrunning clock should hold the edge, not + * wander into negative gain. + */ +export function arcAt(clockMin: number): ArcScales { + const t = Number.isFinite(clockMin) ? clockMin : 0; + + const first = ARC[0]; + const last = ARC[ARC.length - 1]; + /* istanbul ignore next -- ARC is a non-empty literal */ + if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 }; + + if (t <= first.at) return { ...first.scales }; + if (t >= last.at) return { ...last.scales }; + + for (let i = 1; i < ARC.length; i++) { + const prev = ARC[i - 1]; + const next = ARC[i]; + if (!prev || !next || t > next.at) continue; + const span = next.at - prev.at; + const k = span <= 0 ? 1 : (t - prev.at) / span; + const out = {} as ArcScales; + for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k); + return out; + } + + /* istanbul ignore next -- the loop always returns for t inside the range */ + return { ...last.scales }; +} + +/** Nearest keyframe label at or before this minute — for HUD readouts. */ +export function arcLabel(clockMin: number): string { + const t = Number.isFinite(clockMin) ? clockMin : 0; + let label = ARC[0]?.label ?? ''; + for (const k of ARC) { + if (t >= k.at) label = k.label; + } + return label; +} + +/** + * One number for "how hard is the room going", 0..1 — the mean of the voice + * scalars. Handy for anything that wants to follow the night's energy without + * caring which voice is doing it (lighting, crowd density, the DJ role's meter). + */ +export function arcIntensity(clockMin: number): number { + const s = arcAt(clockMin); + return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length; +} diff --git a/src/audio/mix.ts b/src/audio/mix.ts new file mode 100644 index 0000000..1db45c9 --- /dev/null +++ b/src/audio/mix.ts @@ -0,0 +1,159 @@ +// The track's tunable surface, lifted out of TechnoEngine so it can be changed +// while the music is playing. +// +// Three things need this and none of them can use a `const` in a module body: +// the ear pass (a human turns a knob and hears it on the next beat), the night +// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role, +// whose control panel is specified as this exact surface (BUILD_PLAN §4). +// +// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting +// them changed no sound. Anything John retunes in the ear pass gets pasted back +// over DEFAULT_MIX via `formatMix()`. + +export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad'; + +export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad']; + +export interface KickParams { + /** Beater pitch at the transient. */ + startHz: number; + /** Where the drop lands — the body of the cone. */ + endHz: number; + /** Seconds for the pitch drop. Longer reads as a bigger speaker. */ + dropS: number; + attackS: number; + decayS: number; +} + +export interface HatParams { + /** Highpass corner. Lower lets more of the noise body through. */ + hpHz: number; + decayS: number; +} + +export interface BassParams { + rootHz: number; + /** Per-note filter sweep floor and peak — the "wow" on each 16th. */ + filterLoHz: number; + filterHiHz: number; + q: number; + decayS: number; + /** Cents of detune between the two saws. The whole character lives here. */ + detuneCents: number; +} + +export interface PadParams { + lpHz: number; + /** Length of the fade-in/out cycle, in bars. */ + bars: number; +} + +export interface TrackMix { + /** Post-everything output level. */ + master: number; + /** Per-voice peak gains, pre-filter. */ + levels: Record; + /** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */ + enabled: Record; + kick: KickParams; + hat: HatParams; + bass: BassParams; + pad: PadParams; + /** Filter resonance at each location — the door's peak is the "through a wall" cue. */ + doorQ: number; + floorQ: number; +} + +export const DEFAULT_MIX: TrackMix = { + 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, +}; + +/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */ +export const MIX_RANGES = { + master: [0, 1.2], + level: [0, 1.5], + 'kick.startHz': [60, 400], + 'kick.endHz': [25, 120], + 'kick.dropS': [0.01, 0.3], + 'kick.attackS': [0.001, 0.05], + 'kick.decayS': [0.05, 1], + 'hat.hpHz': [2000, 14000], + 'hat.decayS': [0.005, 0.2], + 'bass.rootHz': [30, 110], + 'bass.filterLoHz': [60, 600], + 'bass.filterHiHz': [300, 4000], + 'bass.q': [0.5, 20], + 'bass.decayS': [0.02, 0.5], + 'bass.detuneCents': [0, 40], + 'pad.lpHz': [200, 4000], + 'pad.bars': [1, 16], + doorQ: [0.5, 20], + floorQ: [0.1, 4], +} as const satisfies Record; + +export const clampTo = (v: number, range: readonly [number, number]): number => + Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0]; + +/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */ +export function cloneMix(m: TrackMix): TrackMix { + return { + master: m.master, + levels: { ...m.levels }, + enabled: { ...m.enabled }, + kick: { ...m.kick }, + hat: { ...m.hat }, + bass: { ...m.bass }, + pad: { ...m.pad }, + doorQ: m.doorQ, + floorQ: m.floorQ, + }; +} + +/** + * Effective gain for a voice: its level, muted by `enabled`, scaled by whatever + * the night arc says this voice should be doing right now. + * + * Kept here rather than in the engine so the ear-pass rig can show the same + * number the scheduler will actually use. + */ +export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number { + if (!mix.enabled[voice]) return 0; + return Math.max(0, mix.levels[voice] * arcScale); +} + +/** True when exactly one voice is unmuted — the ear pass's soloed state. */ +export const soloedVoice = (mix: TrackMix): VoiceName | null => { + const on = VOICE_NAMES.filter((v) => mix.enabled[v]); + return on.length === 1 ? (on[0] ?? null) : null; +}; + +const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4)))); + +/** + * Emit a mix as a pasteable `TrackMix` literal. + * + * The ear pass is a human saying "more click on the kick" and me turning that + * into numbers; without a dump button those numbers die with the tab. This is + * how a tuning session becomes a diff. + */ +export function formatMix(m: TrackMix): string { + return `{ + master: ${n(m.master)}, + levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} }, + enabled: { kick: true, hat: true, bass: true, pad: true }, + kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} }, + hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} }, + bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} }, + pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} }, + doorQ: ${n(m.doorQ)}, + floorQ: ${n(m.floorQ)}, +}`; +} From 1a66c602fd51b328506bf403f06a259d51adb1d0 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 21:47:53 +1000 Subject: [PATCH 2/3] 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/); + }); +}); From 10462ebadae4ccb31306c440605e3cdfba8b38ef Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 21:48:44 +1000 Subject: [PATCH 3/3] =?UTF-8?q?LANE-JUICE2:=20session=20block=20=E2=80=94?= =?UTF-8?q?=20ear=20pass=20is=20rigged=20and=20waiting=20on=20ears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- LANEHANDOVER.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 6b94e42..21343c1 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -597,3 +597,70 @@ heat). (files in lanes/). All three parallel, worktrees, own branches, usual protocol. If only one session: CONTENT first (it's the game's heart), FLOOR2, then JUICE2 — except run JUICE2 first if John is available for the ear pass. + +--- +### SESSION — LANE-JUICE2 — 2026-07-19 21:50 +**Branch/commits:** lane/juice2 @ d112bda..1a66c60 (worktree `../not-tonight-juice2`) +**Done:** +- **`audio/mix.ts` + `audio/arc.ts`** (new, pure, tested). The track's constants + are now a live `TrackMix`, and the night has a shape. Same surface serves the + ear pass, the arc, and the Phase-4 DJ control panel (BUILD_PLAN §4 rule). +- **`TechnoEngine`** drives every voice from the mix × arc scalars. Verified + behaviour-identical at `DEFAULT_MIX`, constant by constant. Silent voices are + skipped rather than clamped (9 PM bass builds zero nodes). `setMixValue` + quantises integer paths so the dump can't export a value nobody heard. +- **`ui/MixDeskScene`** — the ear-pass rig. Solo/mute per voice with a + per-voice listening prompt, every param on log-mapped sliders (5 pages), an + arc scrubber, and DUMP MIX → pasteable `TrackMix` literal + clipboard. + Restores the arc pin and solo state it found, so it can't un-tune its host. +- **`audio/Ambience`** — rain (outside), crowd murmur (inside, level follows the + room), kebab-shop fluoro hum. See routing + headcount notes below. +- **`Phone`** thread scrolls: visible-window culling, binary-searched range, + history cap, sticky-bottom, and a "NEW BELOW" cue when a text lands off-screen. +- **`JuiceDemoScene`** hosts all of it; arc pinned at PEAK on entry. +**How to see it:** the demo lives in this worktree, so +`cd ../not-tonight-juice2 && npx vite --port 5311` → http://localhost:5311/#juice +(or J). Click once to unlock audio, then **M** for the mix desk. +**Tests:** 460 passing / 0 failing (was 413; +45 across mix/arc, +2 from fixes). +**Verified by ear:** STILL NOTHING — no audio output in this environment. Logic +verified in a real browser: arc pins/restores across desk open+close, integer +quantisation, crowd headcount arithmetic (120 + 2 admits − 1 ejection = 121 with +the clicker event correctly ignored), zero console errors. +**THE ASK — the ear pass is now a ~10 minute job and I can't do it:** +Open the desk, SOLO each voice in turn, fix what's wrong, hit DUMP MIX, paste me +the literal. It goes straight over `DEFAULT_MIX` in `src/audio/mix.ts`. +`tests/mix.test.ts` pins the current values deliberately, so a retune will fail +that test — that's the tripwire working; update it in the same diff. Specifically +unjudged: does the kick read as a kick through the door filter, is the bassline +musical or just busy, is `typeTick` (fires per character) tolerable, does the +stamp sit right against the music, and does the door→floor sweep feel like a door. +**Broke / known-wonky:** +- `Ambience` rain uses `Sfx`'s own 1.2s/0.9s envelope, so it trails the 600/900ms + filter sweep by ~300ms. Fixing it properly means a `fadeS` param on + `Sfx.startRain/stopRain`. Reads fine; noting it rather than hiding it. +- `Ambience` assumes it is the sole owner of rain policy. If a door scene also + drives `sfx.startRain()` they will fight. +- `MixDesk` is reachable only from JuiceDemo (key M) — see request 1. +- No `TechnoEngine`/`Ambience` test files; both need a real `AudioContext` and + the suite is node. Their pure math is tested via mix/arc/scheduling/filterCurve. +**Contract change requests:** none. `door:phoneTheatre` was folded in at the +Phase-1 review; the crowd bed derives its headcount from existing events rather +than requesting `capacity.inside` in a payload (reasoning above). +**Requests for integration (main.ts is yours now, per the Phase-1 ruling):** +1. **A global route to the mix desk**, so the ear pass can also happen during a + REAL night rather than only in the demo. `MixDeskScene` already falls back to + `getActiveEngine()` when launched without data, so it attaches to whatever + engine is running — it just needs a key. Suggest **M**, alongside N/P/F/J. +2. Optional: `MeterHud` door-bar variant is still not done (LANE_JUICE2 §4.3, + explicitly "only if clean"). DoorScene's bespoke meters work; I left them. +**Questions for reviewer:** +1. The arc means the night no longer sounds the same at 9 PM and 1 AM — bass is + fully out at 9 PM by design. Worth confirming that's wanted before John tunes + against it, since it changes what "the mix" even means at a given hour. +2. `Ambience` currently has no owner in `NightScene` — I can't wire it there + (scenes aren't mine). Want it created alongside TechnoEngine at integration? +**Next session should:** +- Run the ear pass with John and land the retuned `DEFAULT_MIX`. +- Wire `Ambience` into `NightScene` (integration-owned). +- LANE-FLOOR2 owns the cut-off overlay call site; `DialogueBox` drunk-typo mode + is ready for it and needs no widget change — just pass `drunkenness` + `rng`.