Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.
- Menu -> Master routing opens a tab with one graph per project. It has
a node per sound, instrument, pattern, song and bus, named after its
asset, and one Master. Embedded assets have none, their song routes
them.
- The nodes follow the assets. Every edit settles the routing in the
same undo step (KeepRoutingCommand), so an undone delete brings the
links of its asset back. A new asset gets a node and a link to the
master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
song. Out of a sound, an instrument or a pattern a link always takes
the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
project is read, but nothing is added there. The editor adds the
nodes when it opens a project. An asset without a node plays on the
master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
A sound or instrument with more gets a bus of its own with the sends.
A pattern or song sends from the master of its playing, which is
where its Mode taps. Buses of the mix can have sends now too.
Found:
- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
this change. HEAD passed it 2 of 2. Not fixed here.
Tests:
- Unit master-routing (new, 10), sound-library (2 new), buses
rewritten onto the routing, 5 lists without the Bus row. 5 counter
checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
Counter check with the Sound tab ignoring the routing: the routing
check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8891f027db
commit
0e73fd85b9
46 files changed
+1253
-311
No files matched your search
+27
-12
@@ -1,6 +1,6 @@
|
||||
import type { Application, Ticker } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf } from '../core/music/mixer';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
@@ -94,6 +94,7 @@ import type { PluginManager } from './plugins/loader';
|
||||
import { PluginsPane } from './plugins/PluginsPane';
|
||||
import { ManagementPane } from './users/ManagementPane';
|
||||
import { UsersPane } from './users/UsersPane';
|
||||
import { RoutingEditor } from './routing/RoutingEditor';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -115,6 +116,7 @@ export type CentreTab =
|
||||
| 'tile'
|
||||
| 'container'
|
||||
| 'variables'
|
||||
| 'routing'
|
||||
| 'management';
|
||||
|
||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||
@@ -123,10 +125,10 @@ export type PluginTabId = `plugin:${string}`;
|
||||
export type TabId = CentreTab | PluginTabId;
|
||||
/**
|
||||
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
|
||||
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
|
||||
* opens and closes like one.
|
||||
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
|
||||
* (#351) are no documents, but they open and close like one.
|
||||
*/
|
||||
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
|
||||
export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management';
|
||||
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||||
export interface EditorAccount {
|
||||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||||
@@ -144,7 +146,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||||
|
||||
type FixedTab = (typeof FIXED_TABS)[number];
|
||||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'management'>;
|
||||
|
||||
const FIXED_TABS = ['scene', 'game'] as const;
|
||||
|
||||
@@ -168,6 +170,7 @@ const PANE_ORDER = [
|
||||
'tile',
|
||||
'container',
|
||||
'variables',
|
||||
'routing',
|
||||
'management',
|
||||
] as const;
|
||||
|
||||
@@ -192,6 +195,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
variables: 'Variables',
|
||||
routing: 'Master routing',
|
||||
management: 'User management',
|
||||
};
|
||||
|
||||
@@ -202,7 +206,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||
};
|
||||
|
||||
function isDocument(tab: StripTab): tab is DocumentId {
|
||||
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
|
||||
return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:');
|
||||
}
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
@@ -245,6 +249,7 @@ export class Editor implements PlayHost {
|
||||
readonly tileEditor: TileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly variablesEditor: VariablesEditor;
|
||||
readonly routingEditor: RoutingEditor;
|
||||
/** What each open Variables tab shows, by its document. */
|
||||
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
||||
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
|
||||
@@ -335,6 +340,7 @@ export class Editor implements PlayHost {
|
||||
this.tileEditor = new TileEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
this.variablesEditor = new VariablesEditor(state);
|
||||
this.routingEditor = new RoutingEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.tilemapPanel = new TilemapPanel(state);
|
||||
@@ -383,7 +389,10 @@ export class Editor implements PlayHost {
|
||||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
@@ -453,6 +462,7 @@ export class Editor implements PlayHost {
|
||||
game: this.gamePane,
|
||||
...this.assetEditors,
|
||||
variables: this.variablesEditor,
|
||||
routing: this.routingEditor,
|
||||
management: this.managementPane,
|
||||
};
|
||||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||
@@ -532,7 +542,8 @@ export class Editor implements PlayHost {
|
||||
// creating it) is for – see the `open` event below.
|
||||
state.on('selection', () => {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
|
||||
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
|
||||
else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
|
||||
this.openDocument(`asset:${s.assetId}`);
|
||||
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
|
||||
this.openDocument(`asset:${s.holderId}`);
|
||||
@@ -791,6 +802,7 @@ export class Editor implements PlayHost {
|
||||
if (kind && kind in this.assetEditors)
|
||||
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||||
this.valueTargets.delete(doc);
|
||||
if (doc === 'routing') this.routingEditor.close();
|
||||
if (!wasShowing) return;
|
||||
if (this.documents.active) this.showDocument(this.documents.active);
|
||||
else this.showTab('scene');
|
||||
@@ -807,8 +819,8 @@ export class Editor implements PlayHost {
|
||||
if (doc) this.openDocument(doc);
|
||||
return;
|
||||
}
|
||||
if (tab === 'management') {
|
||||
this.openDocument('management');
|
||||
if (tab === 'management' || tab === 'routing') {
|
||||
this.openDocument(tab);
|
||||
return;
|
||||
}
|
||||
if (tab in this.assetEditors) {
|
||||
@@ -856,7 +868,7 @@ export class Editor implements PlayHost {
|
||||
const target = this.valueTargets.get(doc);
|
||||
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
|
||||
}
|
||||
if (doc === 'management') return 'management';
|
||||
if (doc === 'management' || doc === 'routing') return doc;
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||||
return kind && kind !== 'scene' ? kind : null;
|
||||
@@ -870,12 +882,14 @@ export class Editor implements PlayHost {
|
||||
if (kind === 'variables' && target) this.variablesEditor.show(target);
|
||||
// Read on every showing: an account may have been changed from another tab in the meantime.
|
||||
if (kind === 'management') void this.usersPane?.reload();
|
||||
if (kind === 'routing') this.routingEditor.open();
|
||||
this.display(kind, doc);
|
||||
}
|
||||
|
||||
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
||||
private labelOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||||
if (doc === 'routing') return TAB_LABELS.routing;
|
||||
const target = this.valueTargets.get(doc);
|
||||
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
|
||||
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
|
||||
@@ -885,6 +899,7 @@ export class Editor implements PlayHost {
|
||||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||||
private iconOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
|
||||
if (doc === 'routing') return ASSET_ICON_SVG.bus;
|
||||
const target = this.valueTargets.get(doc);
|
||||
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
@@ -900,7 +915,7 @@ export class Editor implements PlayHost {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!this.tabs.has(doc) && kind)
|
||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||||
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||||
kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||||
icon: this.iconOf(doc),
|
||||
onClose: () => this.closeDocument(doc),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user