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 type { Asset, InstrumentAsset } from '../../core/model/Asset';
import { fxVoiceOf } from '../../core/music/needs';
import { MASTER_MIX } from '../../core/music/mixer';
import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentBus, instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import {
Button,
Dropdown,
@@ -168,7 +169,8 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
struck,
PREVIEW_OWNER,
{ ...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),
);
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 { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedRoute } from '../../core/music/mixer';
import { MASTER_MIX } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
@@ -54,10 +54,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */
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(
() => this.state.project,
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => MASTER_MIX,
() => {
const asset = this.asset();
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 { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedRoute } from '../../core/music/mixer';
import { MASTER_MIX } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
type InputDevice,
@@ -93,10 +93,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly snap = new SnapToggle(() => this.routing);
private shown: SongView = 'timeline';
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(
() => this.state.project,
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => MASTER_MIX,
() => {
const asset = this.asset();
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';
import { Button, Dropdown, Label, Menu, NumberField } from '../../ui';
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 { fxVoiceOf } from '../../core/music/needs';
import { DEFAULT_TEMPO } from '../../core/music/time';
@@ -152,11 +152,10 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.stop();
claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352) and along its routing (#351), as the game hears it.
const bus = playedBus(this.state.project, '', asset.id);
// Through its sound FX (#352), then straight into the master: a preview leaves its routing out (#368).
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
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.syncPlayButton();
}
+3 -4
View File
@@ -7,7 +7,7 @@ import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { playedBus } from '../../core/music/mixer';
import { MASTER_MIX } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
@@ -106,10 +106,9 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } };
// Along the routing of the sound, as the game would play it there (#351).
const bus = playedBus(this.state.project, '', sound.asset.id);
// Straight into the master: a preview leaves the routing of the sound out (#368).
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);
this.playedFor = asset.id;
}
+2 -2
View File
@@ -52,8 +52,8 @@ export class Music {
readonly clips: PlannedClip[] = [];
/**
* `output` is where what it plays goes (#351): along the routing of the pattern or song in the
* tab, so it sounds as it would in the game. The master where left out. `fx` is the sound FX of
* `output` is where what it plays goes (#351): the master where left out, as for the preview 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.
*/
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
// 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
// tab then plays it. A click on a node shows its asset, on the master the master's volume.
// there and one deleted send the sound into a bus and that bus into the other; the Sound
// 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++) {
await clickText('+');
await pickAssetType(t, 'Bus');
@@ -2395,6 +2396,9 @@ export async function sound(t) {
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
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;
await click(tabPlay.x, tabPlay.y);
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 heardOn = (await stats()).last?.bus;
const peak = await waitFor(
() => json(`__gm.routing.peaks([${JSON.stringify(`mix:${busB.id}`)}])`),
(peaks) => Math.max(...(peaks[`mix:${busB.id}`] ?? [0])) > 0.01,
() => json(`__gm.routing.peaks(${tapped})`),
(peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01,
);
await click(tabPlay.x, tabPlay.y);
const levelOf = (name) => buses.find((bus) => bus.name === name);
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())
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.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:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
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 }),
);