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:
1 parent
23bdc3f775
commit
076c564415
3 files changed
+61
-20
No files matched your search
@@ -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' };
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
Reference in new issue
Block a user