diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index e74a1dab..2f3a114c 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -10,7 +10,10 @@ import { type FxPassFilter, passOf, SoundFxBitcrusher, + SoundFxDelay, SoundFxFilter, + SoundFxMix, + SoundFxPitch, } from '../soundfx/nodes'; import { CHIP_ROWS, @@ -19,6 +22,8 @@ import { INSTRUMENT_ENVELOPE, INSTRUMENT_NOTES, INSTRUMENT_OUTPUT, + noteNameKey, + noteOutputKey, } from './nodes'; import { HIGHEST_NOTE, LOWEST_NOTE } from './notes'; @@ -27,10 +32,46 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from './notes'; * its generators and its randomizer draw sfxr's settings, 0 to 1 as its sliders have them, which are * then worked out into the numbers of a Chip, an Envelope, a Filter and a Bitcrusher. sfxr is * Copyright (c) 2007 Tomas Pettersson, MIT licence. + * + * Next to those sounds the menu has instruments to play tunes on: Chips that follow the note and hold + * it while it is held, the same every time they are picked. */ -export type ChipPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip'; +export type EffectPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip'; +export type TonePreset = + | 'squareLead' + | 'pulseLead' + | 'echoLead' + | 'octaveArp' + | 'brass' + | 'detunedSaws' + | 'stringPad' + | 'organ' + | 'flute' + | 'bell' + | 'pluck' + | 'triangleBass' + | 'pluckBass' + | 'drumKit'; +export type ChipPreset = EffectPreset | TonePreset; -export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [ +export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [ + { value: 'squareLead', label: 'Square Lead' }, + { value: 'pulseLead', label: 'Pulse Lead' }, + { value: 'echoLead', label: 'Echo Lead' }, + { value: 'octaveArp', label: 'Octave Arp' }, + { value: 'brass', label: 'Synth Brass' }, + { value: 'detunedSaws', label: 'Detuned Saws' }, + { value: 'stringPad', label: 'String Pad' }, + { value: 'organ', label: 'Organ' }, + { value: 'flute', label: 'Flute' }, + { value: 'bell', label: 'Bell' }, + { value: 'pluck', label: 'Pluck' }, + { value: 'triangleBass', label: 'Triangle Bass' }, + { value: 'pluckBass', label: 'Pluck Bass' }, + { value: 'drumKit', label: 'Drum Kit' }, +]; + +export const EFFECT_PRESETS: readonly { value: EffectPreset; label: string }[] = [ { value: 'pickup', label: 'Pickup' }, { value: 'laser', label: 'Laser' }, { value: 'explosion', label: 'Explosion' }, @@ -40,6 +81,15 @@ export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [ { value: 'blip', label: 'Blip' }, ]; +/** Every preset, the instruments first, as the menu has them. */ +export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [ + ...TONE_PRESETS, + ...EFFECT_PRESETS, +]; + +const isTone = (preset: ChipPreset): preset is TonePreset => + TONE_PRESETS.some((option) => option.value === preset); + type Random = () => number; /** sfxr's settings by its own names, with the wave as ours. */ @@ -89,7 +139,7 @@ const reset = (): Sfxr => ({ const SFXR_WAVES: readonly ChipWave[] = ['square', 'saw', 'sine', 'noise']; /** sfxr's generators, one per button. */ -function presetSfxr(preset: ChipPreset, random: Random): Sfxr { +function presetSfxr(preset: EffectPreset, random: Random): Sfxr { const p = reset(); // sfxr's `frnd`: 0 up to `range`, and `rnd`: a whole number from 0 to `n`, both included. const f = (range: number) => random() * range; @@ -297,54 +347,301 @@ function held( return out; } -/** Room between the nodes of a preset, left to right. */ +/** Room between the nodes of a preset, left to right and top to bottom: a Chip is a tall node. */ const COLUMN = 240; +const ROW = 340; + +/** A node to make: its type and its params. */ +type Step = readonly [type: string, params: PropertyBag]; + +/** A graph laid out on a grid while it is made. */ +class Sketch { + readonly nodes: GraphNode[] = []; + readonly edges: GraphEdge[] = []; + + add(type: string, column: number, row: number, params: PropertyBag): GraphNode { + const node: GraphNode = { id: newId(), type, x: column * COLUMN, y: row * ROW, params }; + this.nodes.push(node); + return node; + } + + wire(from: GraphNode, out: string, to: GraphNode, into: string): void { + this.edges.push({ id: newId(), from: { node: from.id, port: out }, to: { node: to.id, port: into } }); + } + + /** Nodes one after another to the right of `from`, each taking the sound of the one before. */ + line(from: GraphNode, ...steps: readonly Step[]): GraphNode { + let last = from; + for (const [type, params] of steps) { + const next = this.add(type, last.x / COLUMN + 1, last.y / ROW, params); + this.wire(last, 'out', next, 'in'); + last = next; + } + return last; + } + + /** The Output, right of what it plays. */ + end(last: GraphNode): Graph { + this.line(last, [INSTRUMENT_OUTPUT, {}]); + return { nodes: this.nodes, edges: this.edges }; + } +} + +const everyNote: PropertyBag = { from: String(LOWEST_NOTE), to: String(HIGHEST_NOTE) }; /** - * A preset as a whole graph (#329): every note plays one Chip, which ends by itself through an - * Envelope, a Filter and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does, + * An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through + * an Envelope, a Filter and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does, * so every key plays the same sound until "follow note" is turned on. Each call draws it anew. */ -export function chipPresetGraph(preset: ChipPreset, random: Random = Math.random): Graph { +function effectGraph(preset: EffectPreset, random: Random): Graph { const p = presetSfxr(preset, random); const filter: FxPassFilter = p.lpfFreq >= 1 && p.hpfFreq > 0 ? 'highpass' : 'lowpass'; const crushed = random() < 0.5; - const node = (type: string, column: number, params: PropertyBag): GraphNode => ({ - id: newId(), - type, - x: column * COLUMN, - y: 0, - params, - }); - const notes = node(INSTRUMENT_NOTES, 0, { from: String(LOWEST_NOTE), to: String(HIGHEST_NOTE) }); - const chip = node(INSTRUMENT_CHIP, 1, { + const s = new Sketch(); + const notes = s.add(INSTRUMENT_NOTES, 0, 0, everyNote); + const chip = s.add(INSTRUMENT_CHIP, 1, 0, { wave: p.wave, followNote: false, ...held(chipNumbers(p), CHIP_ROWS), }); - const envelope = node(INSTRUMENT_ENVELOPE, 2, held(envelopeNumbers(p), ENVELOPE_ROWS)); - const pass = node(SoundFxFilter.type, 3, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }); - const crush = node( - SoundFxBitcrusher.type, - 4, - held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS), + s.wire(notes, 'any', chip, 'trigger'); + return s.end( + s.line( + chip, + [INSTRUMENT_ENVELOPE, held(envelopeNumbers(p), ENVELOPE_ROWS)], + [SoundFxFilter.type, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }], + [ + SoundFxBitcrusher.type, + held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS), + ], + ), ); - const output = node(INSTRUMENT_OUTPUT, 5, {}); - const wire = (from: GraphNode, out: string, to: GraphNode, into: string): GraphEdge => ({ - id: newId(), - from: { node: from.id, port: out }, - to: { node: to.id, port: into }, +} + +type ChipNumbers = Partial>; + +/** A Chip, triggered by `out` of the Notes: at the pitch of the note, unless it has a frequency of its own. */ +function chipOn( + s: Sketch, + notes: GraphNode, + row: number, + wave: ChipWave, + numbers: ChipNumbers = {}, + { out = 'any', column = 1 } = {}, +): GraphNode { + const defaults = Object.fromEntries(Object.values(CHIP_ROWS).map((r) => [r.key, r.default])) as Record< + keyof typeof CHIP_ROWS, + number + >; + const chip = s.add(INSTRUMENT_CHIP, column, row, { + wave, + followNote: numbers.frequency === undefined, + ...held({ ...defaults, ...numbers }, CHIP_ROWS), }); - return { - nodes: [notes, chip, envelope, pass, crush, output], - edges: [ - wire(notes, 'any', chip, 'trigger'), - wire(chip, 'out', envelope, 'in'), - wire(envelope, 'out', pass, 'in'), - wire(pass, 'out', crush, 'in'), - wire(crush, 'out', output, 'in'), - ], - }; + s.wire(notes, out, chip, 'trigger'); + return chip; +} + +const envelope = (attack: number, decay: number, sustain: number, release: number) => + [INSTRUMENT_ENVELOPE, held({ attack, decay, sustain, release }, ENVELOPE_ROWS)] as const; +const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) => + [SoundFxFilter.type, { filter, cutoff, resonance }] as const; +const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const; +const echo = (time: number, feedback: number, mix: number, pingPong = false) => + [SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, pingPong }] as const; + +/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */ +function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode { + const mix = s.add(SoundFxMix.type, Math.max(a.x, b.x) / COLUMN + 1, Math.min(a.y, b.y) / ROW, { + curve, + mix: share, + }); + s.wire(a, 'out', mix, 'a'); + s.wire(b, 'out', mix, 'b'); + return mix; +} + +/** + * A low-pass whose cutoff an Envelope of its own sweeps, from `from` Hz at rest up to `to` and down + * to the sustain between them, as a synth's filter envelope does. The Envelope sits under the Filter. + */ +function swept( + s: Sketch, + from: GraphNode, + resonance: number, + sweep: { from: number; to: number; attack: number; decay: number; sustain: number; release: number }, +): GraphNode { + const filter = s.line(from, pass('lowpass', sweep.from, resonance)); + const { from: low, to: high, ...curve } = sweep; + const moves = s.add(INSTRUMENT_ENVELOPE, filter.x / COLUMN, filter.y / ROW + 1, { + ...held(curve, ENVELOPE_ROWS), + from: low, + to: high, + }); + s.wire(moves, 'value', filter, 'cutoff'); + return filter; +} + +/** Two saws a tenth of a semitone apart, mixed evenly: a fuller sound than one alone. */ +function detuned(s: Sketch, notes: GraphNode, numbers: ChipNumbers = {}): GraphNode { + const a = chipOn(s, notes, 0, 'saw', numbers); + const b = s.line(chipOn(s, notes, 1, 'saw', numbers), pitch(0.1)); + return mixed(s, a, b, 0.5); +} + +/** The drums of the kit, on the keys General MIDI has them on. */ +const KICK = 36; +const SNARE = 38; +const CLOSED_HAT = 42; +const OPEN_HAT = 46; + +/** + * A kit on single notes: a kick that drops in pitch, a snare of a falling triangle and noise, and two + * hi-hats of bright noise, short and long. Each ends by itself; only one of them plays on a key, so + * the Mixes that join them pass it on whole. + */ +function drumKit(s: Sketch): GraphNode { + const notes = s.add(INSTRUMENT_NOTES, 0, 0, { + from: String(KICK), + to: String(OPEN_HAT), + singleNotes: true, + [noteNameKey(KICK)]: 'Kick', + [noteNameKey(SNARE)]: 'Snare', + [noteNameKey(CLOSED_HAT)]: 'Closed hat', + [noteNameKey(OPEN_HAT)]: 'Open hat', + }); + const on = (note: number) => ({ out: noteOutputKey(note) }); + const kick = s.line( + chipOn(s, notes, 0, 'sine', { frequency: 160, slide: -120, minFrequency: 30 }, on(KICK)), + envelope(0.001, 0.3, 0, 0.02), + ); + const body = s.line( + chipOn(s, notes, 1, 'triangle', { frequency: 190, slide: -60 }, on(SNARE)), + envelope(0.001, 0.1, 0, 0.02), + ); + const rattle = s.line( + chipOn(s, notes, 2, 'noise', { frequency: 2500 }, on(SNARE)), + pass('highpass', 1200), + envelope(0.001, 0.18, 0, 0.02), + ); + const closed = s.line( + chipOn(s, notes, 3, 'noise', { frequency: 4000 }, on(CLOSED_HAT)), + pass('highpass', 7000, 0.1), + envelope(0.001, 0.05, 0, 0.01), + ); + const open = s.line( + chipOn(s, notes, 4, 'noise', { frequency: 4000 }, on(OPEN_HAT)), + pass('highpass', 6000, 0.1), + envelope(0.001, 0.35, 0, 0.05), + ); + const snare = mixed(s, body, rattle, 0.6); + const hats = mixed(s, closed, open, 0.5); + return mixed(s, mixed(s, kick, snare, 0.5), hats, 0.5); +} + +/** What each instrument is made of, up to what the Output plays. */ +const TONES: Record, (s: Sketch, notes: GraphNode) => GraphNode> = { + // The plain square of the NES with a slight vibrato, its harshest top taken off. + squareLead: (s, notes) => + s.line( + chipOn(s, notes, 0, 'square', { vibratoDepth: 0.2, vibratoSpeed: 5.5 }), + envelope(0.005, 0.2, 0.6, 0.15), + pass('lowpass', 6000), + ), + // The thin, nasal eighth of a square, as near as the duty's steps of 0.01 come. + pulseLead: (s, notes) => + s.line( + chipOn(s, notes, 0, 'square', { duty: 0.13, vibratoDepth: 0.1, vibratoSpeed: 6 }), + envelope(0.002, 0.1, 0.8, 0.08), + ), + // A quarter square with dotted-eighth echoes, left and right. + echoLead: (s, notes) => + s.line( + chipOn(s, notes, 0, 'square', { duty: 0.25 }), + envelope(0.005, 0.15, 0.5, 0.1), + echo(0.75, 0.35, 0.35, true), + ), + // The note and its octave by turns, 25 times a second: how a console played a chord. + octaveArp: (s, notes) => + s.line( + chipOn(s, notes, 0, 'square', { duty: 0.25, arpeggio: 12, arpeggioTime: 0.04, repeat: 0.08 }), + envelope(0.002, 0.1, 0.7, 0.08), + ), + // A saw that swells and opens up as it is blown. + brass: (s, notes) => + swept( + s, + s.line( + chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }), + envelope(0.04, 0.2, 0.8, 0.2), + ), + 0.15, + { from: 400, to: 3500, attack: 0.08, decay: 0.4, sustain: 0.5, release: 0.2 }, + ), + detunedSaws: (s, notes) => s.line(detuned(s, notes), envelope(0.01, 0.3, 0.8, 0.3), pass('lowpass', 7000)), + // Slow to come and to go, darker, with a little vibrato. + stringPad: (s, notes) => + s.line( + detuned(s, notes, { vibratoDepth: 0.1, vibratoSpeed: 5 }), + envelope(0.5, 0.5, 0.8, 1), + pass('lowpass', 2200, 0.1), + ), + // A square and the square an octave up, held at full while the key is. + organ: (s, notes) => { + const low = chipOn(s, notes, 0, 'square'); + const high = s.line(chipOn(s, notes, 1, 'square'), pitch(12)); + return s.line(mixed(s, low, high, 0.35), envelope(0.005, 0.05, 1, 0.06), pass('lowpass', 4500)); + }, + // A triangle with vibrato and a breath of noise around 2.5 kHz. + flute: (s, notes) => { + const tone = chipOn(s, notes, 0, 'triangle', { vibratoDepth: 0.2, vibratoSpeed: 5 }); + const breath = s.line(chipOn(s, notes, 1, 'noise', { frequency: 2000 }), pass('bandpass', 2500, 0.3)); + return s.line(mixed(s, tone, breath, 0.08), envelope(0.06, 0.1, 0.85, 0.15)); + }, + // Two sines 2.76 times apart, as a bell's partials are, the upper fading first; echoes after. + bell: (s, notes) => { + const low = s.line(chipOn(s, notes, 0, 'sine'), envelope(0.001, 2, 0, 0.4)); + const high = s.line(chipOn(s, notes, 1, 'sine'), pitch(17.6), envelope(0.001, 0.6, 0, 0.2)); + return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.25)); + }, + // Struck, bright at once and dull soon after, gone in a moment however long it is held. + pluck: (s, notes) => + swept(s, s.line(chipOn(s, notes, 0, 'square', { duty: 0.3 }), envelope(0.001, 0.6, 0, 0.1)), 0.2, { + from: 600, + to: 6000, + attack: 0.001, + decay: 0.15, + sustain: 0, + release: 0.1, + }), + // The triangle of the NES as its bass: no fade while it is held. + triangleBass: (s, notes) => s.line(chipOn(s, notes, 0, 'triangle'), envelope(0.002, 0.05, 1, 0.04)), + // A saw whose filter snaps open and shut on every note. + pluckBass: (s, notes) => + swept(s, s.line(chipOn(s, notes, 0, 'saw'), envelope(0.002, 0.4, 0.5, 0.08)), 0.35, { + from: 180, + to: 2400, + attack: 0.002, + decay: 0.25, + sustain: 0.15, + release: 0.08, + }), +}; + +/** An instrument preset: every note at its own pitch, the same graph each time it is picked. */ +function toneGraph(preset: TonePreset): Graph { + const s = new Sketch(); + if (preset === 'drumKit') return s.end(drumKit(s)); + return s.end(TONES[preset](s, s.add(INSTRUMENT_NOTES, 0, 0, everyNote))); +} + +/** + * A preset as a whole graph (#329): an sfxr sound, drawn anew on every call, or an instrument to + * play tunes on, which comes out the same every time. + */ +export function chipPresetGraph(preset: ChipPreset, random: Random = Math.random): Graph { + return isTone(preset) ? toneGraph(preset) : effectGraph(preset, random); } /** One number Randomize or Mutate gives a node. */ diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index e3f90c00..767d3818 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -4,10 +4,12 @@ import { CHIP_PRESETS, type ChipPreset, chipPresetGraph, + EFFECT_PRESETS, hasChipNumbers, mutatedParams, type ParamChange, randomizedParams, + TONE_PRESETS, } from '../../core/instrument/chipPresets'; import { instrumentBuildFor, @@ -202,9 +204,12 @@ export class InstrumentEditor extends AssetTabEditor ({ label: preset.label, onSelect: () => this.applyPreset(preset.value) })), - ).openBelow(this.presetButton); + const items = (presets: readonly { value: ChipPreset; label: string }[]) => + presets.map((preset) => ({ label: preset.label, onSelect: () => this.applyPreset(preset.value) })); + // The instruments to play tunes on first, then the sounds of sfxr. + new Menu([...items(TONE_PRESETS), { separator: true }, ...items(EFFECT_PRESETS)]).openBelow( + this.presetButton, + ); } stop(): void { diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts index 151aa29b..9919362c 100644 --- a/tests/core/chip-presets.test.ts +++ b/tests/core/chip-presets.test.ts @@ -9,15 +9,24 @@ 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, FILTER_ROWS, fxChainOf } from '../../src/core/soundfx/nodes'; +import { + CRUSH_ROWS, + type FxChipPlan, + type FxEnvelopePlan, + FILTER_ROWS, + type FxPlan, + fxChainOf, +} from '../../src/core/soundfx/nodes'; import { makeProject } from '../helpers'; /** A seeded random, so a draw is the same on every run. */ @@ -47,9 +56,17 @@ function withinRows(node: GraphNode, keys: string[]): void { } describe('a preset (#329)', () => { - it('is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => { + 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(14); + }); + + 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 CHIP_PRESETS) { + for (const { value } of EFFECT_PRESETS) { const graph = chipPresetGraph(value, seeded()); expect( graph.nodes.map((n) => n.type), @@ -129,6 +146,89 @@ describe('a preset (#329)', () => { }); }); +/** 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, + }); + }); + + 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 { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 74a5c0ae..ae221b30 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1161,6 +1161,28 @@ export async function sound(t) { restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', JSON.stringify(restored.types), ); + + // 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and + // plays C4 at once. One undo takes it back. + await evaluate('__gm.sound.stopAll()'); + const beforeLead = await stats(); + await clickHook('presetAt'); + await clickText('Square Lead'); + const lead = await waitFor(stats, (s) => s.started > beforeLead.started); + const leadGraph = await insGraph(); + const leadInfo = await json('__gm.instrument.info()'); + await t.clickToolbar('undo'); + const unled = await insGraph(); + check( + 'Preset → Square Lead plays every note at its own pitch and holds it; one undo takes it back', + ['Square Lead', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) && + leadGraph.chip?.followNote === true && + leadGraph.envelope?.sustain > 0 && + lead.last?.file === `instrument:${insId}` && + leadInfo === '128 notes' && + unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', + JSON.stringify({ chip: leadGraph.chip, envelope: leadGraph.envelope, last: lead.last, leadInfo, unled }), + ); await evaluate('__gm.sound.stopAll()'); await mutate(`