Files
feuerwerk/src/editor/PlaySession.ts
T
adminandClaude Opus 5.5 25c1d02b06
game-maker / gate (push) Successful in 3m45s
game-maker / suite (push) Successful in 14m44s
Keep the audio thread free of ended voices and too many vocoders (#356)
A song that had been played a few times in a long session stuttered
at regular intervals until the page was reloaded. A quick run up the
keys of a sampled instrument made every sound drop out for a second
or two. Both came from work the audio thread should not have had.

- Every worklet node of a voice listened on its port. Chrome never
  collects a port that is listened to, and through its listener not
  the voice either. Every node of the voice stayed on its bus and was
  worked on in every block. A way or stage on the worklet now closes
  its port once it ends or is stopped, and the processor closes its
  own side when it is told to stop.
- A voice without echoes now leaves its bus as soon as its sound and
  what its stages hold back are through. Before, only a voice with
  echoes did.
- At most 12 ways play through the pitch worklet at once. One more
  cuts the oldest in 10 ms, a note already let go before one still
  held. Each runs a phase vocoder, whose first block alone took
  2.7 ms of the 2.67 ms a block has on stein2.
- Play for the game ends everything the tabs play: the piece holding
  the transport and every key or preview (`stopPreviews`).
- Closing a Sound, Sound FX, Instrument, Pattern or Song tab stops
  what it plays of that asset (`AssetTabEditor.closed`).
- A preset in the Instrument tab plays nothing any more. With
  Audition on it is heard like any other change.
- Test hooks: `__gm.instrument.play`, `__gm.sound.playing`, and
  `vocoders` in the stats of the sound library.

Measured:
- A copy of the project, its Song 2 played for 12 s and stopped, six
  times (Chrome on stein2, CDP WebAudio): before, 746 nodes were left
  after the first time and 4469 after the sixth, the load at rest
  grew from 0.01 to 0.06. After, 7 nodes are left every time (the
  master and one bus) and the load at rest stays at 0.01 to 0.02.
- Pitch-player voices live in Chrome on melektaus-atx: 12 take 0.50
  of the audio thread, 16 take 0.71, 24 take 0.99. 30 struck 40 ms
  apart held it at 0.99 from the first second on.

Tests:
- Unit sound-library: 110 green (3 new), 4 counter-probes each red
- e2e 34 (melektaus-atx): ALL PASSED, 86 checks (3 new, 2 changed)
- e2e counter-probe with game start and preset undone: their 3
  checks red, and the old check of Play note in the game as well,
  which now counts on the preview being gone
- e2e counter-probe with tab close undone: exactly its check red. A
  first form of it held a key and stayed green, since the keys let go
  of a held key by themselves once the tab shows no asset
- Gate (melektaus-atx): 270 files, 4181 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:57:18 +02:00

357 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Owns everything that only exists while the project is playing: the world, its systems, the
* keyboard and the per-frame step. Play never touches the saved model – the world is built
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
*/
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
import { namedValue } from '../core/graph/values';
import { GameRun, type RunServices } from '../runtime/GameRun';
import { AnimationSystem } from '../runtime/systems/AnimationSystem';
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input';
import type { World } from '../runtime/World';
import { ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import { FrameMeter, type FrameTimes } from './frameMeter';
import { saveArea } from './saves';
import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle';
import type { GameViewport } from './viewport/GameViewport';
import type { GraphEditor } from './graph/GraphEditor';
import type { LiveMachineState } from './graph/GraphCanvas';
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
import type { EditorState } from './state';
import { stopPreviews } from './music/transport';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: {
objectId: string;
assetId: string;
name: string;
x: number;
y: number;
worldX: number;
worldY: number;
parent: string;
props: Record<string, unknown>;
animations: AnimationPlayer[];
}[];
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
shared: SharedState;
log: string[];
}
export interface PlayHost {
gameView: GameViewport;
/** The scene view: while a run holds the panels back, it still draws every frame it is on screen. */
viewport: { visible: boolean; redraw(): void };
graphEditor: GraphEditor;
stateMachineEditor: StateMachineEditor;
showGame(): void;
/** Leave the game view if it is the one on screen. */
leaveGame(): void;
}
export class PlaySession {
private run: GameRun | null = null;
private readonly input = new Input();
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
private waiting = false;
/** When the panels catch up with the run (#265). */
private readonly panels = new PanelClock();
/** How often the panels were brought up to date this run (test hook). */
private updates = { structure: 0, values: 0 };
/** Where the frames of this run go (#275). */
private readonly meter = new FrameMeter();
constructor(
private readonly state: EditorState,
private readonly host: PlayHost,
) {
// An object picked during a run is looked at now: the hierarchy has to list it to show it picked.
state.on('selection', () => {
if (this.world && this.panels.holdsStructure) this.follow(this.panels.flush());
});
}
private get world(): World | null {
return this.run?.world ?? null;
}
private get behaviors(): BehaviorSystem | null {
return this.run?.behaviors ?? null;
}
get isPlaying(): boolean {
return this.world !== null;
}
get isPaused(): boolean {
return this.world !== null && this.state.paused;
}
/** Paused by a behavior (Pause game) rather than by the toolbar – the game's own pause. */
get isGamePaused(): boolean {
return this.world?.paused ?? false;
}
/** Pause or resume; does nothing while not playing. */
togglePause(): void {
if (!this.world) return;
this.state.setPaused(!this.state.paused);
// What you inspect while paused is exact, whatever the panels were holding back.
if (this.state.paused) this.follow(this.panels.flush());
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
// on mid-note, rather than playing to their end over a still image.
soundLibrary.setSuspended(this.state.paused);
ui().focus.blur();
this.state.setNotice(this.playNotice(), 'quiet');
}
traceFor(assetId: string): Trace | null {
return this.behaviors?.traceFor(assetId) ?? null;
}
/** Which state each running instance of a machine holds; empty when nothing is playing. */
machineStates(machineId: string): LiveMachineState[] {
return this.behaviors?.machineStates(machineId) ?? [];
}
get time(): number {
return this.world?.time ?? 0;
}
/** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */
get sceneId(): string {
return this.world?.sceneId ?? '';
}
syncFromModel(): void {
this.world?.syncFromModel();
this.behaviors?.syncFromModel();
}
/** The values a Variables asset shares in this run; null when nothing is playing. */
liveVariables(assetId: string): PropertyBag | null {
return this.behaviors?.liveVariables(assetId) ?? null;
}
play(): void {
const scene = this.state.sceneToPlay();
if (!scene) return;
// The editor loads the engine right after opening, so it is almost always there already; a game
// without it would collide differently, so Play waits for it rather than running without (#146).
const physics = readyPhysics();
if (!physics) {
this.waitForPhysics();
return;
}
ui().focus.blur();
stopPreviews();
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
const runtime = this.state.enterPlay();
const services: RunServices = {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
};
this.run = GameRun.start(runtime, scene.id, this.input, services);
this.updates = { structure: 0, values: 0 };
this.meter.reset();
this.timeSystems(this.run);
this.input.attach();
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
if (this.run.world.stopRequested) this.stop();
}
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
private sceneLoaded(run: GameRun): void {
this.timeSystems(run);
this.host.gameView.setWorld(run.world);
this.state.setNotice(this.playNotice(), 'quiet');
if (run.world.stopRequested) this.stop();
}
private waitForPhysics(): void {
if (this.waiting) return;
this.waiting = true;
this.state.setNotice('Loading the physics engine…', 'quiet');
loadPhysics().then(
() => {
this.waiting = false;
if (this.state.mode === 'edit') this.play();
},
(error: unknown) => {
this.waiting = false;
const reason = error instanceof Error ? error.message : String(error);
console.error('[physics] Play needs the physics engine, which could not be loaded:', error);
this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error');
},
);
}
private playNotice(): string {
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
if (this.world?.paused) return 'The game paused itself · Esc stops';
return this.world?.activeCamera()
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
: 'No camera is switched on – nothing is rendered · Esc stops';
}
stop(): void {
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
gameClock.reset();
// And its sounds end with it, looping music included – nothing may outlive the world.
soundLibrary.setSuspended(false);
soundLibrary.stopAll();
const world = this.world;
this.run = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
this.host.gameView.setWorld(null);
this.state.exitPlay();
this.host.leaveGame();
const lastError = world && behaviorError(world);
this.state.setNotice(lastError ?? '', 'error');
}
/**
* `step` is what the global clock moved this frame – already scaled and capped. `awake` is the
* step it would have moved without the game's own pause, which stops the clock but not the
* behaviors that run while paused.
*/
tick(step: number, awake: number): void {
const run = this.run;
if (!run) return;
const world = run.world;
if (this.state.paused) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
this.input.endFrame();
world.dropPointerEdges();
return;
}
const wasPaused = world.paused;
world.held = this.state.heldObjectIds;
const request = run.tick(step, awake);
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
if (request === 'stop') {
this.stop();
return;
}
if (request === 'restart') {
this.stop();
this.play();
return;
}
if (request === 'scene') {
this.sceneLoaded(run);
return;
}
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
// edit made during the run lands on the model, and it must not land on an old picture of it.
const structure = world.syncToModel().structure;
const update = this.panels.frame(
panelPolicy(this.state.project.whilePlaying),
performance.now(),
structure,
);
// A game that pauses itself is looked at like one paused from the toolbar.
this.follow(world.paused && !wasPaused ? this.panels.flush() : update);
}
/** Brings the panels up to date as far as `update` says; the game's own picture never waits. */
private follow(update: PanelUpdate): void {
if (update.structure) {
this.updates.structure++;
this.state.preview();
}
if (update.values) {
this.updates.values++;
this.state.runtimeTick();
} else {
// The views draw on `runtime`, and what is on screen of the game has to move every frame.
if (this.host.gameView.visible) this.host.gameView.redraw();
if (this.host.viewport.visible) this.host.viewport.redraw();
}
if (!update.graphs) return;
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
}
/** The world tells the meter what each system took; a `Load scene` brings a world of its own. */
private timeSystems(run: GameRun): void {
run.world.timeSystem = (system, ms) => {
const area =
system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics';
this.meter.addSystem(area, ms);
};
}
/**
* Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is
* ready. Nothing is measured while nothing plays.
*/
measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean {
return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered);
}
/** How many entities run and where the frames went lately; null while nothing plays. */
performance(): { entities: number; times: FrameTimes | null } | null {
const world = this.world;
return world ? { entities: world.entities.length, times: this.meter.average } : null;
}
/** How often the panels caught up with this run, by kind (test hook). */
panelUpdates(): { structure: number; values: number } {
return { ...this.updates };
}
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
snapshot(): WorldSnapshot | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return {
objectId: e.objectId,
assetId: e.assetId,
name: e.name,
x: e.x,
y: e.y,
worldX: w.x,
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: namedBag(e.props),
animations: structuredClone(e.animations ?? []),
};
}),
shared: namedShared(this.behaviors?.shared ?? emptySharedState()),
log: [...world.log],
};
}
}
function namedBag(bag: PropertyBag): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value);
return out;
}
function namedShared(shared: SharedState): SharedState {
const variables: Record<string, PropertyBag> = {};
for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag;
return { variables, states: shared.states };
}