From 076c5644151f056f320e1d3db6d08daa1454de40 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 6 Oct 2026 12:59:52 +0200 Subject: [PATCH] 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 --- src/core/instrument/compile.ts | 21 +++++++------ src/core/instrument/nodes.ts | 4 +-- tests/core/instrument.test.ts | 56 +++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index ad18f243..a229af3f 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -1,5 +1,5 @@ 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 { resolveDef, terminalNode } from '../graph/nested'; import { inlineValue, type NodeDef } from '../graph/NodeRegistry'; @@ -29,6 +29,7 @@ import { INSTRUMENT_PARAMETERS, INSTRUMENT_SAMPLE, INSTRUMENT_SOUND_FX, + InstrumentOutput, isInstrumentNodeDef, loops, noteOutputKey, @@ -73,7 +74,7 @@ export function instrumentSourcesOf(project: Project): InstrumentSources { } 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[]; /** Per node id, what keeps the graph from compiling. */ problems: Record; @@ -82,8 +83,8 @@ export interface InstrumentBuild { } /** - * What travels along an edge of an instrument: sounds (none where nothing plays for this note, more - * than one only into the Output), a number, or whether a trigger is on for this note. + * What travels along an edge of an instrument: sounds (none where nothing plays for this note), a + * number, or whether a trigger is on for this note. */ 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), unfed: (node, port) => { if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } }; - if (port.type === 'audio' && port.many) return SILENT; walk.problem( node, port.type === 'trigger' @@ -139,10 +139,8 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen ); return null; }, - gather: (_node, port, values) => - port.type === 'trigger' - ? { on: values.some((v) => 'on' in v && v.on) } - : { plans: values.flatMap((v) => ('plans' in v ? v.plans : [])) }, + // Only a trigger takes more than one edge: either one turns it on. + gather: (_node, _port, values) => ({ on: values.some((v) => 'on' in v && v.on) }), }); function emitNode(def: Def, inputs: Record, node: GraphNode): Record { @@ -214,7 +212,10 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT); 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(); if (failed !== null || !arrived || !('plans' in arrived)) return { layers: [], problems: walk.problems, reason: failed ?? 'Cannot compile' }; diff --git a/src/core/instrument/nodes.ts b/src/core/instrument/nodes.ts index d6fe7912..6cbf1cd1 100644 --- a/src/core/instrument/nodes.ts +++ b/src/core/instrument/nodes.ts @@ -234,8 +234,8 @@ export const InstrumentOutput: InstrumentNodeDef = { category: 'sound', instrument: true, description: - 'What is heard of a note. It takes any number of sounds and plays them together – the layers of the instrument.', - inputs: [{ ...audioIn, many: true }], + 'What is heard of a note. It takes one sound; to play several together, put a Mix in front of it.', + inputs: [audioIn], outputs: [], params: [], }; diff --git a/tests/core/instrument.test.ts b/tests/core/instrument.test.ts index 3b712afd..7826034b 100644 --- a/tests/core/instrument.test.ts +++ b/tests/core/instrument.test.ts @@ -1,6 +1,6 @@ /** * 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. */ import { describe, expect, it } from 'vitest'; @@ -183,7 +183,7 @@ describe('compiling a note', () => { 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 notes = f.edit.addNode('InstrumentNotes', { 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 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'); - f.edit.connect(notes, 'n36', kick, 'trigger'); - f.edit.connect(notes, 'n38', snare, 'trigger'); - f.edit.connect(kick, 'out', output, 'in'); - f.edit.connect(snare, 'out', output, 'in'); - // The Output takes both: a second edge into it does not replace the first. - expect(f.instrument.graph.edges.filter((e) => e.to.node === output.id)).toHaveLength(2); + f.edit.wire([ + [notes, 'n36', kick, 'trigger'], + [notes, 'n38', snare, 'trigger'], + [kick, 'out', mix, 'a'], + [snare, 'out', mix, 'b'], + [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); expect(files(36)).toEqual([KICK]); expect(files(38)).toEqual([SNARE]); @@ -219,6 +222,43 @@ describe('compiling a note', () => { 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', () => { const f = emptyInstrument(); const low = f.edit.addNode('InstrumentNotes', { from: '48', to: '52' });