game-koi 0.1.2 → 0.3.0

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/dist/index.js CHANGED
@@ -9,6 +9,10 @@
9
9
  */
10
10
  import init, { Emulator } from "../wasm/game_koi_web.js";
11
11
  import { WORKLET_SOURCE } from "./worklet.js";
12
+ import { DEFAULT_GAMEPAD_MAP, GamepadInput } from "./gamepad.js";
13
+ import { Emitter } from "./events.js";
14
+ import { FrameStats } from "./stats.js";
15
+ import { StatsOverlay } from "./overlay.js";
12
16
  const DEFAULT_KEYMAP = {
13
17
  ArrowRight: "right",
14
18
  ArrowLeft: "left",
@@ -35,29 +39,46 @@ export class GameKoi {
35
39
  wasmMemory;
36
40
  imageData;
37
41
  keymap;
42
+ gamepad;
38
43
  targetBuffer;
39
44
  maxFramesPerWake;
45
+ stats;
46
+ emitter = new Emitter();
47
+ /** What the joypad currently has down, whichever device put it there. */
48
+ held = new Set();
40
49
  emulator;
41
50
  buffered = 0;
51
+ /** Cumulative worklet counters, as last reported. See `worklet.ts`. */
52
+ underruns = 0;
53
+ dropped = 0;
54
+ /** Built on first toggle, so a page that never opens it gets no element. */
55
+ overlay = null;
42
56
  running = false;
43
57
  rafHandle = 0;
44
58
  keydownListener;
45
59
  keyupListener;
46
- constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, targetBuffer, maxFramesPerWake) {
60
+ overlayListener;
61
+ constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake) {
47
62
  this.emulator = emulator;
48
63
  this.wasmMemory = wasmMemory;
49
64
  this.ctx = ctx;
50
65
  this.audioContext = audioContext;
51
66
  this.worklet = worklet;
52
67
  this.keymap = keymap;
68
+ this.gamepad = gamepadMap ? new GamepadInput(gamepadMap) : null;
53
69
  this.targetBuffer = targetBuffer;
54
70
  this.maxFramesPerWake = maxFramesPerWake;
55
71
  this.imageData = ctx.createImageData(Emulator.width(), Emulator.height());
72
+ this.stats = new FrameStats(performance.now());
56
73
  this.worklet.port.onmessage = (event) => {
57
74
  this.buffered = event.data.buffered;
75
+ this.underruns = event.data.underruns;
76
+ this.dropped = event.data.dropped;
58
77
  };
59
78
  if (keymap)
60
79
  this.attachKeyboard();
80
+ if (overlayKey !== null)
81
+ this.attachOverlayKey(overlayKey);
61
82
  }
62
83
  /**
63
84
  * Builds and starts a running emulator.
@@ -87,25 +108,105 @@ export class GameKoi {
87
108
  worklet.connect(audioContext.destination);
88
109
  const emulator = new Emulator(options.rom, audioContext.sampleRate);
89
110
  const keymap = options.keymap === null ? null : options.keymap ?? DEFAULT_KEYMAP;
90
- const koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
111
+ const gamepadMap = options.gamepadMap === null ? null : options.gamepadMap ?? DEFAULT_GAMEPAD_MAP;
112
+ const koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, gamepadMap, options.overlayKey === null ? null : options.overlayKey ?? "Backquote", options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
91
113
  koi.running = true;
92
114
  koi.loop();
93
115
  return koi;
94
116
  }
95
117
  /** Swaps in a new ROM, keeping the same canvas and audio graph. */
96
118
  loadRom(rom) {
119
+ // The new machine's joypad starts with nothing held, so what this side believes is
120
+ // held has to start over too — both the gamepad's snapshot (or the first poll would
121
+ // emit no press for a direction that was already down) and the set the events are
122
+ // edges against (or a button "already held" would never be pressed on the new
123
+ // machine, and a display would show it stuck down).
124
+ this.gamepad?.releaseAll();
125
+ this.releaseAll();
97
126
  this.emulator.free();
98
127
  this.emulator = new Emulator(rom, this.audioContext.sampleRate);
99
128
  }
100
129
  press(button) {
101
- this.emulator.press(button);
130
+ this.setButton(button, true, "api");
102
131
  }
103
132
  release(button) {
104
- this.emulator.release(button);
133
+ this.setButton(button, false, "api");
134
+ }
135
+ /** Everything the joypad has down right now — a snapshot, safe to keep. */
136
+ get pressed() {
137
+ return new Set(this.held);
138
+ }
139
+ /**
140
+ * Listens for button edges, from any input path — the built-in keyboard and gamepad
141
+ * handling as well as `press`/`release` calls.
142
+ *
143
+ * Returns a function that removes the listener again:
144
+ *
145
+ * ```ts
146
+ * const stop = koi.on("press", ({ button, pressed }) => render(pressed));
147
+ * koi.on("release", ({ button, pressed }) => render(pressed));
148
+ * ```
149
+ *
150
+ * Only *changes* are reported: a key held down with autorepeat, a stick resting past
151
+ * the threshold, or `press("a")` twice in a row each produce one `press` event and no
152
+ * `release` until the button actually comes up.
153
+ */
154
+ on(type, listener) {
155
+ return this.emitter.on(type, listener);
156
+ }
157
+ /** Removes a listener registered with {@link on}. */
158
+ off(type, listener) {
159
+ this.emitter.off(type, listener);
160
+ }
161
+ /**
162
+ * The one path to the emulated joypad, so every device's edges are counted once.
163
+ *
164
+ * A repeat is dropped here rather than forwarded: `Joypad::press` is idempotent, so
165
+ * skipping it changes nothing for the machine, and it is what makes the events edges
166
+ * instead of a restatement of whatever the browser felt like repeating.
167
+ */
168
+ setButton(button, pressed, source) {
169
+ if (this.held.has(button) === pressed)
170
+ return;
171
+ if (pressed) {
172
+ this.held.add(button);
173
+ this.emulator.press(button);
174
+ }
175
+ else {
176
+ this.held.delete(button);
177
+ this.emulator.release(button);
178
+ }
179
+ this.emitter.emit(pressed ? "press" : "release", {
180
+ button,
181
+ source,
182
+ pressed: new Set(this.held),
183
+ });
184
+ }
185
+ /** Lets go of everything held, emitting the releases. */
186
+ releaseAll() {
187
+ for (const button of [...this.held])
188
+ this.setButton(button, false, "api");
189
+ }
190
+ /**
191
+ * Shows or hides the debug stats panel — the same thing the ` key does.
192
+ *
193
+ * The panel is built the first time this is called, so a page that never opens it
194
+ * never gets an extra element in its DOM.
195
+ */
196
+ toggleOverlay() {
197
+ this.overlay ??= new StatsOverlay(this.ctx.canvas);
198
+ this.overlay.toggle();
199
+ }
200
+ /** Whether the stats panel is currently showing. */
201
+ get overlayVisible() {
202
+ return this.overlay?.visible ?? false;
105
203
  }
106
204
  pause() {
107
205
  this.running = false;
108
206
  cancelAnimationFrame(this.rafHandle);
207
+ // A direction held at the moment of the pause has no poll coming to release it,
208
+ // so it would still be down when the machine resumes.
209
+ this.applyGamepadActions(this.gamepad?.releaseAll());
109
210
  void this.audioContext.suspend();
110
211
  }
111
212
  resume() {
@@ -120,6 +221,10 @@ export class GameKoi {
120
221
  this.running = false;
121
222
  cancelAnimationFrame(this.rafHandle);
122
223
  this.detachKeyboard();
224
+ if (this.overlayListener)
225
+ removeEventListener("keydown", this.overlayListener);
226
+ this.emitter.clear();
227
+ this.overlay?.dispose();
123
228
  this.emulator.free();
124
229
  this.worklet.disconnect();
125
230
  void this.audioContext.close();
@@ -127,6 +232,11 @@ export class GameKoi {
127
232
  loop = () => {
128
233
  if (!this.running)
129
234
  return;
235
+ const wokeAt = performance.now();
236
+ this.pollGamepads();
237
+ // Measured from after the input poll: reading a gamepad snapshot is neither
238
+ // emulation nor drawing, and it costs a fraction of a microsecond.
239
+ const emulateStart = performance.now();
130
240
  // Emulate however many frames the audio buffer is short by, capped. Audio wins
131
241
  // when the display's refresh rate and the Game Boy's 59.73Hz disagree, since a
132
242
  // dry buffer is audible and a repeated frame is not.
@@ -148,10 +258,39 @@ export class GameKoi {
148
258
  }
149
259
  frames++;
150
260
  }
261
+ const drawStart = performance.now();
151
262
  if (frames > 0)
152
263
  this.drawFrame();
264
+ const drawEnd = performance.now();
265
+ this.recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames);
153
266
  this.rafHandle = requestAnimationFrame(this.loop);
154
267
  };
268
+ /**
269
+ * Feeds the counters and refreshes the panel.
270
+ *
271
+ * Always run, not just while the panel is open: the averages are computed over half a
272
+ * second, so a panel that only started counting when it opened would show nothing for
273
+ * its first window. The cost is four `performance.now()` reads per wake.
274
+ */
275
+ recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames) {
276
+ this.stats.wake({
277
+ emulate: drawStart - emulateStart,
278
+ draw: frames > 0 ? drawEnd - drawStart : 0,
279
+ frames,
280
+ // The catch-up was clamped and the buffer is still short, so a deficit is
281
+ // being carried into the next wake.
282
+ capped: frames >= this.maxFramesPerWake && this.buffered < this.targetBuffer,
283
+ }, wokeAt);
284
+ this.stats.setAudio({
285
+ buffered: this.buffered,
286
+ target: this.targetBuffer,
287
+ underruns: this.underruns,
288
+ dropped: this.dropped,
289
+ sampleRate: this.audioContext.sampleRate,
290
+ state: this.audioContext.state,
291
+ });
292
+ this.overlay?.update(this.stats.snapshot(), wokeAt);
293
+ }
155
294
  drawFrame() {
156
295
  // Read `wasmMemory.buffer` fresh rather than caching it: any wasm allocation can
157
296
  // grow linear memory, which allocates a new ArrayBuffer and silently detaches
@@ -161,18 +300,41 @@ export class GameKoi {
161
300
  this.imageData.data.set(bytes);
162
301
  this.ctx.putImageData(this.imageData, 0, 0);
163
302
  }
303
+ /**
304
+ * Reads every connected pad and applies whatever changed since the last wake.
305
+ *
306
+ * Done here rather than from a listener because the Gamepad API fires no event for a
307
+ * button: `getGamepads()` is a snapshot and polling it is the only way to see one.
308
+ */
309
+ pollGamepads() {
310
+ if (!this.gamepad)
311
+ return;
312
+ // Guarded because the API is absent outside a secure context, and in a few
313
+ // embedded webviews that still have no gamepad support at all.
314
+ const pads = navigator.getGamepads?.();
315
+ if (!pads)
316
+ return;
317
+ this.applyGamepadActions(this.gamepad.poll(pads));
318
+ }
319
+ applyGamepadActions(actions) {
320
+ for (const action of actions ?? []) {
321
+ this.setButton(action.button, action.type === "press", "gamepad");
322
+ }
323
+ }
164
324
  attachKeyboard() {
165
325
  this.keydownListener = (event) => {
166
326
  const button = this.keymap?.[event.code];
167
327
  if (button) {
168
- this.press(button);
328
+ // Autorepeat still arrives here: `preventDefault` has to run on every repeat,
329
+ // and `setButton` is what collapses them into the one press edge.
330
+ this.setButton(button, true, "keyboard");
169
331
  event.preventDefault();
170
332
  }
171
333
  };
172
334
  this.keyupListener = (event) => {
173
335
  const button = this.keymap?.[event.code];
174
336
  if (button) {
175
- this.release(button);
337
+ this.setButton(button, false, "keyboard");
176
338
  event.preventDefault();
177
339
  }
178
340
  };
@@ -185,4 +347,20 @@ export class GameKoi {
185
347
  if (this.keyupListener)
186
348
  removeEventListener("keyup", this.keyupListener);
187
349
  }
350
+ /**
351
+ * Binds the panel's toggle key.
352
+ *
353
+ * Its own listener rather than a case inside the keymap one, because the two are
354
+ * independent: a page that drives the joypad itself (`keymap: null`) can still want
355
+ * the panel, and the panel key is not a Game Boy button.
356
+ */
357
+ attachOverlayKey(code) {
358
+ this.overlayListener = (event) => {
359
+ if (event.code !== code || event.repeat)
360
+ return;
361
+ this.toggleOverlay();
362
+ event.preventDefault();
363
+ };
364
+ addEventListener("keydown", this.overlayListener);
365
+ }
188
366
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The debug stats panel.
3
+ *
4
+ * This is the browser counterpart of `game-koi-desktop`'s `overlay.rs`, and the split is
5
+ * the same on both sides: `stats.ts` counts, this draws. What differs is the medium.
6
+ * The desktop panel is egui, rendered into the same wgpu pass as the game; egui here
7
+ * would mean bringing wgpu or WebGL into a package whose whole rendering path is one
8
+ * `putImageData` call, and roughly 2 MB of wasm paid by every consumer whether or not
9
+ * they ever press the key. So this is a plain DOM panel with the same content.
10
+ *
11
+ * Three consequences of that choice, all deliberate:
12
+ *
13
+ * - **It is a sibling of the canvas, not a wrapper around it.** The element is appended
14
+ * to `document.body` and positioned over the canvas from its bounding rect. Wrapping
15
+ * the consumer's canvas in a new element would be the tidier CSS, but it would move
16
+ * their node in the document and break any selector or layout rule that depended on
17
+ * where it was.
18
+ * - **`pointer-events: none`.** The panel is read-only, so it must never swallow a click
19
+ * meant for the page underneath. The desktop panel does have widgets, but the two it
20
+ * has — vsync and the CRT mode — are host-render settings that do not exist here.
21
+ * - **Styles are inline.** Nothing to serve, nothing for a bundler to find, and no
22
+ * chance of a page's stylesheet reaching in; the same reasoning that keeps the audio
23
+ * worklet a Blob rather than a file.
24
+ */
25
+ import type { StatsSnapshot } from "./stats.js";
26
+ /** A read-only panel of counters, floating over the emulator's canvas. */
27
+ export declare class StatsOverlay {
28
+ private readonly canvas;
29
+ private readonly element;
30
+ private readonly grid;
31
+ private readonly values;
32
+ private shown;
33
+ private lastRefresh;
34
+ constructor(canvas: HTMLCanvasElement);
35
+ get visible(): boolean;
36
+ toggle(): void;
37
+ /**
38
+ * Rewrites the panel, at most every {@link REFRESH_MS}.
39
+ *
40
+ * Cheap to call every frame — while hidden it does nothing at all, which is what keeps
41
+ * a switched-off feature costing a branch rather than a layout.
42
+ */
43
+ update(snapshot: StatsSnapshot, now: number): void;
44
+ dispose(): void;
45
+ /**
46
+ * Anchors the panel to the canvas's current position.
47
+ *
48
+ * Re-read rather than cached because a page can scroll, resize or reflow under us with
49
+ * no event this module sees. `getBoundingClientRect` forces layout, which is why it
50
+ * happens on the refresh tick and only while the panel is open.
51
+ */
52
+ private position;
53
+ private addRow;
54
+ /**
55
+ * A rule across both columns.
56
+ *
57
+ * `grid-column: 1 / -1` rather than one cell per column: two cells would be broken in
58
+ * the middle by the column gap, which reads as two dashes rather than the single line
59
+ * egui draws.
60
+ */
61
+ private addSeparator;
62
+ private set;
63
+ }
64
+ /**
65
+ * A duration in milliseconds at a fixed width, so the column does not jitter.
66
+ *
67
+ * The desktop formats these as `{:>6.2} ms` for exactly the same reason.
68
+ */
69
+ export declare function formatMs(ms: number): string;
70
+ export declare function formatPercent(percent: number): string;
71
+ /** A plain count, right-aligned to the same width as the percentages above it. */
72
+ export declare function count(value: number): string;
73
+ /**
74
+ * Green through to red as a figure approaches and passes its budget.
75
+ *
76
+ * Only worth colouring near the limit: a number that matters at 100% is easy to miss if
77
+ * it spends most of its time at 12%.
78
+ */
79
+ export declare function loadColour(fraction: number): string;
80
+ /**
81
+ * How alarming the audio buffer's depth is.
82
+ *
83
+ * Zero is not a warning but a failure that was audible: the worklet emitted silence.
84
+ * Below half the target is the warning, because that is the buffer draining rather than
85
+ * sitting where the pacing loop means to hold it.
86
+ */
87
+ export declare function bufferColour(bufferedMs: number, targetMs: number): string;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The debug stats panel.
3
+ *
4
+ * This is the browser counterpart of `game-koi-desktop`'s `overlay.rs`, and the split is
5
+ * the same on both sides: `stats.ts` counts, this draws. What differs is the medium.
6
+ * The desktop panel is egui, rendered into the same wgpu pass as the game; egui here
7
+ * would mean bringing wgpu or WebGL into a package whose whole rendering path is one
8
+ * `putImageData` call, and roughly 2 MB of wasm paid by every consumer whether or not
9
+ * they ever press the key. So this is a plain DOM panel with the same content.
10
+ *
11
+ * Three consequences of that choice, all deliberate:
12
+ *
13
+ * - **It is a sibling of the canvas, not a wrapper around it.** The element is appended
14
+ * to `document.body` and positioned over the canvas from its bounding rect. Wrapping
15
+ * the consumer's canvas in a new element would be the tidier CSS, but it would move
16
+ * their node in the document and break any selector or layout rule that depended on
17
+ * where it was.
18
+ * - **`pointer-events: none`.** The panel is read-only, so it must never swallow a click
19
+ * meant for the page underneath. The desktop panel does have widgets, but the two it
20
+ * has — vsync and the CRT mode — are host-render settings that do not exist here.
21
+ * - **Styles are inline.** Nothing to serve, nothing for a bundler to find, and no
22
+ * chance of a page's stylesheet reaching in; the same reasoning that keeps the audio
23
+ * worklet a Blob rather than a file.
24
+ */
25
+ /**
26
+ * How often the text is rewritten.
27
+ *
28
+ * Deliberately slower than the frame rate: the underlying averages only change when a
29
+ * sampling window closes, and text that changes 60 times a second is unreadable anyway.
30
+ * The DOM writes are what this throttle is really for — the measurements carry on every
31
+ * wake regardless.
32
+ */
33
+ const REFRESH_MS = 250;
34
+ /** Distance from the canvas's top-left corner, matching the desktop panel's [8, 8]. */
35
+ const INSET_PX = 8;
36
+ const TEXT = "#c8d0d8";
37
+ const DIM = "#8896a0";
38
+ const WARN = "#e8c060";
39
+ const BAD = "#e05050";
40
+ const PANEL_STYLE = {
41
+ position: "fixed",
42
+ // Above anything a page is plausibly stacking, since a debug panel hidden behind the
43
+ // page's own chrome would be useless.
44
+ zIndex: "2147483647",
45
+ pointerEvents: "none",
46
+ font: "12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
47
+ color: TEXT,
48
+ background: "rgba(8, 24, 32, 0.86)",
49
+ border: "1px solid rgba(136, 192, 112, 0.4)",
50
+ borderRadius: "4px",
51
+ padding: "6px 8px",
52
+ display: "none",
53
+ };
54
+ /** A read-only panel of counters, floating over the emulator's canvas. */
55
+ export class StatsOverlay {
56
+ canvas;
57
+ element;
58
+ grid;
59
+ values = new Map();
60
+ shown = false;
61
+ lastRefresh = 0;
62
+ constructor(canvas) {
63
+ this.canvas = canvas;
64
+ this.element = document.createElement("div");
65
+ Object.assign(this.element.style, PANEL_STYLE);
66
+ const title = document.createElement("div");
67
+ title.textContent = "Stats";
68
+ title.style.marginBottom = "4px";
69
+ title.style.color = DIM;
70
+ this.element.appendChild(title);
71
+ this.grid = document.createElement("div");
72
+ this.grid.style.display = "grid";
73
+ this.grid.style.gridTemplateColumns = "auto auto";
74
+ this.grid.style.columnGap = "14px";
75
+ this.element.appendChild(this.grid);
76
+ // The same grouping as the desktop panel: how fast, what it cost, how the two
77
+ // clocks relate, and what the audio device is doing.
78
+ this.addRow("FPS");
79
+ this.addRow("Speed");
80
+ this.addSeparator();
81
+ this.addRow(" emulate");
82
+ this.addRow(" draw");
83
+ this.addRow("Work");
84
+ this.addSeparator();
85
+ this.addRow("Frames/wake");
86
+ this.addRow("Refresh");
87
+ this.addRow("Capped");
88
+ this.addSeparator();
89
+ this.addRow("Buffer");
90
+ this.addRow("Underruns");
91
+ this.addRow("Dropped");
92
+ this.addRow("Output");
93
+ document.body.appendChild(this.element);
94
+ }
95
+ get visible() {
96
+ return this.shown;
97
+ }
98
+ toggle() {
99
+ this.shown = !this.shown;
100
+ this.element.style.display = this.shown ? "block" : "none";
101
+ // Force the next update through: the panel should not open showing whatever was
102
+ // last written up to a refresh interval ago.
103
+ this.lastRefresh = 0;
104
+ }
105
+ /**
106
+ * Rewrites the panel, at most every {@link REFRESH_MS}.
107
+ *
108
+ * Cheap to call every frame — while hidden it does nothing at all, which is what keeps
109
+ * a switched-off feature costing a branch rather than a layout.
110
+ */
111
+ update(snapshot, now) {
112
+ if (!this.shown)
113
+ return;
114
+ if (now - this.lastRefresh < REFRESH_MS)
115
+ return;
116
+ this.lastRefresh = now;
117
+ this.position();
118
+ this.set("FPS", snapshot.fps.toFixed(1));
119
+ this.set("Speed", formatPercent(snapshot.speedPercent));
120
+ this.set(" emulate", formatMs(snapshot.emulate));
121
+ this.set(" draw", formatMs(snapshot.draw));
122
+ // The headline cost figure, coloured the same way the desktop colours its budget:
123
+ // worth noticing near the limit, noise the rest of the time.
124
+ this.set("Work", formatPercent(snapshot.workFraction * 100), loadColour(snapshot.workFraction));
125
+ this.set("Frames/wake", snapshot.framesPerWake.toFixed(2));
126
+ this.set("Refresh", `${snapshot.wakeRate.toFixed(1)} Hz`);
127
+ // A capped wake means the catch-up was clamped and a deficit was left behind —
128
+ // usually a backgrounded tab, occasionally a machine that cannot keep up.
129
+ this.set("Capped", count(snapshot.cappedWakes), snapshot.cappedWakes > 0 ? WARN : TEXT);
130
+ const audio = snapshot.audio;
131
+ if (!audio) {
132
+ // Only reachable in the first moments, before the worklet's first report.
133
+ this.set("Buffer", "—");
134
+ this.set("Underruns", "—");
135
+ this.set("Dropped", "—");
136
+ this.set("Output", "—");
137
+ return;
138
+ }
139
+ const targetMs = (audio.target / audio.sampleRate) * 1000;
140
+ this.set("Buffer", `${audio.bufferedMs.toFixed(1)} ms`, bufferColour(audio.bufferedMs, targetMs));
141
+ // Cumulative, and the closest thing here to the desktop's late-frame count: an
142
+ // underrun is a moment of silence that was actually heard.
143
+ this.set("Underruns", count(audio.underruns), audio.underruns > 0 ? WARN : TEXT);
144
+ // The opposite failure — the page ran ahead of the sound card and the ring threw
145
+ // samples away. Normal in small numbers after a tab comes back to the foreground.
146
+ this.set("Dropped", count(audio.dropped), audio.dropped > 0 ? WARN : TEXT);
147
+ this.set("Output", `${(audio.sampleRate / 1000).toFixed(1)}k ${audio.state}`,
148
+ // A suspended context is the first thing to check when there is no sound, and
149
+ // nothing else on the panel would say so.
150
+ audio.state === "running" ? TEXT : WARN);
151
+ }
152
+ dispose() {
153
+ this.element.remove();
154
+ }
155
+ /**
156
+ * Anchors the panel to the canvas's current position.
157
+ *
158
+ * Re-read rather than cached because a page can scroll, resize or reflow under us with
159
+ * no event this module sees. `getBoundingClientRect` forces layout, which is why it
160
+ * happens on the refresh tick and only while the panel is open.
161
+ */
162
+ position() {
163
+ const rect = this.canvas.getBoundingClientRect();
164
+ this.element.style.left = `${rect.left + INSET_PX}px`;
165
+ this.element.style.top = `${rect.top + INSET_PX}px`;
166
+ }
167
+ addRow(label) {
168
+ const labelCell = document.createElement("span");
169
+ labelCell.textContent = label;
170
+ labelCell.style.color = DIM;
171
+ // Leading spaces mark the sub-rows of the work breakdown, as they do on the
172
+ // desktop; without this the browser collapses them.
173
+ labelCell.style.whiteSpace = "pre";
174
+ const valueCell = document.createElement("span");
175
+ valueCell.textContent = "—";
176
+ valueCell.style.textAlign = "right";
177
+ valueCell.style.whiteSpace = "pre";
178
+ this.grid.appendChild(labelCell);
179
+ this.grid.appendChild(valueCell);
180
+ this.values.set(label, valueCell);
181
+ }
182
+ /**
183
+ * A rule across both columns.
184
+ *
185
+ * `grid-column: 1 / -1` rather than one cell per column: two cells would be broken in
186
+ * the middle by the column gap, which reads as two dashes rather than the single line
187
+ * egui draws.
188
+ */
189
+ addSeparator() {
190
+ const rule = document.createElement("div");
191
+ rule.style.gridColumn = "1 / -1";
192
+ rule.style.borderTop = "1px solid rgba(200, 208, 216, 0.15)";
193
+ rule.style.margin = "3px 0";
194
+ this.grid.appendChild(rule);
195
+ }
196
+ set(label, text, colour = TEXT) {
197
+ const cell = this.values.get(label);
198
+ if (!cell)
199
+ return;
200
+ cell.textContent = text;
201
+ cell.style.color = colour;
202
+ }
203
+ }
204
+ /**
205
+ * A duration in milliseconds at a fixed width, so the column does not jitter.
206
+ *
207
+ * The desktop formats these as `{:>6.2} ms` for exactly the same reason.
208
+ */
209
+ export function formatMs(ms) {
210
+ return `${ms.toFixed(2).padStart(6)} ms`;
211
+ }
212
+ export function formatPercent(percent) {
213
+ return `${percent.toFixed(0).padStart(4)}%`;
214
+ }
215
+ /** A plain count, right-aligned to the same width as the percentages above it. */
216
+ export function count(value) {
217
+ return value.toString().padStart(5);
218
+ }
219
+ /**
220
+ * Green through to red as a figure approaches and passes its budget.
221
+ *
222
+ * Only worth colouring near the limit: a number that matters at 100% is easy to miss if
223
+ * it spends most of its time at 12%.
224
+ */
225
+ export function loadColour(fraction) {
226
+ if (fraction > 1)
227
+ return BAD;
228
+ if (fraction > 0.8)
229
+ return WARN;
230
+ return TEXT;
231
+ }
232
+ /**
233
+ * How alarming the audio buffer's depth is.
234
+ *
235
+ * Zero is not a warning but a failure that was audible: the worklet emitted silence.
236
+ * Below half the target is the warning, because that is the buffer draining rather than
237
+ * sitting where the pacing loop means to hold it.
238
+ */
239
+ export function bufferColour(bufferedMs, targetMs) {
240
+ if (bufferedMs <= 0)
241
+ return BAD;
242
+ if (bufferedMs < targetMs / 2)
243
+ return WARN;
244
+ return TEXT;
245
+ }