From d112bda2705380f86ce49514025351c0012062a5 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 20:30:42 +1000 Subject: [PATCH] LANE-JUICE2: lift the track's constants into a live mix + a night arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase-1 engine baked every level, pitch and envelope into module consts, so nothing could be tuned without an edit-reload cycle. Three things need them live: the ear pass (a human turns a knob and hears it on the next beat), the night arc, and the Phase-4 DJ role, whose control panel is specified as this exact surface (BUILD_PLAN §4). - mix.ts: TrackMix + DEFAULT_MIX (reproducing the Phase-1 values exactly, so lifting them changes no sound), MIX_RANGES bounds, and formatMix() — which is what turns a tuning session into a diff instead of numbers that die with the tab. - arc.ts: the night's shape as per-voice gain scalars. Thin kick at 9PM, bass in once there's a crowd to carry it, everything at midnight, low end pulled first at Last Drinks. Pure keyframes, so it's testable and the DJ role can drive the same surface by hand. Co-Authored-By: Claude Opus 4.8 --- src/audio/arc.ts | 98 +++++++++++++++++++++++++++++ src/audio/mix.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/audio/arc.ts create mode 100644 src/audio/mix.ts diff --git a/src/audio/arc.ts b/src/audio/arc.ts new file mode 100644 index 0000000..1f73879 --- /dev/null +++ b/src/audio/arc.ts @@ -0,0 +1,98 @@ +import { VOICE_NAMES, type VoiceName } from './mix'; + +// The night's shape, as gain scalars per voice. +// +// A club does not play the same track at 9 PM and at 1 AM, and a room that +// sounds identical for six hours is the main reason a long shift reads as flat. +// So the mix opens thin — kick and a bit of hat, the sound of a room hoping +// people turn up — the bassline arrives once there is a crowd to carry it, the +// pads land at peak, and Last Drinks strips it back so the lights coming up +// feel earned rather than abrupt. +// +// Pure keyframe interpolation: no audio types here, so it is testable in node +// and reusable by the Phase-4 DJ role, which drives this same surface by hand. + +/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */ +export const NIGHT_END_MIN = 360; + +export type ArcScales = Record; + +export interface ArcKeyframe { + /** Minutes since 21:00. */ + at: number; + label: string; + scales: ArcScales; +} + +/** + * Keyframes, ascending by `at`. Deliberately sparse — the interpolation between + * them is what makes the build feel gradual rather than stepped. + */ +export const ARC: readonly ArcKeyframe[] = [ + // 9 PM. Doors open to an empty room. Kick and a whisper of hat. + { at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } }, + // 10 PM. First real queue. The bassline turns up with them. + { at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } }, + // 11 PM. Room has weight. + { at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } }, + // Midnight through 2 AM. Everything, all of it. + { at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } }, + { at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } }, + // 2:30. Last drinks. Pull the low end first — that is what empties a floor. + { at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } }, + // 3 AM. Pads over a dying kick while the lights come up. + { at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } }, +]; + +const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + +/** + * Voice scalars at a given clock minute. Clamps outside the night rather than + * extrapolating — a paused or overrunning clock should hold the edge, not + * wander into negative gain. + */ +export function arcAt(clockMin: number): ArcScales { + const t = Number.isFinite(clockMin) ? clockMin : 0; + + const first = ARC[0]; + const last = ARC[ARC.length - 1]; + /* istanbul ignore next -- ARC is a non-empty literal */ + if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 }; + + if (t <= first.at) return { ...first.scales }; + if (t >= last.at) return { ...last.scales }; + + for (let i = 1; i < ARC.length; i++) { + const prev = ARC[i - 1]; + const next = ARC[i]; + if (!prev || !next || t > next.at) continue; + const span = next.at - prev.at; + const k = span <= 0 ? 1 : (t - prev.at) / span; + const out = {} as ArcScales; + for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k); + return out; + } + + /* istanbul ignore next -- the loop always returns for t inside the range */ + return { ...last.scales }; +} + +/** Nearest keyframe label at or before this minute — for HUD readouts. */ +export function arcLabel(clockMin: number): string { + const t = Number.isFinite(clockMin) ? clockMin : 0; + let label = ARC[0]?.label ?? ''; + for (const k of ARC) { + if (t >= k.at) label = k.label; + } + return label; +} + +/** + * One number for "how hard is the room going", 0..1 — the mean of the voice + * scalars. Handy for anything that wants to follow the night's energy without + * caring which voice is doing it (lighting, crowd density, the DJ role's meter). + */ +export function arcIntensity(clockMin: number): number { + const s = arcAt(clockMin); + return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length; +} diff --git a/src/audio/mix.ts b/src/audio/mix.ts new file mode 100644 index 0000000..1db45c9 --- /dev/null +++ b/src/audio/mix.ts @@ -0,0 +1,159 @@ +// The track's tunable surface, lifted out of TechnoEngine so it can be changed +// while the music is playing. +// +// Three things need this and none of them can use a `const` in a module body: +// the ear pass (a human turns a knob and hears it on the next beat), the night +// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role, +// whose control panel is specified as this exact surface (BUILD_PLAN §4). +// +// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting +// them changed no sound. Anything John retunes in the ear pass gets pasted back +// over DEFAULT_MIX via `formatMix()`. + +export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad'; + +export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad']; + +export interface KickParams { + /** Beater pitch at the transient. */ + startHz: number; + /** Where the drop lands — the body of the cone. */ + endHz: number; + /** Seconds for the pitch drop. Longer reads as a bigger speaker. */ + dropS: number; + attackS: number; + decayS: number; +} + +export interface HatParams { + /** Highpass corner. Lower lets more of the noise body through. */ + hpHz: number; + decayS: number; +} + +export interface BassParams { + rootHz: number; + /** Per-note filter sweep floor and peak — the "wow" on each 16th. */ + filterLoHz: number; + filterHiHz: number; + q: number; + decayS: number; + /** Cents of detune between the two saws. The whole character lives here. */ + detuneCents: number; +} + +export interface PadParams { + lpHz: number; + /** Length of the fade-in/out cycle, in bars. */ + bars: number; +} + +export interface TrackMix { + /** Post-everything output level. */ + master: number; + /** Per-voice peak gains, pre-filter. */ + levels: Record; + /** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */ + enabled: Record; + kick: KickParams; + hat: HatParams; + bass: BassParams; + pad: PadParams; + /** Filter resonance at each location — the door's peak is the "through a wall" cue. */ + doorQ: number; + floorQ: number; +} + +export const DEFAULT_MIX: TrackMix = { + master: 0.8, + levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 }, + enabled: { kick: true, hat: true, bass: true, pad: true }, + kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 }, + hat: { hpHz: 7000, decayS: 0.04 }, + bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 }, + pad: { lpHz: 600, bars: 8 }, + doorQ: 6, + floorQ: 0.7, +}; + +/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */ +export const MIX_RANGES = { + master: [0, 1.2], + level: [0, 1.5], + 'kick.startHz': [60, 400], + 'kick.endHz': [25, 120], + 'kick.dropS': [0.01, 0.3], + 'kick.attackS': [0.001, 0.05], + 'kick.decayS': [0.05, 1], + 'hat.hpHz': [2000, 14000], + 'hat.decayS': [0.005, 0.2], + 'bass.rootHz': [30, 110], + 'bass.filterLoHz': [60, 600], + 'bass.filterHiHz': [300, 4000], + 'bass.q': [0.5, 20], + 'bass.decayS': [0.02, 0.5], + 'bass.detuneCents': [0, 40], + 'pad.lpHz': [200, 4000], + 'pad.bars': [1, 16], + doorQ: [0.5, 20], + floorQ: [0.1, 4], +} as const satisfies Record; + +export const clampTo = (v: number, range: readonly [number, number]): number => + Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0]; + +/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */ +export function cloneMix(m: TrackMix): TrackMix { + return { + master: m.master, + levels: { ...m.levels }, + enabled: { ...m.enabled }, + kick: { ...m.kick }, + hat: { ...m.hat }, + bass: { ...m.bass }, + pad: { ...m.pad }, + doorQ: m.doorQ, + floorQ: m.floorQ, + }; +} + +/** + * Effective gain for a voice: its level, muted by `enabled`, scaled by whatever + * the night arc says this voice should be doing right now. + * + * Kept here rather than in the engine so the ear-pass rig can show the same + * number the scheduler will actually use. + */ +export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number { + if (!mix.enabled[voice]) return 0; + return Math.max(0, mix.levels[voice] * arcScale); +} + +/** True when exactly one voice is unmuted — the ear pass's soloed state. */ +export const soloedVoice = (mix: TrackMix): VoiceName | null => { + const on = VOICE_NAMES.filter((v) => mix.enabled[v]); + return on.length === 1 ? (on[0] ?? null) : null; +}; + +const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4)))); + +/** + * Emit a mix as a pasteable `TrackMix` literal. + * + * The ear pass is a human saying "more click on the kick" and me turning that + * into numbers; without a dump button those numbers die with the tab. This is + * how a tuning session becomes a diff. + */ +export function formatMix(m: TrackMix): string { + return `{ + master: ${n(m.master)}, + levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} }, + enabled: { kick: true, hat: true, bass: true, pad: true }, + kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} }, + hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} }, + bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} }, + pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} }, + doorQ: ${n(m.doorQ)}, + floorQ: ${n(m.floorQ)}, +}`; +}