diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 9aaddb9d..c0ba7c92 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -326,6 +326,31 @@ function setCollapsed(node: GraphNode, collapsed: boolean): void { else delete node.collapsed; } +/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */ +export class ReplaceGraphCommand implements Command { + private previous?: Pick; + + constructor( + private readonly assetId: string, + private readonly graph: Pick, + readonly label: string, + ) {} + + execute(project: Project): void { + const graph = requireGraph(project, this.assetId); + this.previous = { nodes: graph.nodes, edges: graph.edges }; + graph.nodes = structuredClone(this.graph.nodes); + graph.edges = structuredClone(this.graph.edges); + } + + undo(project: Project): void { + if (!this.previous) return; + const graph = requireGraph(project, this.assetId); + graph.nodes = this.previous.nodes; + graph.edges = this.previous.edges; + } +} + export class DisconnectCommand implements Command { readonly label = 'Disconnect'; private removed: Removed[] = []; diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts new file mode 100644 index 00000000..e74a1dab --- /dev/null +++ b/src/core/instrument/chipPresets.ts @@ -0,0 +1,457 @@ +import { edgesInto, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; +import { asNumber } from '../graph/values'; +import { newId } from '../ids'; +import type { PropertyBag } from '../model/Properties'; +import type { FlatDef } from '../properties/PropertySchema'; +import type { ChipWave } from '../soundfx/chip'; +import { + CRUSH_ROWS, + FILTER_ROWS, + type FxPassFilter, + passOf, + SoundFxBitcrusher, + SoundFxFilter, +} from '../soundfx/nodes'; +import { + CHIP_ROWS, + ENVELOPE_ROWS, + INSTRUMENT_CHIP, + INSTRUMENT_ENVELOPE, + INSTRUMENT_NOTES, + INSTRUMENT_OUTPUT, +} from './nodes'; +import { HIGHEST_NOTE, LOWEST_NOTE } from './notes'; + +/** + * The Preset menu, Randomize and Mutate of the Instrument tab (#329), the way sfxr makes its sounds: + * 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. + */ +export type ChipPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip'; + +export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [ + { value: 'pickup', label: 'Pickup' }, + { value: 'laser', label: 'Laser' }, + { value: 'explosion', label: 'Explosion' }, + { value: 'powerup', label: 'Powerup' }, + { value: 'hit', label: 'Hit' }, + { value: 'jump', label: 'Jump' }, + { value: 'blip', label: 'Blip' }, +]; + +type Random = () => number; + +/** sfxr's settings by its own names, with the wave as ours. */ +interface Sfxr { + wave: ChipWave; + baseFreq: number; + freqLimit: number; + freqRamp: number; + freqDramp: number; + duty: number; + dutyRamp: number; + vibStrength: number; + vibSpeed: number; + envAttack: number; + envSustain: number; + envDecay: number; + lpfFreq: number; + lpfResonance: number; + hpfFreq: number; + repeatSpeed: number; + arpSpeed: number; + arpMod: number; +} + +const reset = (): Sfxr => ({ + wave: 'square', + baseFreq: 0.3, + freqLimit: 0, + freqRamp: 0, + freqDramp: 0, + duty: 0, + dutyRamp: 0, + vibStrength: 0, + vibSpeed: 0, + envAttack: 0, + envSustain: 0.3, + envDecay: 0.4, + lpfFreq: 1, + lpfResonance: 0, + hpfFreq: 0, + repeatSpeed: 0, + arpSpeed: 0, + arpMod: 0, +}); + +/** sfxr's waves by their number there. */ +const SFXR_WAVES: readonly ChipWave[] = ['square', 'saw', 'sine', 'noise']; + +/** sfxr's generators, one per button. */ +function presetSfxr(preset: ChipPreset, 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; + const r = (n: number) => Math.floor(random() * (n + 1)); + switch (preset) { + case 'pickup': + p.baseFreq = 0.4 + f(0.5); + p.envSustain = f(0.1); + p.envDecay = 0.1 + f(0.4); + if (r(1)) { + p.arpSpeed = 0.5 + f(0.2); + p.arpMod = 0.2 + f(0.4); + } + break; + case 'laser': { + let wave = r(2); + if (wave === 2 && r(1)) wave = r(1); + p.wave = SFXR_WAVES[wave]!; + p.baseFreq = 0.5 + f(0.5); + p.freqLimit = Math.max(0.2, p.baseFreq - 0.2 - f(0.6)); + p.freqRamp = -0.15 - f(0.2); + if (r(2) === 0) { + p.baseFreq = 0.3 + f(0.6); + p.freqLimit = f(0.1); + p.freqRamp = -0.35 - f(0.3); + } + if (r(1)) { + p.duty = f(0.5); + p.dutyRamp = f(0.2); + } else { + p.duty = 0.4 + f(0.5); + p.dutyRamp = -f(0.7); + } + p.envSustain = 0.1 + f(0.2); + p.envDecay = f(0.4); + if (r(1)) p.hpfFreq = f(0.3); + break; + } + case 'explosion': + p.wave = 'noise'; + if (r(1)) { + p.baseFreq = 0.1 + f(0.4); + p.freqRamp = -0.1 + f(0.4); + } else { + p.baseFreq = 0.2 + f(0.7); + p.freqRamp = -0.2 - f(0.2); + } + p.baseFreq *= p.baseFreq; + if (r(4) === 0) p.freqRamp = 0; + if (r(2) === 0) p.repeatSpeed = 0.3 + f(0.5); + p.envSustain = 0.1 + f(0.3); + p.envDecay = f(0.5); + if (r(1)) { + p.vibStrength = f(0.7); + p.vibSpeed = f(0.6); + } + if (r(2) === 0) { + p.arpSpeed = 0.6 + f(0.3); + p.arpMod = 0.8 - f(1.6); + } + break; + case 'powerup': + if (r(1)) p.wave = 'saw'; + else p.duty = f(0.6); + p.baseFreq = 0.2 + f(0.3); + if (r(1)) { + p.freqRamp = 0.1 + f(0.4); + p.repeatSpeed = 0.4 + f(0.4); + } else { + p.freqRamp = 0.05 + f(0.2); + if (r(1)) { + p.vibStrength = f(0.7); + p.vibSpeed = f(0.6); + } + } + p.envSustain = f(0.4); + p.envDecay = 0.1 + f(0.4); + break; + case 'hit': { + const wave = r(2); + p.wave = SFXR_WAVES[wave === 2 ? 3 : wave]!; + if (p.wave === 'square') p.duty = f(0.6); + p.baseFreq = 0.2 + f(0.6); + p.freqRamp = -0.3 - f(0.4); + p.envSustain = f(0.1); + p.envDecay = 0.1 + f(0.2); + if (r(1)) p.hpfFreq = f(0.3); + break; + } + case 'jump': + p.duty = f(0.6); + p.baseFreq = 0.3 + f(0.3); + p.freqRamp = 0.1 + f(0.2); + p.envSustain = 0.1 + f(0.3); + p.envDecay = 0.1 + f(0.2); + if (r(1)) p.hpfFreq = f(0.3); + if (r(1)) p.lpfFreq = 1 - f(0.6); + break; + case 'blip': + p.wave = SFXR_WAVES[r(1)]!; + if (p.wave === 'square') p.duty = f(0.6); + p.baseFreq = 0.2 + f(0.4); + p.envSustain = 0.1 + f(0.1); + p.envDecay = f(0.2); + p.hpfFreq = 0.1; + break; + } + return p; +} + +/** sfxr's Randomize: every setting drawn, most of them leaning towards 0. */ +function randomSfxr(random: Random): Sfxr { + const signed = () => random() * 2 - 1; + const p = reset(); + p.baseFreq = signed() ** 2; + if (random() < 0.5) p.baseFreq = signed() ** 3 + 0.5; + p.freqRamp = signed() ** 5; + if (p.baseFreq > 0.7 && p.freqRamp > 0.2) p.freqRamp = -p.freqRamp; + if (p.baseFreq < 0.2 && p.freqRamp < -0.05) p.freqRamp = -p.freqRamp; + p.freqDramp = signed() ** 3; + p.duty = signed(); + p.dutyRamp = signed() ** 3; + p.vibStrength = signed() ** 3; + p.vibSpeed = signed(); + p.envAttack = signed() ** 3; + p.envSustain = signed() ** 2; + p.envDecay = signed(); + if (p.envAttack + p.envSustain + p.envDecay < 0.2) { + p.envSustain += 0.2 + random() * 0.3; + p.envDecay += 0.2 + random() * 0.3; + } + p.lpfResonance = signed(); + p.lpfFreq = 1 - random() ** 3; + p.hpfFreq = random() ** 5; + p.repeatSpeed = signed(); + p.arpSpeed = signed(); + p.arpMod = signed(); + return p; +} + +const SFXR_RATE = 44100; +/** sfxr counts a period in samples taken 8 times over: its frequency is `f² + 0.001` of 3528 Hz. */ +const hzOf = (f: number) => 3528 * (f * f + 0.001); +/** How long sfxr waits before an arpeggio or a repeat, at a speed from 0 to 1. */ +const waitOf = (speed: number) => ((1 - speed) ** 2 * 20000 + 32) / SFXR_RATE; +/** A stage of sfxr's envelope: its setting squared, in 100000 samples. */ +const stageOf = (value: number) => (value * value * 100000) / SFXR_RATE; + +function chipNumbers(p: Sfxr): Record { + const slide = 1 - p.freqRamp ** 3 * 0.01; + const arpeggio = p.arpMod >= 0 ? 1 - p.arpMod ** 2 * 0.9 : 1 + p.arpMod ** 2 * 10; + return { + frequency: hzOf(p.baseFreq), + duty: 0.5 - p.duty * 0.5, + dutySweep: -p.dutyRamp * 0.00005 * SFXR_RATE, + slide: -12 * Math.log2(slide) * SFXR_RATE, + deltaSlide: (12 / Math.LN2) * SFXR_RATE ** 2 * p.freqDramp ** 3 * 1e-6, + minFrequency: p.freqLimit > 0 ? hzOf(p.freqLimit) : 0, + vibratoDepth: 12 * Math.log2(1 + Math.abs(p.vibStrength) * 0.5), + vibratoSpeed: (p.vibSpeed ** 2 * 0.01 * SFXR_RATE) / (2 * Math.PI), + arpeggio: p.arpSpeed === 1 || p.arpMod === 0 ? 0 : -12 * Math.log2(arpeggio), + arpeggioTime: waitOf(p.arpSpeed), + repeat: p.repeatSpeed === 0 ? 0 : waitOf(p.repeatSpeed), + }; +} + +/** + * sfxr holds a sound at full for its sustain, then lets it fall in a straight line; an Envelope + * falls away as a struck string does, quick at first. Three times the whole of sfxr's keeps about + * as much of it heard. Sustain 0 ends the note once it has fallen. + */ +const FALL = 3; +const RELEASE = 0.02; + +function envelopeNumbers(p: Sfxr): Record<'attack' | 'decay' | 'sustain' | 'release', number> { + return { + attack: stageOf(p.envAttack), + decay: FALL * (stageOf(p.envSustain) + stageOf(p.envDecay)), + sustain: 0, + release: RELEASE, + }; +} + +/** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */ +function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resonance', number> { + if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, resonance: 0 }; + // Wide open, as far as the row goes. + if (p.lpfFreq >= 1) return { cutoff: Infinity, resonance: 0 }; + return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) }; +} + +/** A number held to its row and rounded to as many places as its step has. */ +function heldTo(value: number, row: FlatDef<'number'>): number { + const places = Math.max(0, -Math.floor(Math.log10(row.step ?? 0.01))); + const inside = Math.max(row.min ?? -Infinity, Math.min(row.max ?? Infinity, value)); + return Number(inside.toFixed(places)) + 0; +} + +function held( + values: Record, + rows: Record, FlatDef<'number'>>, +): Record { + const out = {} as Record; + for (const key of Object.keys(values) as K[]) out[key] = heldTo(values[key], rows[key]); + return out; +} + +/** Room between the nodes of a preset, left to right. */ +const COLUMN = 240; + +/** + * 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, + * 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 { + 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, { + 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), + ); + 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 }, + }); + 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'), + ], + }; +} + +/** One number Randomize or Mutate gives a node. */ +export interface ParamChange { + node: string; + key: string; + value: number; +} + +function rowsOf(type: string): Record> | null { + switch (type) { + case INSTRUMENT_CHIP: + return CHIP_ROWS; + case INSTRUMENT_ENVELOPE: + return ENVELOPE_ROWS; + case SoundFxFilter.type: + return FILTER_ROWS; + case SoundFxBitcrusher.type: + return CRUSH_ROWS; + default: + return null; + } +} + +/** Whether Randomize and Mutate find anything in a graph to change. */ +export const hasChipNumbers = (graph: Graph): boolean => + graph.nodes.some((node) => rowsOf(node.type) !== null); + +/** An input an edge feeds keeps what it is fed: a number typed in there would do nothing. */ +const typedIn = (graph: Graph, node: GraphNode) => (key: string) => + edgesInto(graph, node.id, key).length === 0; + +function randomNumbers(node: GraphNode, random: Random): Record | null { + switch (node.type) { + case INSTRUMENT_CHIP: + return held(chipNumbers(randomSfxr(random)), CHIP_ROWS); + case INSTRUMENT_ENVELOPE: + return held(envelopeNumbers(randomSfxr(random)), ENVELOPE_ROWS); + case SoundFxFilter.type: + return held(filterNumbers(randomSfxr(random), passOf(node.params)), FILTER_ROWS); + case SoundFxBitcrusher.type: + return held({ bits: 4 + random() * 12, rate: 2000 * 24 ** random() }, CRUSH_ROWS); + default: + return null; + } +} + +/** + * Randomize (#329): the numbers of every Chip, Envelope, Filter and Bitcrusher drawn anew, as sfxr's + * randomizer draws them. Waves, filter types, the Mix of a Bitcrusher and an Envelope's "from" and + * "to" stay, and so does everything else in the graph. + */ +export function randomizedParams(graph: Graph, random: Random = Math.random): ParamChange[] { + const changes: ParamChange[] = []; + for (const node of graph.nodes) { + const values = randomNumbers(node, random); + if (!values) continue; + const open = typedIn(graph, node); + for (const [key, value] of Object.entries(values)) + if (open(key)) changes.push({ node: node.id, key, value }); + } + return changes; +} + +/** How far Mutate moves a number at most: a share of an octave where it is heard as a ratio, else a step and a tenth of it. */ +const MUTATE: Readonly> = { + frequency: { octaves: 0.25 }, + minFrequency: { octaves: 0.25 }, + cutoff: { octaves: 0.25 }, + rate: { octaves: 0.25 }, + duty: { by: 0.025 }, + dutySweep: { by: 0.1 }, + slide: { by: 5 }, + deltaSlide: { by: 20 }, + vibratoDepth: { by: 0.2 }, + vibratoSpeed: { by: 0.5 }, + arpeggio: { by: 1 }, + arpeggioTime: { by: 0.01 }, + repeat: { by: 0.02 }, + attack: { by: 0.005 }, + decay: { by: 0.02 }, + release: { by: 0.01 }, + resonance: { by: 0.05 }, + bits: { by: 0.5 }, +}; + +/** + * Mutate (#329): as sfxr does, each number of those nodes moves a little or not at all, one in two. + * A 0 stays 0, so nothing that was off comes on, and an Envelope keeps its sustain. + */ +export function mutatedParams(graph: Graph, random: Random = Math.random): ParamChange[] { + const changes: ParamChange[] = []; + for (const node of graph.nodes) { + const rows = rowsOf(node.type); + if (!rows) continue; + const open = typedIn(graph, node); + for (const row of Object.values(rows)) { + const how = MUTATE[row.key]; + if (!how || !open(row.key) || random() < 0.5) continue; + const now = asNumber(node.params[row.key] ?? row.default); + if (now === 0) continue; + const u = random() * 2 - 1; + const moved = + 'octaves' in how ? now * 2 ** (u * how.octaves) : now + u * (how.by + Math.abs(now) * 0.1); + const value = heldTo(moved, row); + if (value !== now) changes.push({ node: node.id, key: row.key, value }); + } + } + return changes; +} diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 6729e853..67af1c6f 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -389,6 +389,7 @@ export function passQ(filter: FxPassFilter, resonance: number): number { const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 }; const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01); +export const FILTER_ROWS = { cutoff: CUTOFF, resonance: RESONANCE }; const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [ { value: 'lowpass', label: 'Low-pass' }, { value: 'highpass', label: 'High-pass' }, @@ -402,7 +403,7 @@ const PASS = { options: PASS_FILTERS, }; -function passOf(settings: PropertyBag): FxPassFilter { +export function passOf(settings: PropertyBag): FxPassFilter { return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass'; } @@ -512,6 +513,7 @@ export const SoundFxDelay: SoundFxNodeDef = { const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 }; const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 }; const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; +export const CRUSH_ROWS = { bits: CRUSH_BITS, rate: CRUSH_RATE, mix: CRUSH_MIX }; export const SoundFxBitcrusher: SoundFxNodeDef = { type: 'SoundFxBitcrusher', diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 4dd3807c..8091942a 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -135,6 +135,13 @@ export function graphHooks({ state, editor }: HookContext) { }), keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note), stopAt: () => centreOf(editor.instrumentEditor.stopButton), + presetAt: () => centreOf(editor.instrumentEditor.presetButton), + randomizeAt: () => centreOf(editor.instrumentEditor.randomizeButton), + mutateAt: () => centreOf(editor.instrumentEditor.mutateButton), + auditionAt: () => ({ + ...centreOf(editor.instrumentEditor.auditionButton), + toggled: editor.instrumentEditor.auditionButton.toggled, + }), params: () => editor.instrumentEditor.previewValues, setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value), }, diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index 03c1b043..e3f90c00 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -1,3 +1,14 @@ +import { CompoundCommand } from '../../core/commands/CompoundCommand'; +import { ReplaceGraphCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; +import { + CHIP_PRESETS, + type ChipPreset, + chipPresetGraph, + hasChipNumbers, + mutatedParams, + type ParamChange, + randomizedParams, +} from '../../core/instrument/chipPresets'; import { instrumentBuildFor, instrumentCheck, @@ -5,10 +16,21 @@ import { instrumentSourcesOf, playedNotes, } from '../../core/instrument/compile'; -import { noteName } from '../../core/instrument/notes'; +import { MIDDLE_C, noteName } from '../../core/instrument/notes'; import type { Asset, InstrumentAsset } from '../../core/model/Asset'; import { playNote } from '../../runtime/systems/behaviorContext'; -import { Button, HStack, Label, NumberField, Stack, Theme, VStack, type Widget } from '../../ui'; +import { + Button, + Dropdown, + HStack, + Label, + Menu, + NumberField, + Stack, + Theme, + VStack, + type Widget, +} from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; @@ -17,22 +39,34 @@ import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { PianoKeyboard } from './PianoKeyboard'; -/** Room kept for "N notes", or for the reason the graph does not compile. */ -const INFO_WIDTH = 220; +/** Room kept for "N notes", or for the reason the graph does not compile: "No sound picked" fits, a longer one is cut. */ +const INFO_WIDTH = 140; const PARAM_WIDTH = 48; /** Whose voices the tab's Stop silences: its own, never a running game's. */ const PREVIEW_OWNER = 'instrument-preview'; +/** How long a note is heard after a Preset, Randomize, Mutate or a change with Audition on: a held one is let go then. */ +const AUDITION_SECONDS = 2; +/** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */ +const AUDITION_DELAY_MS = 150; /** * The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held, * by the pointer or by the computer keyboard. The header holds a value for each of its parameters – - * the tab's own, never written into the asset – and a Stop for everything the tab still plays. + * the tab's own, never written into the asset – and a Stop for everything the tab still plays. Its + * Preset menu, Randomize and Mutate make 8-bit sounds the way sfxr does (#329) and play them at once. */ export class InstrumentEditor extends AssetTabEditor { readonly snap = new SnapToggle(() => this.canvas); /** Public for the check scripts. */ readonly keyboard = new PianoKeyboard(); readonly stopButton: Button; + readonly presetButton: Dropdown; + readonly randomizeButton: Button; + readonly mutateButton: Button; + /** Whether a change to the graph is heard; off by default, as in the Pattern tab. */ + readonly auditionButton: Button; + /** The note a Preset, Randomize, Mutate or Audition plays: the one last played. */ + lastNote = MIDDLE_C; private readonly octaveLabel = new Label('', { style: 'small' }); private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' }); private readonly paramFields = new Map(); @@ -42,10 +76,31 @@ export class InstrumentEditor extends AssetTabEditor | undefined; constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() }); + this.presetButton = new Dropdown('Preset', { + tooltip: 'Make the instrument an 8-bit sound of that kind, drawn anew every time', + onClick: () => this.openPresetMenu(), + }); + this.randomizeButton = new Button('Randomize', { + tooltip: 'Draw every number of the Chips, Envelopes, Filters and Bitcrushers anew', + onClick: () => this.randomize(), + }); + this.mutateButton = new Button('Mutate', { + tooltip: 'Move some numbers of the Chips, Envelopes, Filters and Bitcrushers a little', + onClick: () => this.mutate(), + }); + this.auditionButton = new Button('Audition', { + toggle: true, + tooltip: 'Play the last note again when the instrument changes', + onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled), + }); this.keyboard.onNoteOn = (note) => this.playNote(note); this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice); this.keyboard.isPlaying = (voice) => soundLibrary.playing(voice); @@ -54,12 +109,17 @@ export class InstrumentEditor extends AssetTabEditor { this.valuesFor = ''; + this.heardFor = ''; }); } @@ -89,11 +149,12 @@ export class InstrumentEditor extends AssetTabEditor option.value === preset)?.label ?? preset; + if (!this.state.execute(new ReplaceGraphCommand(asset.id, chipPresetGraph(preset), `Preset ${label}`))) + return; + this.canvas.fit(); + this.audition(); + } + + randomize(): void { + const asset = this.asset(); + if (asset) this.change('Randomize', randomizedParams(asset.graph)); + } + + mutate(): void { + const asset = this.asset(); + if (asset) this.change('Mutate', mutatedParams(asset.graph)); + } + + private change(label: string, changes: readonly ParamChange[]): void { + const asset = this.asset(); + if (!asset || changes.length === 0) return; + const commands = changes.map((c) => new SetNodeParamCommand(asset.id, c.node, c.key, c.value)); + if (this.state.execute(new CompoundCommand(label, commands))) this.audition(); + } + + private openPresetMenu(): void { + if (!this.asset()) return; + new Menu( + CHIP_PRESETS.map((preset) => ({ label: preset.label, onSelect: () => this.applyPreset(preset.value) })), + ).openBelow(this.presetButton); + } + stop(): void { this.keyboard.releaseAll(); soundLibrary.stopOwner(PREVIEW_OWNER); @@ -137,10 +239,26 @@ export class InstrumentEditor extends AssetTabEditor this.audition(), AUDITION_DELAY_MS); } private syncOctave(): void { @@ -186,3 +304,9 @@ export class InstrumentEditor extends AssetTabEditor [n.id, n.type, n.params]), edges.map((e) => [e.from, e.to])]); +} diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts new file mode 100644 index 00000000..151aa29b --- /dev/null +++ b/tests/core/chip-presets.test.ts @@ -0,0 +1,195 @@ +/** + * The Preset menu, Randomize and Mutate of the Instrument tab (#329): sfxr's generators worked out + * into a Chip, an Envelope, a Filter and a Bitcrusher, 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, + hasChipNumbers, + mutatedParams, + randomizedParams, +} 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 { 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, +}; + +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('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) { + const graph = chipPresetGraph(value, seeded()); + expect( + graph.nodes.map((n) => n.type), + value, + ).toEqual([ + 'InstrumentNotes', + 'InstrumentChip', + 'InstrumentEnvelope', + 'SoundFxFilter', + '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', + '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('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); + }); +}); + +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('find nothing to change in an instrument of Samples', () => { + expect(hasChipNumbers(starterInstrumentGraph())).toBe(false); + expect(randomizedParams(starterInstrumentGraph())).toEqual([]); + expect(hasChipNumbers(chipPresetGraph('blip'))).toBe(true); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 188613ee..74a5c0ae 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1076,6 +1076,93 @@ export async function sound(t) { ); await press('ArrowLeft'); + // 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and + // Bitcrusher and plays the note last played at once – C4, typed just now. Randomize and + // Mutate play anew; a change only plays with Audition on. Three undos bring the blip back. + const insGraph = () => + json(`(() => { + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; + const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null; + return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') }; + })()`); + const clickHook = async (hook) => { + const at = await json(`__gm.instrument.${hook}()`); + await click(at.x, at.y); + }; + await evaluate('__gm.sound.stopAll()'); + const beforePreset = await stats(); + await clickHook('presetAt'); + const presetMenu = await json('__gm.overlayTexts()'); + await clickText('Laser'); + const preset = await waitFor(stats, (s) => s.started > beforePreset.started); + const laser = await insGraph(); + const presetInfo = await json('__gm.instrument.info()'); + check( + 'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays at once', + ['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) => + presetMenu.includes(label), + ) && + laser.types.join() === + 'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' && + laser.chip?.followNote === false && + laser.chip.slide < 0 && + laser.envelope?.sustain === 0 && + preset.last?.file === `instrument:${insId}` && + presetInfo === '128 notes', + JSON.stringify({ presetMenu, laser, last: preset.last, presetInfo }), + ); + await shot('34-instrument-preset'); + + const beforeRandom = await stats(); + await clickHook('randomizeAt'); + const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started); + const drawn = await insGraph(); + await clickHook('mutateAt'); + const mutated = await waitFor(stats, (s) => s.started > randomized.started); + const moved = await insGraph(); + check( + 'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once', + randomized.started > beforeRandom.started && + drawn.chip?.wave === laser.chip.wave && + drawn.chip.frequency !== laser.chip.frequency && + mutated.started > randomized.started && + JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]), + JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }), + ); + + const setFrequency = (hz) => + mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}) + .graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`); + await sleep(300); + const silent = await stats(); + await setFrequency(300); + await sleep(500); + const unheard = await stats(); + await clickHook('auditionAt'); + const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled; + await setFrequency(400); + const auditioned = await waitFor(stats, (s) => s.started > unheard.started); + await clickHook('auditionAt'); + check( + 'a change to the instrument is heard only with Audition on', + unheard.started === silent.started && auditionOn && auditioned.started > unheard.started, + JSON.stringify({ + silent: silent.started, + unheard: unheard.started, + auditionOn, + auditioned: auditioned.started, + }), + ); + + for (let i = 0; i < 3; i++) await t.clickToolbar('undo'); + const restored = await insGraph(); + check( + 'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again', + restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', + JSON.stringify(restored.types), + ); + await evaluate('__gm.sound.stopAll()'); + await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify(