@vgai/engine 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -11
- package/package.json +10 -6
- package/schemas/engine-api.json +1 -68
- package/schemas/engine-api.md +1 -32
- package/schemas/engine-capabilities.json +30 -42
- package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
- package/src/adapter/adapter-surface.ts +5 -5
- package/src/adapter/authoring.ts +168 -226
- package/src/adapter/colyseus-networking-adapter.ts +44 -5
- package/src/adapter/first-party-systems.ts +156 -42
- package/src/adapter/host-context.ts +177 -46
- package/src/adapter/index.ts +47 -51
- package/src/adapter/ingest/game-contract.ts +2 -2
- package/src/adapter/ingest/scene-capture.ts +18 -19
- package/src/adapter/ingest/structural-ids.ts +127 -0
- package/src/adapter/ingest/upstream-pin.ts +9 -12
- package/src/adapter/loop-gate-report.ts +11 -11
- package/src/adapter/rapier-physics-adapter.ts +27 -9
- package/src/adapter/root-adapter.ts +217 -0
- package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
- package/src/adapter/system-adapter.ts +80 -63
- package/src/ai/navigation.ts +1 -1
- package/src/animation/animation-clock.ts +1 -1
- package/src/animation/camera-ownership.ts +1 -2
- package/src/animation/cubic-spline-interpolant.ts +132 -0
- package/src/animation/theatre-clock-binding.ts +2 -2
- package/src/animation/theatre-object-binding.ts +4 -4
- package/src/animation/xstate-animation-binding.ts +75 -5
- package/src/{scene/schema → asset-formats}/camera.ts +2 -5
- package/src/{scene/schema → asset-formats}/collider.ts +2 -5
- package/src/asset-formats/index.ts +54 -0
- package/src/{scene/schema → asset-formats}/instances.ts +10 -6
- package/src/{scene/schema → asset-formats}/light.ts +3 -6
- package/src/{scene/schema → asset-formats}/material.ts +4 -7
- package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
- package/src/asset-formats/parse.ts +39 -0
- package/src/{scene/schema → asset-formats}/particles.ts +3 -6
- package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
- package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
- package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
- package/src/asset-parse-error.ts +33 -0
- package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
- package/src/assets.ts +1 -1
- package/src/audio/wav-encode.ts +9 -9
- package/src/canvas-react/engine-bridge.ts +59 -0
- package/src/canvas-react/index.ts +50 -0
- package/src/canvas-react/pixi-primitive.tsx +202 -0
- package/src/canvas-react/pixi-react-adapter.tsx +290 -0
- package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
- package/src/canvas-react/world-context.ts +328 -0
- package/src/core/frame-pacing.ts +100 -0
- package/src/core/game-loop.ts +50 -28
- package/src/core/seeded-random.ts +7 -7
- package/src/core/sim-clock.ts +388 -0
- package/src/core/system-runner.ts +17 -63
- package/src/core/types.ts +34 -15
- package/src/data/data-asset.ts +3 -3
- package/src/data/data-check-core.ts +6 -7
- package/src/data/data-ref.ts +11 -11
- package/src/data/vite-plugin-data.ts +10 -10
- package/src/{scene/defaults.ts → defaults.ts} +18 -40
- package/src/dev/render-debug-adapter.ts +1 -1
- package/src/dev/webgl-frame-capture.ts +1 -1
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/{scene → ecs}/user-data.ts +17 -36
- package/src/index.ts +7 -9
- package/src/input/input-manager.ts +29 -32
- package/src/input/input-types.ts +2 -2
- package/src/input/schema.ts +5 -5
- package/src/loader.ts +57 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +49 -0
- package/src/manifest/index.ts +8 -2
- package/src/manifest/load-file.ts +11 -0
- package/src/manifest/load.ts +65 -77
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/schema.ts +400 -233
- package/src/{scene → physics}/collider-dimensions.ts +3 -3
- package/src/physics/physics-registry.ts +1 -1
- package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
- package/src/pixi/index.ts +43 -0
- package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
- package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
- package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
- package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
- package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
- package/src/react/unmanaged-root-detector.ts +26 -1
- package/src/react/use-data.ts +5 -5
- package/src/react/use-selection.tsx +15 -42
- package/src/react/{game-state.tsx → world-state.tsx} +44 -46
- package/src/render/auto-batcher.ts +1 -2
- package/src/{scene → render}/instance-mesh.ts +1 -1
- package/src/{scene → render}/light-camera-factory.ts +14 -13
- package/src/render/lod.ts +17 -0
- package/src/{scene → render}/material-factory.ts +8 -7
- package/src/{scene → render}/particles-factory.ts +62 -12
- package/src/render/render-batch-system.ts +14 -41
- package/src/render/render-features.ts +1 -1
- package/src/render/render-settings.ts +1 -2
- package/src/render/spark-renderer-lifecycle.ts +1 -1
- package/src/runtime/create-runtime.ts +352 -463
- package/src/runtime/debug-bridge.ts +148 -93
- package/src/runtime/debug-registry.ts +79 -54
- package/src/runtime/dev-layers.ts +40 -0
- package/src/runtime/frame-selector-cache.ts +4 -4
- package/src/runtime/game.ts +618 -369
- package/src/runtime/gameplay-rng-trap.ts +6 -7
- package/src/runtime/input-router.ts +11 -11
- package/src/runtime/mount-game.ts +54 -55
- package/src/runtime/mount-manifest.ts +154 -150
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-audio-control.ts +64 -53
- package/src/runtime/render-control.ts +45 -51
- package/src/runtime/render-seed.ts +3 -4
- package/src/runtime/state-bridge.ts +17 -18
- package/src/runtime/types.ts +94 -61
- package/src/setup/setup-renderer.ts +11 -6
- package/src/world3d-react/engine-bridge.ts +46 -33
- package/src/world3d-react/index.ts +31 -26
- package/src/world3d-react/r3f-adapter.tsx +211 -96
- package/src/world3d-react/r3f-root-factory.tsx +91 -0
- package/src/world3d-react/renderer-config.ts +137 -0
- package/src/world3d-react/world-context.ts +141 -111
- package/schemas/entity2d.schema.json +0 -468
- package/schemas/prefab.schema.json +0 -9992
- package/schemas/scn2d.schema.json +0 -494
- package/schemas/vscn.schema.json +0 -10851
- package/src/adapter/game-adapter.ts +0 -164
- package/src/adapter/ingest/overlay-applier.ts +0 -207
- package/src/adapter/ingest/overlay-apply.ts +0 -168
- package/src/adapter/ingest/overlay-file.ts +0 -126
- package/src/adapter/ingest/overlay-report.ts +0 -176
- package/src/animation/gsap-registration.ts +0 -184
- package/src/audio/audio-introspection.ts +0 -290
- package/src/audio/index.ts +0 -39
- package/src/audio/tone-clock-binding.ts +0 -98
- package/src/audio/tone-context.ts +0 -175
- package/src/audio/tone-offline-render.ts +0 -167
- package/src/ecs/component-manager.ts +0 -814
- package/src/ecs/game-component.ts +0 -260
- package/src/ecs/hmr-swap-report.ts +0 -65
- package/src/physics/trigger-dispatch.ts +0 -97
- package/src/react/root-adapter.tsx +0 -49
- package/src/scene/asset-paths.ts +0 -121
- package/src/scene/asset-ref-check.ts +0 -248
- package/src/scene/component-registry.ts +0 -51
- package/src/scene/parse.ts +0 -204
- package/src/scene/scene-apply.ts +0 -407
- package/src/scene/scene-diff-schema.ts +0 -115
- package/src/scene/scene-diff-types.ts +0 -29
- package/src/scene/scene-loader.ts +0 -1526
- package/src/scene/scene-query.ts +0 -63
- package/src/scene/scene-types.ts +0 -33
- package/src/scene/scene-version.ts +0 -40
- package/src/scene/schema/animation.ts +0 -46
- package/src/scene/schema/audio.ts +0 -25
- package/src/scene/schema/entity-ref.ts +0 -78
- package/src/scene/schema/entity.ts +0 -189
- package/src/scene/schema/index.ts +0 -51
- package/src/scene/schema/joint.ts +0 -26
- package/src/scene/schema/physics.ts +0 -49
- package/src/scene/schema/scene-file.ts +0 -292
- package/src/scene/schema/shadow.ts +0 -24
- package/src/scene/schema/spline.ts +0 -21
- package/src/world2d/asset-paths2d.ts +0 -44
- package/src/world2d/capture-to-scene2d.ts +0 -52
- package/src/world2d/collision-2d.ts +0 -99
- package/src/world2d/entity2d-asset.ts +0 -22
- package/src/world2d/index.ts +0 -91
- package/src/world2d/physics2d-transform.ts +0 -173
- package/src/world2d/physics2d-units.ts +0 -10
- package/src/world2d/pixi-game-adapter.ts +0 -439
- package/src/world2d/pixi-surface.ts +0 -78
- package/src/world2d/scene2d-identity.ts +0 -49
- package/src/world2d/scene2d-loader.ts +0 -433
- package/src/world2d/schema/entity2d.ts +0 -163
- package/src/world2d/schema/physics2d.ts +0 -64
- package/src/world2d/schema/sprite.ts +0 -99
- package/src/world2d/schema/tilemap.ts +0 -39
- package/src/world2d/schema/tuples2d.ts +0 -25
- package/src/world2d/transform-writer-2d.ts +0 -42
- package/src/world2d/types.ts +0 -74
- package/src/world3d-react/behavior.tsx +0 -146
- /package/src/{scene → render}/mesh-shadow.ts +0 -0
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Overlay apply report — D9 (docs/DECISIONS-PENDING.md §D9): decide + surface
|
|
3
|
-
* what happens when a saved ingest overlay is reapplied onto a scene that may
|
|
4
|
-
* have moved on since the overlay was authored (a vendored game bump, or a
|
|
5
|
-
* dynamic subtree that never respawned). Two independent signals, either of
|
|
6
|
-
* which is worth a loud (never silent) structured warning:
|
|
7
|
-
* - a GAME-VERSION mismatch: the overlay's `authoredAgainst.gameVersion`
|
|
8
|
-
* (`./overlay-file.ts`'s envelope, stamped from the manifest's `version`
|
|
9
|
-
* at save time) disagrees with the currently-mounted game's version;
|
|
10
|
-
* - ORPHANED ids: structural-path ids in the overlay that resolved to
|
|
11
|
-
* nothing on the current scene walk (`./overlay-apply.ts`'s
|
|
12
|
-
* `applyOverlayToObjects`).
|
|
13
|
-
*
|
|
14
|
-
* Neither condition blocks mounting — the overlay still applies everything it
|
|
15
|
-
* CAN; this module only decides whether/how to report what it couldn't.
|
|
16
|
-
*
|
|
17
|
-
* This is the SEAM T3.8 (the runtime overlay applier, i.e. reapplying an
|
|
18
|
-
* ingest overlay against a shipped/played build rather than the editor)
|
|
19
|
-
* reuses: `./overlay-applier.ts`'s `applyVgaiOverlay` builds + logs this EXACT
|
|
20
|
-
* report shape, same as the editor does. Moved here from the editor-only
|
|
21
|
-
* module that used to own it (`packages/editor/src/authoring/overlay-report.ts`,
|
|
22
|
-
* which now re-exports everything below unchanged plus the editor-only
|
|
23
|
-
* "active report" banner slot) — kept dependency-free (no `editor-console`/
|
|
24
|
-
* `window` import) so it stays importable from a headless runtime context —
|
|
25
|
-
* same discipline as `../achieved-tier.ts`.
|
|
26
|
-
*
|
|
27
|
-
* R5 (docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)/§7.1 item 9) adds a THIRD
|
|
28
|
-
* independent signal alongside version-mismatch/orphans: an UPSTREAM-PIN
|
|
29
|
-
* mismatch. `gameVersion` alone cannot catch a same-slot substitution after a
|
|
30
|
-
* re-vendor (structural ids are position+type+name; a re-vendor that keeps a
|
|
31
|
-
* slot's shape reapplies the overlay onto a semantically different object
|
|
32
|
-
* with `applied:1, orphanedIds:[]`, and every vendored game's manifest
|
|
33
|
-
* `version` is hand-set and need not change on a re-vendor). The upstream pin
|
|
34
|
-
* (`./upstream-pin.ts`, read from the vendored game's `UPSTREAM.md`) is bound
|
|
35
|
-
* to the thing that actually changes. Like the other two signals, a mismatch
|
|
36
|
-
* never blocks application — it's a report, not a block (matching orphan
|
|
37
|
-
* semantics) — so a re-vendor surfaces as an explicit "authored against pin
|
|
38
|
-
* X, applying onto pin Y" instead of silent success.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/** One mount's version + orphan findings — the D9 acceptance-criteria shape. */
|
|
42
|
-
export interface OverlayApplyReport {
|
|
43
|
-
gameId: string;
|
|
44
|
-
/** `authoredAgainst.gameVersion` from the loaded overlay envelope, or `null` if never stamped. */
|
|
45
|
-
authoredAgainst: string | null;
|
|
46
|
-
/** The currently-mounted game's manifest `version`, or `null` if unknown at this call site. */
|
|
47
|
-
currentVersion: string | null;
|
|
48
|
-
/** Both sides known AND different — never true if either side is `null` (anti-shim: no guessing). */
|
|
49
|
-
versionMismatch: boolean;
|
|
50
|
-
applied: number;
|
|
51
|
-
/** Structural-path ids present in the overlay that resolved to nothing on this scene walk. */
|
|
52
|
-
orphanedIds: string[];
|
|
53
|
-
/**
|
|
54
|
-
* R5 — upstream-pin identity check (see this module's header). Distinct
|
|
55
|
-
* from `versionMismatch`/`orphanedIds`: it catches a same-slot
|
|
56
|
-
* SUBSTITUTION (id persists, meaning changed) that those two cannot.
|
|
57
|
-
*/
|
|
58
|
-
upstreamPin: {
|
|
59
|
-
/** The pin the overlay was authored against, or `null` if never recorded/discoverable. */
|
|
60
|
-
authored: string | null;
|
|
61
|
-
/** The currently-mounted game's own discoverable pin, or `null` if undiscoverable here
|
|
62
|
-
* (e.g. the runtime ship-path applier, which has no `UPSTREAM.md` access at all — a
|
|
63
|
-
* documented no-op there — or an externally-authored, non-vendored project). */
|
|
64
|
-
current: string | null;
|
|
65
|
-
/**
|
|
66
|
-
* Whether the SAVED overlay envelope recorded the upstream-pin concept at
|
|
67
|
-
* all (i.e. `authoredAgainst.upstreamPin` was a present key, even if its
|
|
68
|
-
* value was `null`). `false` means a legacy overlay — saved by pre-R5
|
|
69
|
-
* code (or the pre-D9 flat-file shape) — for which the pin check is
|
|
70
|
-
* skipped entirely, not silently treated as a match. This is the
|
|
71
|
-
* "explicit legacy indication" the report surfaces so a legacy overlay's
|
|
72
|
-
* clean apply doesn't read as "verified" when it was never checked.
|
|
73
|
-
*/
|
|
74
|
-
recorded: boolean;
|
|
75
|
-
/** `recorded` AND both sides known AND different — never true otherwise (anti-shim). */
|
|
76
|
-
mismatch: boolean;
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Greppable prefix shared by every overlay-related log line (mirrors `achieved-tier.ts`'s `TIER_SHORTFALL_PREFIX`). */
|
|
81
|
-
export const OVERLAY_REPORT_PREFIX = 'overlay-report';
|
|
82
|
-
|
|
83
|
-
/** Build the report from a mount's raw findings — pure, no logging. */
|
|
84
|
-
export function buildOverlayApplyReport(args: {
|
|
85
|
-
gameId: string;
|
|
86
|
-
authoredAgainst: string | null;
|
|
87
|
-
currentVersion: string | null;
|
|
88
|
-
applied: number;
|
|
89
|
-
orphanedIds: string[];
|
|
90
|
-
/**
|
|
91
|
-
* R5: the loaded overlay's `authoredAgainst.upstreamPin`. Pass `undefined`
|
|
92
|
-
* (or omit) when the saved envelope never had the key at all (legacy
|
|
93
|
-
* overlay — `upstreamPin.recorded` becomes `false`); pass `null` when the
|
|
94
|
-
* envelope DID have the key but its value was `null` (R5-aware code
|
|
95
|
-
* looked, found nothing — `recorded` is `true`, `authored` is `null`); pass
|
|
96
|
-
* the pin string otherwise.
|
|
97
|
-
*/
|
|
98
|
-
authoredUpstreamPin?: string | null | undefined;
|
|
99
|
-
/**
|
|
100
|
-
* R5: the currently-mounted game's own discoverable pin, or `null`/omitted
|
|
101
|
-
* when undiscoverable at this call site (anti-shim: never fabricated).
|
|
102
|
-
*/
|
|
103
|
-
currentUpstreamPin?: string | null | undefined;
|
|
104
|
-
}): OverlayApplyReport {
|
|
105
|
-
const versionMismatch =
|
|
106
|
-
args.authoredAgainst !== null &&
|
|
107
|
-
args.currentVersion !== null &&
|
|
108
|
-
args.authoredAgainst !== args.currentVersion;
|
|
109
|
-
const recorded = args.authoredUpstreamPin !== undefined;
|
|
110
|
-
const authoredPin = args.authoredUpstreamPin ?? null;
|
|
111
|
-
const currentPin = args.currentUpstreamPin ?? null;
|
|
112
|
-
const upstreamPinMismatch =
|
|
113
|
-
recorded && authoredPin !== null && currentPin !== null && authoredPin !== currentPin;
|
|
114
|
-
return {
|
|
115
|
-
gameId: args.gameId,
|
|
116
|
-
authoredAgainst: args.authoredAgainst,
|
|
117
|
-
currentVersion: args.currentVersion,
|
|
118
|
-
applied: args.applied,
|
|
119
|
-
orphanedIds: args.orphanedIds,
|
|
120
|
-
versionMismatch,
|
|
121
|
-
upstreamPin: {
|
|
122
|
-
authored: authoredPin,
|
|
123
|
-
current: currentPin,
|
|
124
|
-
recorded,
|
|
125
|
-
mismatch: upstreamPinMismatch,
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Whether this report is worth surfacing (mismatch OR any orphan) — pure, no logging. */
|
|
131
|
-
export function overlayReportWarrants(report: OverlayApplyReport): boolean {
|
|
132
|
-
return report.versionMismatch || report.orphanedIds.length > 0 || report.upstreamPin.mismatch;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The one shared message shape — greppable on the prefix, JSON-parseable on the trailing payload. */
|
|
136
|
-
export function formatOverlayApplyReportMessage(report: OverlayApplyReport): string {
|
|
137
|
-
const parts: string[] = [];
|
|
138
|
-
if (report.versionMismatch) {
|
|
139
|
-
parts.push(
|
|
140
|
-
`overlay authored against v${report.authoredAgainst}, game is v${report.currentVersion}`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
if (report.upstreamPin.mismatch) {
|
|
144
|
-
parts.push(
|
|
145
|
-
`overlay authored against upstream pin ${report.upstreamPin.authored}, ` +
|
|
146
|
-
`game is vendored at pin ${report.upstreamPin.current}`,
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
if (report.orphanedIds.length > 0) {
|
|
150
|
-
parts.push(
|
|
151
|
-
`${report.orphanedIds.length} orphaned overlay entr${report.orphanedIds.length === 1 ? 'y' : 'ies'}`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
return (
|
|
155
|
-
`${OVERLAY_REPORT_PREFIX} game "${report.gameId}": ${parts.join('; ')} ` +
|
|
156
|
-
`(D9, docs/DECISIONS-PENDING.md §D9; upstream-pin check R5, ` +
|
|
157
|
-
`docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)). ${OVERLAY_REPORT_PREFIX} ${JSON.stringify(report)}`
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Emit the ONE structured `console.warn` this report warrants (mirroring the
|
|
163
|
-
* tier-shortfall pattern), or do nothing when neither signal fires. Returns
|
|
164
|
-
* whether it warned (callers that also want the panel-visible `editorConsole`
|
|
165
|
-
* echo, or the inline banner, use the return value / the report itself).
|
|
166
|
-
*/
|
|
167
|
-
export function logOverlayApplyReport(report: OverlayApplyReport): boolean {
|
|
168
|
-
if (!overlayReportWarrants(report)) return false;
|
|
169
|
-
// The D9 AC requires this EXACT native console.warn (spied on directly in
|
|
170
|
-
// overlay-versioning.test.ts) — other deliberate native console.warn call sites in
|
|
171
|
-
// this codebase (e.g. editor-store.ts) are unsuppressed and already counted in the
|
|
172
|
-
// lint baseline; this one is suppressed to keep this task's diff at zero NEW warnings.
|
|
173
|
-
// biome-ignore lint/suspicious/noConsole: see comment above
|
|
174
|
-
console.warn(formatOverlayApplyReportMessage(report));
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import gsap from 'gsap';
|
|
2
|
-
import type { SystemPhaseName } from '../core/types';
|
|
3
|
-
import type { AnimationClock, ClockEvaluatorHandle } from './animation-clock';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* D7 — register a native, paused GSAP timeline on the canonical
|
|
7
|
-
* `AnimationClock` (spec §10 D7, §5.5 GSAP Contract, §3.2).
|
|
8
|
-
*
|
|
9
|
-
* Mirrors `theatre-clock-binding.ts` (D2)'s shape: VGAI's entire GSAP
|
|
10
|
-
* integration is "who drives time". Games build GSAP timelines with GSAP's
|
|
11
|
-
* own native, un-renamed API (`gsap.timeline()`, `.to()`, `.from()`, …,
|
|
12
|
-
* spec §5.5) — this module never wraps or renames the tween-building API. It
|
|
13
|
-
* only stores the native `gsap.core.Timeline` and seeks it every time the
|
|
14
|
-
* clock moves, exactly like the Theatre sequence binding seeks
|
|
15
|
-
* `sequence.position`.
|
|
16
|
-
*
|
|
17
|
-
* ## Unregistered GSAP is unaffected (§5.5 contract)
|
|
18
|
-
*
|
|
19
|
-
* `gsap.to(enemy.position, { … })` called anywhere in game code — without
|
|
20
|
-
* ever touching this module — keeps running on GSAP's own real-time,
|
|
21
|
-
* `requestAnimationFrame`-driven global ticker exactly as GSAP ships it. This
|
|
22
|
-
* module does not touch `gsap.ticker`, `gsap.globalTimeline`, or any other
|
|
23
|
-
* global GSAP state, precisely so it cannot perturb that ordinary real-time
|
|
24
|
-
* code path. Only a timeline explicitly passed to `registerGsap` — and
|
|
25
|
-
* created paused — participates in deterministic scrub/export.
|
|
26
|
-
*
|
|
27
|
-
* ## Why a PAUSED timeline seeked with `.time()` is deterministic
|
|
28
|
-
*
|
|
29
|
-
* GSAP's global ticker only auto-advances animations that are actually
|
|
30
|
-
* playing; a timeline created with `{ paused: true }` (and never resumed) is
|
|
31
|
-
* never touched by the ticker's rAF-driven `dt` accumulation, so wall-clock
|
|
32
|
-
* elapsed time, dropped frames, and GSAP's lag-smoothing heuristics (which
|
|
33
|
-
* exist purely to compensate a *playing* animation for an irregular ticker
|
|
34
|
-
* `dt`) cannot affect it. The only thing that ever moves a registered
|
|
35
|
-
* timeline's playhead is this module's own `timeline.time(clock.time)` call,
|
|
36
|
-
* driven synchronously off the clock — same story as `theatre-clock-binding`
|
|
37
|
-
* driving `sequence.position` off the clock instead of Theatre's own raf
|
|
38
|
-
* loop. This is proven directly against the real `gsap` runtime in
|
|
39
|
-
* `packages/engine/test/gsap-registration.test.ts` (including a real
|
|
40
|
-
* wall-clock `setTimeout` wait between two `clock.seek()` calls to the same
|
|
41
|
-
* time, asserting the sampled value is bit-for-bit identical — nothing
|
|
42
|
-
* "polished" the value while real time passed). `timeline.time(value)` (not
|
|
43
|
-
* `.seek(value)`) is used because it takes only a numeric offset in the
|
|
44
|
-
* clock's own second-denominated domain — `.seek()`'s label-string overload
|
|
45
|
-
* is irrelevant here and would only widen the accepted input past what a
|
|
46
|
-
* clock time actually is. Neither call resumes playback; both simply
|
|
47
|
-
* position an already-paused timeline, which is why this module never calls
|
|
48
|
-
* `.play()`/`.resume()`.
|
|
49
|
-
*
|
|
50
|
-
* ## Phase choice
|
|
51
|
-
*
|
|
52
|
-
* Registered by default at the `'animation'` phase — the same phase
|
|
53
|
-
* `bindClockToTheatreSequence` (D2) uses, and the same phase
|
|
54
|
-
* XState-bound `AnimationMixer` playback ticks in (see `ARCHITECTURE.md`'s
|
|
55
|
-
* phase table). A registered GSAP timeline commonly targets the same
|
|
56
|
-
* Object3D/material properties a Theatre-driven object binding also writes,
|
|
57
|
-
* so keeping both in the `'animation'` phase (ahead of `'preRender'`, where
|
|
58
|
-
* camera/material reads happen) avoids ordering surprises.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
export interface GsapRegistrationOptions {
|
|
62
|
-
/** Which `AnimationClock` phase to seek the timeline in. Defaults to `'animation'`. */
|
|
63
|
-
phase?: SystemPhaseName;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface GsapRegistration {
|
|
67
|
-
/**
|
|
68
|
-
* Removes the clock evaluator and stops driving the timeline (no further
|
|
69
|
-
* `timeline.time(...)` calls occur after this). Also frees this
|
|
70
|
-
* clock+timeline pair for re-registration (see `GsapRegistrationError`'s
|
|
71
|
-
* `'duplicate'` case below).
|
|
72
|
-
*/
|
|
73
|
-
dispose(): void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Thrown by `registerGsap` when the input does not satisfy the D7 contract.
|
|
78
|
-
* `code` distinguishes the three rejection reasons so a caller can branch on
|
|
79
|
-
* it programmatically instead of string-matching `message`:
|
|
80
|
-
*
|
|
81
|
-
* - `'not-a-timeline'` — the second argument is not a real
|
|
82
|
-
* `gsap.core.Timeline` instance (e.g. a plain object, a single GSAP
|
|
83
|
-
* `Tween`, or `undefined`). Checked with `instanceof gsap.core.Timeline`
|
|
84
|
-
* against the actual installed GSAP runtime, not duck-typing, so a
|
|
85
|
-
* same-shaped fake cannot slip through.
|
|
86
|
-
* - `'not-paused'` — the timeline exists but was not created with
|
|
87
|
-
* `{ paused: true }` (or was later resumed) at registration time. This
|
|
88
|
-
* module deliberately REJECTS rather than silently force-pausing: a
|
|
89
|
-
* caller who already relies on the timeline advancing in real time (e.g.
|
|
90
|
-
* a UI polish tween that happens to also get registered by mistake) would
|
|
91
|
-
* otherwise have its behavior silently changed. Rejecting makes the fix
|
|
92
|
-
* (`gsap.timeline({ paused: true })`) obvious at the call site instead of
|
|
93
|
-
* producing a hard-to-notice runtime pause.
|
|
94
|
-
* - `'duplicate'` — this exact timeline is already registered against this
|
|
95
|
-
* exact clock. Detected per `(clock, timeline)` pair (not globally), so
|
|
96
|
-
* the same timeline may legitimately be registered against two
|
|
97
|
-
* independent clocks/cinematic contexts — mirroring D2's "multiple
|
|
98
|
-
* projects/sheets coexist" allowance — while registering it twice on the
|
|
99
|
-
* SAME clock (almost always a bug: a duplicate `useEffect` run, a missed
|
|
100
|
-
* disposer, …) is caught immediately instead of silently doubling the
|
|
101
|
-
* number of `.time()` writes per crossing.
|
|
102
|
-
*/
|
|
103
|
-
export class GsapRegistrationError extends Error {
|
|
104
|
-
constructor(
|
|
105
|
-
message: string,
|
|
106
|
-
readonly code: 'not-a-timeline' | 'not-paused' | 'duplicate',
|
|
107
|
-
) {
|
|
108
|
-
super(message);
|
|
109
|
-
this.name = 'GsapRegistrationError';
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Per-clock membership of already-registered timelines. A WeakMap keyed by
|
|
114
|
-
// clock (not a field on AnimationClock itself — D1 owns no knowledge of
|
|
115
|
-
// GSAP) holding a WeakSet keyed by timeline, so neither the clock nor a
|
|
116
|
-
// disposed/dropped timeline is kept alive by this module.
|
|
117
|
-
const registeredByClock = new WeakMap<AnimationClock, WeakSet<gsap.core.Timeline>>();
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Register a native, paused `gsap.core.Timeline` on `clock`: from now on,
|
|
121
|
-
* every clock crossing (`seek`, `advance`, idempotent re-seeks, `setRange`
|
|
122
|
-
* clamps — anything that fires a `ClockEvaluator`, per `animation-clock.ts`)
|
|
123
|
-
* positions the timeline at `clock.time` via `timeline.time(clock.time)`.
|
|
124
|
-
* The timeline is never played/resumed — it stays paused and is driven
|
|
125
|
-
* exclusively by the clock, both in live preview and in offline
|
|
126
|
-
* frame-by-frame export (whatever calls `clock.seek`/`clock.seekFrame`).
|
|
127
|
-
*
|
|
128
|
-
* Throws `GsapRegistrationError` if `timeline` is not a real
|
|
129
|
-
* `gsap.core.Timeline`, is not paused, or is already registered against this
|
|
130
|
-
* `clock` — see that class's doc comment for the exact policy on each case.
|
|
131
|
-
*
|
|
132
|
-
* Returns a `GsapRegistration` whose `dispose()` removes the clock evaluator
|
|
133
|
-
* (mirrors `bindClockToTheatreSequence`'s `TheatreClockBinding`).
|
|
134
|
-
*/
|
|
135
|
-
export function registerGsap(
|
|
136
|
-
clock: AnimationClock,
|
|
137
|
-
timeline: gsap.core.Timeline,
|
|
138
|
-
options: GsapRegistrationOptions = {},
|
|
139
|
-
): GsapRegistration {
|
|
140
|
-
if (!(timeline instanceof gsap.core.Timeline)) {
|
|
141
|
-
throw new GsapRegistrationError(
|
|
142
|
-
'[registerGsap] expected a native gsap.core.Timeline (e.g. gsap.timeline({ paused: true })), ' +
|
|
143
|
-
`got ${timeline === null ? 'null' : typeof timeline}`,
|
|
144
|
-
'not-a-timeline',
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (!timeline.paused()) {
|
|
148
|
-
throw new GsapRegistrationError(
|
|
149
|
-
'[registerGsap] timeline must be created paused — gsap.timeline({ paused: true }) — before ' +
|
|
150
|
-
'registration; a registered timeline is driven exclusively by the clock and must never also ' +
|
|
151
|
-
'be advancing under its own real-time playback',
|
|
152
|
-
'not-paused',
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
let seenForClock = registeredByClock.get(clock);
|
|
157
|
-
if (!seenForClock) {
|
|
158
|
-
seenForClock = new WeakSet();
|
|
159
|
-
registeredByClock.set(clock, seenForClock);
|
|
160
|
-
}
|
|
161
|
-
if (seenForClock.has(timeline)) {
|
|
162
|
-
throw new GsapRegistrationError(
|
|
163
|
-
'[registerGsap] this timeline is already registered against this clock — dispose() the prior ' +
|
|
164
|
-
'registration before registering it again',
|
|
165
|
-
'duplicate',
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
seenForClock.add(timeline);
|
|
169
|
-
|
|
170
|
-
const phase = options.phase ?? 'animation';
|
|
171
|
-
const handle: ClockEvaluatorHandle = clock.subscribe(phase, () => {
|
|
172
|
-
timeline.time(clock.time);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
let disposed = false;
|
|
176
|
-
return {
|
|
177
|
-
dispose(): void {
|
|
178
|
-
if (disposed) return;
|
|
179
|
-
disposed = true;
|
|
180
|
-
handle.dispose();
|
|
181
|
-
seenForClock!.delete(timeline);
|
|
182
|
-
},
|
|
183
|
-
};
|
|
184
|
-
}
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* W3c — first-party `AudioAdapter` INTROSPECTION over the engine's own audio
|
|
3
|
-
* stack (the engine is its own first adapter). `attachAudioIntrospection`
|
|
4
|
-
* augments the mute-only adapter `createAudioSystemAdapter` built with the
|
|
5
|
-
* optional read-only debugger capabilities the editor's Audio tab consumes:
|
|
6
|
-
*
|
|
7
|
-
* - `graphSnapshot()` — the engine bus hierarchy (`setup-audio.ts`'s
|
|
8
|
-
* music/sfx/voice → master → destination, plus the THREE listener tap)
|
|
9
|
-
* and every Tone node a game routed through `connectToneBusToMasterGain`
|
|
10
|
-
* (recorded at that seam — see `tone-context.ts`). Tone-INTERNAL wiring
|
|
11
|
-
* (a game's private synth chains) is not visible from here and is not
|
|
12
|
-
* fabricated; a game wanting richer graphs registers its own adapter.
|
|
13
|
-
* - `transportState()` — Tone's real transport (`Tone.getTransport()`),
|
|
14
|
-
* reported ONLY when the game bridged Tone onto this world's context
|
|
15
|
-
* (`hasToneBridge`); otherwise an honest `null` ("no musical transport"),
|
|
16
|
-
* and Tone's global context is never instantiated just by asking.
|
|
17
|
-
* - `acquireMeters()` — per-bus linear-RMS levels via native `AnalyserNode`
|
|
18
|
-
* taps (the "Tone.Meter or equivalent": analysers work identically
|
|
19
|
-
* whether or not Tone is bridged, and disposal is a plain disconnect).
|
|
20
|
-
* Returns `null` on a headless world (no real `createAnalyser`).
|
|
21
|
-
* - `audioEvents()` — bounded seq-fenced ring fed by REAL observation
|
|
22
|
-
* points only: Tone transport start/stop/pause emitter events (attached
|
|
23
|
-
* lazily once a bridge exists), native context `statechange`, and the
|
|
24
|
-
* adapter's own mute/unmute calls.
|
|
25
|
-
*
|
|
26
|
-
* SQ-4 boundary: this module is the READ-ONLY debugger seam. The full mixer
|
|
27
|
-
* (sends/effects/per-bus gain EDITING, FMOD-class adaptive-audio authoring)
|
|
28
|
-
* waits on SQ-4 and is deliberately absent here.
|
|
29
|
-
*
|
|
30
|
-
* Uses Tone directly (no wrapper). The EDITOR never imports this module or
|
|
31
|
-
* Tone — it consumes only `SystemAdapters.audio` (mirror of the networking
|
|
32
|
-
* rule). Wired by `vgai-scene-game-adapter.ts` via dynamic import so `tone`
|
|
33
|
-
* stays out of the mount-critical chunk, with the returned disposer called
|
|
34
|
-
* from `disposeGame` (no leaked transport listeners across mounts — the
|
|
35
|
-
* Tone transport is a singleton that outlives any one world).
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import * as Tone from 'tone';
|
|
39
|
-
import type {
|
|
40
|
-
AudioAdapter,
|
|
41
|
-
AudioDebugEvent,
|
|
42
|
-
AudioGraphNode,
|
|
43
|
-
AudioMeterFrame,
|
|
44
|
-
AudioMeterHandle,
|
|
45
|
-
AudioTransportState,
|
|
46
|
-
} from '../adapter/system-adapter';
|
|
47
|
-
import type { AudioContext as GameAudio } from '../setup/setup-audio';
|
|
48
|
-
import { getToneMasterGainRoutes, hasToneBridge } from './tone-context';
|
|
49
|
-
|
|
50
|
-
/** Ring capacity — mirrors the debug-event ring's bounded-drop-oldest idiom. */
|
|
51
|
-
const EVENT_RING_CAP = 200;
|
|
52
|
-
|
|
53
|
-
function now(): number {
|
|
54
|
-
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function ctorName(value: object): string {
|
|
58
|
-
const name = (value as { constructor?: { name?: string } }).constructor?.name;
|
|
59
|
-
return name && name !== 'Object' ? name : 'AudioNode';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Attach the W3c introspection capabilities to `adapter` (mutating it — the
|
|
64
|
-
* same object stays registered on `SystemAdapters.audio`) and return a
|
|
65
|
-
* disposer that detaches every listener and releases any meter handle still
|
|
66
|
-
* alive. Idempotent per adapter object is NOT required: call once per mount.
|
|
67
|
-
*/
|
|
68
|
-
export function attachAudioIntrospection(adapter: AudioAdapter, audio: GameAudio): () => void {
|
|
69
|
-
// --- event ring -----------------------------------------------------------
|
|
70
|
-
const ring: AudioDebugEvent[] = [];
|
|
71
|
-
let nextSeq = 1;
|
|
72
|
-
const record = (kind: AudioDebugEvent['kind'], detail?: string): void => {
|
|
73
|
-
const event: AudioDebugEvent = { seq: nextSeq++, time: now(), kind };
|
|
74
|
-
if (detail !== undefined) event.detail = detail;
|
|
75
|
-
ring.push(event);
|
|
76
|
-
if (ring.length > EVENT_RING_CAP) ring.splice(0, ring.length - EVENT_RING_CAP);
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// The native context, when this world has a real one (headless worlds use
|
|
80
|
-
// a plain stand-in whose `listener.context` may be absent).
|
|
81
|
-
const nativeContext = (): (BaseAudioContext & Partial<AudioContext>) | null => {
|
|
82
|
-
const context = (audio.listener as { context?: BaseAudioContext } | undefined)?.context;
|
|
83
|
-
return context && typeof context.state === 'string'
|
|
84
|
-
? (context as BaseAudioContext & Partial<AudioContext>)
|
|
85
|
-
: null;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/** Tone is CURRENTLY bridged onto this world's context. `hasToneBridge`
|
|
89
|
-
* gates first so `Tone.getContext()` is only consulted when a Tone global
|
|
90
|
-
* context already exists (never fabricating one — see tone-context.ts). */
|
|
91
|
-
const isBridged = (): boolean => {
|
|
92
|
-
const context = nativeContext();
|
|
93
|
-
if (!context || !hasToneBridge(context as AudioContext)) return false;
|
|
94
|
-
try {
|
|
95
|
-
return Tone.getContext().rawContext === context;
|
|
96
|
-
} catch {
|
|
97
|
-
return false; // no constructible Tone context in this environment (Node)
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
// --- mute/unmute events: wrap the adapter's OWN entry point ---------------
|
|
102
|
-
const baseSetMuted = adapter.setMuted.bind(adapter);
|
|
103
|
-
adapter.setMuted = (muted: boolean): void => {
|
|
104
|
-
baseSetMuted(muted);
|
|
105
|
-
record(muted ? 'mute' : 'unmute');
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
// --- context statechange --------------------------------------------------
|
|
109
|
-
const context = nativeContext();
|
|
110
|
-
const onStateChange = (): void => {
|
|
111
|
-
record('context-statechange', nativeContext()?.state ?? 'unknown');
|
|
112
|
-
};
|
|
113
|
-
if (context && typeof context.addEventListener === 'function') {
|
|
114
|
-
context.addEventListener('statechange', onStateChange);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// --- transport events (lazy: the game bridges Tone DURING its setup, after
|
|
118
|
-
// this adapter is seeded — so hooks attach on the first capability read
|
|
119
|
-
// that finds a live bridge) ------------------------------------------
|
|
120
|
-
let transportHooked = false;
|
|
121
|
-
const onTransportStart = (): void => record('transport-start');
|
|
122
|
-
const onTransportStop = (): void => record('transport-stop');
|
|
123
|
-
const onTransportPause = (): void => record('transport-pause');
|
|
124
|
-
const ensureTransportHooks = (): void => {
|
|
125
|
-
if (transportHooked || !isBridged()) return;
|
|
126
|
-
transportHooked = true;
|
|
127
|
-
const transport = Tone.getTransport();
|
|
128
|
-
transport.on('start', onTransportStart);
|
|
129
|
-
transport.on('stop', onTransportStop);
|
|
130
|
-
transport.on('pause', onTransportPause);
|
|
131
|
-
// A game typically bridges Tone and starts its transport DURING setup(),
|
|
132
|
-
// before anything polls this adapter — that start predates the listeners
|
|
133
|
-
// above. If the transport is already running at hook time, record that
|
|
134
|
-
// truthful observation (marked as such) so the log's first entry isn't a
|
|
135
|
-
// hole where the session's defining event happened.
|
|
136
|
-
if (transport.state === 'started') {
|
|
137
|
-
record('transport-start', 'already running when observed');
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// --- capabilities ---------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
adapter.graphSnapshot = (): AudioGraphNode[] => {
|
|
144
|
-
const nodes: AudioGraphNode[] = [];
|
|
145
|
-
const contextState = nativeContext()?.state;
|
|
146
|
-
const destination: AudioGraphNode = {
|
|
147
|
-
id: 'destination',
|
|
148
|
-
type: 'AudioDestinationNode',
|
|
149
|
-
label: 'Output',
|
|
150
|
-
outputs: [],
|
|
151
|
-
};
|
|
152
|
-
if (contextState !== undefined) destination.state = contextState;
|
|
153
|
-
nodes.push(destination);
|
|
154
|
-
nodes.push({
|
|
155
|
-
id: 'master',
|
|
156
|
-
type: ctorName(audio.masterGain),
|
|
157
|
-
label: 'Master',
|
|
158
|
-
outputs: ['destination'],
|
|
159
|
-
});
|
|
160
|
-
nodes.push({
|
|
161
|
-
id: 'bus-music',
|
|
162
|
-
type: ctorName(audio.buses.music),
|
|
163
|
-
label: 'Music bus',
|
|
164
|
-
outputs: ['master'],
|
|
165
|
-
});
|
|
166
|
-
nodes.push({
|
|
167
|
-
id: 'bus-sfx',
|
|
168
|
-
type: ctorName(audio.buses.sfx),
|
|
169
|
-
label: 'SFX bus',
|
|
170
|
-
outputs: ['master'],
|
|
171
|
-
});
|
|
172
|
-
nodes.push({
|
|
173
|
-
id: 'bus-voice',
|
|
174
|
-
type: ctorName(audio.buses.voice),
|
|
175
|
-
label: 'Voice bus',
|
|
176
|
-
outputs: ['master'],
|
|
177
|
-
});
|
|
178
|
-
if (audio.listener) {
|
|
179
|
-
nodes.push({
|
|
180
|
-
id: 'listener',
|
|
181
|
-
type: ctorName(audio.listener),
|
|
182
|
-
label: '3D listener (THREE)',
|
|
183
|
-
outputs: ['master'],
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
// Tone nodes routed into THIS world's master bus (seam-recorded; disposed
|
|
187
|
-
// nodes are pruned by the registry itself).
|
|
188
|
-
getToneMasterGainRoutes(audio.masterGain).forEach((toneNode, index) => {
|
|
189
|
-
nodes.push({
|
|
190
|
-
id: `tone-${index}`,
|
|
191
|
-
// Tone nodes carry their own class name (`'Gain'`, `'Volume'`…).
|
|
192
|
-
type: (toneNode as { name?: string }).name ?? ctorName(toneNode),
|
|
193
|
-
label: `Tone route ${index + 1}`,
|
|
194
|
-
outputs: ['master'],
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
return nodes;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
adapter.transportState = (): AudioTransportState | null => {
|
|
201
|
-
ensureTransportHooks();
|
|
202
|
-
if (!isBridged()) return null; // honest: no musical transport in this world
|
|
203
|
-
const transport = Tone.getTransport();
|
|
204
|
-
return {
|
|
205
|
-
state: transport.state,
|
|
206
|
-
seconds: transport.seconds,
|
|
207
|
-
bpm: transport.bpm.value,
|
|
208
|
-
position: String(transport.position),
|
|
209
|
-
};
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const liveMeterHandles = new Set<AudioMeterHandle>();
|
|
213
|
-
adapter.acquireMeters = (): AudioMeterHandle | null => {
|
|
214
|
-
const meterContext = nativeContext();
|
|
215
|
-
if (!meterContext || typeof meterContext.createAnalyser !== 'function') {
|
|
216
|
-
return null; // headless world — metering is impossible, and says so
|
|
217
|
-
}
|
|
218
|
-
const taps = (
|
|
219
|
-
[
|
|
220
|
-
['master', 'Master', audio.masterGain],
|
|
221
|
-
['bus-music', 'Music', audio.buses.music],
|
|
222
|
-
['bus-sfx', 'SFX', audio.buses.sfx],
|
|
223
|
-
['bus-voice', 'Voice', audio.buses.voice],
|
|
224
|
-
] as const
|
|
225
|
-
).map(([id, label, gain]) => {
|
|
226
|
-
const analyser = meterContext.createAnalyser();
|
|
227
|
-
analyser.fftSize = 1024;
|
|
228
|
-
gain.connect(analyser); // a TAP (analyser is a sink), not an insert
|
|
229
|
-
return { id, label, gain, analyser, buffer: new Float32Array(analyser.fftSize) };
|
|
230
|
-
});
|
|
231
|
-
let disposed = false;
|
|
232
|
-
const handle: AudioMeterHandle = {
|
|
233
|
-
read: (): AudioMeterFrame[] =>
|
|
234
|
-
taps.map(({ id, label, analyser, buffer }) => {
|
|
235
|
-
analyser.getFloatTimeDomainData(buffer);
|
|
236
|
-
let sum = 0;
|
|
237
|
-
for (let i = 0; i < buffer.length; i++) {
|
|
238
|
-
const sample = buffer[i] as number;
|
|
239
|
-
sum += sample * sample;
|
|
240
|
-
}
|
|
241
|
-
return { id, label, level: Math.sqrt(sum / buffer.length) };
|
|
242
|
-
}),
|
|
243
|
-
dispose: (): void => {
|
|
244
|
-
if (disposed) return;
|
|
245
|
-
disposed = true;
|
|
246
|
-
for (const tap of taps) {
|
|
247
|
-
try {
|
|
248
|
-
tap.gain.disconnect(tap.analyser);
|
|
249
|
-
} catch {
|
|
250
|
-
// already torn down with the context — nothing to release
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
liveMeterHandles.delete(handle);
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
liveMeterHandles.add(handle);
|
|
257
|
-
return handle;
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
adapter.audioEvents = (sinceSeq?: number): AudioDebugEvent[] => {
|
|
261
|
-
ensureTransportHooks();
|
|
262
|
-
return sinceSeq !== undefined ? ring.filter((e) => e.seq > sinceSeq) : [...ring];
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
// --- teardown -------------------------------------------------------------
|
|
266
|
-
return (): void => {
|
|
267
|
-
if (transportHooked) {
|
|
268
|
-
try {
|
|
269
|
-
const transport = Tone.getTransport();
|
|
270
|
-
transport.off('start', onTransportStart);
|
|
271
|
-
transport.off('stop', onTransportStop);
|
|
272
|
-
transport.off('pause', onTransportPause);
|
|
273
|
-
} catch {
|
|
274
|
-
// Tone context already closed — the listeners died with it
|
|
275
|
-
}
|
|
276
|
-
transportHooked = false;
|
|
277
|
-
}
|
|
278
|
-
if (context && typeof context.removeEventListener === 'function') {
|
|
279
|
-
context.removeEventListener('statechange', onStateChange);
|
|
280
|
-
}
|
|
281
|
-
// Safety net: a consumer should dispose its own handle, but Stop must
|
|
282
|
-
// never leak analyser taps regardless.
|
|
283
|
-
for (const handle of [...liveMeterHandles]) handle.dispose();
|
|
284
|
-
adapter.setMuted = baseSetMuted;
|
|
285
|
-
delete adapter.graphSnapshot;
|
|
286
|
-
delete adapter.transportState;
|
|
287
|
-
delete adapter.acquireMeters;
|
|
288
|
-
delete adapter.audioEvents;
|
|
289
|
-
};
|
|
290
|
-
}
|