LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine

The Phase-1 debt was 'verified by ear: NOTHING'. I still can't hear, so this
lane builds the instrument that lets someone who can tell me what's wrong, and
makes their answer a diff instead of a conversation.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 21:47:53 +10:00
parent d112bda270
commit 1a66c602fd
7 changed files with 2568 additions and 122 deletions

445
src/audio/Ambience.ts Normal file
View File

@ -0,0 +1,445 @@
// The beds under the night: rain outside, bodies inside, and the kebab shop's
// fluoro tube humming to itself while both happen.
//
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
// The music lowpass models "the track heard through a wall" — that is a
// statement about the PA being on the other side of a door. Rain is not on the
// other side of anything; it is falling on your head, so filtering it with the
// music would put the weather indoors with the speakers. What makes rain
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
// The crowd bed is the same argument run backwards: it is the room you are
// standing in when you are inside, and a wall's worth of muffling belongs on
// the PA, not on the people. Location is a mix decision here, not a filter one.
import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import type { Sfx } from './Sfx';
export interface AmbienceOptions {
rain?: boolean;
crowd?: boolean;
kebab?: boolean;
}
type Bed = 'rain' | 'crowd' | 'kebab';
type Location = 'door' | 'floor';
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
const NOISE_SECONDS = 4;
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
* stepping through the door reads as one movement rather than two events. */
const LOC_FADE_S = 0.7;
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
const COUNT_FADE_S = 0.4;
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
const TOGGLE_FADE_S = 0.25;
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
const CROWD_PEAK = 0.09;
/**
* Saturation constant for the headcount curve. At k=14 the first dozen people
* buy most of the loudness and the hundredth buys almost none which is how a
* room actually behaves, and the opposite of what a linear map does.
*/
const CROWD_K = 14;
/** Crowd bed at the door: the room leaking past you, not silence. */
const CROWD_DOOR_DUCK = 0.15;
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
const KEBAB_LEVEL = 0.014;
export class Ambience {
private readonly bus: EventBus;
private readonly ctx: AudioContext;
private readonly dest: AudioNode;
private readonly sfx: Sfx;
private readonly offs: Array<() => void> = [];
private readonly enabled: Record<Bed, boolean>;
private loc: Location = 'door';
private running = false;
private count = 0;
private crowd: CrowdBed | null = null;
private kebab: KebabBed | null = null;
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
private noise: AudioBuffer | null = null;
constructor(
bus: EventBus,
ctx: AudioContext,
destination: AudioNode,
sfx: Sfx,
opts: AmbienceOptions = {},
) {
this.bus = bus;
this.ctx = ctx;
this.dest = destination;
this.sfx = sfx;
this.enabled = {
rain: opts.rain ?? true,
crowd: opts.crowd ?? true,
// The kebab shop is the moral centre of the thing (design doc §5). It gets
// a sound whether or not anyone is listening for it.
kebab: opts.kebab ?? true,
};
this.offs.push(
this.bus.on('audio:location', ({ location }) => {
if (location === this.loc) return; // door -> door must not restack a fade
this.loc = location;
this.applyLocation();
}),
);
// Headcount is derived, not published: NightState.capacity.inside is in no
// event payload and we may not add one. So we count the truth log — an
// admission puts a body in the room, an ejection takes one out.
//
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
// count, and the gap between it and reality is a game mechanic. Driving the
// crowd bed off it would make the room sound like the lie instead of the
// room, and would hand the player a way to hear their own miscount.
this.offs.push(
this.bus.on('door:verdict', ({ verdict }) => {
if (verdict === 'admit') this.setInsideCount(this.count + 1);
}),
);
this.offs.push(
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
);
}
get insideCount(): number {
return this.count;
}
/** Lets a demo drive the room without staging a hundred real admissions. */
setInsideCount(n: number): void {
const next = Math.max(0, Math.floor(n));
if (next === this.count) return;
this.count = next;
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
}
start(): void {
if (this.running) return;
this.running = true;
this.noise ??= noiseBuffer(this.ctx);
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
this.kebab = new KebabBed(this.ctx, this.dest);
// Snap rather than fade on the first frame: there is no previous location to
// cross from, and a 700ms swell out of nothing sounds like a mistake.
this.crowd.setLevel(this.crowdTarget(), 0);
this.crowd.setLocation(this.crowdDuck(), 0);
this.kebab.setLevel(this.kebabTarget(), 0);
this.kebab.setLocation(this.kebabDuck(), 0);
this.applyRain();
}
stop(): void {
if (!this.running) return;
this.running = false;
this.crowd?.stop();
this.crowd = null;
this.kebab?.stop();
this.kebab = null;
this.sfx.stopRain();
}
setEnabled(bed: Bed, on: boolean): void {
if (this.enabled[bed] === on) return;
this.enabled[bed] = on;
if (!this.running) return;
switch (bed) {
case 'rain':
return this.applyRain();
case 'crowd':
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
case 'kebab':
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
}
}
destroy(): void {
this.stop();
for (const off of this.offs) off();
this.offs.length = 0;
}
// --- policy ---------------------------------------------------------------
private applyLocation(): void {
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
this.applyRain();
}
/**
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
* bus; a second one would just be the first one twice as loud. Both calls are
* idempotent, so repeated door -> door is a no-op there as well as here.
*
* The `running` guard is what the crowd and kebab beds get for free from their
* null refs. Without it a location change after stop() the subscription
* outlives stop(), only destroy() drains it would start rain under a room
* with no crowd and no kebab in it, and nothing would take it back down.
*/
private applyRain(): void {
if (!this.running) return;
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
else this.sfx.stopRain();
}
private crowdTarget(): number {
if (!this.enabled.crowd) return 0;
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
}
private crowdDuck(): number {
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
}
private kebabTarget(): number {
return this.enabled.kebab ? KEBAB_LEVEL : 0;
}
private kebabDuck(): number {
return this.loc === 'door' ? 1 : 0;
}
}
/**
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
* band between them is where a room full of people sits.
*/
class CrowdBed {
private readonly src: AudioBufferSourceNode;
private readonly lfos: OscillatorNode[] = [];
private readonly chain: AudioNode[] = [];
private readonly level: GainNode;
private readonly loc: GainNode;
private readonly ctx: AudioContext;
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
this.ctx = ctx;
this.src = ctx.createBufferSource();
this.src.buffer = buffer;
this.src.loop = true;
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
this.src.playbackRate.value = 0.85;
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 500;
band.Q.value = 0.75; // ~300-800Hz at -3dB
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1100;
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
// gain of 0 would push it negative, and an empty room has to be silent.
const wobble = ctx.createGain();
wobble.gain.value = 1;
this.wobbler(wobble, 0.19, 0.12);
this.wobbler(wobble, 0.07, 0.07);
this.level = ctx.createGain();
this.level.gain.value = 0;
this.loc = ctx.createGain();
this.loc.gain.value = 0;
this.chain.push(band, lp, wobble, this.level, this.loc);
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
this.loc.connect(dest);
this.src.start();
}
setLevel(to: number, durS: number): void {
ramp(this.ctx, this.level.gain, to, durS);
}
setLocation(to: number, durS: number): void {
ramp(this.ctx, this.loc.gain, to, durS);
}
stop(): void {
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
}
private wobbler(target: GainNode, hz: number, depth: number): void {
const lfo = this.ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = hz;
const amount = this.ctx.createGain();
amount.gain.value = depth;
lfo.connect(amount).connect(target.gain);
lfo.start();
this.lfos.push(lfo);
this.chain.push(amount);
}
}
/**
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
* work. Mono and completely dry it is six metres away through an open
* shopfront, and reverb would put it in a cathedral.
*/
class KebabBed {
private readonly oscs: OscillatorNode[] = [];
private readonly chain: AudioNode[] = [];
private readonly level: GainNode;
private readonly loc: GainNode;
private readonly ctx: AudioContext;
constructor(ctx: AudioContext, dest: AudioNode) {
this.ctx = ctx;
const sum = ctx.createGain();
sum.gain.value = 1;
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
// as a clean test tone.
this.partial(100, 1, sum);
this.partial(50, 0.45, sum);
this.partial(200, 0.18, sum);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 400;
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
// difference between "hum" and "sine wave someone left running".
const flicker = ctx.createGain();
flicker.gain.value = 1;
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 0.6;
const depth = ctx.createGain();
depth.gain.value = 0.08;
lfo.connect(depth).connect(flicker.gain);
lfo.start();
this.oscs.push(lfo);
this.level = ctx.createGain();
this.level.gain.value = 0;
this.loc = ctx.createGain();
this.loc.gain.value = 0;
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
this.loc.connect(dest);
}
setLevel(to: number, durS: number): void {
ramp(this.ctx, this.level.gain, to, durS);
}
setLocation(to: number, durS: number): void {
ramp(this.ctx, this.loc.gain, to, durS);
}
stop(): void {
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
}
private partial(hz: number, gain: number, sum: GainNode): void {
const osc = this.ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = hz;
const g = this.ctx.createGain();
g.gain.value = gain;
osc.connect(g).connect(sum);
osc.start();
this.oscs.push(osc);
this.chain.push(g);
}
}
// --- plumbing ---------------------------------------------------------------
/**
* Linear throughout, unlike the engine's sweeps: these targets legitimately
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
* Re-anchoring at the live value first keeps an interrupted fade continuous
* players do walk in and straight back out.
*
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
* `cancelScheduledValues(now)` removes every event at or after `now`, including
* the in-flight ramp's end event, and the spec does not say what the getter
* reads afterwards: Blink returns the last rendered value, but a UA computing it
* from the surviving timeline returns the ramp's origin. Reading first pins the
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
*
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
* which is the same thing done properly but Firefox has never shipped it, so
* feature-detect and fall back.
*/
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
const now = ctx.currentTime;
const from = param.value;
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
else param.cancelScheduledValues(now);
param.setValueAtTime(from, now);
if (durS <= 0) param.setValueAtTime(to, now);
else param.linearRampToValueAtTime(to, now + durS);
}
/**
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
* running noise bed is audible as a click, and leaving the nodes attached means
* a six-hour night accumulates every bed it ever started.
*/
function stopBed(
ctx: AudioContext,
out: GainNode,
src: AudioBufferSourceNode | null,
oscs: readonly OscillatorNode[],
chain: readonly AudioNode[],
): void {
const fade = 0.25;
const at = ctx.currentTime + fade;
ramp(ctx, out.gain, 0, fade);
const tail = src ?? oscs[0];
if (tail) {
tail.onended = () => {
for (const n of chain) n.disconnect();
src?.disconnect();
for (const o of oscs) o.disconnect();
};
}
src?.stop(at);
for (const o of oscs) o.stop(at);
}
/** Deterministic white noise house rule forbids Math.random, and a fixed seed
* means the room sounds the same on every run. */
function noiseBuffer(ctx: AudioContext): AudioBuffer {
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
return buf;
}

