Files
feuerwerk/src/editor/debugHook.ts
T
adminandClaude Opus 5.5 8f9c250eb6
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s
Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:28:25 +02:00

142 lines
6.9 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 { Application } from 'pixi.js';
import { physicsStatus } from '../runtime/physics/engine';
import type { SchedulerOptions } from '../core/music/Scheduler';
import type { Sequence } from '../core/music/sequence';
import type { FxEnvelope, FxStage } from '../core/soundfx/nodes';
import { Music } from '../viewport/music';
import {
bothAtCenter,
envelopeDriver,
followCurve,
releaseEnvelope,
soundLibrary,
stageNodes,
} from '../viewport/SoundLibrary';
import { curveHooks } from './debug/curveHooks';
import { patternHooks } from './debug/patternHooks';
import { songHooks } from './debug/songHooks';
import { documentHooks } from './debug/documentHooks';
import { graphHooks } from './debug/graphHooks';
import { centreOf, type HookContext } from './debug/hookHelpers';
import { pixelArtHooks } from './debug/pixelArtHooks';
import { renderHooks } from './debug/renderHooks';
import { sceneHooks } from './debug/sceneHooks';
import { uiHooks } from './debug/uiHooks';
import { vectorArtHooks } from './debug/vectorArtHooks';
import type { DocumentId, Editor, TabId } from './Editor';
import { NodeBoard } from './plugins/NodeBoard';
import type { EditorState } from './state';
import { rectOf } from './textProbe';
/**
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
* visible text and to read editor state. The hooks of each area live in `debug/`.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const context: HookContext = { app, state, editor };
const music = new Music(() => state.project);
(window as unknown as { __gm: unknown }).__gm = {
state,
/** The Pixi application, for probes that need the renderer or the stage. */
app,
...uiHooks(context),
...sceneHooks(context),
...graphHooks(context),
...documentHooks(context),
...pixelArtHooks(context),
...vectorArtHooks(context),
...curveHooks(context),
...patternHooks(context),
...songHooks(context),
...renderHooks(context),
/**
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
* is ever heard there: these counters are the only way a check can see that a graph played
* anything at all, the way `renderStats` is for a repaint that did not happen.
*/
sound: {
stats: () => soundLibrary.stats(),
duration: (file: string) => soundLibrary.duration(file),
/** Where the knobs of each voice of that file are now, oldest first, a pitch as its speed (#315). */
levels: (file: string) => soundLibrary.levels(file),
pitchWorkletUrl: () => soundLibrary.pitchWorkletUrl(),
/** Seconds on the audio clock: the time a voice really runs on. */
clock: () => soundLibrary.clock(),
/** Decodes ahead of a check, so a play can be asserted without racing the network. */
load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null),
stopAll: () => soundLibrary.stopAll(),
/** What Stop sound asks for: every voice of that file, its echoes cut or left to ring (#324). */
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
/** The patterns a game plays, with the notes last planned of each. */
patterns: () => soundLibrary.patterns(),
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
/** What drives the two shares of a Mix "Both at center", in any context (unstarted). */
bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) =>
bothAtCenter(context, mix, a, b),
/** The curve of an Envelope from `at` (unstarted), its release, and how it moves a number (#335). */
envelopeDriver: (context: BaseAudioContext, envelope: FxEnvelope, at: number) =>
envelopeDriver(context, envelope, at),
releaseEnvelope: (level: AudioParam, envelope: FxEnvelope, startedAt: number, at: number) =>
releaseEnvelope(level, envelope, startedAt, at),
stageWith: (context: BaseAudioContext, stage: FxStage, driver: ConstantSourceNode) =>
stageNodes(context, stage, (mod, param, ratio, map) =>
followCurve(context, driver, mod, param, ratio, map),
),
},
/**
* The scheduler (#332), which nothing in the editor plays yet: a check hands it a sequence of
* notes and reads back what was planned for which second of the audio clock.
*/
music: {
play: (sequence: Sequence, options?: SchedulerOptions & { from?: number }) =>
music.play(sequence, options),
edit: (sequence: Sequence) => music.edit(sequence),
stop: () => music.stop(),
position: () => music.current?.position() ?? null,
playing: () => music.current?.playing ?? false,
planned: () => [...music.planned],
latency: () => soundLibrary.latency(),
},
world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(),
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
playFrom: () => editor.playFromInfo(),
/** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */
runButtons: () => editor.runButtons(),
/** Where to click one of the icon buttons: menu, undo, redo, play, pause. */
toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]),
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
editorRun: () => editor.editorRunInfo(),
physics: () => physicsStatus(),
tab: () => editor.tab,
showTab: (tab: TabId) => editor.showTab(tab),
/** The open documents in tab order and the one showing (#171); a document is `asset:<id>` or `list`. */
documents: () => editor.openDocuments(),
openDocument: (doc: DocumentId) => editor.openDocument(doc),
closeDocument: (doc: DocumentId) => editor.closeDocument(doc),
plugins: {
/** Every file in `data/plugins/` and what became of it. */
records: () => [...(editor.plugins?.records ?? [])],
/** The plugins in the user management tab (#179): one row per file, with its switch. */
pane: () => ({ ...editor.pluginsPane.debugInfo(), visible: editor.tab === 'management' }),
/** Where to click the switch of one plugin; null when it has no row. */
switchAt: (file: string) => {
const box = editor.pluginsPane.switchOf(file);
return box ? centreOf(box) : null;
},
/** A plugin tab's node board as drawn, with its canvas and Fit button; null without one. */
board: (tabId: string) => {
const pane = editor.pluginPane(tabId);
if (!(pane instanceof NodeBoard)) return null;
return {
...pane.debugInfo(),
visible: pane.visible,
rect: rectOf(pane),
fit: centreOf(pane.fitButton),
};
},
},
};
}