Files
feuerwerk/src/editor/AssetTabEditor.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

151 lines
5.0 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.
import type { Asset } from '../core/model/Asset';
import { findAsset } from '../core/model/Project';
import { Button, Label, Spacer, VStack, type Widget } from '../ui';
import type { EditorState } from './state';
import { TabHeader } from './TabHeader';
/**
* The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much.
* A canvas without `fit` belongs to a tab whose Fit frames something else (see `AssetTabEditor.fit`).
* `saveView`/`restoreView` carry zoom and scroll across a switch to another document and back.
*/
export interface TabCanvas {
readonly currentAssetId: string;
show(assetId: string): void;
fit?(): void;
saveView?(): unknown;
restoreView?(view: unknown): void;
}
export interface AssetTabOptions {
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
infoWidth: number;
}
/**
* The Behavior, State machine, Texture, Container … tabs. One editor per kind serves every open
* document of that kind (#171): the tab row tells it which one to show, and it keeps each one's
* view for when it comes back. A subclass must call `mount()`: header and canvas depend on each
* other, so the base cannot build them.
*/
export abstract class AssetTabEditor<
A extends Asset,
C extends TabCanvas & Widget = TabCanvas & Widget,
> extends VStack {
readonly fitButton: Button;
protected readonly info: Label;
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
canvas!: C;
private readonly views = new Map<string, unknown>();
constructor(
protected readonly state: EditorState,
options: AssetTabOptions,
) {
super();
this.fitButton = new Button('Fit', { onClick: () => this.fit() });
this.info = new Label('', { style: 'small', align: 'right' });
this.info.minWidth = options.infoWidth;
}
/** Builds the header (with `strip` below it) around `canvas` and wires the state events. */
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
this.canvas = canvas;
const header = new TabHeader();
for (const widget of extras) header.add(widget);
header.add(this.fitButton);
header.add(new Spacer(), 1);
header.add(this.info);
this.add(header);
if (strip) this.add(strip);
this.add(this.body(canvas), 1);
this.state.on('project', () => this.refresh());
this.state.on('load', () => {
this.views.clear();
this.canvas.show('');
this.refresh();
});
this.refresh();
}
/** The asset currently shown, `''` for none – the check scripts read this. */
get currentAssetId(): string {
return this.canvas.currentAssetId;
}
/** The right-hand readout as shown – the check scripts read this. */
get shownInfo(): string {
return this.info.text;
}
/** Shows a document, with the view it had when it was last on screen. */
showAsset(assetId: string): void {
const from = this.canvas.currentAssetId;
if (from !== assetId) {
if (from) this.views.set(from, this.saveView());
this.canvas.show(assetId);
const view = this.views.get(assetId);
if (view !== undefined) this.restoreView(view);
}
this.refresh();
}
/** A closed tab: opened again, the document starts from a fitted view. What it played stops. */
forgetView(assetId: string): void {
this.views.delete(assetId);
this.closed(assetId);
}
/** Stops what the tab plays of that asset; a tab that plays nothing has nothing to stop. */
protected closed(_assetId: string): void {}
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
abstract selectedId(): string;
abstract isMine(asset: Asset): asset is A;
/** The right-hand readout; `null` is the empty state. */
protected abstract infoText(asset: A | null): string;
/** What fills the tab below the header. Override to put something beside the canvas. */
protected body(canvas: C): Widget {
return canvas;
}
/** What the header's Fit button frames. Override when the tab has more than one view. */
protected fit(): void {
this.canvas.fit?.();
}
/** The view to come back to; override when the tab has more than one. */
protected saveView(): unknown {
return this.canvas.saveView?.();
}
protected restoreView(view: unknown): void {
this.canvas.restoreView?.(view);
}
/** The canvas's area on screen, without the header – so a check can ask what lies outside it. */
canvasRect(): { x: number; y: number; w: number; h: number } {
const p = this.canvas.getGlobalPosition();
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
}
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
protected syncExtras(_asset: A | null): void {}
protected asset(): A | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && this.isMine(asset) ? asset : null;
}
protected refresh(): void {
const asset = this.asset();
this.info.setText(asset ? this.infoText(asset) : '');
this.syncExtras(asset);
}
}