From f840ec3c4407f00a00af7d74ea325639ca817107 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 09:37:44 +0200 Subject: [PATCH] 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 --- src/core/music/projectRouting.ts | 68 ++++++++++++++++++++++++++++--- src/core/music/song.ts | 2 +- src/editor/debug/songHooks.ts | 1 + src/editor/song/SongEditor.ts | 9 ++-- tests/core/master-routing.test.ts | 45 ++++++++++++++++++++ tests/core/song.test.ts | 4 +- tools/e2e/65-song.mjs | 13 ++++++ 7 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/core/music/projectRouting.ts b/src/core/music/projectRouting.ts index 9f13bbeb..e6c22aa1 100644 --- a/src/core/music/projectRouting.ts +++ b/src/core/music/projectRouting.ts @@ -1,7 +1,8 @@ /** * 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 - * 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 { 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 master. An asset without a node gets one below the others of its column and a link to the master - * – every asset of a project written before there was a routing. A link goes where one of its ends + * the master. An asset without a node gets one below the others of its column and the links most + * 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 * 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 @@ -120,9 +122,19 @@ export function heldRouting(graph: Graph | undefined, assets: readonly Asset[], }); } 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) - if (fresh.has(node.id) || (lost.has(node.id) && !linked.has(node.id))) - edges.push({ id: newId(), from: { node: node.id, port: 'out' }, to: { node: MASTER_BUS, port: 'in' } }); + if (fresh.has(node.id)) { + 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 { nodes, 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(); + 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(); + 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. */ export interface RoutingChange { nodes: { added: GraphNode[]; removed: Removed[]; renamed: RenamedNode[] }; diff --git a/src/core/music/song.ts b/src/core/music/song.ts index eb2730ed..fb6a40f2 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -59,7 +59,7 @@ export const SONG_TEMPO_KEY = 'tempo'; export const SONG_SIGN_KEY = 'sign'; 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]; /** A new song has this many tracks. */ export const DEFAULT_TRACKS = 1; diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index 04f8abcb..5d08bdf5 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -33,6 +33,7 @@ export function songHooks({ state, editor }: HookContext) { items: () => timeline.itemsDrawn(), playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }), fitAt: () => centreOf(songEditor.fitButton), + gridAt: () => ({ ...centreOf(songEditor.gridButton), text: songEditor.gridButton.text }), playing: () => songEditor.isPlaying, /** Record (#342): its button, whether a take is being made, and the input's level. */ recordAt: () => ({ ...centreOf(songEditor.recordButton), text: songEditor.recordButton.text }), diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 41d1e643..4a7c38aa 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -3,6 +3,7 @@ import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; import { SetSongCommand } from '../../core/commands/songCommands'; import { + DEFAULT_SONG_GRID, newItem, SONG_GRID_KEY, SONG_GRID_OPTIONS, @@ -79,7 +80,7 @@ interface Take { export class SongEditor extends AssetTabEditor { /** Public for the check scripts. */ 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 inputButton = new Dropdown('Default input', { width: INPUT_WIDTH }); readonly meter = new TakeMeter(); @@ -518,7 +519,7 @@ export class SongEditor extends AssetTabEditor { private openGridMenu(): void { const asset = this.asset(); 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( SONG_GRID_OPTIONS.map((option) => ({ label: option.label, @@ -574,8 +575,8 @@ export class SongEditor extends AssetTabEditor { this.fitButton.setEnabled(asset !== null); this.syncViewButtons(asset); this.syncPlayButton(); - const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar'; - this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar'); + const grid = textOf(asset?.props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID); + this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? DEFAULT_SONG_GRID); this.follow(asset); } } diff --git a/tests/core/master-routing.test.ts b/tests/core/master-routing.test.ts index 482a8dae..90d646ec 100644 --- a/tests/core/master-routing.test.ts +++ b/tests/core/master-routing.test.ts @@ -7,6 +7,7 @@ import { CreateAssetCommand, CreateSoundCommand, DeleteAssetCommand, + DuplicateAssetCommand, RenameAssetCommand, } from '../../src/core/commands/assetCommands'; 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]); }); + 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', () => { const f = makeProject(); for (const command of [ diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts index bb6302b9..d4715c84 100644 --- a/tests/core/song.test.ts +++ b/tests/core/song.test.ts @@ -67,11 +67,13 @@ function makeSong(tracks = 4) { } 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); expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']); expect(f.song().items).toEqual([]); 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); f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T')); expect(songGrid(f.song().props)).toBe(160); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 4c8a2657..743cd2fa 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -133,6 +133,19 @@ export async function song(t) { ); const id = created.songs[0]?.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: * the tracks a fit shares the height out to leave the one added last below the edge.