Play every tab's preview straight into the master (#368)

Since #351 the tabs played their asset along its links in the Master
routing, as the game does. A song linked into a reverb bus was heard
through that bus while it was edited. A preview should sound like the
asset itself, so the tabs leave the Master routing out again.

- The Sound, Sound FX, Instrument, Pattern and Song tab play into the
  master. The sound FX of the asset still applies, and so does the
  master's own volume and FX.
- Inside a song its own routing of tracks and sends still counts.
- The game is unchanged: Play nodes go along the routing as before.

Tests:
- e2e 34 (melektaus-atx): ALL PASSED, 96 checks. The routing check
  now holds the mix of the two buses as before, and the Sound tab on
  the master with the bus silent.
- Counter-probe e2e: the Sound tab along the routing again, the
  routing check red.
- Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 13:09:36 +02:00
1 parent 1f13794b0c
commit af2312a5e7
7 files changed
+30 -25

No files matched your search

+4 -2
View File
@@ -21,8 +21,9 @@ import {
import { MIDDLE_C, noteName } from '../../core/instrument/notes'; import { MIDDLE_C, noteName } from '../../core/instrument/notes';
import type { Asset, InstrumentAsset } from '../../core/model/Asset'; import type { Asset, InstrumentAsset } from '../../core/model/Asset';
import { fxVoiceOf } from '../../core/music/needs'; import { fxVoiceOf } from '../../core/music/needs';
import { MASTER_MIX } from '../../core/music/mixer';
import { DEFAULT_TEMPO } from '../../core/music/time'; import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentBus, instrumentFx, playNote } from '../../runtime/systems/behaviorContext'; import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { import {
Button, Button,
Dropdown, Dropdown,
@@ -168,7 +169,8 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
struck, struck,
PREVIEW_OWNER, PREVIEW_OWNER,
{ ...this.values }, { ...this.values },
{ bus: instrumentBus(this.state.project, asset.id, '') }, // Straight into the master: a preview leaves the routing of the instrument out (#368).
{ bus: MASTER_MIX },
fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO), fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO),
); );
if (voice) this.voices.push(voice); if (voice) this.voices.push(voice);
+3 -3
View File
@@ -24,7 +24,7 @@ import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext'; import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui'; import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music'; import { Music } from '../../viewport/music';
import { playedRoute } from '../../core/music/mixer'; import { MASTER_MIX } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport'; import { claimTransport, releaseTransport } from '../music/transport';
@@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button; readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */ /** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' }); readonly ghostSource = new Label('', { style: 'muted' });
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */ /** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
readonly music = new Music( readonly music = new Music(
() => this.state.project, () => this.state.project,
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''), () => MASTER_MIX,
() => { () => {
const asset = this.asset(); const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props)); return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props));
+3 -3
View File
@@ -28,7 +28,7 @@ import { assetFxOf } from '../../core/properties/assetFx';
import type { SoundStore } from '../../core/store/SoundStore'; import type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui'; import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music'; import { Music } from '../../viewport/music';
import { playedRoute } from '../../core/music/mixer'; import { MASTER_MIX } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { import {
type InputDevice, type InputDevice,
@@ -93,10 +93,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly snap = new SnapToggle(() => this.routing); readonly snap = new SnapToggle(() => this.routing);
private shown: SongView = 'timeline'; private shown: SongView = 'timeline';
private readonly pane = new VStack(); private readonly pane = new VStack();
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */ /** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
readonly music = new Music( readonly music = new Music(
() => this.state.project, () => this.state.project,
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''), () => MASTER_MIX,
() => { () => {
const asset = this.asset(); const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props)); return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props));
+3 -4
View File
@@ -16,7 +16,7 @@ import {
} from '../../core/properties/sounds'; } from '../../core/properties/sounds';
import { Button, Dropdown, Label, Menu, NumberField } from '../../ui'; import { Button, Dropdown, Label, Menu, NumberField } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { playedBus } from '../../core/music/mixer'; import { MASTER_MIX } from '../../core/music/mixer';
import { DRY_VOICE } from '../../core/soundfx/nodes'; import { DRY_VOICE } from '../../core/soundfx/nodes';
import { fxVoiceOf } from '../../core/music/needs'; import { fxVoiceOf } from '../../core/music/needs';
import { DEFAULT_TEMPO } from '../../core/music/time'; import { DEFAULT_TEMPO } from '../../core/music/time';
@@ -152,11 +152,10 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.stop(); this.stop();
claimTransport(this.halt); claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset); const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352) and along its routing (#351), as the game hears it. // Through its sound FX (#352), then straight into the master: a preview leaves its routing out (#368).
const bus = playedBus(this.state.project, '', asset.id);
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE; const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
const file = playedSoundOf({ asset, clip }); const file = playedSoundOf({ asset, clip });
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, MASTER_MIX);
this.playingId = this.voice ? `${asset.id}:${clip?.id ?? ''}` : ''; this.playingId = this.voice ? `${asset.id}:${clip?.id ?? ''}` : '';
this.syncPlayButton(); this.syncPlayButton();
} }
+3 -4
View File
@@ -7,7 +7,7 @@ import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile'; import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui'; import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { playedBus } from '../../core/music/mixer'; import { MASTER_MIX } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas'; import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle'; import { SnapToggle } from '../graph/SnapToggle';
@@ -106,10 +106,9 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
if (!sound?.asset.file || !plan) return; if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset); const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } }; const fx = { plan, params: { ...this.values } };
// Along the routing of the sound, as the game would play it there (#351). // Straight into the master: a preview leaves the routing of the sound out (#368).
const bus = playedBus(this.state.project, '', sound.asset.id);
const file = playedSoundOf(sound); const file = playedSoundOf(sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, MASTER_MIX);
if (voice) this.voices.push(voice); if (voice) this.voices.push(voice);
this.playedFor = asset.id; this.playedFor = asset.id;
} }
+2 -2
View File
@@ -52,8 +52,8 @@ export class Music {
readonly clips: PlannedClip[] = []; readonly clips: PlannedClip[] = [];
/** /**
* `output` is where what it plays goes (#351): along the routing of the pattern or song in the * `output` is where what it plays goes (#351): the master where left out, as for the preview of
* tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of * a tab, which leaves the routing of its pattern or song out (#368). `fx` is the sound FX of
* that pattern or song (#352), on the way there. * that pattern or song (#352), on the way there.
*/ */
constructor( constructor(
+12 -7
View File
@@ -2339,8 +2339,9 @@ export async function sound(t) {
// 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes // 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
// one; the tab has a node for it and for the sound, each linked to the master. Links drawn // one; the tab has a node for it and for the sound, each linked to the master. Links drawn
// there and one deleted send the sound into a bus and that bus into the other, as the Sound // there and one deleted send the sound into a bus and that bus into the other; the Sound
// tab then plays it. A click on a node shows its asset, on the master the master's volume. // tab still plays it on the master (#368). A click on a node shows its asset, on the
// master the master's volume.
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
await clickText('+'); await clickText('+');
await pickAssetType(t, 'Bus'); await pickAssetType(t, 'Bus');
@@ -2395,6 +2396,9 @@ export async function sound(t) {
await mutate(` await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`); __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
// The preview of a tab leaves the routing out (#368): heard on the master, the buses stay silent.
const tapped = JSON.stringify(['mix', `mix:${busB.id}`]);
await json(`__gm.routing.peaks(${tapped})`);
const startedBefore = (await stats()).started; const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y); await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10); await waitFor(stats, (s) => s.started > startedBefore, 10);
@@ -2402,22 +2406,23 @@ export async function sound(t) {
const buses = await json('__gm.sound.buses()'); const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus; const heardOn = (await stats()).last?.bus;
const peak = await waitFor( const peak = await waitFor(
() => json(`__gm.routing.peaks([${JSON.stringify(`mix:${busB.id}`)}])`), () => json(`__gm.routing.peaks(${tapped})`),
(peaks) => Math.max(...(peaks[`mix:${busB.id}`] ?? [0])) > 0.01, (peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01,
); );
await click(tabPlay.x, tabPlay.y); await click(tabPlay.x, tabPlay.y);
const levelOf = (name) => buses.find((bus) => bus.name === name); const levelOf = (name) => buses.find((bus) => bus.name === name);
check( check(
'linked in the routing, the Sound tab plays the sound on its bus, into the bus that one goes to, on into the master', 'linked in the routing, the mix sends the sound’s bus into the other; the Sound tab plays it on the master, past both',
links(await routing()) links(await routing())
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.id)) .filter((l) => l.startsWith(droneId) || l.startsWith(busB.id))
.join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` && .join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` &&
heardOn === `mix:${busB.id}` && heardOn === 'mix' &&
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` && levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' && levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 && levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null && levelOf('mix')?.into === null &&
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) > 0.01, Math.max(...(peak?.mix ?? [0])) > 0.01 &&
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) < 0.001,
JSON.stringify({ links: links(await routing()), heardOn, buses, peak }), JSON.stringify({ links: links(await routing()), heardOn, buses, peak }),
); );