/** * The Preset menu, Randomize and Mutate of the Instrument tab (#329): sfxr's generators worked out * into a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341), and its randomizer and mutator * on their numbers. */ import { describe, expect, it } from 'vitest'; import { ReplaceGraphCommand } from '../../src/core/commands/graphCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import type { Graph, GraphNode } from '../../src/core/graph/Graph'; import { CHIP_PRESETS, chipPresetGraph, EFFECT_PRESETS, hasChipNumbers, mutatedParams, randomizedParams, TONE_PRESETS, } from '../../src/core/instrument/chipPresets'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { CHIP_ROWS, ENVELOPE_ROWS, starterInstrumentGraph } from '../../src/core/instrument/nodes'; import type { InstrumentAsset } from '../../src/core/model/Asset'; import type { FlatDef } from '../../src/core/properties/PropertySchema'; import { CRUSH_ROWS, FLANGER_ROWS, type FxChipPlan, type FxEnvelopePlan, FILTER_ROWS, type FxPlan, fxChainOf, PHASER_ROWS, } from '../../src/core/soundfx/nodes'; import { makeProject } from '../helpers'; /** A seeded random, so a draw is the same on every run. */ function seeded(seed = 1): () => number { let state = seed; return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32; }; } const byType = (graph: Graph, type: string) => graph.nodes.find((node) => node.type === type)!; const ROWS: Record>> = { InstrumentChip: CHIP_ROWS, InstrumentEnvelope: ENVELOPE_ROWS, SoundFxFilter: FILTER_ROWS, SoundFxBitcrusher: CRUSH_ROWS, SoundFxPhaser: PHASER_ROWS, SoundFxFlanger: FLANGER_ROWS, }; function withinRows(node: GraphNode, keys: string[]): void { for (const key of keys) { const row = ROWS[node.type]![key]!; const value = node.params[key] as number; expect(value, `${node.type}.${key}`).toBeGreaterThanOrEqual(row.min ?? -Infinity); expect(value, `${node.type}.${key}`).toBeLessThanOrEqual(row.max ?? Infinity); } } describe('a preset (#329)', () => { it('has the instruments first in the menu, then the sounds of sfxr', () => { expect(CHIP_PRESETS.map((p) => p.value)).toEqual([ ...TONE_PRESETS.map((p) => p.value), ...EFFECT_PRESETS.map((p) => p.value), ]); expect(TONE_PRESETS).toHaveLength(15); }); it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => { const sources = instrumentSourcesOf(makeProject().project); for (const { value } of EFFECT_PRESETS) { const graph = chipPresetGraph(value, seeded()); // Only an explosion can have sfxr's phaser, as a Flanger after the Filter. const flanged = graph.nodes.some((n) => n.type === 'SoundFxFlanger'); expect(flanged && value !== 'explosion', value).toBe(false); expect( graph.nodes.map((n) => n.type), value, ).toEqual([ 'InstrumentNotes', 'InstrumentChip', 'InstrumentEnvelope', 'SoundFxFilter', ...(flanged ? ['SoundFxFlanger'] : []), 'SoundFxBitcrusher', 'InstrumentOutput', ]); const frequencies = [0, 60, 127].map((note) => { const build = compileInstrument(graph, note, sources); expect(build.reason, value).toBe(''); const chain = fxChainOf(build.layers[0]!); expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([ 'envelope', 'pass', ...(flanged ? ['flanger'] : []), 'crush', ]); const envelope = chain.stages[0]!; expect(envelope.kind === 'envelope' && envelope.envelope.sustain, value).toBe(0); return 'chip' in chain ? chain.chip?.frequency : undefined; }); // Its own frequency on every key, as an sfxr sound has. expect(new Set(frequencies).size, value).toBe(1); withinRows(byType(graph, 'InstrumentChip'), Object.keys(CHIP_ROWS)); } }); it("works sfxr's settings out into Hz and seconds", () => { // At 0 every draw takes the low end: a coin at sfxr's 0.4, no arpeggio, a decay of 0.1. const low = chipPresetGraph('pickup', () => 0); expect(byType(low, 'InstrumentNotes').params).toEqual({ from: '0', to: '127' }); expect(byType(low, 'InstrumentChip').params).toMatchObject({ wave: 'square', followNote: false, frequency: 568, duty: 0.5, slide: 0, arpeggio: 0, }); expect(byType(low, 'InstrumentEnvelope').params).toMatchObject({ attack: 0, decay: 0.068, sustain: 0 }); expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, resonance: 0 }); expect(byType(low, 'SoundFxBitcrusher').params).toEqual({ bits: 8, rate: 22050, mix: 1 }); // Near 1 the coin jumps up by sfxr's arpeggio: a period 0.676 times as long is about 7 semitones. const high = byType( chipPresetGraph('pickup', () => 0.999), 'InstrumentChip', ).params; expect(high).toMatchObject({ frequency: 2858, arpeggio: 7, arpeggioTime: 0.042 }); // A laser slides down, an explosion is noise. expect(byType(chipPresetGraph('laser', seeded(3)), 'InstrumentChip').params.slide).toBeLessThan(0); expect(byType(chipPresetGraph('explosion', seeded(3)), 'InstrumentChip').params.wave).toBe('noise'); }); it("gives one explosion in two sfxr's phaser, as a Flanger with its offset and sweep (#341)", () => { // Near 1: sfxr's offset 0.5991 and ramp -0.2997, a delay of 1.04 ms falling 11.2 ms a second. expect( byType( chipPresetGraph('explosion', () => 0.999), 'SoundFxFlanger', ).params, ).toEqual({ offset: 1.04, sweep: -11.2, rate: 0, depth: 0, feedback: 0, mix: 1, }); expect(chipPresetGraph('explosion', () => 0).nodes.some((n) => n.type === 'SoundFxFlanger')).toBe(false); const random = seeded(4); const flangers = Array.from({ length: 400 }, () => chipPresetGraph('explosion', random)).flatMap( (graph) => graph.nodes.filter((n) => n.type === 'SoundFxFlanger'), ); expect(flangers.length).toBeGreaterThan(160); expect(flangers.length).toBeLessThan(240); for (const flanger of flangers) withinRows(flanger, Object.keys(FLANGER_ROWS)); }); it('is drawn anew every time', () => { const random = seeded(7); const a = byType(chipPresetGraph('laser', random), 'InstrumentChip').params; const b = byType(chipPresetGraph('laser', random), 'InstrumentChip').params; expect(a).not.toEqual(b); }); it('takes the place of the graph in one step', () => { const f = makeProject(); const command = new CreateAssetCommand('instrument', 'Coin'); f.stack.execute(command); const instrument = command.asset as InstrumentAsset; const before = instrument.graph.nodes.map((n) => n.id); f.stack.execute( new ReplaceGraphCommand(instrument.id, chipPresetGraph('pickup', seeded()), 'Preset Pickup'), ); expect(instrument.graph.nodes).toHaveLength(6); f.stack.undo(); expect(instrument.graph.nodes.map((n) => n.id)).toEqual(before); }); }); /** The Chips and the Envelopes on the ways of a plan, a Mix's two ways included. */ function walkPlan(plan: FxPlan): { chips: FxChipPlan[]; envelopes: FxEnvelopePlan[] } { const envelopes = plan.stages.flatMap((stage) => (stage.kind === 'envelope' ? [stage.envelope] : [])); if ('mix' in plan) { const a = walkPlan(plan.a); const b = walkPlan(plan.b); return { chips: [...a.chips, ...b.chips], envelopes: [...a.envelopes, ...b.envelopes, ...envelopes] }; } return { chips: plan.chip ? [plan.chip] : [], envelopes }; } describe('an instrument preset', () => { const sources = () => instrumentSourcesOf(makeProject().project); const build = (graph: Graph, note: number) => { const built = compileInstrument(graph, note, sources()); return { ...built, ...walkPlan(built.layers[0] ?? { pitch: [], stages: [] }) }; }; /** Those that die away however long the key is held. */ const STRUCK = new Set(['bell', 'pluck', 'drumKit']); it('plays every key at its own pitch, without a problem, and holds it unless it is struck', () => { for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit')) { const graph = chipPresetGraph(value); const [c4, c5] = [60, 72].map((note) => build(graph, note)); expect(c4!.reason, value).toBe(''); expect(c4!.problems, value).toEqual({}); // The first Chip follows the note: an octave up is twice the frequency. expect(c5!.chips[0]!.frequency.value / c4!.chips[0]!.frequency.value, value).toBeCloseTo(2, 6); const held = c4!.envelopes.some((envelope) => envelope.sustain.value > 0); expect(held, value).toBe(!STRUCK.has(value)); for (const node of graph.nodes.filter((n) => n.type === 'InstrumentChip')) withinRows(node, Object.keys(CHIP_ROWS)); } }); it('comes out the same every time it is picked', () => { const shape = (graph: Graph) => graph.nodes.map(({ type, x, y, params }) => ({ type, x, y, params })); for (const { value } of TONE_PRESETS) expect(shape(chipPresetGraph(value, seeded(1))), value).toEqual( shape(chipPresetGraph(value, seeded(2))), ); }); it('builds what it says: two Chips a little apart, a filter swept by an Envelope, an arpeggio', () => { const saws = build(chipPresetGraph('detunedSaws'), 60); expect(saws.chips.map((chip) => chip.wave)).toEqual(['saw', 'saw']); const ways = saws.layers[0]!; expect('mix' in ways && 'pitch' in ways.b && ways.b.pitch.map((p) => p.value)).toEqual([0.1]); const bass = chipPresetGraph('pluckBass'); const filter = byType(bass, 'SoundFxFilter'); const sweep = bass.edges.find((e) => e.to.node === filter.id && e.to.port === 'cutoff'); expect(bass.nodes.find((n) => n.id === sweep?.from.node)?.params).toMatchObject({ from: 180, to: 2400 }); expect(byType(chipPresetGraph('octaveArp'), 'InstrumentChip').params).toMatchObject({ arpeggio: 12, repeat: 0.08, }); // Phased Pad: a saw through a Phaser of six stages (#341). const pad = build(chipPresetGraph('phasedPad'), 60).layers[0]!; expect(fxChainOf(pad).stages.at(-1)).toMatchObject({ kind: 'phaser', stages: 6, frequency: 600, mix: 1 }); }); it('Drum Kit: kick, snare and hi-hats on their General MIDI keys, each ending by itself', () => { const graph = chipPresetGraph('drumKit'); const notes = byType(graph, 'InstrumentNotes').params; expect(notes).toMatchObject({ singleNotes: true, name36: 'Kick', name38: 'Snare', name42: 'Closed hat' }); const sounding = []; for (let note = 36; note <= 46; note++) { const drum = build(graph, note); expect(drum.reason, String(note)).toBe(''); if (drum.layers.length === 0) continue; sounding.push(note); expect( drum.envelopes.every((envelope) => envelope.sustain.value === 0), String(note), ).toBe(true); } expect(sounding).toEqual([36, 38, 42, 46]); // The kick drops from its own 160 Hz on every key, the snare is a tone and noise together. const kick = build(graph, 36).chips; expect(kick).toHaveLength(1); expect(kick[0]).toMatchObject({ wave: 'sine', frequency: { value: 160 } }); expect(kick[0]!.slide.value).toBeLessThan(0); expect(build(graph, 38).chips.map((chip) => chip.wave)).toEqual(['triangle', 'noise']); }); }); describe('Randomize and Mutate (#329)', () => { /** A preset with a parameter fed into the Chip's duty, which neither may touch. */ function fedPreset(): Graph { const graph = chipPresetGraph('laser', seeded(5)); const chip = byType(graph, 'InstrumentChip'); const notes = byType(graph, 'InstrumentNotes'); chip.params.wave = 'triangle'; chip.params.duty = 0.3; graph.edges.push({ id: 'fed', from: { node: notes.id, port: 'velocity' }, to: { node: chip.id, port: 'duty' }, }); byType(graph, 'SoundFxBitcrusher').params.mix = 0.4; byType(graph, 'SoundFxFilter').params.filter = 'highpass'; return graph; } function apply(graph: Graph, changes: ReturnType): void { for (const change of changes) graph.nodes.find((n) => n.id === change.node)!.params[change.key] = change.value; } it('Randomize draws only the numbers, held to their rows, and leaves a fed input alone', () => { const graph = fedPreset(); const changes = randomizedParams(graph, seeded(11)); expect(changes.some((c) => c.key === 'duty')).toBe(false); expect(changes.some((c) => ['wave', 'filter', 'followNote', 'mix', 'from', 'to'].includes(c.key))).toBe( false, ); expect(new Set(changes.map((c) => graph.nodes.find((n) => n.id === c.node)!.type))).toEqual( new Set(['InstrumentChip', 'InstrumentEnvelope', 'SoundFxFilter', 'SoundFxBitcrusher']), ); apply(graph, changes); const chip = byType(graph, 'InstrumentChip'); expect(chip.params).toMatchObject({ wave: 'triangle', duty: 0.3 }); withinRows(chip, Object.keys(CHIP_ROWS)); withinRows(byType(graph, 'SoundFxBitcrusher'), ['bits', 'rate']); expect(byType(graph, 'SoundFxBitcrusher').params.mix).toBe(0.4); expect(byType(graph, 'SoundFxFilter').params.filter).toBe('highpass'); expect(byType(graph, 'InstrumentEnvelope').params.sustain).toBe(0); }); it('Mutate moves some numbers a little and leaves a 0 at 0', () => { const graph = chipPresetGraph('pickup', () => 0); const chip = byType(graph, 'InstrumentChip'); // 0.9 every time: each number moves, by 0.8 of its most. const changes = mutatedParams(graph, () => 0.9); const of = (key: string) => changes.find((c) => c.node === chip.id && c.key === key)?.value; expect(of('frequency')).toBe(Math.round(568 * 2 ** 0.2)); expect(of('duty')).toBeCloseTo(0.5 + 0.8 * (0.025 + 0.05), 2); for (const key of ['slide', 'arpeggio', 'repeat', 'minFrequency', 'vibratoDepth']) expect(of(key), key).toBeUndefined(); expect(changes.some((c) => c.key === 'sustain')).toBe(false); // Below 0.5 a number stays as it is. expect(mutatedParams(graph, () => 0.1)).toEqual([]); }); it('draw and move the numbers of a Phaser and a Flanger too, but not their mix or stages (#341)', () => { const graph = chipPresetGraph('explosion', () => 0.999); const pad = chipPresetGraph('phasedPad'); graph.nodes.push(byType(pad, 'SoundFxPhaser')); byType(graph, 'SoundFxFlanger').params.mix = 0.4; const changes = randomizedParams(graph, seeded(13)); const keysOf = (type: string) => changes.filter((c) => c.node === byType(graph, type).id).map((c) => c.key); expect(keysOf('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']); expect(keysOf('SoundFxFlanger')).toEqual(['offset', 'sweep', 'rate', 'depth', 'feedback']); apply(graph, changes); withinRows(byType(graph, 'SoundFxPhaser'), Object.keys(PHASER_ROWS)); withinRows(byType(graph, 'SoundFxFlanger'), Object.keys(FLANGER_ROWS)); expect(byType(graph, 'SoundFxPhaser').params).toMatchObject({ stages: '6', mix: 1 }); expect(byType(graph, 'SoundFxFlanger').params.mix).toBe(0.4); // Mutate leaves the Flanger's LFO off and moves its offset and sweep, and the Phaser's numbers. const fresh = chipPresetGraph('explosion', () => 0.999); fresh.nodes.push(byType(chipPresetGraph('phasedPad'), 'SoundFxPhaser')); const moved = (type: string) => mutatedParams(fresh, () => 0.9) .filter((c) => c.node === byType(fresh, type).id) .map((c) => c.key); expect(moved('SoundFxFlanger')).toEqual(['offset', 'sweep']); expect(moved('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']); }); it('find nothing to change in an instrument of Samples', () => { expect(hasChipNumbers(starterInstrumentGraph())).toBe(false); expect(randomizedParams(starterInstrumentGraph())).toEqual([]); expect(hasChipNumbers(chipPresetGraph('blip'))).toBe(true); }); });