View File

@ -1,11 +1,22 @@
import type { EventBus } from '../core/EventBus'; import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG'; import { SeededRNG } from '../core/SeededRNG';
import { type ArcScales, arcAt } from './arc';
import { import {
type AudioLocation, type AudioLocation,
cutoffFor, cutoffFor,
gainFor, gainFor,
sweepMsFor, sweepMsFor,
} from './filterCurve'; } from './filterCurve';
import {
DEFAULT_MIX,
MIX_RANGES,
type TrackMix,
VOICE_NAMES,
type VoiceName,
clampTo,
cloneMix,
voiceGain,
} from './mix';
import { import {
type BeatGrid, type BeatGrid,
barOf, barOf,
@ -29,28 +40,33 @@ const TIMER_MS = 25;
*/ */
const START_LEAD_MS = 60; 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 SILENT = 0.0001; // exponential ramps cannot reach 0
const BASS_ROOT_HZ = 55; // A1
const BASS_STEPS = 32; // 2 bars of 16ths 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 * The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
* owns that slot, and leaving it empty is what makes the bassline roll instead * note *starts*, and resting exactly there makes the release sound like a gate.
* of thud. * 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)[] = [ const BASS_PATTERN: readonly (number | null)[] = [
null, null, 0, 0, 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' }, { hz: 164.81, detune: 0, type: 'triangle' },
]; ];
/** Widened once so a dotted path can be looked up without a cast at each site. */
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;
const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;
interface MixLens {
read: () => number;
write: (v: number) => void;
range: readonly [number, number];
}
/**
* The engine currently owning the AudioContext, or null.
*
* The tuning desk has to reach the engine during a real night, when the scene
* that constructed it belongs to another lane and there is no route to it. A
* module variable is the smallest thing that solves that deliberately not a
* window global, which would make it part of the page's public surface.
*/
let activeEngine: TechnoEngine | null = null;
export const getActiveEngine = (): TechnoEngine | null => activeEngine;
export interface TechnoEngineOptions { export interface TechnoEngineOptions {
bpm?: number; bpm?: number;
lookaheadMs?: 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 * 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 * 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. * 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 { export class TechnoEngine {
readonly ctx: AudioContext; readonly ctx: AudioContext;
@ -105,6 +148,12 @@ export class TechnoEngine {
*/ */
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>(); private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
private mixState: TrackMix = cloneMix(DEFAULT_MIX);
private clockMinute = 0;
private pinnedMinute: number | null = null;
/** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
private scales: ArcScales = arcAt(0);
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private nextBeat = 0; private nextBeat = 0;
private loc: AudioLocation = 'door'; private loc: AudioLocation = 'door';
@ -122,13 +171,13 @@ export class TechnoEngine {
this.grid = { bpm: this.bpm, originMs: 0 }; this.grid = { bpm: this.bpm, originMs: 0 };
this.master = this.ctx.createGain(); 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.master.connect(this.ctx.destination);
this.filter = this.ctx.createBiquadFilter(); this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass'; this.filter.type = 'lowpass';
this.filter.frequency.value = cutoffFor(this.loc); 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.filter.connect(this.master);
this.musicBus = this.ctx.createGain(); this.musicBus = this.ctx.createGain();
@ -144,7 +193,14 @@ export class TechnoEngine {
this.unsubs.push( this.unsubs.push(
this.bus.on('audio:location', ({ location }) => this.setLocation(location)), 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 { get running(): boolean {
@ -163,6 +219,76 @@ export class TechnoEngine {
return this.loc; return this.loc;
} }
// --- mix ------------------------------------------------------------------
get mix(): Readonly<TrackMix> {
return this.mixState;
}
/**
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
* `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
*
* An unknown path is inert rather than fatal the tuning desk is generated
* from a table, and a typo there should cost one dead slider, not the night.
*/
setMixValue(path: string, value: number): void {
const lens = this.lens(path);
if (!lens) return;
// Quantise here rather than at the slider: what we store is what formatMix
// dumps, so a fractional bar count would export a value nobody ever heard.
// The engine owns the invariant because it is the thing that rounds at use.
const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
lens.write(v);
// Levels and shapes land on the next scheduled note (<500ms, inaudible as
// lag). These two are continuous, so they have to move now.
if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
if (path === this.loc + 'Q' && this.override === null) {
this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
}
}
/** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
getMixValue(path: string): number {
return this.lens(path)?.read() ?? Number.NaN;
}
setVoiceEnabled(voice: VoiceName, on: boolean): void {
this.mixState.enabled[voice] = on;
}
resetMix(): void {
this.mixState = cloneMix(DEFAULT_MIX);
this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
if (this.override === null) {
this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
}
}
// --- night arc ------------------------------------------------------------
/**
* Pin the arc to a minute for the ear pass this is how 1 AM gets auditioned
* at 9 PM. null hands the arc back to `clock:tick`.
*/
setArcMinute(min: number | null): void {
this.pinnedMinute = min;
this.scales = arcAt(this.arcMinute);
}
get arcMinute(): number {
return this.pinnedMinute ?? this.clockMinute;
}
get arcScales(): ArcScales {
return { ...this.scales };
}
get arcPinned(): boolean {
return this.pinnedMinute !== null;
}
/** /**
* Resume after a real user gesture. Idempotent, and safe to call concurrently * Resume after a real user gesture. Idempotent, and safe to call concurrently
* overlapping callers await the same resume rather than racing the origin. * 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; const durS = immediate ? 0 : sweepMsFor(location) / 1000;
this.ramp(this.filter.frequency, cutoffFor(location), durS, true); this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
this.ramp(this.musicBus.gain, gainFor(location), durS, false); 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. */ /** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
@ -245,6 +371,7 @@ export class TechnoEngine {
this.stop(); this.stop();
for (const off of this.unsubs) off(); for (const off of this.unsubs) off();
this.unsubs.length = 0; this.unsubs.length = 0;
if (activeEngine === this) activeEngine = null;
void this.ctx.close().catch(() => undefined); void this.ctx.close().catch(() => undefined);
} }
@ -290,104 +417,148 @@ export class TechnoEngine {
this.nextBeat = Math.ceil(firstFuture / 4) * 4; 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 { private scheduleBeat(beatIndex: number, timeMs: number): void {
const sixteenths = subdivisions(this.grid, beatIndex, 4); 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 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 bassGain = this.gainOf('bass');
const at = sixteenths[s]; if (bassGain > SILENT) {
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)]; for (let s = 0; s < sixteenths.length; s++) {
if (at === undefined || semi === undefined || semi === null) continue; const at = sixteenths[s];
this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12)); 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 --------------------------------------------------------------- // --- voices ---------------------------------------------------------------
private kick(at: number): void { private kick(at: number, peak: number): void {
const k = this.mixState.kick;
const osc = this.ctx.createOscillator(); const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
osc.type = 'sine'; osc.type = 'sine';
// The pitch drop is the beater; the tail is the cone. This is the one voice // 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. // that has to survive a 250Hz lowpass, so it gets a long body.
osc.frequency.setValueAtTime(150, at); osc.frequency.setValueAtTime(k.startHz, at);
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06); 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.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008); gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
osc.connect(gain).connect(this.musicBus); osc.connect(gain).connect(this.musicBus);
osc.start(at); osc.start(at);
osc.stop(at + 0.36); osc.stop(at + decayS + 0.02);
this.hold(osc, [gain]); 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(); const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer; 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(); const hp = this.ctx.createBiquadFilter();
hp.type = 'highpass'; hp.type = 'highpass';
hp.frequency.value = 7000; hp.frequency.value = h.hpHz;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(HAT_PEAK, at); gain.gain.setValueAtTime(peak, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04); gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
src.connect(hp).connect(gain).connect(this.musicBus); 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]); 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(); const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass'; 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. // Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
lp.frequency.setValueAtTime(180, at); lp.frequency.setValueAtTime(b.filterLoHz, at);
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03); lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11); lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, at); gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006); gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
lp.connect(gain).connect(this.musicBus); 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(); const osc = this.ctx.createOscillator();
osc.type = 'sawtooth'; osc.type = 'sawtooth';
osc.frequency.value = hz; osc.frequency.value = hz;
osc.detune.value = detune; osc.detune.value = detune;
osc.connect(lp); osc.connect(lp);
osc.start(at); osc.start(at);
osc.stop(at + 0.13); osc.stop(at + decayS + 0.01);
this.hold(osc, detune > 0 ? [lp, gain] : []); // 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 { private pad(at: number, peak: number, bars: number): void {
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000; const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
const lp = this.ctx.createBiquadFilter(); const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.type = 'lowpass';
lp.frequency.value = 600; lp.frequency.value = this.mixState.pad.lpHz;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at); 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); gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus); lp.connect(gain).connect(this.musicBus);
@ -406,6 +577,44 @@ export class TechnoEngine {
// --- plumbing ------------------------------------------------------------- // --- plumbing -------------------------------------------------------------
private qFor(location: AudioLocation): number {
return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
}
/** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
private lens(path: string): MixLens | null {
const m = this.mixState;
if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
const range = RANGES[path];
if (!range) return null;
const key = path;
return { read: () => m[key], write: (v) => { m[key] = v; }, range };
}
const dot = path.indexOf('.');
if (dot <= 0) return null;
const head = path.slice(0, dot);
const tail = path.slice(dot + 1);
if (head === 'levels') {
// MIX_RANGES keys the shared bound as 'level' (singular) — one range for
// all four faders — while the path is per-voice.
if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
const v = tail as VoiceName;
return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
}
if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
// The four param groups have no index signature by design — the widening is
// confined here, behind the hasOwn check below.
const group = m[head as VoiceName] as unknown as Record<string, number>;
if (!Object.hasOwn(group, tail)) return null;
const range = RANGES[path];
if (!range) return null;
return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
}
/** /**
* One second of deterministic white noise, built once and re-read per hat. * 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. * Allocating a buffer per hit would mean ~8 buffers a second for six hours.

View File

@ -3,6 +3,9 @@ import { EventBus } from '../core/EventBus';
import { GameClock } from '../core/GameClock'; import { GameClock } from '../core/GameClock';
import { SeededRNG } from '../core/SeededRNG'; import { SeededRNG } from '../core/SeededRNG';
import { Meters, freshNightState } from '../core/meters'; 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 { Sfx, type SfxName } from '../audio/Sfx';
import { TechnoEngine } from '../audio/TechnoEngine'; import { TechnoEngine } from '../audio/TechnoEngine';
import { DAZZA_TEXTS } from '../data/strings/dazza'; import { DAZZA_TEXTS } from '../data/strings/dazza';
@ -10,6 +13,7 @@ import type { DressCodeRule } from '../data/types';
import { Clicker } from './Clicker'; import { Clicker } from './Clicker';
import { DialogueBox } from './DialogueBox'; import { DialogueBox } from './DialogueBox';
import { MeterHud } from './MeterHud'; import { MeterHud } from './MeterHud';
import { MixDeskScene, type MixDeskData } from './MixDeskScene';
import { Phone } from './Phone'; import { Phone } from './Phone';
import { stamp, type StampHandle } from './Stamp'; import { stamp, type StampHandle } from './Stamp';
import { UI, chunkyButton, font } from './style'; import { UI, chunkyButton, font } from './style';
@ -56,6 +60,37 @@ const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
['typeTick', 'TYPE TICK'], ['typeTick', 'TYPE TICK'],
]; ];
/** Structural copy of Ambience's private `Bed` union — it doesn't export one. */
type BedName = 'rain' | 'crowd' | 'kebab';
/** Beds default to a quiet street: room and kebab shop on, weather off, so RAIN
* still reads as a thing you switch on the way Phase 1 had it. */
const BED_DEFAULTS: Readonly<Record<BedName, boolean>> = { rain: false, crowd: true, kebab: true };
const BEDS: readonly BedName[] = ['rain', 'crowd', 'kebab'];
/** Inside-count nudges. ±10 exists because the crowd bed's curve saturates and
* you cannot hear that by clicking +1 eleven times. */
const COUNT_STEPS: readonly number[] = [-10, -1, 1, 10];
const MIX_BTN_X = 176;
const MIX_BTN_Y = 84;
const ROOM_X = 100;
const NIGHT_X = 396;
/**
* Midnight the PEAK keyframe, where every voice scales to 1. The scene pins here
* on open because it is a tuning rig, not a night: unpinned it starts at 21:00
* EMPTY, where bass and pad scale to 0, and GameClock's 360 game-minutes over 13
* real minutes means nobody hears a bassline for the first ~2 minutes. That reads
* as a broken engine. The button beside the readout hands the arc back.
*/
const PIN_MINUTE = 180;
const PIN_LABEL = 'PINNED @ PEAK · TAP=FOLLOW';
const FOLLOW_LABEL = 'FOLLOWING CLOCK · TAP=PIN';
interface PendingBeat { interface PendingBeat {
beatIndex: number; beatIndex: number;
audioTimeMs: 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 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 hzFromT = (t: number): number => MIN_HZ * Math.pow(HZ_RATIO, clamp(t, 0, 1));
const tFromHz = (hz: number): number => const tFromHz = (hz: number): number =>
clamp(Math.log(clamp(hz, MIN_HZ, MAX_HZ) / MIN_HZ) / Math.log(HZ_RATIO), 0, 1); 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 engine!: TechnoEngine;
private sfx!: Sfx; private sfx!: Sfx;
private ambience!: Ambience;
private hud!: MeterHud; private hud!: MeterHud;
private phone!: Phone; private phone!: Phone;
@ -95,15 +146,19 @@ export class JuiceDemoScene extends Phaser.Scene {
private statusText!: Phaser.GameObjects.Text; private statusText!: Phaser.GameObjects.Text;
private hzText!: Phaser.GameObjects.Text; private hzText!: Phaser.GameObjects.Text;
private arcText!: Phaser.GameObjects.Text;
private insideText!: Phaser.GameObjects.Text;
private beatPad!: Phaser.GameObjects.Rectangle; private beatPad!: Phaser.GameObjects.Rectangle;
private beatRing!: Phaser.GameObjects.Rectangle; private beatRing!: Phaser.GameObjects.Rectangle;
private handle!: Phaser.GameObjects.Rectangle; private handle!: Phaser.GameObjects.Rectangle;
private locButton!: Phaser.GameObjects.Container; private locButton!: Phaser.GameObjects.Container;
private rainButton!: Phaser.GameObjects.Container; private arcPinButton!: Phaser.GameObjects.Container;
private patronRoot!: Phaser.GameObjects.Container; private patronRoot!: Phaser.GameObjects.Container;
private readonly stampMarks: StampHandle[] = []; private readonly stampMarks: StampHandle[] = [];
private readonly pendingBeats: PendingBeat[] = []; private readonly pendingBeats: PendingBeat[] = [];
private readonly bedButtons: Array<{ bed: BedName; btn: Phaser.GameObjects.Container }> = [];
private readonly bedsOn: Record<BedName, boolean> = { ...BED_DEFAULTS };
private beatFlash = 0; private beatFlash = 0;
private beatFlashBar = false; private beatFlashBar = false;
private lastBeat = -1; private lastBeat = -1;
@ -111,9 +166,21 @@ export class JuiceDemoScene extends Phaser.Scene {
private overrideHz: number | null = null; private overrideHz: number | null = null;
private dragging = false; private dragging = false;
private raining = false;
private dazzaIndex = 0; private dazzaIndex = 0;
private audioStarting = false; 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() { constructor() {
super('JuiceDemo'); super('JuiceDemo');
@ -135,14 +202,16 @@ export class JuiceDemoScene extends Phaser.Scene {
this.overrideHz = null; this.overrideHz = null;
this.dragging = false; this.dragging = false;
this.raining = false;
this.dazzaIndex = 0; this.dazzaIndex = 0;
this.audioStarting = false; this.audioStarting = false;
this.arcPinShown = null;
this.dialogue = null; this.dialogue = null;
this.dialogueKill = false; this.dialogueKill = false;
// Shutdown already destroyed the objects these handles point at. // Shutdown already destroyed the objects these handles point at.
this.stampMarks.length = 0; this.stampMarks.length = 0;
this.bedButtons.length = 0;
Object.assign(this.bedsOn, BED_DEFAULTS);
} }
create(): void { 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 // 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. // stays silent until the context resumes. No post-unlock re-wiring needed.
this.engine = new TechnoEngine(this.bus); 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); 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.buildBackdrop();
this.buildBeatIndicator(); this.buildBeatIndicator();
@ -164,6 +246,9 @@ export class JuiceDemoScene extends Phaser.Scene {
this.buildStatus(); this.buildStatus();
this.buildSfxColumn(); this.buildSfxColumn();
this.buildActionColumn(); this.buildActionColumn();
this.buildMixDeskButton();
this.buildRoomPanel();
this.buildNightPanel();
this.buildAudioStrip(); this.buildAudioStrip();
this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 }); this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
@ -186,6 +271,7 @@ export class JuiceDemoScene extends Phaser.Scene {
this.buildUnlockOverlay(); this.buildUnlockOverlay();
this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip()); this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
this.input.keyboard?.on('keydown-M', () => this.openMixDesk());
this.events.once('shutdown', () => this.teardown()); this.events.once('shutdown', () => this.teardown());
} }
@ -204,7 +290,7 @@ export class JuiceDemoScene extends Phaser.Scene {
.text( .text(
TRACK_X0, TRACK_X0,
336, 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), font(7, UI.paper),
) )
.setAlpha(0.45); .setAlpha(0.45);
@ -253,13 +339,89 @@ export class JuiceDemoScene extends Phaser.Scene {
onClick: () => this.sfx.play(name), onClick: () => this.sfx.play(name),
}); });
}); });
this.rainButton = chunkyButton(this, 50, 80 + SFX_BUTTONS.length * 16, 'RAIN: OFF', { }
w: 76,
h: 13, /**
fontSize: 7, * The lane's headline: everything else on this screen is a widget, this is the
fill: UI.grime, * thing you sit at for ten minutes and leave with a diff. Sized to say so.
onClick: () => this.toggleRain(), */
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 { private buildActionColumn(): void {
@ -291,7 +453,9 @@ export class JuiceDemoScene extends Phaser.Scene {
} }
private buildAudioStrip(): void { 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 const track = this.add
.rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel) .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(() => { .then(() => {
if (stale()) return; if (stale()) return;
engine.start(); 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); root.destroy(true);
}) })
.catch(() => { .catch(() => {
@ -442,11 +609,30 @@ export class JuiceDemoScene extends Phaser.Scene {
this.bus.emit('dazza:text', { text: line.text, rule }); this.bus.emit('dazza:text', { text: line.text, rule });
} }
private toggleRain(): void { private bedLabel(bed: BedName): string {
this.raining = !this.raining; return `${bed.toUpperCase().padEnd(5)} ${this.bedsOn[bed] ? 'ON' : 'OFF'}`;
if (this.raining) this.sfx.startRain(); }
else this.sfx.stopRain();
labelOf(this.rainButton)?.setText(this.raining ? 'RAIN: ON' : 'RAIN: 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 { private setCutoffFromX(x: number): void {
@ -481,6 +667,7 @@ export class JuiceDemoScene extends Phaser.Scene {
this.renderBeat(delta); this.renderBeat(delta);
this.syncSlider(); this.syncSlider();
this.renderStatus(); this.renderStatus();
this.renderNight();
this.hud.update(delta); this.hud.update(delta);
this.phone.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 { 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'); this.input.keyboard?.off('keydown-SPACE');
for (const m of this.stampMarks) m.decal.destroy(); for (const m of this.stampMarks) m.decal.destroy();
this.stampMarks.length = 0; this.stampMarks.length = 0;
@ -554,7 +766,9 @@ export class JuiceDemoScene extends Phaser.Scene {
this.phone.destroy(); this.phone.destroy();
this.clicker.destroy(); this.clicker.destroy();
this.hud.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.sfx.destroy();
this.engine.destroy(); this.engine.destroy();
this.meters.destroy(); this.meters.destroy();

724
src/ui/MixDeskScene.ts Normal file
View File

@ -0,0 +1,724 @@
import Phaser from 'phaser';
import { NIGHT_END_MIN, arcLabel } from '../audio/arc';
import { getActiveEngine, type TechnoEngine } from '../audio/TechnoEngine';
import {
MIX_RANGES,
VOICE_NAMES,
clampTo,
formatMix,
soloedVoice,
voiceGain,
type VoiceName,
} from '../audio/mix';
import { UI, chunkyButton, font, panel } from './style';
// The ear pass, as a scene.
//
// Every number in the track was chosen by an agent that cannot hear. This desk
// exists so a human can sit down for ten minutes, solo one voice, drag the knob
// that is wrong, and leave with a diff. That last part is the whole point: a
// tuning session that does not end in a pasteable TrackMix literal was a
// conversation, not a change. Hence DUMP MIX, and hence the readouts showing
// *effective* gain rather than the raw level — the arc is a multiplier, and a
// kick that reads as too quiet at 9 PM may be perfectly set for midnight.
const CANVAS_W = 640;
const CANVAS_H = 360;
/** Hint grey. UI.dim is tuned for the game's panels and vanishes at 6px on this backdrop. */
const HINT = 0x8a8aa0;
const PARAM_ROWS_MAX = 6;
export interface MixDeskData {
engine?: TechnoEngine;
/** Duck-typed: Ambience is a sibling lane's file and the desk must not hard-depend on it. */
ambience?: unknown;
}
interface RainSource {
startRain(): void;
stopRain(): void;
}
const isRainSource = (o: unknown): o is RainSource => {
if (typeof o !== 'object' || o === null) return false;
const r = o as Partial<RainSource>;
return typeof r.startRain === 'function' && typeof r.stopRain === 'function';
};
interface ParamSpec {
/** Dotted path understood by TechnoEngine.setMixValue. */
readonly path: string;
readonly label: string;
readonly hint: string;
readonly range: keyof typeof MIX_RANGES;
/** Engine quantises this one, so the desk must too — see SliderSpec.integer. */
readonly integer?: true;
}
interface ParamGroup {
readonly name: string;
readonly rows: readonly ParamSpec[];
}
/**
* The knob table. Rows are data so the layout code is written once twenty
* hand-rolled rows is twenty places for a path typo to hide.
*/
const GROUPS: readonly ParamGroup[] = [
{
name: 'LEVELS',
rows: [
{ path: 'master', label: 'master', hint: 'post-filter output. mind your ears.', range: 'master' },
{ path: 'levels.kick', label: 'kick level', hint: 'raw gain, before the arc scales it.', range: 'level' },
{ path: 'levels.hat', label: 'hat level', hint: 'the tick. easy to over-serve.', range: 'level' },
{ path: 'levels.bass', label: 'bass level', hint: 'the door filter eats most of this.', range: 'level' },
{ path: 'levels.pad', label: 'pad level', hint: 'glue. hear it as a part, too loud.', range: 'level' },
],
},
{
name: 'KICK',
rows: [
{ path: 'kick.startHz', label: 'kick startHz', hint: 'beater pitch at the transient. click.', range: 'kick.startHz' },
{ path: 'kick.endHz', label: 'kick endHz', hint: 'where the drop lands. the body.', range: 'kick.endHz' },
{ path: 'kick.dropS', label: 'kick dropS', hint: 'length of the fall. longer = bigger box.', range: 'kick.dropS' },
{ path: 'kick.attackS', label: 'kick attackS', hint: 'slower attack rounds off the front.', range: 'kick.attackS' },
{ path: 'kick.decayS', label: 'kick decayS', hint: 'tail. too long and it smears the bass.', range: 'kick.decayS' },
],
},
{
name: 'HAT + PAD',
rows: [
{ path: 'hat.hpHz', label: 'hat hpHz', hint: 'highpass corner. lower = more body.', range: 'hat.hpHz' },
{ path: 'hat.decayS', label: 'hat decayS', hint: 'tick versus sizzle lives right here.', range: 'hat.decayS' },
{ path: 'pad.lpHz', label: 'pad lpHz', hint: 'brightness of the wash behind it all.', range: 'pad.lpHz' },
{ path: 'pad.bars', label: 'pad bars', hint: 'bars per fade. longer = unnoticed.', range: 'pad.bars', integer: true },
],
},
{
name: 'BASS',
rows: [
{ path: 'bass.rootHz', label: 'bass rootHz', hint: 'A1 by default. move it, move the lot.', range: 'bass.rootHz' },
{ path: 'bass.filterLoHz', label: 'bass filterLo', hint: 'floor of the per-note filter sweep.', range: 'bass.filterLoHz' },
{ path: 'bass.filterHiHz', label: 'bass filterHi', hint: 'peak of the sweep. this is the wow.', range: 'bass.filterHiHz' },
{ path: 'bass.q', label: 'bass q', hint: 'resonance on the sweep. past 12 it whistles.', range: 'bass.q' },
{ path: 'bass.decayS', label: 'bass decayS', hint: 'note length. short rolls, long drones.', range: 'bass.decayS' },
{ path: 'bass.detuneCents', label: 'bass detune', hint: 'cents between the saws. the character.', range: 'bass.detuneCents' },
],
},
{
name: 'ROOM',
rows: [
{ path: 'doorQ', label: 'door Q', hint: 'resonant peak outside — the wall cue.', range: 'doorQ' },
{ path: 'floorQ', label: 'floor Q', hint: 'inside. flat is right; you are in the room.', range: 'floorQ' },
],
},
];
/**
* What to actually listen for. The difference between "sounds fine" and a useful
* answer is usually just knowing which question was being asked.
*/
const LISTEN_FOR: Record<VoiceName, string> = {
kick: 'KICK — does it read as a kick through a wall, or just a thud? If it thuds, more startHz and a shorter dropS.',
hat: 'HAT — should tick, not hiss. Sizzling means hpHz is too low or decayS too long.',
bass: 'BASS — should roll under the kick, not fight it. If the two blur together, cut kick decayS first.',
pad: 'PAD — glue, not a part. If you can name the chord, the level is too high.',
};
const NO_SOLO = 'Nothing soloed — SOLO a voice to judge it on its own, then unsolo to hear it in the room.';
/** Readable at a glance across four orders of magnitude (0.008 s up to 14000 Hz). */
const fmt = (v: number): string => {
const a = Math.abs(v);
if (a >= 100) return v.toFixed(0);
if (a >= 10) return v.toFixed(1);
if (a >= 1) return v.toFixed(2);
return v.toFixed(3);
};
/**
* Text.setColor is unguarded: it goes through TextStyle.setFill to updateText(),
* which resizes and clears the backing canvas, re-measures, re-fills and re-uploads
* the GL texture no no-op check, unlike setText. refreshAll() runs every frame, so
* an unconditional setColor is hundreds of texture uploads a second on the same main
* thread as the audio scheduler, and the rig degrades the sound it exists to judge.
* Keyed weakly: entries die with the Text, so a scene restart starts cold.
*/
const lastColour = new WeakMap<Phaser.GameObjects.Text, string>();
const setColour = (t: Phaser.GameObjects.Text | null | undefined, css: string): void => {
if (!t || lastColour.get(t) === css) return;
lastColour.set(t, css);
t.setColor(css);
};
const pad2 = (n: number): string => String(n).padStart(2, '0');
/** clockMin is minutes since 21:00. */
const wallClock = (clockMin: number): string => {
const t = (21 * 60 + Math.round(clockMin)) % (24 * 60);
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
};
interface SliderSpec {
x: number;
y: number;
w: number;
range: readonly [number, number];
/** Frequencies get a log taper: linear Hz spends most of its travel where nothing changes. */
log?: boolean;
/**
* Snap to whole numbers on write. Without it a slider can hold a value the engine
* never uses (pad.bars rounds internally) and DUMP MIX exports that phantom a
* mix literal that does not describe what was heard is worse than no dump at all.
*/
integer?: boolean;
read: () => number;
write: (v: number) => void;
format?: (v: number) => string;
}
interface Refreshable {
refresh: () => void;
}
interface ToggleSpec {
x: number;
y: number;
w: number;
h?: number;
label: string;
on: () => boolean;
click: () => void;
onFill?: number;
}
/**
* Tuning desk for the techno engine, run as an overlay above whatever scene owns
* the audio. It never pauses that scene the music has to keep playing while
* you turn things.
*/
export class MixDeskScene extends Phaser.Scene {
private engine: TechnoEngine | null = null;
/** Arc pin as we found it, restored on close. null = was following the clock. */
private entryArc: number | null = null;
private entryEnabled: Record<VoiceName, boolean> | null = null;
private rain: RainSource | null = null;
private raining = false;
/** Everything alive for the whole scene; page-scoped objects live in `paramOwned`. */
private owned: Phaser.GameObjects.GameObject[] = [];
private paramOwned: Phaser.GameObjects.GameObject[] = [];
private refreshers: Refreshable[] = [];
private paramRefreshers: Refreshable[] = [];
private page = 0;
private groupTitle: Phaser.GameObjects.Text | null = null;
private pageLabel: Phaser.GameObjects.Text | null = null;
private listenText: Phaser.GameObjects.Text | null = null;
private statusText: Phaser.GameObjects.Text | null = null;
private arcText: Phaser.GameObjects.Text | null = null;
private scaleText: Phaser.GameObjects.Text | null = null;
private toastText: Phaser.GameObjects.Text | null = null;
private toastTween: Phaser.Tweens.Tween | null = null;
private readonly onEsc = (): void => {
this.scene.stop();
};
constructor() {
super('MixDesk');
}
init(data: MixDeskData): void {
// A desk that only works in the demo is useless on a real night, where the
// scene holding the engine is not one we are allowed to import.
this.engine = data.engine ?? getActiveEngine();
this.rain = isRainSource(data.ambience) ? data.ambience : null;
}
create(): void {
// Phaser reuses the scene instance across restarts and only re-runs create();
// field initialisers fired once, ever.
this.owned = [];
this.paramOwned = [];
this.refreshers = [];
this.paramRefreshers = [];
this.page = 0;
this.raining = false;
this.toastTween = null;
// Snapshot the monitoring state we are about to borrow. Closing the desk
// restores this rather than forcing a default: JuiceDemoScene deliberately
// pins the arc at PEAK so every voice is audible the moment you open it, and
// a desk that resets that on the way out silently un-tunes the very scene it
// was opened from.
const eng = this.engine;
this.entryArc = eng && eng.arcPinned ? eng.arcMinute : null;
this.entryEnabled = eng
? Object.fromEntries(VOICE_NAMES.map((v) => [v, eng.mix.enabled[v]])) as Record<VoiceName, boolean>
: null;
// Interactive so clicks land on the desk instead of leaking through to the
// scene underneath, which is still running and still listening.
this.own(
this.add.rectangle(CANVAS_W / 2, CANVAS_H / 2, CANVAS_W, CANVAS_H, UI.ink, 0.93).setInteractive(),
);
this.own(this.add.text(10, 4, 'MIX DESK', font(10, UI.neonPink)));
this.own(chunkyButton(this, 600, 11, 'CLOSE ESC', { w: 54, h: 13, onClick: () => this.scene.stop() }));
this.input.keyboard?.on('keydown-ESC', this.onEsc);
this.events.once('shutdown', () => this.teardown());
const engine = this.engine;
if (!engine) {
this.own(
this.add
.text(
CANVAS_W / 2,
CANVAS_H / 2,
'NO AUDIO ENGINE\n\nLaunch this scene with { engine } in its scene data,\nor start the audio first so getActiveEngine() has something to hand back.',
{ ...font(9, UI.paper), align: 'center' },
)
.setOrigin(0.5),
);
return;
}
if (this.rain) {
const rain = this.toggle({
x: 540,
y: 11,
w: 44,
h: 13,
label: 'RAIN',
on: () => this.raining,
click: () => this.setRain(!this.raining),
});
this.own(rain.node);
this.refreshers.push(rain);
}
this.statusText = this.own(this.add.text(90, 6, '', font(7, HINT)));
this.buildVoiceStrip(engine);
this.buildParamRegion(engine);
this.buildArcRegion(engine);
this.buildExportBar(engine);
this.refreshAll();
}
// ---------------------------------------------------------------- regions
private buildVoiceStrip(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 49, 632, 58));
VOICE_NAMES.forEach((v, i) => {
const y = 32 + i * 13;
this.own(this.add.text(10, y, v.toUpperCase(), font(8, UI.paper)).setOrigin(0, 0.5));
const mute = this.toggle({
x: 46,
y,
w: 32,
h: 11,
label: 'MUTE',
on: () => !engine.mix.enabled[v],
click: () => engine.setVoiceEnabled(v, !engine.mix.enabled[v]),
onFill: UI.danger,
});
const solo = this.toggle({
x: 82,
y,
w: 32,
h: 11,
label: 'SOLO',
on: () => soloedVoice(engine.mix) === v,
click: () => this.solo(engine, v),
onFill: UI.kebabAmber,
});
this.own(mute.node);
this.own(solo.node);
const level = this.slider(
{
x: 108,
y,
w: 110,
range: MIX_RANGES.level,
read: () => engine.getMixValue(`levels.${v}`),
write: (val) => engine.setMixValue(`levels.${v}`, val),
},
this.owned,
);
// Effective gain, not the slider value: the arc is a multiplier, and hiding
// it is how you end up "fixing" a level that the arc had already dropped.
const eff = this.own(this.add.text(272, y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
this.refreshers.push({
refresh: () => {
mute.refresh();
solo.refresh();
level.refresh();
const g = voiceGain(engine.mix, v, engine.arcScales[v]);
eff.setText(`= ${fmt(g)}`);
setColour(eff, g > 0.0005 ? '#9fe8a0' : '#c03434');
},
});
});
this.listenText = this.own(
this.add.text(336, 49, '', { ...font(7, UI.kebabAmber), wordWrap: { width: 292 } }).setOrigin(0, 0.5),
);
}
/** Solo toggles: on, this voice alone; off, everyone back. */
private solo(engine: TechnoEngine, v: VoiceName): void {
const already = soloedVoice(engine.mix) === v;
for (const other of VOICE_NAMES) engine.setVoiceEnabled(other, already || other === v);
}
private buildParamRegion(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 163, 632, 162));
this.groupTitle = this.own(this.add.text(14, 92, '', font(9, UI.neonPink)).setOrigin(0, 0.5));
this.pageLabel = this.own(this.add.text(160, 92, '', font(7, HINT)).setOrigin(0, 0.5));
this.own(
chunkyButton(this, 600, 92, 'PAGE >', {
w: 54,
h: 13,
onClick: () => {
this.page = (this.page + 1) % GROUPS.length;
this.buildParamPage(engine);
},
}),
);
this.buildParamPage(engine);
}
/** Rebuilt wholesale per page — cheaper to reason about than repointing closures. */
private buildParamPage(engine: TechnoEngine): void {
for (const o of this.paramOwned) o.destroy();
this.paramOwned = [];
this.paramRefreshers = [];
const group = GROUPS[this.page] ?? GROUPS[0];
if (!group) return;
this.groupTitle?.setText(group.name);
this.pageLabel?.setText(`page ${this.page + 1} of ${GROUPS.length}`);
group.rows.slice(0, PARAM_ROWS_MAX).forEach((spec, i) => {
const y = 106 + i * 22;
this.paramOwned.push(this.add.text(14, y - 5, spec.label, font(8, UI.paper)).setOrigin(0, 0.5));
this.paramOwned.push(this.add.text(14, y + 5, spec.hint, font(6, HINT)).setOrigin(0, 0.5));
const handle = this.slider(
{
x: 200,
y,
w: 320,
range: MIX_RANGES[spec.range],
log: spec.path.endsWith('Hz'),
integer: spec.integer === true,
read: () => engine.getMixValue(spec.path),
write: (v) => engine.setMixValue(spec.path, v),
},
this.paramOwned,
);
this.paramRefreshers.push(handle);
});
}
private buildArcRegion(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 275, 632, 54));
this.own(this.add.text(14, 262, 'ARC', font(8, UI.paper)).setOrigin(0, 0.5));
// Pinning is the only way the night's shape gets checked at all — otherwise
// auditioning 1 AM means waiting four hours for it.
const arc = this.slider(
{
x: 48,
y: 262,
w: 260,
range: [0, NIGHT_END_MIN],
integer: true,
read: () => engine.arcMinute,
write: (v) => engine.setArcMinute(v),
format: wallClock,
},
this.owned,
);
this.refreshers.push(arc);
this.arcText = this.own(this.add.text(370, 262, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
const follow = this.toggle({
x: 580,
y: 262,
w: 100,
h: 14,
label: 'FOLLOW CLOCK',
on: () => !engine.arcPinned,
click: () => engine.setArcMinute(null),
onFill: UI.ok,
});
this.own(follow.node);
this.refreshers.push(follow);
this.scaleText = this.own(this.add.text(14, 284, '', font(8, UI.paper)).setOrigin(0, 0.5));
this.own(
this.add
.text(370, 284, 'closing the desk puts the arc back how you found it', font(6, HINT))
.setOrigin(0, 0.5),
);
}
private buildExportBar(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 329, 632, 46));
this.own(
chunkyButton(this, 60, 320, 'DUMP MIX', {
w: 100,
h: 16,
fill: UI.neonPink,
fontSize: 9,
onClick: () => this.dump(engine),
}),
);
this.own(
chunkyButton(this, 155, 320, 'RESET', {
w: 70,
h: 16,
fontSize: 9,
onClick: () => {
engine.resetMix();
this.refreshAll();
this.toast('back to DEFAULT_MIX');
},
}),
);
this.toastText = this.own(this.add.text(200, 320, '', font(8, UI.neonGreen)).setOrigin(0, 0.5).setAlpha(0));
this.own(
this.add.text(10, 340, 'DUMP writes a TrackMix literal — paste it over DEFAULT_MIX in src/audio/mix.ts', font(6, HINT)),
);
}
// ---------------------------------------------------------------- widgets
/**
* The one slider. Track, fill, handle, readout; drag the handle or click the
* track. Reads its value from the engine every refresh rather than caching, so
* a knob moved elsewhere (the arc, a reset) shows up here without plumbing.
*/
private slider(spec: SliderSpec, sink: Phaser.GameObjects.GameObject[]): Refreshable {
const lo = spec.range[0];
const hi = spec.range[1];
const logOk = spec.log === true && lo > 0 && hi > lo;
const int = spec.integer === true;
const format = spec.format ?? (int ? (v: number): string => v.toFixed(0) : fmt);
const quantise = (v: number): number => (int ? Math.round(v) : v);
const toT = (v: number): number => {
const c = clampTo(v, spec.range);
if (logOk) return Math.log(c / lo) / Math.log(hi / lo);
return hi === lo ? 0 : (c - lo) / (hi - lo);
};
const toV = (t: number): number => {
const k = Math.max(0, Math.min(1, t));
return logOk ? lo * Math.pow(hi / lo, k) : lo + (hi - lo) * k;
};
const groove = this.add
.rectangle(spec.x, spec.y, spec.w, 5, UI.grime)
.setOrigin(0, 0.5)
.setStrokeStyle(1, UI.ink);
const fill = this.add.rectangle(spec.x, spec.y, 1, 3, UI.neonPink).setOrigin(0, 0.5);
const knob = this.add
.rectangle(spec.x, spec.y, 5, 13, UI.paper)
.setOrigin(0.5)
.setStrokeStyle(1, UI.ink);
const readout = this.add.text(spec.x + spec.w + 8, spec.y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5);
sink.push(groove, fill, knob, readout);
let lastW = -1;
const refresh = (): void => {
const v = spec.read();
const t = toT(v);
knob.x = spec.x + t * spec.w;
// setSize rebuilds the shape's path data; refresh() is per-frame, so diff it.
const w = Math.max(1, t * spec.w);
if (w !== lastW) {
lastW = w;
fill.setSize(w, 3);
}
readout.setText(format(v));
};
const setFromX = (px: number): void => {
// Quantise after clamping — every integer param's range has whole bounds.
spec.write(quantise(clampTo(toV((px - spec.x) / spec.w), spec.range)));
refresh();
};
groove.setInteractive({ useHandCursor: true });
groove.on('pointerdown', (p: Phaser.Input.Pointer) => setFromX(p.x));
knob.setInteractive({ draggable: true, useHandCursor: true });
knob.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => setFromX(dragX));
return { refresh };
}
/**
* A button that shows a state. chunkyButton restores its own fill on release,
* so a latching control has to draw itself.
*/
private toggle(spec: ToggleSpec): Refreshable & { node: Phaser.GameObjects.Container } {
const h = spec.h ?? 13;
const onFill = spec.onFill ?? UI.neonGreen;
const node = this.add.container(spec.x, spec.y);
const face = this.add.rectangle(0, 0, spec.w, h, UI.panelLip).setStrokeStyle(1, UI.ink);
const text = this.add.text(0, 0, spec.label, font(7, UI.paper)).setOrigin(0.5);
node.add([face, text]);
// refreshAll() runs every frame; both setters below are unguarded in Phaser.
let lastOn: boolean | null = null;
const refresh = (): void => {
const on = spec.on();
if (on === lastOn) return;
lastOn = on;
face.setFillStyle(on ? onFill : UI.panelLip);
setColour(text, on ? '#0b0b12' : '#dad4c4');
};
// Same anti-phantom-click arming as chunkyButton: Phaser fires pointerup on
// any release over an object, including one whose press landed elsewhere.
let armed = false;
face.setInteractive({ useHandCursor: true });
face.on('pointerdown', () => {
armed = true;
});
face.on('pointerout', () => {
armed = false;
});
face.on('pointerup', () => {
if (!armed) return;
armed = false;
spec.click();
this.refreshAll();
});
refresh();
return { refresh, node };
}
// ---------------------------------------------------------------- actions
private setRain(on: boolean): void {
if (!this.rain) return;
this.raining = on;
if (on) this.rain.startRain();
else this.rain.stopRain();
}
private dump(engine: TechnoEngine): void {
const text = formatMix(engine.mix);
// The console copy is the one that always survives; the clipboard is a
// convenience that browsers refuse without focus or permission.
console.log(`[MixDesk] TrackMix @ ${wallClock(engine.arcMinute)}\n${text}`);
const clip = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
if (!clip) {
this.toast('dumped to console (no clipboard API)');
return;
}
clip.writeText(text).then(
() => this.toast('copied to clipboard + console'),
() => this.toast('clipboard refused — it is in the console'),
);
}
private toast(msg: string): void {
const t = this.toastText;
if (!t) return;
this.toastTween?.remove();
t.setText(msg).setAlpha(1);
this.toastTween = this.tweens.add({ targets: t, alpha: 0, delay: 2200, duration: 500 });
}
// ---------------------------------------------------------------- refresh
private refreshAll(): void {
const engine = this.engine;
if (!engine) return;
for (const r of this.refreshers) r.refresh();
for (const r of this.paramRefreshers) r.refresh();
const solo = soloedVoice(engine.mix);
this.listenText?.setText(solo ? LISTEN_FOR[solo] : NO_SOLO);
setColour(this.listenText, solo ? '#d8a020' : '#8a8aa0');
const min = engine.arcMinute;
this.arcText?.setText(`${arcLabel(min)}${engine.arcPinned ? ' [PINNED]' : ''}`);
setColour(this.arcText, engine.arcPinned ? '#d8a020' : '#9fe8a0');
const s = engine.arcScales;
this.scaleText?.setText(
`arc scales ${VOICE_NAMES.map((v) => `${v} ${s[v].toFixed(2)}`).join(' ')}`,
);
const running = engine.ctx.state === 'running';
this.statusText?.setText(
running ? `audio running · ${engine.bpm} bpm` : `AUDIO ${engine.ctx.state.toUpperCase()} — start the audio before trusting your ears`,
);
setColour(this.statusText, running ? '#8a8aa0' : '#c03434');
}
override update(): void {
// Cheap enough at a dozen sliders, and it keeps the desk honest about state
// it does not own: the clock moves the arc, and the arc moves every readout.
this.refreshAll();
}
// ---------------------------------------------------------------- teardown
private own<T extends Phaser.GameObjects.GameObject>(o: T): T {
this.owned.push(o);
return o;
}
private teardown(): void {
this.input.keyboard?.off('keydown-ESC', this.onEsc);
this.toastTween?.remove();
this.toastTween = null;
this.tweens.killAll();
// Solo and arc-pin are monitoring state, not mix state — formatMix even hard-codes
// `enabled` back to all-true. Leaving either latched after the desk closes would
// silently break a real night with no UI left to undo it, so hand back exactly
// what we borrowed. On a real night nothing is pinned or muted, so this restores
// to all-true / follow-clock anyway.
const engine = this.engine;
if (engine) {
for (const v of VOICE_NAMES) engine.setVoiceEnabled(v, this.entryEnabled?.[v] ?? true);
engine.setArcMinute(this.entryArc);
}
if (this.raining) this.setRain(false);
for (const o of this.paramOwned) o.destroy();
for (const o of this.owned) o.destroy();
this.paramOwned = [];
this.owned = [];
this.refreshers = [];
this.paramRefreshers = [];
this.groupTitle = null;
this.pageLabel = null;
this.listenText = null;
this.statusText = null;
this.arcText = null;
this.scaleText = null;
this.toastText = null;
this.engine = null;
this.rain = null;
}
}

View File

@ -17,10 +17,38 @@ const PANEL_W = 150;
const PANEL_H = 180; const PANEL_H = 180;
const PAD = 6; const PAD = 6;
const BUBBLE_MAX_W = 104; const BUBBLE_MAX_W = 104;
const BUBBLE_PAD = 4;
const BUBBLE_GAP = 4; const BUBBLE_GAP = 4;
/** Vertical room the clock stamp adds under a bubble. */
const STAMP_H = 8;
const HEADER_H = 13; const HEADER_H = 13;
const DOTS_H = 12; 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_W = 112;
const TOAST_H = 26; const TOAST_H = 26;
const TOAST_MS = 3500; const TOAST_MS = 3500;
@ -38,6 +66,10 @@ interface ThreadMessage {
carriedRule: boolean; carriedRule: boolean;
/** clock-min the text landed, if a clock:tick has been seen yet */ /** clock-min the text landed, if a clock:tick has been seen yet */
clockMin?: number; 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 { export interface PhoneSfx {
@ -77,6 +109,30 @@ export class Phone {
private readonly threadBody: Phaser.GameObjects.Container; private readonly threadBody: Phaser.GameObjects.Container;
private readonly dots: Phaser.GameObjects.Text; 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 toast: Phaser.GameObjects.Container | undefined;
private toastMs = 0; private toastMs = 0;
private toastTween: Phaser.Tweens.Tween | undefined; 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' }); this.glowTween = scene.tweens.add({ targets: glow, alpha: 0.2, duration: 1800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
body.setInteractive({ useHandCursor: true }); 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(); const anchor = this.threadAnchor();
this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false); 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)) .text(-PANEL_W / 2 + PAD + 4, PANEL_H / 2 - PAD - 9, '', font(8, UI.paper))
.setAlpha(0.6) .setAlpha(0.6)
.setVisible(false); .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('dazza:text', ({ text, rule: dressRule }) => this.receive(text, dressRule !== undefined)));
this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; })); this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; }));
@ -145,7 +294,7 @@ export class Phone {
if (this.open_) return; if (this.open_) return;
this.open_ = true; this.open_ = true;
this.openMs = 0; this.openMs = 0;
this.unreadDot.setVisible(false); this.clearUnreadBelow();
this.screen.setFillStyle(UI.panel); this.screen.setFillStyle(UI.panel);
this.dismissToast(); this.dismissToast();
@ -159,7 +308,7 @@ export class Phone {
duration: 160, duration: 160,
ease: 'Back.easeOut', ease: 'Back.easeOut',
}); });
this.layoutThread(); this.layoutThread(true);
} }
close(): void { close(): void {
@ -191,6 +340,8 @@ export class Phone {
update(deltaMs: number): void { update(deltaMs: number): void {
if (this.open_) this.openMs += deltaMs; 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) { if (this.pending) {
this.pendingMs += deltaMs; this.pendingMs += deltaMs;
@ -198,10 +349,11 @@ export class Phone {
const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3; const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3;
this.dots.setText('.'.repeat(step + 1)); this.dots.setText('.'.repeat(step + 1));
if (this.pendingMs >= TYPING_MS) { if (this.pendingMs >= TYPING_MS) {
this.messages.push(this.pending); this.pushMessage(this.pending);
this.pending = undefined; this.pending = undefined;
this.dots.setVisible(false); this.dots.setVisible(false);
this.layoutThread(); this.layoutThread();
this.flagIfLandedOffscreen();
} }
} }
@ -220,6 +372,11 @@ export class Phone {
// anything still running writing to a dead GameObject every frame. // anything still running writing to a dead GameObject every frame.
this.glowTween.remove(); this.glowTween.remove();
this.dismissToast(); 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.threadRoot.destroy(true);
this.root.destroy(true); this.root.destroy(true);
} }
@ -232,12 +389,13 @@ export class Phone {
if (this.open_) { if (this.open_) {
// Land any earlier pending line first so nothing gets swallowed by a // Land any earlier pending line first so nothing gets swallowed by a
// second buzz arriving mid-typing. // second buzz arriving mid-typing.
if (this.pending) this.messages.push(this.pending); if (this.pending) this.pushMessage(this.pending);
this.pending = msg; this.pending = msg;
this.pendingMs = 0; this.pendingMs = 0;
this.dotsMs = 0; this.dotsMs = 0;
this.dots.setText('.').setVisible(true); this.dots.setText('.').setVisible(true);
this.layoutThread(); this.layoutThread();
this.flagIfLandedOffscreen();
return; return;
} }
@ -245,11 +403,57 @@ export class Phone {
} }
private deliverClosed(msg: ThreadMessage): void { private deliverClosed(msg: ThreadMessage): void {
this.messages.push(msg); this.pushMessage(msg);
if (msg.carriedRule) this.unreadDot.setVisible(true); if (msg.carriedRule) this.unreadDot.setVisible(true);
this.showToast(msg.text); 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 * 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 * would arrive behind anything received meanwhile out of order, and with no
@ -320,76 +524,178 @@ export class Phone {
this.toastMs = 0; this.toastMs = 0;
} }
private layoutThread(): void { /** Visible thread height; the typing dots eat into it while Dazza is typing. */
this.threadBody.removeAll(true); private get interiorH(): number {
if (!this.open_) return; return INTERIOR_BOTTOM - INTERIOR_TOP - (this.pending ? DOTS_H : 0);
}
const top = -PANEL_H / 2 + HEADER_H + 3; private get maxScroll(): number {
const floor = PANEL_H / 2 - PAD - (this.pending ? DOTS_H : 0); return Math.max(0, this.contentH - this.interiorH);
let cursor = floor; }
// Newest at the bottom: build upward and stop as soon as one won't fit. The private pointerOverPanel(p: Phaser.Input.Pointer): boolean {
// newest is clipped to the interior instead of dropped — a text long enough return (
// to overflow the panel on its own would otherwise blank the whole thread. Math.abs(p.worldX - this.threadRoot.x) <= PANEL_W / 2 &&
let placed = 0; Math.abs(p.worldY - this.threadRoot.y) <= PANEL_H / 2
for (let i = this.messages.length - 1; i >= 0; i--) { );
}
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]; const msg = this.messages[i];
if (!msg) continue; if (!msg) continue;
const bubble = this.buildBubble(msg, placed === 0 ? floor - top : Number.POSITIVE_INFINITY); this.offsets[i] = cursor;
if (placed > 0 && cursor - bubble.height < top) { cursor += this.heightOf(msg) + BUBBLE_GAP;
bubble.container.destroy(true); }
break; this.contentH = Math.max(0, cursor - BUBBLE_GAP);
} }
placed++;
bubble.container.setY(cursor - bubble.height); /** Inclusive message range intersecting the interior, plus overscan. */
this.threadBody.add(bubble.container); private visibleRange(): { from: number; to: number } {
cursor -= bubble.height + BUBBLE_GAP; 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. */ /** Container origin is the bubble's top-left; caller only sets Y. */
private buildBubble( private buildBubble(msg: ThreadMessage): Phaser.GameObjects.Container {
msg: ThreadMessage,
maxHeight = Number.POSITIVE_INFINITY,
): { container: Phaser.GameObjects.Container; height: number } {
const incoming = msg.from === 'dazza'; 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 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), ...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 const bg = this.scene.add
.rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink) .rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink)
.setOrigin(0, 0) .setOrigin(0, 0)
.setStrokeStyle(1, UI.ink, 0.4); .setStrokeStyle(1, UI.ink, 0.4);
c.add([bg, text]); c.add([bg, text]);
let height = h;
if (msg.carriedRule) { if (msg.carriedRule) {
// Rule-bearing texts stay flagged forever — this is the one you get sacked // Rule-bearing texts stay flagged forever — this is the one you get sacked
// for missing. // for missing. Mirrored on right-aligned bubbles, which sit flush against
const badge = this.scene.add.text(w + 3, 0, 'NEW', font(6, UI.neonPink)); // 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); c.add(badge);
} }
if (msg.clockMin !== undefined) { if (msg.clockMin !== undefined) {
const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper)); const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper));
stamp.setAlpha(0.4); stamp.setAlpha(0.4);
c.add(stamp); c.add(stamp);
height += 8;
} }
c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w); c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w);
return { container: c, height }; return c;
} }
} }

