jsbeeb 1.19.3 → 1.20.1

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 CHANGED
@@ -287,7 +287,13 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
287
287
  Doesn't support the sth: pseudo URL unlike `disc` and `tape`, but if given a ZIP file will attempt to use the `.rom`
288
288
  file assumed to be within.
289
289
  - (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
290
- - `audioDebug` - show audio queue stats chart.
290
+ - `audioDebug` - show the audio lead chart, and log one console line per second in which the emulator tick ran late or the sound stalled or skipped.
291
+ - `audioLatencyMs` - how far the sound runs behind the emulator, in milliseconds (default 20). Raising it lets the sound ride out longer stalls of the emulator, at the cost of lagging the picture by that much.
292
+ - `audiofilterfreq` / `audiofilterq` - the corner frequency in Hz and the Q of the lowpass modelling the board's output
293
+ filter, applied to the sound chip before it is resampled. The defaults, 7234 and 0.696, are the Beeb's own.
294
+ `audiofilterfreq=0` turns the filter off.
295
+ - `displayMode=X` - picks the display: `rgb` (the default, a plain monitor), `pal` (a television fed by the Beeb's UHF modulator)
296
+ or `xbr` (an upscaler, see [docs/xbr-display-mode.md](docs/xbr-display-mode.md)).
291
297
 
292
298
  ### Atom-specific parameters
293
299
 
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.19.3",
10
+ "version": "1.20.1",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
package/src/acia.js CHANGED
@@ -4,8 +4,9 @@
4
4
  // https://books.google.com/books?id=wUecAQAAQBAJ&pg=PA431&lpg=PA431&dq=acia+tdre&source=bl&ots=mp-yF-mK-P&sig=e6aXkFRfiIOb57WZmrvdIGsCooI&hl=en&sa=X&ei=0g2fVdDyFIXT-QG8-JD4BA&ved=0CCwQ6AEwAw#v=onepage&q=acia%20tdre&f=false
5
5
  // http://www.classiccmp.org/dunfield/r/6850.pdf
6
6
 
7
- export class Acia {
7
+ export class Acia extends EventTarget {
8
8
  constructor(cpu, toneGen, scheduler, relayNoise) {
9
+ super();
9
10
  this.cpu = cpu;
10
11
  this.toneGen = toneGen;
11
12
  this.rs423Handler = null;
@@ -20,6 +21,7 @@ export class Acia {
20
21
  this.tapeCarrierCount = 0;
21
22
  this.tapeDcdLineLevel = false;
22
23
  this.hadDcdHigh = false;
24
+ this.saidOverrun = false;
23
25
  this.serialReceiveRate = 0;
24
26
  this.serialReceiveCyclesPerByte = 0;
25
27
  this.serialTransmitRate = 0;
@@ -200,8 +202,8 @@ export class Acia {
200
202
  // TODO: this doesn't match the datasheet:
201
203
  // "The Overrun does not occur in the Status Register until the
202
204
  // valid character prior to Overrun has been read."
203
- console.log("Serial overrun");
204
205
  this.sr |= 0xa0;
206
+ this.noteOverrun();
205
207
  } else {
206
208
  // If bit 7 contains parity, mask it off.
207
209
  this.dr = byte & (this.cr & 0x10 ? 0xff : 0x7f);
@@ -210,6 +212,22 @@ export class Acia {
210
212
  this.updateIrq();
211
213
  }
212
214
 
215
+ noteOverrun() {
216
+ if (this.saidOverrun) return;
217
+ this.saidOverrun = true;
218
+ this.dispatchEvent(
219
+ new CustomEvent("notice", {
220
+ detail: {
221
+ message:
222
+ "A serial byte arrived before the previous one was read, so it was lost. " +
223
+ "A tape load that stops with Data? is usually this.",
224
+ title: "Serial",
225
+ quietKey: "quietSerialOverrun",
226
+ },
227
+ }),
228
+ );
229
+ }
230
+
213
231
  snapshotState() {
214
232
  const scheduler = this.txCompleteTask.scheduler;
215
233
  return {
package/src/biquad.js ADDED
@@ -0,0 +1,36 @@
1
+ // Coefficients from the RBJ Audio EQ Cookbook: the second-order prototype
2
+ // under a bilinear transform prewarped so the corner lands on `frequency`.
3
+ export class LowPassBiquad {
4
+ constructor(sampleRate, frequency, q) {
5
+ const w0 = (2 * Math.PI * frequency) / sampleRate;
6
+ const alpha = Math.sin(w0) / (2 * q);
7
+ const cosW0 = Math.cos(w0);
8
+ const a0 = 1 + alpha;
9
+ this.b0 = (1 - cosW0) / (2 * a0);
10
+ this.b1 = (1 - cosW0) / a0;
11
+ this.a1 = (-2 * cosW0) / a0;
12
+ this.a2 = (1 - alpha) / a0;
13
+ this.x1 = 0;
14
+ this.x2 = 0;
15
+ this.y1 = 0;
16
+ this.y2 = 0;
17
+ }
18
+
19
+ process(buffer, offset, length) {
20
+ const { b0, b1, a1, a2 } = this;
21
+ let { x1, x2, y1, y2 } = this;
22
+ for (let i = offset; i < offset + length; ++i) {
23
+ const x = buffer[i];
24
+ const y = b0 * x + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2;
25
+ x2 = x1;
26
+ x1 = x;
27
+ y2 = y1;
28
+ y1 = y;
29
+ buffer[i] = y;
30
+ }
31
+ this.x1 = x1;
32
+ this.x2 = x2;
33
+ this.y1 = y1;
34
+ this.y2 = y2;
35
+ }
36
+ }
package/src/canvas.js CHANGED
@@ -240,7 +240,8 @@ export class GlCanvas {
240
240
  this.filter.setUniforms({
241
241
  width,
242
242
  height,
243
- frameCount: frame.frameCount,
243
+ lineBaseEven: frame.lineBaseEven,
244
+ lineBaseOdd: frame.lineBaseOdd,
244
245
  lineGrid: frame.lineGrid,
245
246
  // How much of the framebuffer each output pixel covers, which sets
246
247
  // how wide an edge-smoothing ramp should be. `extent` holds texel
@@ -23,7 +23,17 @@ export class GoogleDriveLoader {
23
23
  this.driveClient = undefined;
24
24
  }
25
25
 
26
- async initialise() {
26
+ initialise() {
27
+ if (!this._initialising) {
28
+ this._initialising = this._initialise().catch((error) => {
29
+ this._initialising = undefined;
30
+ throw error;
31
+ });
32
+ }
33
+ return this._initialising;
34
+ }
35
+
36
+ async _initialise() {
27
37
  console.log("Creating GAPI");
28
38
  await this._loadScript("https://apis.google.com/js/api.js");
29
39
  console.log("Got GAPI, creating token client");
package/src/main.js CHANGED
@@ -89,6 +89,11 @@ function stringToMachineKeys(text) {
89
89
  }
90
90
 
91
91
  const gamepad = new GamePad();
92
+ if (!window.isSecureContext)
93
+ toast("Gamepads only work over https, so any joystick plugged in here is not seen.", {
94
+ title: "Gamepads",
95
+ quietKey: "quietInsecureGamepads",
96
+ });
92
97
  const availableImages = [
93
98
  {
94
99
  name: "Elite",
@@ -145,6 +150,7 @@ const paramTypes = {
145
150
  frameSkip: ParamTypes.INT,
146
151
  audiofilterfreq: ParamTypes.FLOAT,
147
152
  audiofilterq: ParamTypes.FLOAT,
153
+ audioLatencyMs: ParamTypes.FLOAT,
148
154
  cpuMultiplier: ParamTypes.FLOAT,
149
155
  tubeCpuMultiplier: ParamTypes.FLOAT,
150
156
  microphoneChannel: ParamTypes.INT,
@@ -174,12 +180,6 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
174
180
  let fastAsPossible = false;
175
181
  let fastTape = false;
176
182
  let noSeek;
177
- // The board's output filter is an equal-component Sallen-Key (Service Manual
178
- // section 3.8: 10K and 2n2 twice, gain 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
179
- // Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. BiquadFilterNode
180
- // takes lowpass Q in decibels: 20*log10(0.696) = -3.15.
181
- let audioFilterFreq = 7234;
182
- let audioFilterQ = -3.15;
183
183
  let stationId = 101;
184
184
  let econet = null;
185
185
 
@@ -208,8 +208,6 @@ if (parsedQuery.embed) {
208
208
  fastTape = !!parsedQuery.fasttape;
209
209
  noSeek = !!parsedQuery.noseek;
210
210
 
211
- if (parsedQuery.audiofilterfreq !== undefined) audioFilterFreq = parsedQuery.audiofilterfreq;
212
- if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilterq;
213
211
  if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
214
212
  if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
215
213
 
@@ -238,7 +236,15 @@ const userPort = {
238
236
  // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
239
237
  // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
240
238
  const speechOutput = new SpeechOutput();
241
- speechOutput.enabled = !!parsedQuery.speechOutput;
239
+
240
+ function setSpeechOutput(enabled) {
241
+ speechOutput.enabled = enabled;
242
+ if (enabled && typeof speechSynthesis === "undefined")
243
+ toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
244
+ title: "Speech",
245
+ });
246
+ }
247
+ setSpeechOutput(!!parsedQuery.speechOutput);
242
248
 
243
249
  const config = new Config(
244
250
  function onChange(changed) {
@@ -265,9 +271,7 @@ const config = new Config(
265
271
  setupMicrophone();
266
272
  }
267
273
  }
268
- if (changed.speechOutput !== undefined) {
269
- speechOutput.enabled = !!changed.speechOutput;
270
- }
274
+ if (changed.speechOutput !== undefined) setSpeechOutput(!!changed.speechOutput);
271
275
  if (changed.tubeCpuMultiplier !== undefined) {
272
276
  emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
273
277
  config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
@@ -372,7 +376,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
372
376
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
373
377
  const cpuSpeed = model.cyclesPerSecond;
374
378
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
375
- const MaxCyclesPerFrame = clocksPerSecond / 10;
379
+ const MaxCyclesPerTick = clocksPerSecond / 10;
376
380
 
377
381
  let tryGl = true;
378
382
  if (parsedQuery.glEnabled !== undefined) {
@@ -552,14 +556,55 @@ function swapCanvas(newFilterClass) {
552
556
  const canvas = createCanvasForFilter(displayModeFilter);
553
557
  displayModeFilter = canvas.filterClass;
554
558
 
559
+ // The emulator paints into its own framebuffer; flyback copies the finished
560
+ // frame into the canvas and an animation frame presents it, so a stalled
561
+ // display holds up the picture and not the emulation (issue #885).
562
+ const videoFb32 = new Uint32Array(canvas.fb32.length);
563
+ const pendingFrame = {
564
+ minx: 0,
565
+ miny: 0,
566
+ maxx: 0,
567
+ maxy: 0,
568
+ lineBaseEven: 0,
569
+ lineBaseOdd: 0,
570
+ lineGrid: new Uint8Array(0),
571
+ };
572
+ let presentScheduled = false;
573
+ let paintMsThisTick = 0;
574
+ let presentMsMax = 0;
575
+
576
+ function present() {
577
+ presentScheduled = false;
578
+ const start = performance.now();
579
+ canvas.paint(pendingFrame.minx, pendingFrame.miny, pendingFrame.maxx, pendingFrame.maxy, pendingFrame);
580
+ presentMsMax = Math.max(presentMsMax, performance.now() - start);
581
+ }
582
+
555
583
  video = new Video(
556
584
  model.isMaster,
557
- canvas.fb32,
585
+ videoFb32,
558
586
  function paint(minx, miny, maxx, maxy) {
559
587
  frames++;
560
588
  if (frames < frameSkip) return;
561
589
  frames = 0;
562
- canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
590
+ const start = performance.now();
591
+ canvas.fb32.set(videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
592
+ if (pendingFrame.lineGrid.length !== this.lineGrid.length)
593
+ pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
594
+ pendingFrame.lineGrid.set(this.lineGrid);
595
+ Object.assign(pendingFrame, {
596
+ minx,
597
+ miny,
598
+ maxx,
599
+ maxy,
600
+ lineBaseEven: this.lineBaseEven,
601
+ lineBaseOdd: this.lineBaseOdd,
602
+ });
603
+ paintMsThisTick += performance.now() - start;
604
+ if (!presentScheduled) {
605
+ presentScheduled = true;
606
+ window.requestAnimationFrame(present);
607
+ }
563
608
  },
564
609
  { isAtom: model.isAtom },
565
610
  );
@@ -571,11 +616,13 @@ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
571
616
  const audioHandler = new AudioHandler({
572
617
  warningNode: document.getElementById("audio-warning"),
573
618
  statsNode: audioStatsNode,
574
- audioFilterFreq,
575
- audioFilterQ,
619
+ audioFilterFreq: parsedQuery.audiofilterfreq,
620
+ audioFilterQ: parsedQuery.audiofilterq,
621
+ audioLatencyMs: parsedQuery.audioLatencyMs,
576
622
  noSeek,
577
623
  cpuSpeed,
578
624
  isAtom: model.isAtom,
625
+ hasMusic5000: config.hasMusic5000,
579
626
  });
580
627
  // Firefox will report that audio is suspended even when it will
581
628
  // start playing without user interaction, so we need to delay a
@@ -675,8 +722,10 @@ pastetext.addEventListener("drop", async function (event) {
675
722
  } else if (file.name.toLowerCase().endsWith(".uef")) {
676
723
  // Regular UEF tape image (not a BeebEm save state)
677
724
  setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
725
+ toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
678
726
  } else {
679
727
  await loadHTMLFile(file);
728
+ toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
680
729
  }
681
730
  } catch (error) {
682
731
  reportLoadFailure(file.name, error);
@@ -724,12 +773,23 @@ setCrtPic(displayModeFilter);
724
773
 
725
774
  window.addEventListener("blur", function () {
726
775
  keyboard.clearKeys();
776
+ setEmulationLead(audioHandler.setWindowFocused(false));
727
777
  });
778
+ window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
728
779
 
729
- document.getElementById("fs").addEventListener("click", function (event) {
730
- screenCanvas.requestFullscreen();
731
- event.preventDefault();
732
- });
780
+ const fullscreenItem = document.getElementById("fs");
781
+ if (document.fullscreenEnabled) {
782
+ fullscreenItem.addEventListener("click", async (event) => {
783
+ event.preventDefault();
784
+ try {
785
+ await screenCanvas.requestFullscreen();
786
+ } catch (error) {
787
+ toast(`Could not go fullscreen: ${errorText(error)}`, { title: "Fullscreen" });
788
+ }
789
+ });
790
+ } else {
791
+ fullscreenItem.closest("li").hidden = true;
792
+ }
733
793
 
734
794
  let keyboard; // This will be initialised after the processor is created
735
795
 
@@ -817,6 +877,7 @@ processor = new CpuClass(model, {
817
877
  printer.attach(processor.uservia);
818
878
 
819
879
  processor.teletextAdaptor?.addEventListener("notice", showNotice);
880
+ processor.acia.addEventListener("notice", showNotice);
820
881
 
821
882
  // Create input sources
822
883
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
@@ -1661,12 +1722,6 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
1661
1722
  });
1662
1723
 
1663
1724
  async function gdLoad(cat, layout) {
1664
- // TODO: have a onclose flush event, handle errors
1665
- /*
1666
- $(window).bind("beforeunload", function() {
1667
- return confirm("Do you really want to close?");
1668
- });
1669
- */
1670
1725
  popupLoading("Loading '" + cat.name + "' from Google Drive");
1671
1726
  try {
1672
1727
  const available = await googleDrive.initialise();
@@ -1687,6 +1742,12 @@ async function gdLoad(cat, layout) {
1687
1742
  const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1688
1743
  console.log("Google Drive loading finished");
1689
1744
  loadingFinished();
1745
+ if (!ssd.savesChanges) {
1746
+ toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
1747
+ title: "Google Drive",
1748
+ quietKey: "quietDriveReadOnly",
1749
+ });
1750
+ }
1690
1751
  return ssd;
1691
1752
  } catch (error) {
1692
1753
  console.error("Google Drive loading error:", error);
@@ -1694,20 +1755,16 @@ async function gdLoad(cat, layout) {
1694
1755
  }
1695
1756
  }
1696
1757
 
1697
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1698
- (async () => {
1758
+ const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1759
+ // Loading the Google client holds the main thread for ~100ms, so it waits for
1760
+ // someone to ask for Drive.
1761
+ document.getElementById("open-drive-link").addEventListener("click", async function () {
1699
1762
  try {
1700
- const available = await googleDrive.initialise();
1701
- if (available) {
1702
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1703
- await gdAuth(true);
1704
- }
1763
+ await googleDrive.initialise();
1705
1764
  } catch (error) {
1706
- console.log(`Google Drive is unavailable: ${errorText(error)}`);
1765
+ toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
1766
+ return false;
1707
1767
  }
1708
- })();
1709
- const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1710
- document.getElementById("open-drive-link").addEventListener("click", async function () {
1711
1768
  const authed = await gdAuth(false);
1712
1769
  if (authed) {
1713
1770
  googleDriveModal.show();
@@ -2248,6 +2305,7 @@ function profileVideo(arg) {
2248
2305
  }
2249
2306
 
2250
2307
  let last = 0;
2308
+ let lastEnd = 0;
2251
2309
 
2252
2310
  function VirtualSpeedUpdater() {
2253
2311
  this.cycles = 0;
@@ -2281,8 +2339,51 @@ function VirtualSpeedUpdater() {
2281
2339
  const virtualSpeedUpdater = new VirtualSpeedUpdater();
2282
2340
 
2283
2341
  const rewindBuffer = new RewindBuffer(30);
2284
- let rewindFrameCounter = 0;
2285
- const RewindCaptureInterval = 50; // ~1 second at 50fps
2342
+ let rewindCycleCounter = 0;
2343
+ const RewindCaptureInterval = 50; // emulated frames, ~1 second
2344
+ const RewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
2345
+
2346
+ // Under ?audioDebug, one console line per second in which the emulator sat
2347
+ // idle between ticks or a tick ran long, or the audio queue underran or
2348
+ // dropped, so a click can be matched to a cause. The sound chip posts samples
2349
+ // throughout execute(), so only the idle time starves the audio queue.
2350
+ const AudioDebugLogIntervalMs = 1000;
2351
+ const AudioDebugSlowTickMs = 30;
2352
+ const audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
2353
+ const AudioDebugSlowPresentMs = 30;
2354
+
2355
+ function logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
2356
+ const log = audioDebugLog;
2357
+ if (log.start === 0) log.start = now;
2358
+ log.ticks++;
2359
+ log.cycles += cycles;
2360
+ log.maxIdle = Math.max(log.maxIdle, idleMs);
2361
+ log.maxExecute = Math.max(log.maxExecute, executeMs);
2362
+ log.maxPaint = Math.max(log.maxPaint, paintMs);
2363
+ log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
2364
+ if (now - log.start < AudioDebugLogIntervalMs) return;
2365
+ const audio = audioHandler.takeEventCounts();
2366
+ const present = presentMsMax;
2367
+ presentMsMax = 0;
2368
+ const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
2369
+ if (
2370
+ log.maxIdle > AudioDebugSlowTickMs ||
2371
+ log.maxExecute > AudioDebugSlowTickMs ||
2372
+ present > AudioDebugSlowPresentMs ||
2373
+ audio.stall ||
2374
+ audio.skip
2375
+ ) {
2376
+ console.log(
2377
+ `${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / clocksPerSecond).toFixed(0)}ms, ` +
2378
+ `idle max ${log.maxIdle.toFixed(0)}ms, ` +
2379
+ `execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
2380
+ `present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
2381
+ `audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
2382
+ );
2383
+ }
2384
+ log.start = now;
2385
+ log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
2386
+ }
2286
2387
 
2287
2388
  rewindUI = new RewindUI({
2288
2389
  rewindBuffer,
@@ -2315,21 +2416,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
2315
2416
  if (drive) showDriveTracks(driveIndex);
2316
2417
  }
2317
2418
 
2318
- function draw(now) {
2419
+ // A timer, not requestAnimationFrame: a display presentation stall withholds
2420
+ // animation frames, and with them the sound chip's samples (issue #885).
2421
+ const TickMs = 10;
2422
+ let tickToken = null;
2423
+
2424
+ // A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
2425
+ // compositor does not hold the tick off too.
2426
+ function scheduleTick(delayMs) {
2427
+ const token = (tickToken = {});
2428
+ const fire = () => {
2429
+ if (tickToken === token) tick();
2430
+ };
2431
+ if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
2432
+ else window.setTimeout(fire, delayMs);
2433
+ }
2434
+
2435
+ function tick() {
2319
2436
  if (!running) {
2320
2437
  last = 0;
2321
2438
  return;
2322
2439
  }
2323
- // If we got here via setTimeout, we don't get passed the time.
2324
- if (now === undefined) {
2325
- now = window.performance.now();
2326
- }
2440
+ const now = performance.now();
2327
2441
 
2328
2442
  const motorOn = processor.acia.motorOn;
2329
- const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
2330
2443
  const speedy = fastAsPossible || (fastTape && motorOn);
2331
- const useTimeout = speedy || motorOn || discOn;
2332
- const timeout = speedy ? 0 : 1000.0 / 50;
2333
2444
 
2334
2445
  // In speedy mode, we still run all the state machines accurately
2335
2446
  // but we paint less often because painting is the most expensive
@@ -2338,25 +2449,16 @@ function draw(now) {
2338
2449
  // modes, i.e. MODE 7, still look ok.
2339
2450
  video.frameSkipCount = speedy ? 9 : 0;
2340
2451
 
2341
- // We use setTimeout instead of requestAnimationFrame in two cases:
2342
- // a) We're trying to run as fast as possible.
2343
- // b) Tape is playing, normal speed but backgrounded tab should run.
2344
- if (useTimeout) {
2345
- window.setTimeout(draw, timeout);
2346
- } else {
2347
- window.requestAnimationFrame(draw);
2348
- }
2452
+ scheduleTick(speedy ? 0 : TickMs);
2349
2453
 
2350
- audioHandler.soundChip.catchUp();
2351
2454
  gamepad.update(processor.sysvia);
2352
2455
  syncLights();
2353
2456
  if (last !== 0) {
2354
2457
  let cycles;
2355
2458
  if (!speedy) {
2356
- // Now and last are DOMHighResTimeStamp, just a double.
2357
- const sinceLast = now - last;
2459
+ const sinceLast = Math.max(0, now - last);
2358
2460
  cycles = (sinceLast * clocksPerSecond) / 1000;
2359
- cycles = Math.min(cycles, MaxCyclesPerFrame);
2461
+ cycles = Math.min(cycles, MaxCyclesPerTick);
2360
2462
  } else {
2361
2463
  cycles = clocksPerSecond / 50;
2362
2464
  }
@@ -2365,14 +2467,20 @@ function draw(now) {
2365
2467
  if (!processor.execute(cycles)) {
2366
2468
  stop(true);
2367
2469
  }
2470
+ audioHandler.flushChipEvents();
2368
2471
  const end = performance.now();
2369
2472
  virtualSpeedUpdater.update(cycles, end - now, speedy);
2370
- // Capture rewind snapshot periodically
2371
- if (++rewindFrameCounter >= RewindCaptureInterval) {
2372
- rewindFrameCounter = 0;
2473
+ let snapshotMs = 0;
2474
+ rewindCycleCounter += cycles;
2475
+ if (rewindCycleCounter >= RewindCaptureCycles) {
2476
+ rewindCycleCounter -= RewindCaptureCycles;
2373
2477
  rewindBuffer.push(processor.snapshotState());
2374
2478
  rewindUI.updateButtonState();
2479
+ snapshotMs = performance.now() - end;
2375
2480
  }
2481
+ if (audioStatsNode)
2482
+ logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
2483
+ paintMsThisTick = 0;
2376
2484
  } catch (e) {
2377
2485
  running = false;
2378
2486
  utils.noteEvent("exception", "thrown", e.stack);
@@ -2383,11 +2491,29 @@ function draw(now) {
2383
2491
  stop(false);
2384
2492
  }
2385
2493
  }
2386
- last = now;
2494
+ last = Math.max(last, now);
2495
+ lastEnd = performance.now();
2387
2496
  }
2388
2497
 
2389
2498
  function run() {
2390
- window.requestAnimationFrame(draw);
2499
+ scheduleTick(0);
2500
+ }
2501
+
2502
+ // A change of audio buffer depth is taken by the picture, not the sound:
2503
+ // gaining lead emulates ahead at once; losing it moves `last` forward so the
2504
+ // ticks emulate nothing until the queue has drained by that much.
2505
+ let emulationLeadMs = 0;
2506
+
2507
+ function setEmulationLead(leadMs) {
2508
+ if (!running) return;
2509
+ const aheadMs = leadMs - emulationLeadMs;
2510
+ emulationLeadMs = leadMs;
2511
+ if (aheadMs > 0) {
2512
+ if (!processor.execute((aheadMs * clocksPerSecond) / 1000)) stop(true);
2513
+ audioHandler.flushChipEvents();
2514
+ } else {
2515
+ last -= aheadMs;
2516
+ }
2391
2517
  }
2392
2518
 
2393
2519
  let wasPreviouslyRunning = false;