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)}, +}`; +}