Play tunes on 14 instrument presets next to the sfxr sounds (#329)
game-maker / gate (push) Canceled after 9s
game-maker / suite (push) Canceled after 13s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 07:09:37 +02:00
1 parent f3ae19431a
commit a2d0a02645
4 files changed
+467 -43

No files matched your search

+103 -3
View File
@@ -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 {