Take one sound at the Output of an instrument, mixed by a Mix #331

The Output summed any number of edges as layers. That made it the only
sound input of its kind, and it read as if a note could be several
voices. A note was always one voice, but the graph did not say so.
Now the Output takes one sound like every other sound input, and
several are put together with a Mix in front of it (decided by the
user after #331).

- The Output's input is no longer `many`. A second edge replaces the
  first, as at any data input.
- With only one drum sounding for a note, a Mix is its other side. A
  drum kit keeps the volume of each drum.
- An instrument saved with several edges into the Output keeps them
  but does not compile. The Output says "Takes one sound: put a Mix in
  front of it to play several". No saved project has one: the only
  instrument there (cbbf160bb7e8) has a single edge.
- An Output with nothing in it is a problem now, not silence.
- The compiler gathers only triggers from several edges. The player
  still takes a list of layers, which an instrument fills with one.

Tests:
- instrument.test.ts: drum kit through a Mix, a second edge replacing
  the first, an old Output with two edges, an empty Output (33 tests)
- Counter-check: `many` back on the Output turns 15 tests red, the
  new one about the second edge among them
- e2e 34 on melektaus-atx: ALL PASSED, 47 checks
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3789 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 12:59:52 +02:00
1 parent 23bdc3f775
commit 076c564415
3 files changed
+61 -20

No files matched your search

+11 -10
View File
@@ -1,5 +1,5 @@
import { graphEmitter } from '../graph/emitGraph'; import { graphEmitter } from '../graph/emitGraph';
import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph'; import { type DeclaredPort, edgesInto, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph';
import type { GraphLookup } from '../graph/lookup'; import type { GraphLookup } from '../graph/lookup';
import { resolveDef, terminalNode } from '../graph/nested'; import { resolveDef, terminalNode } from '../graph/nested';
import { inlineValue, type NodeDef } from '../graph/NodeRegistry'; import { inlineValue, type NodeDef } from '../graph/NodeRegistry';
@@ -29,6 +29,7 @@ import {
INSTRUMENT_PARAMETERS, INSTRUMENT_PARAMETERS,
INSTRUMENT_SAMPLE, INSTRUMENT_SAMPLE,
INSTRUMENT_SOUND_FX, INSTRUMENT_SOUND_FX,
InstrumentOutput,
isInstrumentNodeDef, isInstrumentNodeDef,
loops, loops,
noteOutputKey, noteOutputKey,
@@ -73,7 +74,7 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
} }
export interface InstrumentBuild { export interface InstrumentBuild {
/** What the note is made of, each played on its own and summed; empty is silence. */ /** What the note is made of: the one sound at the Output, or none for silence. */
layers: readonly FxPlan[]; layers: readonly FxPlan[];
/** Per node id, what keeps the graph from compiling. */ /** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>; problems: Record<string, string>;
@@ -82,8 +83,8 @@ export interface InstrumentBuild {
} }
/** /**
* What travels along an edge of an instrument: sounds (none where nothing plays for this note, more * What travels along an edge of an instrument: sounds (none where nothing plays for this note), a
* than one only into the Output), a number, or whether a trigger is on for this note. * number, or whether a trigger is on for this note.
*/ */
type Value = { plans: FxPlan[] } | { amount: FxAmount } | { on: boolean }; type Value = { plans: FxPlan[] } | { amount: FxAmount } | { on: boolean };
@@ -116,7 +117,6 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
emit: (def, inputs, node) => emitNode(def, inputs, node), emit: (def, inputs, node) => emitNode(def, inputs, node),
unfed: (node, port) => { unfed: (node, port) => {
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } }; if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
if (port.type === 'audio' && port.many) return SILENT;
walk.problem( walk.problem(
node, node,
port.type === 'trigger' port.type === 'trigger'
@@ -139,10 +139,8 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
); );
return null; return null;
}, },
gather: (_node, port, values) => // Only a trigger takes more than one edge: either one turns it on.
port.type === 'trigger' gather: (_node, _port, values) => ({ on: values.some((v) => 'on' in v && v.on) }),
? { on: values.some((v) => 'on' in v && v.on) }
: { plans: values.flatMap((v) => ('plans' in v ? v.plans : [])) },
}); });
function emitNode(def: Def, inputs: Record<string, Value>, node: GraphNode): Record<string, Value> { function emitNode(def: Def, inputs: Record<string, Value>, node: GraphNode): Record<string, Value> {
@@ -214,7 +212,10 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT); const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' }; if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
const arrived = walk.input(output, { key: 'in', label: 'in', type: 'audio', many: true }); // Saved while the Output still took any number of sounds: only the first would be heard.
if (edgesInto(graph, output.id, 'in').length > 1)
walk.problem(output, 'Takes one sound: put a Mix in front of it to play several');
const arrived = walk.input(output, InstrumentOutput.inputs[0]!);
const failed = walk.firstProblem(); const failed = walk.firstProblem();
if (failed !== null || !arrived || !('plans' in arrived)) if (failed !== null || !arrived || !('plans' in arrived))
return { layers: [], problems: walk.problems, reason: failed ?? 'Cannot compile' }; return { layers: [], problems: walk.problems, reason: failed ?? 'Cannot compile' };
+2 -2
View File
@@ -234,8 +234,8 @@ export const InstrumentOutput: InstrumentNodeDef = {
category: 'sound', category: 'sound',
instrument: true, instrument: true,
description: description:
'What is heard of a note. It takes any number of sounds and plays them together – the layers of the instrument.', 'What is heard of a note. It takes one sound; to play several together, put a Mix in front of it.',
inputs: [{ ...audioIn, many: true }], inputs: [audioIn],
outputs: [], outputs: [],
params: [], params: [],
}; };
+48 -8
View File
@@ -1,6 +1,6 @@
/** /**
* An instrument (#331) is a graph that makes a note a sound: Notes nodes bring the note in, Samples * An instrument (#331) is a graph that makes a note a sound: Notes nodes bring the note in, Samples
* play sounds for it, the effects of a sound FX shape them and the Output sums them. It is compiled * play sounds for it, the effects of a sound FX shape and mix them, and the Output takes one. It is compiled
* once per note; a trigger is on only for the notes it stands for. * once per note; a trigger is on only for the notes it stands for.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
@@ -183,7 +183,7 @@ describe('compiling a note', () => {
expect(way(compile(f, 67).layers[0])?.pitch).toEqual([]); expect(way(compile(f, 67).layers[0])?.pitch).toEqual([]);
}); });
it('a drum kit: one Notes node with single notes, a Sample on each, both into the Output', () => { it('a drum kit: one Notes node with single notes, a Sample on each, mixed into the Output', () => {
const f = emptyInstrument(); const f = emptyInstrument();
const notes = f.edit.addNode('InstrumentNotes', { const notes = f.edit.addNode('InstrumentNotes', {
from: '36', from: '36',
@@ -193,13 +193,16 @@ describe('compiling a note', () => {
}); });
const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false }); const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false });
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, play: 'oneshot' }); const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, play: 'oneshot' });
const mix = f.edit.addNode('SoundFxMix', { mix: 0.5 });
const output = f.edit.addNode('InstrumentOutput'); const output = f.edit.addNode('InstrumentOutput');
f.edit.connect(notes, 'n36', kick, 'trigger'); f.edit.wire([
f.edit.connect(notes, 'n38', snare, 'trigger'); [notes, 'n36', kick, 'trigger'],
f.edit.connect(kick, 'out', output, 'in'); [notes, 'n38', snare, 'trigger'],
f.edit.connect(snare, 'out', output, 'in'); [kick, 'out', mix, 'a'],
// The Output takes both: a second edge into it does not replace the first. [snare, 'out', mix, 'b'],
expect(f.instrument.graph.edges.filter((e) => e.to.node === output.id)).toHaveLength(2); [mix, 'out', output, 'in'],
]);
// Only one drum sounds for a note, so the Mix is the other side and plays it at its own volume.
const files = (note: number) => compile(f, note).layers.map((plan) => way(plan)?.source?.file); const files = (note: number) => compile(f, note).layers.map((plan) => way(plan)?.source?.file);
expect(files(36)).toEqual([KICK]); expect(files(36)).toEqual([KICK]);
expect(files(38)).toEqual([SNARE]); expect(files(38)).toEqual([SNARE]);
@@ -219,6 +222,43 @@ describe('compiling a note', () => {
expect(def.outputs).toHaveLength(4 + 12); expect(def.outputs).toHaveLength(4 + 12);
}); });
it('takes one sound at the Output: a second edge replaces the first', () => {
const f = emptyInstrument();
const notes = f.edit.addNode('InstrumentNotes');
const a = f.edit.addNode('InstrumentSample', { sound: f.sound('A', KICK).id });
const b = f.edit.addNode('InstrumentSample', { sound: f.sound('B', SNARE).id });
const output = f.edit.addNode('InstrumentOutput');
f.edit.wire([
[notes, 'any', a, 'trigger'],
[notes, 'any', b, 'trigger'],
[a, 'out', output, 'in'],
[b, 'out', output, 'in'],
]);
const into = f.instrument.graph.edges.filter((e) => e.to.node === output.id);
expect(into.map((e) => e.from.node)).toEqual([b.id]);
expect(compile(f, 60).layers.map((plan) => way(plan)?.source?.file)).toEqual([SNARE]);
});
it('says so of an Output saved with more than one sound, which it played together then', () => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
sample.params.sound = f.sound('A', KICK).id;
const output = nodeOf(f, 'InstrumentOutput');
const edge = f.instrument.graph.edges.find((e) => e.to.node === output.id)!;
f.instrument.graph.edges.push({ ...edge, id: 'second' });
expect(compile(f, 60)).toMatchObject({
layers: [],
problems: { [output.id]: 'Takes one sound: put a Mix in front of it to play several' },
});
});
it('an Output with nothing in it says so', () => {
const f = makeInstrument();
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
expect(compile(f, 60)).toMatchObject({ layers: [], reason: 'Nothing comes in: connect it to a Sample' });
});
it('two Notes nodes over one Sample: a trigger takes more than one edge, either one plays it', () => { it('two Notes nodes over one Sample: a trigger takes more than one edge, either one plays it', () => {
const f = emptyInstrument(); const f = emptyInstrument();
const low = f.edit.addNode('InstrumentNotes', { from: '48', to: '52' }); const low = f.edit.addNode('InstrumentNotes', { from: '48', to: '52' });