277
tests/arc.test.ts Normal file
View File

@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import { ARC, arcAt, arcIntensity, arcLabel, NIGHT_END_MIN, type ArcScales } from '../src/audio/arc';
import { VOICE_NAMES } from '../src/audio/mix';
const first = ARC[0]!;
const last = ARC[ARC.length - 1]!;
const mean = (a: number, b: number): number => (a + b) / 2;
const PLATEAU_MIN = 180;
const KICK_FULL_MIN = 120;
// The plateau is read straight off the keyframe, never sampled out of arcAt —
// a bound derived from the function under test cannot catch that function
// drifting. Kick is full by 11 PM by design; these three climb to midnight.
const plateau: ArcScales = ARC.find((k) => k.at === PLATEAU_MIN)!.scales;
const LATE_BUILDERS = ['hat', 'bass', 'pad'] as const;
describe('ARC keyframes', () => {
// Every loop below iterates one of these two; a zero-length either would turn
// the whole suite green by iterating nothing.
it('has the keyframes and voices the rest of this suite loops over', () => {
expect(ARC.length).toBe(7);
expect(VOICE_NAMES.length).toBe(4);
expect([...VOICE_NAMES].sort()).toEqual(['bass', 'hat', 'kick', 'pad']);
});
it('runs strictly ascending from minute 0 to the end of the night', () => {
expect(first.at).toBe(0);
expect(last.at).toBe(NIGHT_END_MIN);
expect(NIGHT_END_MIN).toBe(360);
for (let i = 1; i < ARC.length; i++) {
expect(ARC[i]!.at).toBeGreaterThan(ARC[i - 1]!.at);
}
});
it('keeps every voice scale inside 0..1 and carries a label', () => {
for (const k of ARC) {
expect(k.label.length).toBeGreaterThan(0);
for (const v of VOICE_NAMES) {
expect(k.scales[v]).toBeGreaterThanOrEqual(0);
expect(k.scales[v]).toBeLessThanOrEqual(1);
}
}
});
});
describe('arcAt', () => {
it('returns the exact keyframe scales at each keyframe minute', () => {
for (const k of ARC) {
expect(arcAt(k.at)).toEqual(k.scales);
}
});
// Every branch has to copy. Handing back a live keyframe means one caller
// doing `arcAt(-1).bass = 0` silently rewrites ARC for the whole session, and
// every subsequent 9 PM opens wrong.
it('returns a fresh object on every branch, including both clamps', () => {
// Clamp low.
const low = arcAt(-1);
expect(low).not.toBe(first.scales);
low.bass = 0.123;
expect(first.scales.bass).toBe(0);
expect(arcAt(-1).bass).toBe(0);
// Clamp high.
const high = arcAt(NIGHT_END_MIN + 1);
expect(high).not.toBe(last.scales);
high.pad = 0.123;
expect(last.scales.pad).toBe(0.6);
expect(arcAt(NIGHT_END_MIN + 1).pad).toBe(0.6);
// The edge minutes themselves take the clamp branches, not interpolation.
const atFirst = arcAt(first.at);
expect(atFirst).not.toBe(first.scales);
atFirst.kick = 0.111;
expect(arcAt(first.at).kick).toBe(0.8);
const atLast = arcAt(last.at);
expect(atLast).not.toBe(last.scales);
atLast.kick = 0.111;
expect(arcAt(last.at).kick).toBe(0.45);
// A non-finite minute funnels into the clamp-low branch.
const stalled = arcAt(NaN);
expect(stalled).not.toBe(first.scales);
stalled.bass = 0.9;
expect(arcAt(NaN).bass).toBe(0);
// Interpolation branch.
const k = ARC[3]!;
const s = arcAt(k.at);
expect(s).not.toBe(k.scales);
s.kick = 0;
expect(k.scales.kick).toBe(1);
});
it('clamps outside the night instead of extrapolating', () => {
expect(arcAt(-1)).toEqual(first.scales);
expect(arcAt(-10_000)).toEqual(first.scales);
expect(arcAt(NIGHT_END_MIN + 1)).toEqual(last.scales);
expect(arcAt(10_000)).toEqual(last.scales);
});
it('never produces a negative or >1 gain at any minute, including past the end', () => {
for (let t = -120; t <= NIGHT_END_MIN + 120; t++) {
const s = arcAt(t);
for (const v of VOICE_NAMES) {
expect(s[v]).toBeGreaterThanOrEqual(0);
expect(s[v]).toBeLessThanOrEqual(1);
}
}
});
it('interpolates linearly between keyframes', () => {
// 30 is the midpoint of the 0 -> 60 span.
const a = ARC[0]!.scales;
const b = ARC[1]!.scales;
const mid = arcAt(30);
for (const v of VOICE_NAMES) expect(mid[v]).toBeCloseTo(mean(a[v], b[v]), 10);
// 90 is the midpoint of 60 -> 120.
const c = ARC[2]!.scales;
const mid2 = arcAt(90);
for (const v of VOICE_NAMES) expect(mid2[v]).toBeCloseTo(mean(b[v], c[v]), 10);
// A quarter of the way across the 330 -> 360 close-down.
const d = ARC[5]!.scales;
const e = ARC[6]!.scales;
const q = arcAt(337.5);
for (const v of VOICE_NAMES) expect(q[v]).toBeCloseTo(d[v] + (e[v] - d[v]) * 0.25, 10);
});
it('survives a non-finite clock minute without emitting NaN', () => {
for (const bad of [NaN, Infinity, -Infinity]) {
const s: ArcScales = arcAt(bad);
for (const v of VOICE_NAMES) expect(Number.isFinite(s[v])).toBe(true);
}
// A stalled clock reads as the start of the night, not as silence.
expect(arcAt(NaN)).toEqual(first.scales);
});
});
describe('the shape of the night', () => {
it('opens with no bassline — the room is empty', () => {
expect(arcAt(0).bass).toBe(0);
expect(arcAt(0).pad).toBe(0);
expect(arcAt(0).kick).toBeGreaterThan(0);
});
it('brings the bass up once there is a crowd to carry it', () => {
expect(arcAt(60).bass).toBeGreaterThan(arcAt(0).bass);
expect(arcAt(120).bass).toBeGreaterThan(arcAt(60).bass);
});
it('builds every voice through the first half of the night', () => {
for (const v of VOICE_NAMES) {
let prev = -Infinity;
for (let t = 0; t <= 180; t += 5) {
const s = arcAt(t)[v];
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
expect(arcAt(180)[v]).toBeGreaterThan(arcAt(0)[v]);
}
});
// The build has to be a real build: strictly quieter than the plateau on the
// way up, so a keyframe that arrives at full volume early — flattening the
// climb the whole arc exists for — is a failure, not a pass.
it('stays strictly under the midnight plateau on the way up', () => {
for (const k of ARC) {
if (k.at >= KICK_FULL_MIN) continue;
for (const v of VOICE_NAMES) expect(k.scales[v]).toBeLessThan(plateau[v]);
}
const eleven = ARC.find((k) => k.at === KICK_FULL_MIN)!;
expect(eleven.scales.kick).toBe(plateau.kick);
for (const v of LATE_BUILDERS) expect(eleven.scales[v]).toBeLessThan(plateau[v]);
for (let t = 0; t < KICK_FULL_MIN; t++) {
for (const v of VOICE_NAMES) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
for (let t = KICK_FULL_MIN; t < PLATEAU_MIN; t++) {
for (const v of LATE_BUILDERS) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
});
it('holds every voice at its loudest across the midnight..2 AM window', () => {
for (const v of VOICE_NAMES) {
for (let t = PLATEAU_MIN; t <= 300; t += 10) expect(arcAt(t)[v]).toBeCloseTo(plateau[v], 10);
for (let t = 301; t <= NIGHT_END_MIN; t++) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
});
it('strips every voice back by lights up', () => {
for (const v of VOICE_NAMES) {
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(plateau[v]);
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(arcAt(330)[v]);
}
// Low end goes first — that is what actually empties a floor.
expect(arcAt(330).bass).toBeLessThan(arcAt(330).pad);
});
});
describe('arcLabel', () => {
it('names each keyframe at its minute and holds it until the next', () => {
for (let i = 0; i < ARC.length; i++) {
const k = ARC[i]!;
expect(arcLabel(k.at)).toBe(k.label);
const next = ARC[i + 1];
if (next && next.at > k.at + 1) expect(arcLabel(k.at + 1)).toBe(k.label);
}
});
it('reads the expected labels across the night', () => {
expect(arcLabel(0)).toBe('EMPTY');
expect(arcLabel(59)).toBe('EMPTY');
expect(arcLabel(60)).toBe('FILLING');
expect(arcLabel(119)).toBe('FILLING');
expect(arcLabel(120)).toBe('BUILDING');
expect(arcLabel(180)).toBe('PEAK');
expect(arcLabel(299)).toBe('PEAK');
expect(arcLabel(300)).toBe('PEAK');
expect(arcLabel(330)).toBe('LAST DRINKS');
expect(arcLabel(359)).toBe('LAST DRINKS');
expect(arcLabel(NIGHT_END_MIN)).toBe('LIGHTS UP');
});
it('holds the edges before doors and after close', () => {
expect(arcLabel(-30)).toBe(first.label);
expect(arcLabel(NIGHT_END_MIN + 30)).toBe(last.label);
expect(arcLabel(NaN)).toBe(first.label);
});
});
describe('arcIntensity', () => {
it('stays within 0..1 across and beyond the night', () => {
for (let t = -60; t <= NIGHT_END_MIN + 60; t++) {
const i = arcIntensity(t);
expect(i).toBeGreaterThanOrEqual(0);
expect(i).toBeLessThanOrEqual(1);
}
});
it('is the mean of the four voice scalars', () => {
for (const t of [0, 45, 120, 240, 345, 360]) {
const s = arcAt(t);
expect(arcIntensity(t)).toBeCloseTo((s.kick + s.hat + s.bass + s.pad) / 4, 10);
}
});
it('rises from doors to peak', () => {
let prev = -Infinity;
for (let t = 0; t <= 180; t += 10) {
const i = arcIntensity(t);
expect(i).toBeGreaterThan(prev);
prev = i;
}
expect(arcIntensity(180)).toBe(1);
expect(arcIntensity(0)).toBeLessThan(0.5);
});
it('holds flat across the peak plateau', () => {
for (let t = 180; t <= 300; t += 20) expect(arcIntensity(t)).toBeCloseTo(1, 10);
});
it('falls from the plateau to lights up', () => {
let prev = Infinity;
for (let t = 300; t <= NIGHT_END_MIN; t += 10) {
const i = arcIntensity(t);
expect(i).toBeLessThan(prev);
prev = i;
}
expect(arcIntensity(NIGHT_END_MIN)).toBeLessThan(arcIntensity(300));
});
});

271
tests/mix.test.ts Normal file
View File

@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest';
import {
clampTo,
cloneMix,
DEFAULT_MIX,
formatMix,
MIX_RANGES,
soloedVoice,
voiceGain,
VOICE_NAMES,
type TrackMix,
type VoiceName,
} from '../src/audio/mix';
const mix = (over: Partial<TrackMix> = {}): TrackMix => ({ ...cloneMix(DEFAULT_MIX), ...over });
const enabled = (...on: VoiceName[]): Record<VoiceName, boolean> => ({
kick: on.includes('kick'),
hat: on.includes('hat'),
bass: on.includes('bass'),
pad: on.includes('pad'),
});
describe('DEFAULT_MIX', () => {
// Tripwire. A retune is a deliberate, reviewed diff — if this goes red because
// someone pasted an ear-pass dump, update the literals in the same commit.
it('holds the Phase-1 hardcoded values exactly', () => {
expect(DEFAULT_MIX).toEqual({
master: 0.8,
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
enabled: { kick: true, hat: true, bass: true, pad: true },
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
hat: { hpHz: 7000, decayS: 0.04 },
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
pad: { lpHz: 600, bars: 8 },
doorQ: 6,
floorQ: 0.7,
});
});
it('ships every voice unmuted and every level inside its range', () => {
for (const v of VOICE_NAMES) {
expect(DEFAULT_MIX.enabled[v]).toBe(true);
expect(DEFAULT_MIX.levels[v]).toBe(clampTo(DEFAULT_MIX.levels[v], MIX_RANGES.level));
}
expect(DEFAULT_MIX.master).toBe(clampTo(DEFAULT_MIX.master, MIX_RANGES.master));
});
});
describe('cloneMix', () => {
it('deep-copies every nested branch', () => {
const src = cloneMix(DEFAULT_MIX);
const copy = cloneMix(src);
expect(copy).toEqual(src);
expect(copy.levels).not.toBe(src.levels);
expect(copy.enabled).not.toBe(src.enabled);
expect(copy.kick).not.toBe(src.kick);
expect(copy.hat).not.toBe(src.hat);
expect(copy.bass).not.toBe(src.bass);
expect(copy.pad).not.toBe(src.pad);
});
it('leaves the source untouched when every branch of the clone is mutated', () => {
const src = cloneMix(DEFAULT_MIX);
const copy = cloneMix(src);
copy.master = 0.1;
copy.levels.kick = 0.01;
copy.levels.hat = 0.02;
copy.levels.bass = 0.03;
copy.levels.pad = 0.04;
copy.enabled.kick = false;
copy.enabled.hat = false;
copy.enabled.bass = false;
copy.enabled.pad = false;
copy.kick.startHz = 999;
copy.hat.hpHz = 999;
copy.bass.rootHz = 999;
copy.pad.lpHz = 999;
copy.doorQ = 99;
copy.floorQ = 99;
expect(src.master).toBe(0.8);
expect(src.levels).toEqual({ kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 });
expect(src.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
expect(src.kick.startHz).toBe(150);
expect(src.hat.hpHz).toBe(7000);
expect(src.bass.rootHz).toBe(55);
expect(src.pad.lpHz).toBe(600);
expect(src.doorQ).toBe(6);
expect(src.floorQ).toBe(0.7);
});
it('does not let a clone of DEFAULT_MIX mutate the module-level default', () => {
const c = cloneMix(DEFAULT_MIX);
c.levels.kick = 0;
c.enabled.pad = false;
expect(DEFAULT_MIX.levels.kick).toBe(0.9);
expect(DEFAULT_MIX.enabled.pad).toBe(true);
});
});
describe('clampTo', () => {
const range: readonly [number, number] = [0.5, 20];
it('passes values inside the range through unchanged', () => {
expect(clampTo(0.5, range)).toBe(0.5);
expect(clampTo(6, range)).toBe(6);
expect(clampTo(20, range)).toBe(20);
});
it('clamps both ends', () => {
expect(clampTo(-100, range)).toBe(0.5);
expect(clampTo(1e9, range)).toBe(20);
expect(clampTo(20.0001, range)).toBe(20);
expect(clampTo(0.4999, range)).toBe(0.5);
});
// A NaN gain reaching WebAudio silently kills a voice for the rest of the
// night, so a bad input must land on the minimum, never propagate. Infinity
// takes the same branch: non-finite reads as a broken value, not a loud one.
it('maps every non-finite input to the range minimum, not NaN and not the max', () => {
for (const bad of [NaN, Infinity, -Infinity]) {
expect(clampTo(bad, range)).toBe(0.5);
expect(clampTo(bad, MIX_RANGES.master)).toBe(0);
expect(Number.isNaN(clampTo(bad, range))).toBe(false);
}
});
it('keeps every declared range within its own bounds', () => {
for (const [lo, hi] of Object.values(MIX_RANGES)) {
expect(lo).toBeLessThan(hi);
expect(clampTo(lo - 1, [lo, hi])).toBe(lo);
expect(clampTo(hi + 1, [lo, hi])).toBe(hi);
}
});
});
describe('voiceGain', () => {
it('multiplies level by the arc scale', () => {
const m = mix();
expect(voiceGain(m, 'kick', 1)).toBeCloseTo(0.9, 10);
expect(voiceGain(m, 'kick', 0.5)).toBeCloseTo(0.45, 10);
expect(voiceGain(m, 'bass', 0.5)).toBeCloseTo(0.055, 10);
});
it('defaults the arc scale to 1', () => {
expect(voiceGain(mix(), 'hat')).toBe(mix().levels.hat);
});
it('returns 0 for a disabled voice regardless of level or arc scale', () => {
const m = mix({ enabled: enabled('hat'), levels: { kick: 1.5, hat: 0.25, bass: 1, pad: 1 } });
expect(voiceGain(m, 'kick', 1)).toBe(0);
expect(voiceGain(m, 'kick', 10)).toBe(0);
expect(voiceGain(m, 'bass', 1)).toBe(0);
expect(voiceGain(m, 'pad', 0.5)).toBe(0);
expect(voiceGain(m, 'hat', 1)).toBe(0.25);
});
it('yields exactly 0 at a 0 arc scale', () => {
for (const v of VOICE_NAMES) expect(voiceGain(mix(), v, 0)).toBe(0);
});
it('never returns a negative gain', () => {
const m = mix();
for (const v of VOICE_NAMES) {
expect(voiceGain(m, v, -1)).toBe(0);
expect(voiceGain(m, v, -1e6)).toBe(0);
expect(voiceGain(m, v, 2)).toBeGreaterThanOrEqual(0);
}
expect(voiceGain(mix({ levels: { kick: -1, hat: 0, bass: 0, pad: 0 } }), 'kick')).toBe(0);
});
});
describe('soloedVoice', () => {
it('is null when every voice is on', () => {
expect(soloedVoice(mix())).toBeNull();
});
it('is null when no voice is on', () => {
expect(soloedVoice(mix({ enabled: enabled() }))).toBeNull();
});
it('is null with two voices on', () => {
expect(soloedVoice(mix({ enabled: enabled('kick', 'bass') }))).toBeNull();
expect(soloedVoice(mix({ enabled: enabled('hat', 'pad') }))).toBeNull();
});
it('is null with three voices on', () => {
expect(soloedVoice(mix({ enabled: enabled('kick', 'hat', 'bass') }))).toBeNull();
});
it('names the voice when exactly one is on', () => {
for (const v of VOICE_NAMES) {
expect(soloedVoice(mix({ enabled: enabled(v) }))).toBe(v);
}
});
});
describe('formatMix', () => {
// The dump is how a tuning session becomes a diff; parse it back and compare
// field by field so a silently dropped knob goes red.
const parseDump = (s: string): unknown =>
JSON.parse(
s
.replace(/([{,]\s*)([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":')
.replace(/,(\s*})/g, '$1'),
);
const tuned: TrackMix = {
master: 0.95,
levels: { kick: 1.05, hat: 0.3125, bass: 0.123456789, pad: 0.07 },
enabled: enabled('bass'),
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
hat: { hpHz: 8200, decayS: 0.055 },
bass: { rootHz: 41, filterLoHz: 220, filterHiHz: 1400, q: 11.5, decayS: 0.19, detuneCents: 14 },
pad: { lpHz: 780, bars: 4 },
doorQ: 7.25,
floorQ: 0.85,
};
it('round-trips every numeric field, rounded to 4 decimal places', () => {
expect(parseDump(formatMix(tuned))).toEqual({
master: 0.95,
levels: { kick: 1.05, hat: 0.3125, bass: 0.1235, pad: 0.07 },
enabled: { kick: true, hat: true, bass: true, pad: true },
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
hat: { hpHz: 8200, decayS: 0.055 },
bass: {
rootHz: 41,
filterLoHz: 220,
filterHiHz: 1400,
q: 11.5,
decayS: 0.19,
detuneCents: 14,
},
pad: { lpHz: 780, bars: 4 },
doorQ: 7.25,
floorQ: 0.85,
});
});
it('round-trips DEFAULT_MIX back to itself', () => {
expect(parseDump(formatMix(DEFAULT_MIX))).toEqual(DEFAULT_MIX);
});
it('emits every key of every branch', () => {
const out = parseDump(formatMix(tuned)) as Record<string, Record<string, unknown>>;
expect(Object.keys(out).sort()).toEqual(Object.keys(tuned).sort());
for (const branch of ['levels', 'enabled', 'kick', 'hat', 'bass', 'pad'] as const) {
expect(Object.keys(out[branch] ?? {}).sort()).toEqual(Object.keys(tuned[branch]).sort());
}
});
// Mutes are an ear-pass tool, not a shipped state: the dump deliberately
// un-mutes everything so a solo pass cannot ship as silence.
it('always emits enabled: all true, even from a soloed mix', () => {
expect(soloedVoice(tuned)).toBe('bass');
const out = parseDump(formatMix(tuned)) as { enabled: Record<string, boolean> };
expect(out.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
});
it('emits integers without a decimal point', () => {
const dump = formatMix(DEFAULT_MIX);
expect(dump).toContain('startHz: 150');
expect(dump).toContain('hpHz: 7000');
expect(dump).toContain('doorQ: 6');
expect(dump).not.toMatch(/\d\.0000/);
});
});