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, FLANGER_ROWS, PHASER_ROWS, SoundFxDelay, SoundFxFilter, SoundFxFlanger, SoundFxMix, SoundFxPhaser, SoundFxPitch, } from '../soundfx/nodes'; import { CHIP_ROWS, ENVELOPE_ROWS, INSTRUMENT_CHIP, INSTRUMENT_ENVELOPE, INSTRUMENT_NOTES, INSTRUMENT_OUTPUT, noteNameKey, noteOutputKey, } 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, a Bitcrusher and a Flanger (#341), * which sfxr calls its phaser. 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 EffectPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip'; export type TonePreset = | 'squareLead' | 'pulseLead' | 'echoLead' | 'octaveArp' | 'brass' | 'detunedSaws' | 'stringPad' | 'phasedPad' | 'organ' | 'flute' | 'bell' | 'pluck' | 'triangleBass' | 'pluckBass' | 'drumKit'; export type ChipPreset = EffectPreset | TonePreset; 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: 'phasedPad', label: 'Phased 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' }, { value: 'powerup', label: 'Powerup' }, { value: 'hit', label: 'Hit' }, { value: 'jump', label: 'Jump' }, { 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. */ 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; phaOffset: number; phaRamp: 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, phaOffset: 0, phaRamp: 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: 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; 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.phaOffset = -0.3 + f(0.9); p.phaRamp = -f(0.3); } 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(); // Drawn last, not where sfxr has them, so the numbers before stay as they were. p.phaOffset = signed() ** 3; p.phaRamp = signed() ** 3; 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) }; } /** * sfxr's phaser as a Flanger: its delay is `offset²` of 1020 samples taken 8 times over, and moves * `ramp²` of one of them every sample. Mirrored at 0 as it is, a sweep towards 0 is one with the * other sign than the offset. */ function flangerNumbers(p: Sfxr): Record<'offset' | 'sweep', number> { const taken = 8 * SFXR_RATE; const away = Math.sign(p.phaOffset || 1) * Math.sign(p.phaRamp); return { offset: ((p.phaOffset ** 2 * 1020) / taken) * 1000, sweep: (away * p.phaRamp ** 2 * SFXR_RATE * 1000) / taken, }; } /** 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 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) }; /** * An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through * an Envelope, a Filter, sfxr's phaser where it has one, 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. */ 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 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), }); s.wire(notes, 'any', chip, 'trigger'); const flanged: Step[] = p.phaOffset !== 0 || p.phaRamp !== 0 ? [ [ SoundFxFlanger.type, held({ ...flangerNumbers(p), rate: 0, depth: 0, feedback: 0, mix: 1 }, FLANGER_ROWS), ], ] : []; return s.end( s.line( chip, [INSTRUMENT_ENVELOPE, held(envelopeNumbers(p), ENVELOPE_ROWS)], [SoundFxFilter.type, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }], ...flanged, [ SoundFxBitcrusher.type, held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS), ], ), ); } 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), }); 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 phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) => [SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] 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 saw pad, slow to come, swirled by a phaser of six stages. phasedPad: (s, notes) => s.line( chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }), envelope(0.3, 0.5, 0.8, 0.8), pass('lowpass', 3000, 0.1), phaser(6, 600, 0.3, 2.5, 0.5), ), // 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. */ 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; case SoundFxPhaser.type: return PHASER_ROWS; case SoundFxFlanger.type: return FLANGER_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); case SoundFxPhaser.type: return held( { frequency: 200 * 20 ** random(), rate: 0.05 * 100 ** random(), depth: 0.5 + random() * 3.5, feedback: random() * 0.8, }, PHASER_ROWS, ); case SoundFxFlanger.type: return held( { ...flangerNumbers(randomSfxr(random)), rate: 0.05 * 100 ** random(), depth: random() * 4, feedback: random() * 0.8, }, FLANGER_ROWS, ); default: return null; } } /** * Randomize (#329): the numbers of every Chip, Envelope, Filter, Bitcrusher, Phaser and Flanger * (#341) drawn anew, as sfxr's randomizer draws them; a Phaser's, and a Flanger's LFO, which sfxr * has not, within bounds of their own. Waves, filter types, the stages of a Phaser, every Mix 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 }, offset: { by: 0.1 }, sweep: { by: 2 }, depth: { by: 0.2 }, feedback: { by: 0.05 }, }; /** * 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; }