Snap a new song to 1/16, and route new assets as the others (#365)
A new song snapped to whole bars, too coarse to lay out a beat. And a new or duplicated sound, instrument, pattern or song went to the master alone, so every one had to be linked into the buses by hand again. - A song without a grid of its own snaps to 1/16. The songs in the user's project have Bar written in and keep it. - A new or duplicated sound, instrument, pattern or song gets the links of the one whose targets most others of its type share, at their volume. On a tie the latest of them decides, so a single song decides for the next one. - Where nothing of its type is linked yet, it goes as most of all that plays, and where nothing is, to the master. A new bus still goes to the master alone. - Assets made at once are matched against what stood before, not against each other. A project from before the routing still opens with everything on the master. - The song tab has a gridAt hook. e2e 65 checks the new grid and sets Bar, which its checks go by. Tests: - Unit master-routing (1 new) and song (1 rewritten): 26 green. Counter checks (rule off, grid back to Bar) red. - e2e 65 (melektaus-atx): ALL PASSED, 58 checks (1 new). - Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4226 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e73fd85b9
commit
f840ec3c44
7 files changed
+131
-11
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song and
|
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song and
|
||||||
* bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one
|
* bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one
|
||||||
* that comes gets a node of its own and a link to the master, one that goes takes its node along.
|
* that comes gets a node of its own and the links most others of its type have, one that goes takes
|
||||||
|
* its node along.
|
||||||
*/
|
*/
|
||||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, reaches } from '../graph/Graph';
|
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, reaches } from '../graph/Graph';
|
||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
@@ -44,8 +45,9 @@ export function routedKind(node: GraphNode): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The routing as it has to be for the assets of the project: a node for each, named after it, and
|
* The routing as it has to be for the assets of the project: a node for each, named after it, and
|
||||||
* the master. An asset without a node gets one below the others of its column and a link to the master
|
* the master. An asset without a node gets one below the others of its column and the links most
|
||||||
* – every asset of a project written before there was a routing. A link goes where one of its ends
|
* others of its type have (`linksLikeTheirs`), else one to the master – so does every asset of a
|
||||||
|
* project written before there was a routing. A link goes where one of its ends
|
||||||
* is gone, where it would close a loop or say again what another one says; a node whose every link
|
* is gone, where it would close a loop or say again what another one says; a node whose every link
|
||||||
* led to a bus that is gone is linked to the master instead, so deleting a bus silences nothing.
|
* led to a bus that is gone is linked to the master instead, so deleting a bus silences nothing.
|
||||||
* Without `adding`, as a project is read, an asset without a node stays without one – it is heard on
|
* Without `adding`, as a project is read, an asset without a node stays without one – it is heard on
|
||||||
@@ -120,9 +122,19 @@ export function heldRouting(graph: Graph | undefined, assets: readonly Asset[],
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const linked = new Set(edges.map((e) => e.from.node));
|
const linked = new Set(edges.map((e) => e.from.node));
|
||||||
|
const toMaster = (from: string): GraphEdge => ({
|
||||||
|
id: newId(),
|
||||||
|
from: { node: from, port: 'out' },
|
||||||
|
to: { node: MASTER_BUS, port: 'in' },
|
||||||
|
});
|
||||||
|
const added: GraphEdge[] = [];
|
||||||
for (const node of nodes)
|
for (const node of nodes)
|
||||||
if (fresh.has(node.id) || (lost.has(node.id) && !linked.has(node.id)))
|
if (fresh.has(node.id)) {
|
||||||
edges.push({ id: newId(), from: { node: node.id, port: 'out' }, to: { node: MASTER_BUS, port: 'in' } });
|
const kind = routedKind(node);
|
||||||
|
const like = linksLikeTheirs(kind, nodes, edges);
|
||||||
|
added.push(...(like.length > 0 ? like.map((e) => copiedLink(e, node.id, kind)) : [toMaster(node.id)]));
|
||||||
|
} else if (lost.has(node.id) && !linked.has(node.id)) added.push(toMaster(node.id));
|
||||||
|
edges.push(...added);
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
@@ -131,6 +143,52 @@ export function heldRouting(graph: Graph | undefined, assets: readonly Asset[],
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The links a new sound, instrument, pattern or song takes, so it is heard where the others are: those
|
||||||
|
* of the one whose targets most others of its type share too – the latest of them on a tie –, or of
|
||||||
|
* all that plays where nothing of its type is linked yet. Empty where nothing is, and for a bus.
|
||||||
|
*/
|
||||||
|
function linksLikeTheirs(
|
||||||
|
kind: string,
|
||||||
|
nodes: readonly GraphNode[],
|
||||||
|
edges: readonly GraphEdge[],
|
||||||
|
): GraphEdge[] {
|
||||||
|
if (kind === 'bus') return [];
|
||||||
|
const out = new Map<string, GraphEdge[]>();
|
||||||
|
for (const edge of edges) out.set(edge.from.node, [...(out.get(edge.from.node) ?? []), edge]);
|
||||||
|
const pick = (peers: readonly GraphNode[]): GraphEdge[] => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const targetsOf = (id: string) =>
|
||||||
|
(out.get(id) ?? [])
|
||||||
|
.map((e) => e.to.node)
|
||||||
|
.sort()
|
||||||
|
.join(' ');
|
||||||
|
for (const peer of peers) counts.set(targetsOf(peer.id), (counts.get(targetsOf(peer.id)) ?? 0) + 1);
|
||||||
|
let best: GraphNode | null = null;
|
||||||
|
for (const peer of peers)
|
||||||
|
if (!best || counts.get(targetsOf(peer.id))! >= counts.get(targetsOf(best.id))!) best = peer;
|
||||||
|
return best ? out.get(best.id)! : [];
|
||||||
|
};
|
||||||
|
const playing = nodes.filter((node) => {
|
||||||
|
const of = routedKind(node);
|
||||||
|
return of !== '' && of !== 'bus' && out.has(node.id);
|
||||||
|
});
|
||||||
|
const own = playing.filter((node) => routedKind(node) === kind);
|
||||||
|
return pick(own.length > 0 ? own : playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link like another, out of a new node: as loud, and after the sound FX where it cannot tap before. */
|
||||||
|
function copiedLink(edge: GraphEdge, from: string, kind: string): GraphEdge {
|
||||||
|
const raw = heldSend(edge.send);
|
||||||
|
const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw;
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
from: { node: from, port: 'out' },
|
||||||
|
to: { node: edge.to.node, port: 'in' },
|
||||||
|
...(send && { send }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** What one settling of the routing changed, by id, so it can be taken back and done again. */
|
/** What one settling of the routing changed, by id, so it can be taken back and done again. */
|
||||||
export interface RoutingChange {
|
export interface RoutingChange {
|
||||||
nodes: { added: GraphNode[]; removed: Removed<GraphNode>[]; renamed: RenamedNode[] };
|
nodes: { added: GraphNode[]; removed: Removed<GraphNode>[]; renamed: RenamedNode[] };
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const SONG_TEMPO_KEY = 'tempo';
|
|||||||
export const SONG_SIGN_KEY = 'sign';
|
export const SONG_SIGN_KEY = 'sign';
|
||||||
export const SONG_GRID_KEY = 'grid';
|
export const SONG_GRID_KEY = 'grid';
|
||||||
|
|
||||||
export const DEFAULT_SONG_GRID = 'bar';
|
export const DEFAULT_SONG_GRID = '1/16';
|
||||||
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
|
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
|
||||||
/** A new song has this many tracks. */
|
/** A new song has this many tracks. */
|
||||||
export const DEFAULT_TRACKS = 1;
|
export const DEFAULT_TRACKS = 1;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
items: () => timeline.itemsDrawn(),
|
items: () => timeline.itemsDrawn(),
|
||||||
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
|
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
|
||||||
fitAt: () => centreOf(songEditor.fitButton),
|
fitAt: () => centreOf(songEditor.fitButton),
|
||||||
|
gridAt: () => ({ ...centreOf(songEditor.gridButton), text: songEditor.gridButton.text }),
|
||||||
playing: () => songEditor.isPlaying,
|
playing: () => songEditor.isPlaying,
|
||||||
/** Record (#342): its button, whether a take is being made, and the input's level. */
|
/** Record (#342): its button, whether a take is being made, and the input's level. */
|
||||||
recordAt: () => ({ ...centreOf(songEditor.recordButton), text: songEditor.recordButton.text }),
|
recordAt: () => ({ ...centreOf(songEditor.recordButton), text: songEditor.recordButton.text }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
|||||||
import { nextAssetName } from '../../core/commands/naming';
|
import { nextAssetName } from '../../core/commands/naming';
|
||||||
import { SetSongCommand } from '../../core/commands/songCommands';
|
import { SetSongCommand } from '../../core/commands/songCommands';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SONG_GRID,
|
||||||
newItem,
|
newItem,
|
||||||
SONG_GRID_KEY,
|
SONG_GRID_KEY,
|
||||||
SONG_GRID_OPTIONS,
|
SONG_GRID_OPTIONS,
|
||||||
@@ -79,7 +80,7 @@ interface Take {
|
|||||||
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly playButton: Button;
|
readonly playButton: Button;
|
||||||
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
|
readonly gridButton = new Dropdown(DEFAULT_SONG_GRID, { width: GRID_WIDTH });
|
||||||
readonly recordButton: Button;
|
readonly recordButton: Button;
|
||||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||||
readonly meter = new TakeMeter();
|
readonly meter = new TakeMeter();
|
||||||
@@ -518,7 +519,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
private openGridMenu(): void {
|
private openGridMenu(): void {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar');
|
const current = textOf(asset.props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID);
|
||||||
new Menu(
|
new Menu(
|
||||||
SONG_GRID_OPTIONS.map((option) => ({
|
SONG_GRID_OPTIONS.map((option) => ({
|
||||||
label: option.label,
|
label: option.label,
|
||||||
@@ -574,8 +575,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
this.fitButton.setEnabled(asset !== null);
|
this.fitButton.setEnabled(asset !== null);
|
||||||
this.syncViewButtons(asset);
|
this.syncViewButtons(asset);
|
||||||
this.syncPlayButton();
|
this.syncPlayButton();
|
||||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
const grid = textOf(asset?.props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID);
|
||||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? DEFAULT_SONG_GRID);
|
||||||
this.follow(asset);
|
this.follow(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CreateAssetCommand,
|
CreateAssetCommand,
|
||||||
CreateSoundCommand,
|
CreateSoundCommand,
|
||||||
DeleteAssetCommand,
|
DeleteAssetCommand,
|
||||||
|
DuplicateAssetCommand,
|
||||||
RenameAssetCommand,
|
RenameAssetCommand,
|
||||||
} from '../../src/core/commands/assetCommands';
|
} from '../../src/core/commands/assetCommands';
|
||||||
import type { Command } from '../../src/core/commands/Command';
|
import type { Command } from '../../src/core/commands/Command';
|
||||||
@@ -80,6 +81,50 @@ describe('the nodes of the master routing (#351)', () => {
|
|||||||
expect([at(MASTER_BUS).x, at(MASTER_BUS).y]).toEqual([640, -80]);
|
expect([at(MASTER_BUS).x, at(MASTER_BUS).y]).toEqual([640, -80]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('link a new or duplicated asset where most others of its type go, else where all that plays goes', () => {
|
||||||
|
const f = edited();
|
||||||
|
const reverb = f.create('bus', 'Reverb');
|
||||||
|
const drums = f.create('bus', 'Drums');
|
||||||
|
const kick = f.sound('Kick');
|
||||||
|
const snare = f.sound('Snare');
|
||||||
|
const hat = f.sound('Hat');
|
||||||
|
const offMaster = (id: string) =>
|
||||||
|
f.run(new DisconnectCommand(ROUTING_ID, f.project.routing.edges.find((e) => e.from.node === id)!.id));
|
||||||
|
// Two sounds go into Drums and Reverb, one into the master alone: a new one goes with the two.
|
||||||
|
for (const id of [kick, snare]) {
|
||||||
|
offMaster(id);
|
||||||
|
f.link(id, drums);
|
||||||
|
f.run(new SetSendCommand(ROUTING_ID, f.link(id, reverb), { volume: -9, tap: 'post' }));
|
||||||
|
}
|
||||||
|
const tom = f.sound('Tom');
|
||||||
|
expect(f.linksOf(tom).sort()).toEqual([drums, reverb].sort());
|
||||||
|
const sendTo = (id: string, to: string) =>
|
||||||
|
f.project.routing.edges.find((e) => e.from.node === id && e.to.node === to)?.send;
|
||||||
|
expect(sendTo(tom, reverb)).toEqual({ volume: -9, tap: 'post' });
|
||||||
|
const copy = new DuplicateAssetCommand(hat);
|
||||||
|
f.run(copy);
|
||||||
|
expect(f.linksOf(copy.copyId).sort()).toEqual([drums, reverb].sort());
|
||||||
|
expect(f.linksOf(hat)).toEqual([MASTER_BUS]);
|
||||||
|
|
||||||
|
// The first song goes as most that plays; the one song there is then decides for the next.
|
||||||
|
const theme = f.create('song', 'Theme');
|
||||||
|
expect(f.linksOf(theme).sort()).toEqual([drums, reverb].sort());
|
||||||
|
f.run(
|
||||||
|
new DisconnectCommand(
|
||||||
|
ROUTING_ID,
|
||||||
|
f.project.routing.edges.find((e) => e.from.node === theme && e.to.node === drums)!.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(f.linksOf(f.create('song', 'Outro'))).toEqual([reverb]);
|
||||||
|
expect(f.linksOf(f.create('pattern', 'Riff')).sort()).toEqual([drums, reverb].sort());
|
||||||
|
// A bus goes to the master as before.
|
||||||
|
expect(f.linksOf(f.create('bus', 'Delay'))).toEqual([MASTER_BUS]);
|
||||||
|
// Undone, a new asset takes its links along.
|
||||||
|
const links = f.project.routing.edges.length;
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.project.routing.edges.length).toBe(links - 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('come in by type as a project from before the routing is opened', () => {
|
it('come in by type as a project from before the routing is opened', () => {
|
||||||
const f = makeProject();
|
const f = makeProject();
|
||||||
for (const command of [
|
for (const command of [
|
||||||
|
|||||||
@@ -67,11 +67,13 @@ function makeSong(tracks = 4) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('the song asset (#334)', () => {
|
describe('the song asset (#334)', () => {
|
||||||
it('starts with one track, nothing on it, at 120 BPM in 4/4 on a grid of a bar', () => {
|
it('starts with one track, nothing on it, at 120 BPM in 4/4 on a grid of 1/16', () => {
|
||||||
const f = makeSong(1);
|
const f = makeSong(1);
|
||||||
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
|
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
|
||||||
expect(f.song().items).toEqual([]);
|
expect(f.song().items).toEqual([]);
|
||||||
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']);
|
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']);
|
||||||
|
expect(songGrid(f.song().props)).toBe(120);
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', 'bar'));
|
||||||
expect(songGrid(f.song().props)).toBe(BAR);
|
expect(songGrid(f.song().props)).toBe(BAR);
|
||||||
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
|
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
|
||||||
expect(songGrid(f.song().props)).toBe(160);
|
expect(songGrid(f.song().props)).toBe(160);
|
||||||
|
|||||||
@@ -133,6 +133,19 @@ export async function song(t) {
|
|||||||
);
|
);
|
||||||
const id = created.songs[0]?.id;
|
const id = created.songs[0]?.id;
|
||||||
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
|
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
|
||||||
|
// A new song snaps to 1/16; the checks below go by bars, so the grid is set to a bar here.
|
||||||
|
const newGrid = await json('__gm.song.gridAt()');
|
||||||
|
await click(newGrid.x, newGrid.y);
|
||||||
|
await sleep(150);
|
||||||
|
const barEntry = (await json("__gm.locateAll('Bar')")).find((hit) => hit.y > newGrid.y);
|
||||||
|
if (barEntry) await click(barEntry.x, barEntry.y);
|
||||||
|
await sleep(150);
|
||||||
|
const barGrid = await json('__gm.song.gridAt()');
|
||||||
|
check(
|
||||||
|
'a new song snaps to 1/16, and its Grid menu sets a bar',
|
||||||
|
newGrid.text === '1/16' && barGrid.text === 'Bar',
|
||||||
|
JSON.stringify({ newGrid, barGrid }),
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* Adds a track under the last one through its head's menu: there is no other way to. Fit first:
|
* Adds a track under the last one through its head's menu: there is no other way to. Fit first:
|
||||||
* the tracks a fit shares the height out to leave the one added last below the edge.
|
* the tracks a fit shares the height out to leave the one added last below the edge.
|
||||||
|
|||||||
Reference in new issue
Block a user