From a2d0a026451ee41605eb19d5a638217e759a6813 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 07:09:37 +0200 Subject: [PATCH] Play tunes on 14 instrument presets next to the sfxr sounds (#329) The Preset menu only had sfxr's effects: each a fixed pitch on every key that dies away by itself. Now its top half holds instruments to play melodies, basses and drums on. - Instruments: Square Lead, Pulse Lead, Echo Lead, Octave Arp, Synth Brass, Detuned Saws, String Pad, Organ, Flute, Bell, Pluck, Triangle Bass, Pluck Bass and Drum Kit. A line in the menu parts them from the seven effects below. - Their Chips follow the note, and all but Bell, Pluck and the drums hold it while the key is held. - They come out the same every time they are picked. Randomize and Mutate make a variant, as before. - They use what the graph already has: two Chips through a Mix for Detuned Saws, String Pad, Organ, Flute and Bell, an Envelope on a Filter's cutoff for Brass, Pluck and Pluck Bass, a Pitch for the detune and the octave, a Delay for Echo Lead and Bell. - Drum Kit uses single notes 36 to 46: kick, snare, closed and open hat on their General MIDI keys, each ending by itself. - A small Sketch helper lays the nodes on a grid. The sfxr presets use it too and come out as before. - Numbers keep to the steps of their rows. Pulse Lead's duty is 0.13, not 0.125, because the field rounds to 0.01. Tests: - Unit chip-presets: 12 green (5 new). Every instrument compiles without a problem, doubles its frequency an octave up, holds unless struck and is the same on every pick. The drum kit sounds on exactly 36, 38, 42 and 46. Countercheck with Follow note forced off: red. - e2e 34 (CI image on stein2, melektaus-atx was off): ALL PASSED, 65 checks (1 new). A first run had the scheduler's looped-bar check red by 10 ticks between two clock reads, which the presets do not touch; the second run was green. - Gate (stein2): CHECK EXIT 0, 256 files, 3974 tests. Co-Authored-By: Claude Opus 5.5 --- src/core/instrument/chipPresets.ts | 371 +++++++++++++++++++--- src/editor/instrument/InstrumentEditor.ts | 11 +- tests/core/chip-presets.test.ts | 106 ++++++- tools/e2e/34-sound.mjs | 22 ++ 4 files changed, 467 insertions(+), 43 deletions(-) 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(`