Snap a new song to 1/16, and route new assets as the others (#365)
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Failing after 14m40s

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:
adminandClaude Opus 5.5 committed 2026-10-09 09:37:44 +02:00
1 parent 0e73fd85b9
commit f840ec3c44
7 files changed
+131 -11

No files matched your search

+63 -5
View File
@@ -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<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. */
export interface RoutingChange {
nodes: { added: GraphNode[]; removed: Removed<GraphNode>[]; renamed: RenamedNode[] };
+1 -1
View File
@@ -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;
+1
View File
@@ -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 }),
+5 -4
View File
@@ -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<SongAsset, SongTimeline> {
/** 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<SongAsset, SongTimeline> {
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<SongAsset, SongTimeline> {
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);
}
}