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 <noreply@anthropic.com>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
// 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<VoiceName, number>;
|
|
/** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */
|
|
enabled: Record<VoiceName, boolean>;
|
|
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<string, readonly [number, number]>;
|
|
|
|
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)},
|
|
}`;
|
|
}
|