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>
272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
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/);
|
|
});
|
|
});
|