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