jsbeeb 1.19.2 → 1.20.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/README.md CHANGED
@@ -287,7 +287,8 @@ 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.
291
292
 
292
293
  ### Atom-specific parameters
293
294
 
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.2",
10
+ "version": "1.20.0",
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"
@@ -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
@@ -145,6 +145,7 @@ const paramTypes = {
145
145
  frameSkip: ParamTypes.INT,
146
146
  audiofilterfreq: ParamTypes.FLOAT,
147
147
  audiofilterq: ParamTypes.FLOAT,
148
+ audioLatencyMs: ParamTypes.FLOAT,
148
149
  cpuMultiplier: ParamTypes.FLOAT,
149
150
  tubeCpuMultiplier: ParamTypes.FLOAT,
150
151
  microphoneChannel: ParamTypes.INT,
@@ -174,8 +175,12 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
174
175
  let fastAsPossible = false;
175
176
  let fastTape = false;
176
177
  let noSeek;
177
- let audioFilterFreq = 7000;
178
- let audioFilterQ = 5;
178
+ // The board's output filter is an equal-component Sallen-Key (Service Manual
179
+ // section 3.8: 10K and 2n2 twice, gain 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
180
+ // Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. BiquadFilterNode
181
+ // takes lowpass Q in decibels: 20*log10(0.696) = -3.15.
182
+ let audioFilterFreq = 7234;
183
+ let audioFilterQ = -3.15;
179
184
  let stationId = 101;
180
185
  let econet = null;
181
186
 
@@ -368,7 +373,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
368
373
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
369
374
  const cpuSpeed = model.cyclesPerSecond;
370
375
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
371
- const MaxCyclesPerFrame = clocksPerSecond / 10;
376
+ const MaxCyclesPerTick = clocksPerSecond / 10;
372
377
 
373
378
  let tryGl = true;
374
379
  if (parsedQuery.glEnabled !== undefined) {
@@ -548,14 +553,40 @@ function swapCanvas(newFilterClass) {
548
553
  const canvas = createCanvasForFilter(displayModeFilter);
549
554
  displayModeFilter = canvas.filterClass;
550
555
 
556
+ // The emulator paints into its own framebuffer; flyback copies the finished
557
+ // frame into the canvas and an animation frame presents it, so a stalled
558
+ // display holds up the picture and not the emulation (issue #885).
559
+ const videoFb32 = new Uint32Array(canvas.fb32.length);
560
+ const pendingFrame = { minx: 0, miny: 0, maxx: 0, maxy: 0, frameCount: 0, lineGrid: new Uint8Array(0) };
561
+ let presentScheduled = false;
562
+ let paintMsThisTick = 0;
563
+ let presentMsMax = 0;
564
+
565
+ function present() {
566
+ presentScheduled = false;
567
+ const start = performance.now();
568
+ canvas.paint(pendingFrame.minx, pendingFrame.miny, pendingFrame.maxx, pendingFrame.maxy, pendingFrame);
569
+ presentMsMax = Math.max(presentMsMax, performance.now() - start);
570
+ }
571
+
551
572
  video = new Video(
552
573
  model.isMaster,
553
- canvas.fb32,
574
+ videoFb32,
554
575
  function paint(minx, miny, maxx, maxy) {
555
576
  frames++;
556
577
  if (frames < frameSkip) return;
557
578
  frames = 0;
558
- canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
579
+ const start = performance.now();
580
+ canvas.fb32.set(videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
581
+ if (pendingFrame.lineGrid.length !== this.lineGrid.length)
582
+ pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
583
+ pendingFrame.lineGrid.set(this.lineGrid);
584
+ Object.assign(pendingFrame, { minx, miny, maxx, maxy, frameCount: this.frameCount });
585
+ paintMsThisTick += performance.now() - start;
586
+ if (!presentScheduled) {
587
+ presentScheduled = true;
588
+ window.requestAnimationFrame(present);
589
+ }
559
590
  },
560
591
  { isAtom: model.isAtom },
561
592
  );
@@ -569,9 +600,11 @@ const audioHandler = new AudioHandler({
569
600
  statsNode: audioStatsNode,
570
601
  audioFilterFreq,
571
602
  audioFilterQ,
603
+ audioLatencyMs: parsedQuery.audioLatencyMs,
572
604
  noSeek,
573
605
  cpuSpeed,
574
606
  isAtom: model.isAtom,
607
+ hasMusic5000: config.hasMusic5000,
575
608
  });
576
609
  // Firefox will report that audio is suspended even when it will
577
610
  // start playing without user interaction, so we need to delay a
@@ -720,7 +753,9 @@ setCrtPic(displayModeFilter);
720
753
 
721
754
  window.addEventListener("blur", function () {
722
755
  keyboard.clearKeys();
756
+ setEmulationLead(audioHandler.setWindowFocused(false));
723
757
  });
758
+ window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
724
759
 
725
760
  document.getElementById("fs").addEventListener("click", function (event) {
726
761
  screenCanvas.requestFullscreen();
@@ -1690,20 +1725,16 @@ async function gdLoad(cat, layout) {
1690
1725
  }
1691
1726
  }
1692
1727
 
1693
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1694
- (async () => {
1728
+ const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1729
+ // Loading the Google client holds the main thread for ~100ms, so it waits for
1730
+ // someone to ask for Drive.
1731
+ document.getElementById("open-drive-link").addEventListener("click", async function () {
1695
1732
  try {
1696
- const available = await googleDrive.initialise();
1697
- if (available) {
1698
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1699
- await gdAuth(true);
1700
- }
1733
+ await googleDrive.initialise();
1701
1734
  } catch (error) {
1702
- console.log(`Google Drive is unavailable: ${errorText(error)}`);
1735
+ toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
1736
+ return false;
1703
1737
  }
1704
- })();
1705
- const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1706
- document.getElementById("open-drive-link").addEventListener("click", async function () {
1707
1738
  const authed = await gdAuth(false);
1708
1739
  if (authed) {
1709
1740
  googleDriveModal.show();
@@ -2244,6 +2275,7 @@ function profileVideo(arg) {
2244
2275
  }
2245
2276
 
2246
2277
  let last = 0;
2278
+ let lastEnd = 0;
2247
2279
 
2248
2280
  function VirtualSpeedUpdater() {
2249
2281
  this.cycles = 0;
@@ -2277,8 +2309,51 @@ function VirtualSpeedUpdater() {
2277
2309
  const virtualSpeedUpdater = new VirtualSpeedUpdater();
2278
2310
 
2279
2311
  const rewindBuffer = new RewindBuffer(30);
2280
- let rewindFrameCounter = 0;
2281
- const RewindCaptureInterval = 50; // ~1 second at 50fps
2312
+ let rewindCycleCounter = 0;
2313
+ const RewindCaptureInterval = 50; // emulated frames, ~1 second
2314
+ const RewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
2315
+
2316
+ // Under ?audioDebug, one console line per second in which the emulator sat
2317
+ // idle between ticks or a tick ran long, or the audio queue underran or
2318
+ // dropped, so a click can be matched to a cause. The sound chip posts samples
2319
+ // throughout execute(), so only the idle time starves the audio queue.
2320
+ const AudioDebugLogIntervalMs = 1000;
2321
+ const AudioDebugSlowTickMs = 30;
2322
+ const audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
2323
+ const AudioDebugSlowPresentMs = 30;
2324
+
2325
+ function logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
2326
+ const log = audioDebugLog;
2327
+ if (log.start === 0) log.start = now;
2328
+ log.ticks++;
2329
+ log.cycles += cycles;
2330
+ log.maxIdle = Math.max(log.maxIdle, idleMs);
2331
+ log.maxExecute = Math.max(log.maxExecute, executeMs);
2332
+ log.maxPaint = Math.max(log.maxPaint, paintMs);
2333
+ log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
2334
+ if (now - log.start < AudioDebugLogIntervalMs) return;
2335
+ const audio = audioHandler.takeEventCounts();
2336
+ const present = presentMsMax;
2337
+ presentMsMax = 0;
2338
+ const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
2339
+ if (
2340
+ log.maxIdle > AudioDebugSlowTickMs ||
2341
+ log.maxExecute > AudioDebugSlowTickMs ||
2342
+ present > AudioDebugSlowPresentMs ||
2343
+ audio.stall ||
2344
+ audio.skip
2345
+ ) {
2346
+ console.log(
2347
+ `${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / clocksPerSecond).toFixed(0)}ms, ` +
2348
+ `idle max ${log.maxIdle.toFixed(0)}ms, ` +
2349
+ `execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
2350
+ `present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
2351
+ `audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
2352
+ );
2353
+ }
2354
+ log.start = now;
2355
+ log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
2356
+ }
2282
2357
 
2283
2358
  rewindUI = new RewindUI({
2284
2359
  rewindBuffer,
@@ -2311,21 +2386,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
2311
2386
  if (drive) showDriveTracks(driveIndex);
2312
2387
  }
2313
2388
 
2314
- function draw(now) {
2389
+ // A timer, not requestAnimationFrame: a display presentation stall withholds
2390
+ // animation frames, and with them the sound chip's samples (issue #885).
2391
+ const TickMs = 10;
2392
+ let tickToken = null;
2393
+
2394
+ // A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
2395
+ // compositor does not hold the tick off too.
2396
+ function scheduleTick(delayMs) {
2397
+ const token = (tickToken = {});
2398
+ const fire = () => {
2399
+ if (tickToken === token) tick();
2400
+ };
2401
+ if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
2402
+ else window.setTimeout(fire, delayMs);
2403
+ }
2404
+
2405
+ function tick() {
2315
2406
  if (!running) {
2316
2407
  last = 0;
2317
2408
  return;
2318
2409
  }
2319
- // If we got here via setTimeout, we don't get passed the time.
2320
- if (now === undefined) {
2321
- now = window.performance.now();
2322
- }
2410
+ const now = performance.now();
2323
2411
 
2324
2412
  const motorOn = processor.acia.motorOn;
2325
- const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
2326
2413
  const speedy = fastAsPossible || (fastTape && motorOn);
2327
- const useTimeout = speedy || motorOn || discOn;
2328
- const timeout = speedy ? 0 : 1000.0 / 50;
2329
2414
 
2330
2415
  // In speedy mode, we still run all the state machines accurately
2331
2416
  // but we paint less often because painting is the most expensive
@@ -2334,25 +2419,16 @@ function draw(now) {
2334
2419
  // modes, i.e. MODE 7, still look ok.
2335
2420
  video.frameSkipCount = speedy ? 9 : 0;
2336
2421
 
2337
- // We use setTimeout instead of requestAnimationFrame in two cases:
2338
- // a) We're trying to run as fast as possible.
2339
- // b) Tape is playing, normal speed but backgrounded tab should run.
2340
- if (useTimeout) {
2341
- window.setTimeout(draw, timeout);
2342
- } else {
2343
- window.requestAnimationFrame(draw);
2344
- }
2422
+ scheduleTick(speedy ? 0 : TickMs);
2345
2423
 
2346
- audioHandler.soundChip.catchUp();
2347
2424
  gamepad.update(processor.sysvia);
2348
2425
  syncLights();
2349
2426
  if (last !== 0) {
2350
2427
  let cycles;
2351
2428
  if (!speedy) {
2352
- // Now and last are DOMHighResTimeStamp, just a double.
2353
- const sinceLast = now - last;
2429
+ const sinceLast = Math.max(0, now - last);
2354
2430
  cycles = (sinceLast * clocksPerSecond) / 1000;
2355
- cycles = Math.min(cycles, MaxCyclesPerFrame);
2431
+ cycles = Math.min(cycles, MaxCyclesPerTick);
2356
2432
  } else {
2357
2433
  cycles = clocksPerSecond / 50;
2358
2434
  }
@@ -2361,14 +2437,20 @@ function draw(now) {
2361
2437
  if (!processor.execute(cycles)) {
2362
2438
  stop(true);
2363
2439
  }
2440
+ audioHandler.flushChipEvents();
2364
2441
  const end = performance.now();
2365
2442
  virtualSpeedUpdater.update(cycles, end - now, speedy);
2366
- // Capture rewind snapshot periodically
2367
- if (++rewindFrameCounter >= RewindCaptureInterval) {
2368
- rewindFrameCounter = 0;
2443
+ let snapshotMs = 0;
2444
+ rewindCycleCounter += cycles;
2445
+ if (rewindCycleCounter >= RewindCaptureCycles) {
2446
+ rewindCycleCounter -= RewindCaptureCycles;
2369
2447
  rewindBuffer.push(processor.snapshotState());
2370
2448
  rewindUI.updateButtonState();
2449
+ snapshotMs = performance.now() - end;
2371
2450
  }
2451
+ if (audioStatsNode)
2452
+ logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
2453
+ paintMsThisTick = 0;
2372
2454
  } catch (e) {
2373
2455
  running = false;
2374
2456
  utils.noteEvent("exception", "thrown", e.stack);
@@ -2379,11 +2461,29 @@ function draw(now) {
2379
2461
  stop(false);
2380
2462
  }
2381
2463
  }
2382
- last = now;
2464
+ last = Math.max(last, now);
2465
+ lastEnd = performance.now();
2383
2466
  }
2384
2467
 
2385
2468
  function run() {
2386
- window.requestAnimationFrame(draw);
2469
+ scheduleTick(0);
2470
+ }
2471
+
2472
+ // A change of audio buffer depth is taken by the picture, not the sound:
2473
+ // gaining lead emulates ahead at once; losing it moves `last` forward so the
2474
+ // ticks emulate nothing until the queue has drained by that much.
2475
+ let emulationLeadMs = 0;
2476
+
2477
+ function setEmulationLead(leadMs) {
2478
+ if (!running) return;
2479
+ const aheadMs = leadMs - emulationLeadMs;
2480
+ emulationLeadMs = leadMs;
2481
+ if (aheadMs > 0) {
2482
+ if (!processor.execute((aheadMs * clocksPerSecond) / 1000)) stop(true);
2483
+ audioHandler.flushChipEvents();
2484
+ } else {
2485
+ last -= aheadMs;
2486
+ }
2387
2487
  }
2388
2488
 
2389
2489
  let wasPreviouslyRunning = false;
package/src/soundchip.js CHANGED
@@ -5,6 +5,17 @@ const speakerVolume = 0.5;
5
5
  // Samples per output chunk handed to the onBuffer callback.
6
6
  export const SoundBufferSamples = 512;
7
7
 
8
+ // The BBC's DC restoration circuit (Service Manual section 3.8: R8 10K, C2
9
+ // 4u7) cancels the SN76489's unipolar pedestal. Modelled as a first-order DC
10
+ // blocker with the same corner, 1/(2*pi*R*C). The generators must stay
11
+ // unipolar: sampled speech is amplitude modulation of a 125 kHz carrier's
12
+ // mean level, which a zero-mean output would silence (see issue #863).
13
+ const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
14
+
15
+ // A chip with an event sink reports progress this often, so its events can
16
+ // stream through a long execute() rather than all arriving at its end.
17
+ const EventProgressCycles = 4000;
18
+
8
19
  const volumeTable = new Float32Array(16);
9
20
  (() => {
10
21
  let f = 1.0;
@@ -28,9 +39,15 @@ export class SoundChip {
28
39
  * @param {function(Float32Array): void} onBuffer called with each full
29
40
  * SoundBufferSamples-sized buffer of output. Receives the same buffer
30
41
  * every call, overwritten afterwards: copy the contents if they are kept.
42
+ * @param {object} [options]
43
+ * @param {function(object): void} [options.onEvent] called with each
44
+ * change to the chip's state, stamped with the cycle it takes effect at.
45
+ * A chip with an event sink does not render; something else renders
46
+ * from the events (see audio-renderer.js).
31
47
  */
32
- constructor(onBuffer) {
48
+ constructor(onBuffer, { onEvent = null } = {}) {
33
49
  this._onBuffer = onBuffer;
50
+ this._onEvent = onEvent;
34
51
  // 4MHz input signal. Internal divide-by-8
35
52
  this.soundchipFreq = 4000000.0 / 8;
36
53
  const sampleRate = this.soundchipFreq;
@@ -72,6 +89,10 @@ export class SoundChip {
72
89
  this.position = 0;
73
90
  this.buffer = new Float32Array(SoundBufferSamples);
74
91
 
92
+ this.dcAlpha = 1 - (2 * Math.PI * DcRestoreCornerHz) / sampleRate;
93
+ this.dcPrevIn = 0;
94
+ this.dcPrevOut = 0;
95
+
75
96
  this.latchedRegister = 0;
76
97
  this.slowDataBus = 0;
77
98
  this.active = false;
@@ -80,15 +101,38 @@ export class SoundChip {
80
101
  mute: () => {
81
102
  this.catchUp();
82
103
  this.sineOn = false;
104
+ this._emit({ sine: 0 });
83
105
  },
84
106
  tone: (freq) => {
85
107
  this.catchUp();
86
108
  this.sineOn = true;
87
109
  this.sineStep = (freq / sampleRate) * this.sineTable.length;
110
+ this._emit({ sine: freq });
88
111
  },
89
112
  };
90
113
  }
91
114
 
115
+ _emit(event) {
116
+ if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, ...event });
117
+ }
118
+
119
+ /** Applies an event from another chip's onEvent, at the cycle this chip is rendering. */
120
+ applyEvent(event) {
121
+ if (event.poke !== undefined) this.poke(event.poke);
122
+ else if (event.sine !== undefined) {
123
+ if (event.sine) this.toneGenerator.tone(event.sine);
124
+ else this.toneGenerator.mute();
125
+ } else if (event.enabled !== undefined) this.enabled = event.enabled;
126
+ else if (event.state !== undefined) this.restoreState(event.state);
127
+ else if (event.reset !== undefined) this.reset(event.reset);
128
+ }
129
+
130
+ /** Renders `length` samples of output from `cycle`, for a chip that is driven by events. */
131
+ renderAt(cycle, out, offset, length) {
132
+ this.scheduler.epoch = this.lastRunEpoch = cycle;
133
+ this.generate(out, offset, length);
134
+ }
135
+
92
136
  sineChannel(channel, out, offset, length) {
93
137
  if (!this.sineOn) return;
94
138
 
@@ -170,6 +214,7 @@ export class SoundChip {
170
214
  this.volume[2] = volumeTable[v2];
171
215
  this.volume[3] = volumeTable[v3];
172
216
  this.noisePoked();
217
+ this._emit({ state: this.snapshotState() });
173
218
  }
174
219
 
175
220
  generate(out, offset, length) {
@@ -178,10 +223,22 @@ export class SoundChip {
178
223
  for (let i = 0; i < length; ++i) {
179
224
  out[i + offset] = 0.0;
180
225
  }
181
- if (!this.enabled) return;
182
- for (let i = 0; i < this.generators.length; ++i) {
183
- this.generators[i](i, out, offset, length);
226
+ if (this.enabled) {
227
+ for (let i = 0; i < this.generators.length; ++i) {
228
+ this.generators[i](i, out, offset, length);
229
+ }
184
230
  }
231
+ const alpha = this.dcAlpha;
232
+ let prevIn = this.dcPrevIn;
233
+ let prevOut = this.dcPrevOut;
234
+ for (let i = 0; i < length; ++i) {
235
+ const x = out[i + offset];
236
+ prevOut = x - prevIn + alpha * prevOut;
237
+ prevIn = x;
238
+ out[i + offset] = prevOut;
239
+ }
240
+ this.dcPrevIn = prevIn;
241
+ this.dcPrevOut = prevOut;
185
242
  }
186
243
 
187
244
  catchUp() {
@@ -196,6 +253,13 @@ export class SoundChip {
196
253
  this.activeTask = this.scheduler.newTask(() => {
197
254
  if (this.active) this.poke(this.slowDataBus);
198
255
  });
256
+ if (this._onEvent) {
257
+ this.progressTask = this.scheduler.newTask(() => {
258
+ this._emit({ progress: true });
259
+ this.progressTask.schedule(EventProgressCycles);
260
+ });
261
+ this.progressTask.schedule(EventProgressCycles);
262
+ }
199
263
  }
200
264
 
201
265
  render(out, offset, length) {
@@ -216,6 +280,7 @@ export class SoundChip {
216
280
  }
217
281
 
218
282
  advance(cycles) {
283
+ if (this._onEvent) return;
219
284
  const num = cycles * this.samplesPerCycle + this.residual;
220
285
  let rounded = num | 0;
221
286
  this.residual = num - rounded;
@@ -247,6 +312,7 @@ export class SoundChip {
247
312
 
248
313
  poke(value) {
249
314
  this.catchUp();
315
+ this._emit({ poke: value });
250
316
 
251
317
  let command;
252
318
  if (value & 0x80) {
@@ -297,6 +363,8 @@ export class SoundChip {
297
363
  sineOn: this.sineOn,
298
364
  sineStep: this.sineStep,
299
365
  sineTime: this.sineTime,
366
+ dcPrevIn: this.dcPrevIn,
367
+ dcPrevOut: this.dcPrevOut,
300
368
  };
301
369
  }
302
370
 
@@ -322,10 +390,16 @@ export class SoundChip {
322
390
  // Reset output buffer
323
391
  this.position = 0;
324
392
  this.buffer.fill(0);
393
+ // Older snapshots predate the DC blocker
394
+ this.dcPrevIn = state.dcPrevIn ?? 0;
395
+ this.dcPrevOut = state.dcPrevOut ?? 0;
396
+ this.progressTask?.ensureScheduled(true, EventProgressCycles);
397
+ this._emit({ state: this.snapshotState() });
325
398
  }
326
399
 
327
400
  reset(hard) {
328
401
  if (!hard) return;
402
+ this._emit({ reset: true });
329
403
  for (let i = 0; i < 4; ++i) {
330
404
  this.counter[i] = 0;
331
405
  this.registers[i] = 0;
@@ -338,14 +412,15 @@ export class SoundChip {
338
412
 
339
413
  enable(e) {
340
414
  this.enabled = e;
415
+ this._emit({ enabled: e });
341
416
  }
342
417
 
343
418
  mute() {
344
- this.enabled = false;
419
+ this.enable(false);
345
420
  }
346
421
 
347
422
  unmute() {
348
- this.enabled = true;
423
+ this.enable(true);
349
424
  }
350
425
  }
351
426
 
@@ -355,8 +430,8 @@ export class SoundChip {
355
430
  * channel with DC-blocking filter.
356
431
  */
357
432
  export class AtomSoundChip extends SoundChip {
358
- constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
359
- super(onBuffer);
433
+ constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
434
+ super(onBuffer, { onEvent });
360
435
  this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
361
436
  this.secondsPerCycle = 1 / cpuSpeed;
362
437
 
@@ -395,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
395
470
  this._speakerCycleOffset = 0;
396
471
  }
397
472
 
473
+ applyEvent(event) {
474
+ if (event.bit !== undefined) this.bitChange.push({ bit: event.bit, cycles: event.cycle });
475
+ else if (event.speakerReset !== undefined) this.speakerReset();
476
+ else super.applyEvent(event);
477
+ }
478
+
479
+ renderAt(cycle, out, offset, length) {
480
+ this._speakerCycleOffset = 0;
481
+ super.renderAt(cycle, out, offset, length);
482
+ }
483
+
398
484
  speakerReset() {
485
+ this._emit({ speakerReset: true });
399
486
  this.bitChange = [];
400
487
  this.currentSpeakerBit = 0.0;
401
488
  this._speakerPrevIn = 0;
@@ -433,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
433
520
 
434
521
  updateSpeaker(value, microCycle, seconds) {
435
522
  const cycles = microCycle + seconds / this.secondsPerCycle;
436
- this.bitChange.push({ bit: value ? 1.0 : 0.0, cycles });
523
+ const bit = value ? 1.0 : 0.0;
524
+ if (this._onEvent) this._onEvent({ cycle: cycles, bit });
525
+ else this.bitChange.push({ bit, cycles });
437
526
  }
438
527
  }
439
528
 
@@ -6,19 +6,39 @@ import { createAudioContext } from "../audio-utils.js";
6
6
  import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
7
7
  import { toast } from "./toast.js";
8
8
 
9
- // Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
10
- // See https://github.com/vitejs/vite/discussions/6459
11
- const rendererUrl = new URL("./audio-renderer.js", import.meta.url).href;
12
- const music5000WorkletUrl = new URL("../music5000-worklet.js", import.meta.url).href;
9
+ import rendererUrl from "./audio-renderer.js?worker&url";
10
+ import music5000WorkletUrl from "../music5000-worklet.js?worker&url";
11
+
12
+ // Skips plot as the milliseconds skipped, on the lead scale; stalls as a fixed spike.
13
+ const StallSpikeHeight = 20;
14
+
15
+ // Nobody is watching an unfocused window, so its sound can run far behind
16
+ // the picture, deep enough to ride out the browser starving the tab.
17
+ export const UnfocusedLatencyMs = 200;
18
+ export const DefaultLatencyMs = 20;
13
19
 
14
20
  export class AudioHandler {
15
- constructor({ warningNode, statsNode, audioFilterFreq, audioFilterQ, noSeek, cpuSpeed, isAtom } = {}) {
21
+ constructor({
22
+ warningNode,
23
+ statsNode,
24
+ audioFilterFreq,
25
+ audioFilterQ,
26
+ audioLatencyMs,
27
+ noSeek,
28
+ cpuSpeed,
29
+ isAtom,
30
+ hasMusic5000,
31
+ } = {}) {
16
32
  this.cpuSpeed = cpuSpeed;
17
33
  this.isAtom = isAtom;
34
+ this.audioLatencyMs = audioLatencyMs ?? DefaultLatencyMs;
35
+ this.windowFocused = document.hasFocus();
18
36
  this.warningNode = warningNode;
19
37
  this.noAudio = false;
20
38
  toggle(this.warningNode, false);
21
39
  this.stats = {};
40
+ this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
41
+ this._chipEvents = [];
22
42
  if (statsNode) {
23
43
  this._initStats(statsNode).catch((error) => {
24
44
  console.error("Unable to initialise audio stats", error);
@@ -30,10 +50,13 @@ export class AudioHandler {
30
50
  this._jsAudioNode = null;
31
51
  if (this.audioContext && this.audioContext.audioWorklet) {
32
52
  this.audioContext.onstatechange = () => this.checkStatus();
33
- const onBuffer = (buffer, time) => this._onBuffer(buffer, time);
53
+ const onEvent = (event) => {
54
+ if (event.progress) this.flushChipEvents();
55
+ else this._chipEvents.push(event);
56
+ };
34
57
  this.soundChip = this.isAtom
35
- ? new AtomSoundChip(onBuffer, { cpuSpeed: this.cpuSpeed })
36
- : new SoundChip(onBuffer);
58
+ ? new AtomSoundChip(null, { cpuSpeed: this.cpuSpeed, onEvent })
59
+ : new SoundChip(null, { onEvent });
37
60
  // Master gain node for all sample-based audio (disc, relay, etc.).
38
61
  this.masterGain = this.audioContext.createGain();
39
62
  this.masterGain.connect(this.audioContext.destination);
@@ -60,31 +83,31 @@ export class AudioHandler {
60
83
 
61
84
  this.warningNode.addEventListener("mousedown", () => this.tryResume());
62
85
 
63
- // Initialise Music 5000 audio context
64
- this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
65
-
66
- if (this.audioContextM5000 && this.audioContextM5000.audioWorklet) {
67
- this.audioContextM5000.onstatechange = () => this.checkStatus();
68
- this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
69
-
70
- this.audioContextM5000.audioWorklet
71
- .addModule(music5000WorkletUrl)
72
- .then(() => {
73
- this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
74
- outputChannelCount: [2],
75
- });
76
- this._music5000workletnode.connect(this.audioContextM5000.destination);
77
- })
78
- .catch((error) => {
79
- console.error("Unable to initialise Music 5000 audio", error);
80
- toast(
81
- `The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
82
- { title: "Music 5000", quietKey: "quietMusic5000Audio" },
83
- );
84
- });
85
- } else {
86
- this.music5000 = new FakeMusic5000();
87
- }
86
+ this.audioContextM5000 = null;
87
+ this._music5000workletnode = null;
88
+ this.music5000 = hasMusic5000 ? this._createMusic5000() : new FakeMusic5000();
89
+ }
90
+
91
+ // The Music 5000 gets its own context, running at the board's own sample rate.
92
+ _createMusic5000() {
93
+ if (!this.audioContext?.audioWorklet) return new FakeMusic5000();
94
+ const context = createAudioContext({ sampleRate: 46875 });
95
+ this.audioContextM5000 = context;
96
+ context.onstatechange = () => this.checkStatus();
97
+ context.audioWorklet
98
+ .addModule(music5000WorkletUrl)
99
+ .then(() => {
100
+ this._music5000workletnode = new AudioWorkletNode(context, "music5000", { outputChannelCount: [2] });
101
+ this._music5000workletnode.connect(context.destination);
102
+ })
103
+ .catch((error) => {
104
+ console.error("Unable to initialise Music 5000 audio", error);
105
+ toast(
106
+ `The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
107
+ { title: "Music 5000", quietKey: "quietMusic5000Audio" },
108
+ );
109
+ });
110
+ return new Music5000((buffer) => this._onBufferMusic5000(buffer));
88
111
  }
89
112
 
90
113
  // Lazily load smoothie and set up the audio stats chart.
@@ -98,8 +121,9 @@ export class AudioHandler {
98
121
  return { min: 0, max: range.max };
99
122
  },
100
123
  });
101
- this._addStat("queueSize", { strokeStyle: "rgb(51,126,108)" });
102
- this._addStat("queueAge", { strokeStyle: "rgb(162,119,22)" });
124
+ this._addStat("leadMs", { strokeStyle: "rgb(51,126,108)" });
125
+ this._addStat("stall", { strokeStyle: "rgb(220,50,50)", lineWidth: 2 });
126
+ this._addStat("skip", { strokeStyle: "rgb(120,80,200)", lineWidth: 2 });
103
127
  this.chart.streamTo(statsNode, 100);
104
128
  }
105
129
 
@@ -116,16 +140,54 @@ export class AudioHandler {
116
140
  this._audioDestination = this.audioContext.destination;
117
141
  }
118
142
 
119
- this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor");
143
+ this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
144
+ processorOptions: {
145
+ targetLatencyMs: this._targetLatencyMs(),
146
+ isAtom: this.isAtom,
147
+ cpuSpeed: this.cpuSpeed,
148
+ },
149
+ });
120
150
  this._jsAudioNode.connect(this._audioDestination);
121
151
  this._jsAudioNode.port.onmessage = (event) => {
122
152
  const now = Date.now();
153
+ if (event.data.event) {
154
+ this._onAudioEvent(now, event.data);
155
+ return;
156
+ }
157
+ this.eventCounts.leadMinMs = Math.min(this.eventCounts.leadMinMs, event.data.leadMinMs);
123
158
  for (const stat of Object.keys(event.data)) {
124
159
  if (this.stats[stat]) this.stats[stat].append(now, event.data[stat]);
125
160
  }
126
161
  };
127
162
  }
128
163
 
164
+ _onAudioEvent(now, { event, count }) {
165
+ this.eventCounts[event] += count;
166
+ const series = this.stats[event];
167
+ if (!series) return;
168
+ series.append(now - 1, 0);
169
+ series.append(now, event === "stall" ? StallSpikeHeight : count);
170
+ series.append(now + 1, 0);
171
+ }
172
+
173
+ _targetLatencyMs() {
174
+ return this.windowFocused ? this.audioLatencyMs : UnfocusedLatencyMs;
175
+ }
176
+
177
+ // Returns how far ahead of the sound the picture should now run, in ms.
178
+ setWindowFocused(focused) {
179
+ this.windowFocused = focused;
180
+ const targetLatencyMs = this._targetLatencyMs();
181
+ this._jsAudioNode?.port.postMessage({ command: "setTargetLatency", targetLatencyMs });
182
+ return targetLatencyMs - this.audioLatencyMs;
183
+ }
184
+
185
+ takeEventCounts() {
186
+ const counts = this.eventCounts;
187
+ this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
188
+ return counts;
189
+ }
190
+
129
191
  _audioUnavailable(error) {
130
192
  console.error("Unable to initialise audio", error);
131
193
  this.noAudio = true;
@@ -140,11 +202,12 @@ export class AudioHandler {
140
202
  this.chart.addTimeSeries(timeSeries, info);
141
203
  }
142
204
 
143
- _onBuffer(buffer) {
144
- // No transfer list, deliberately: the chip reuses this buffer, and
145
- // transferring would detach it and trip crbug.com/537801199. The clone
146
- // costs little (512 floats per 1.024ms of chip output, ~2MB/s).
147
- if (this._jsAudioNode) this._jsAudioNode.port.postMessage({ time: Date.now(), buffer });
205
+ // Ships the chip's state changes since the last call, and how far the
206
+ // emulator has got, so the worklet knows its lead even when nothing changed.
207
+ flushChipEvents() {
208
+ if (!this._jsAudioNode) return;
209
+ this._jsAudioNode.port.postMessage({ upTo: this.soundChip.scheduler.epoch, events: this._chipEvents });
210
+ this._chipEvents = [];
148
211
  }
149
212
 
150
213
  // Recent browsers, particularly Safari and Chrome, require a user interaction in order to enable sound playback.
@@ -177,13 +240,16 @@ export class AudioHandler {
177
240
  await this.relayNoise.initialise();
178
241
  }
179
242
 
243
+ // The emulator is stopping, so no tick will ship the change; send it now.
180
244
  mute() {
181
245
  this.soundChip.mute();
246
+ this.flushChipEvents();
182
247
  if (this.masterGain) this.masterGain.gain.value = 0;
183
248
  }
184
249
 
185
250
  unmute() {
186
251
  this.soundChip.unmute();
252
+ this.flushChipEvents();
187
253
  if (this.masterGain) this.masterGain.gain.value = 1;
188
254
  }
189
255
  }
@@ -1,117 +1,224 @@
1
1
  /* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
2
+ import { SoundChip, AtomSoundChip } from "../soundchip.js";
2
3
 
3
4
  const lowPassFilterFreq = sampleRate / 2;
4
5
  const RC = 1 / (2 * Math.PI * lowPassFilterFreq);
5
6
 
6
- const InputSampleRate = 4000000.0 / 8;
7
- const MaxQueuedMs = 250;
7
+ const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
8
+ const MaxTargetLatencyMs = 250;
8
9
 
9
- const samplesFor = (ms) => (InputSampleRate * ms) / 1000;
10
+ // Smoothing rejects the producer's per-tick bursts; proportional only, as
11
+ // lead already integrates rate error; 0.05% authority covers clock skew
12
+ // without audibly bending pitch.
13
+ const LeadSmoothingTau = 0.5;
14
+ const ProportionalGain = 0.2;
15
+ const MaxAdjustFraction = 0.0005;
10
16
 
17
+ const isResync = (event) => event.state !== undefined || event.reset !== undefined;
18
+
19
+ // Renders the chip from its timestamped state changes, so a producer that
20
+ // falls behind leaves the chip sounding its current state (a stall) rather
21
+ // than silent; the missed time is skipped once the producer is ahead again.
11
22
  class SoundChipProcessor extends AudioWorkletProcessor {
12
- constructor(...args) {
13
- super(...args);
23
+ constructor(options) {
24
+ super(options);
25
+ const { isAtom = false, cpuSpeed = 1000000, targetLatencyMs } = options?.processorOptions ?? {};
26
+ this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
27
+ this.inputSampleRate = this.chip.soundchipFreq;
28
+ this.samplesPerCycle = this.chip.samplesPerCycle;
29
+
30
+ this.events = [];
31
+ this.eventsHead = 0;
32
+ this.clock = 0;
33
+ this.upTo = 0;
34
+ this.stalled = true;
35
+ this.stalls = 0;
36
+ this.skippedMs = 0;
37
+ this.minLeadMs = Infinity;
14
38
 
15
- this.inputSampleRate = InputSampleRate;
16
- this._lastSample = 0;
17
39
  this._lastFilteredOutput = 0;
18
- this.queue = [];
19
- this._queueSizeSamples = 0;
20
- this.dropped = 0;
21
- this.underruns = 0;
22
- this.targetLatencyMs = 1000 * (1 / 50); // One frame
23
- this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
24
- this.running = false;
25
- this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
40
+ this._phase = 0;
41
+ this._source = new Float32Array(0);
42
+ this._rendered = new Float32Array(0);
43
+ this.smoothedLeadError = 0;
44
+ this.setTargetLatency(targetLatencyMs);
26
45
  this.port.onmessage = (event) => {
27
- // TODO: even better than this, send over register settings/catch up and run the audio work _here_
28
- this.onBuffer(event.data.time, event.data.buffer);
46
+ if (event.data.command === "setTargetLatency") this.setTargetLatency(event.data.targetLatencyMs);
47
+ else this.onProduced(event.data.upTo, event.data.events);
29
48
  };
30
49
  this.nextStats = 0;
31
50
  }
32
51
 
52
+ setTargetLatency(ms) {
53
+ const valid = Number.isFinite(ms) && ms > 0;
54
+ this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
55
+ this.targetLeadCycles = this._cycles(this.targetLatencyMs);
56
+ this.smoothedLeadError = 0;
57
+ }
58
+
59
+ _cycles(ms) {
60
+ return ((ms / 1000) * this.inputSampleRate) / this.samplesPerCycle;
61
+ }
62
+
63
+ _ms(cycles) {
64
+ return (1000 * cycles * this.samplesPerCycle) / this.inputSampleRate;
65
+ }
66
+
67
+ leadMs() {
68
+ return this._ms(this.upTo - this.clock);
69
+ }
70
+
71
+ // A resync (restore or reset) starts a new timeline; anything queued
72
+ // before it belongs to the old one.
73
+ onProduced(upTo, events) {
74
+ let from = 0;
75
+ for (let i = events.length - 1; i >= 0; --i) {
76
+ if (isResync(events[i])) {
77
+ from = i;
78
+ this.events = [];
79
+ this.eventsHead = 0;
80
+ break;
81
+ }
82
+ }
83
+ if (this.eventsHead > 0) {
84
+ this.events = this.events.slice(this.eventsHead);
85
+ this.eventsHead = 0;
86
+ }
87
+ for (let i = from; i < events.length; ++i) this.events.push(events[i]);
88
+ this.upTo = upTo;
89
+ }
90
+
33
91
  stats(sampleRatio) {
34
92
  if (currentTime < this.nextStats) return;
35
93
  this.nextStats = currentTime + 0.25;
36
94
  this.port.postMessage({
37
95
  sampleRate: sampleRate,
38
96
  inputSampleRate: this.inputSampleRate,
39
- dropped: this.dropped,
40
- underruns: this.underruns,
41
- queueSize: this.queue.length,
42
- queueAge: this._queueAge(),
97
+ stalls: this.stalls,
98
+ skippedMs: this.skippedMs,
99
+ leadMs: this.leadMs(),
100
+ leadMinMs: this.minLeadMs,
101
+ queuedEvents: this.events.length - this.eventsHead,
43
102
  sampleRatio: sampleRatio,
44
103
  });
104
+ this.minLeadMs = Infinity;
45
105
  }
46
106
 
47
- _queueAge() {
48
- if (this.queue.length === 0) return 0;
49
- const timeInBufferMs = 1000 * (this.queue[0].offset / this.inputSampleRate) + this.queue[0].time;
50
- return Date.now() - timeInBufferMs;
107
+ _notify(event, count) {
108
+ this.port.postMessage({ event, count });
51
109
  }
52
110
 
53
- onBuffer(time, buffer) {
54
- this.queue.push({ offset: 0, time, buffer });
55
- this._queueSizeSamples += buffer.length;
56
- this.cleanQueue();
57
- if (!this.running && this._queueSizeSamples >= this.startQueueSizeSamples) this.running = true;
111
+ _effectiveSampleRate(dtSeconds) {
112
+ const error = this.upTo - this.clock - this.targetLeadCycles;
113
+ const alpha = Math.min(1, dtSeconds / LeadSmoothingTau);
114
+ this.smoothedLeadError += alpha * (error - this.smoothedLeadError);
115
+ const adjustment = ProportionalGain * this.smoothedLeadError * this.samplesPerCycle;
116
+ const maxAdjust = this.inputSampleRate * MaxAdjustFraction;
117
+ return this.inputSampleRate + Math.min(maxAdjust, Math.max(-maxAdjust, adjustment));
58
118
  }
59
119
 
60
- _shift() {
61
- const dropped = this.queue.shift();
62
- this._queueSizeSamples -= dropped.buffer.length;
120
+ _applyHead() {
121
+ this.chip.applyEvent(this.events[this.eventsHead++]);
63
122
  }
64
123
 
65
- cleanQueue() {
66
- const maxLatency = this.targetLatencyMs * 2;
67
- while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
68
- this._shift();
69
- this.dropped++;
124
+ // Applies every queued change up to `cycle` at once and moves the clock
125
+ // there, so the sound continues from the producer's state without a gap.
126
+ _skipTo(cycle) {
127
+ while (this.eventsHead < this.events.length && this.events[this.eventsHead].cycle <= cycle) this._applyHead();
128
+ this.skippedMs += this._ms(cycle - this.clock);
129
+ this.clock = cycle;
130
+ }
131
+
132
+ _restart() {
133
+ const skipTo = this.upTo - this.targetLeadCycles;
134
+ if (skipTo > this.clock) {
135
+ this._notify("skip", this._ms(skipTo - this.clock));
136
+ this._skipTo(skipTo);
137
+ }
138
+ this.stalled = false;
139
+ }
140
+
141
+ _stall(out, offset, length) {
142
+ if (!this.stalled) {
143
+ this.stalled = true;
144
+ this.stalls++;
145
+ this._notify("stall", 1);
70
146
  }
147
+ this.chip.renderAt(this.clock, out, offset, length);
148
+ }
149
+
150
+ // Input samples before the next change of state: the head event, or
151
+ // the producer's position, whichever the clock reaches first. A resync
152
+ // takes effect at once, since it starts a new timeline.
153
+ _samplesUntilNextChange() {
154
+ const head = this.events[this.eventsHead];
155
+ if (head !== undefined && isResync(head)) return 0;
156
+ const boundary = head === undefined ? this.upTo : Math.min(head.cycle, this.upTo);
157
+ return Math.floor((boundary - this.clock) * this.samplesPerCycle);
71
158
  }
72
159
 
73
- nextSample() {
74
- if (this.running && this.queue.length) {
75
- const queueElement = this.queue[0];
76
- this._lastSample = queueElement.buffer[queueElement.offset];
77
- if (++queueElement.offset === queueElement.buffer.length) this._shift();
78
- } else {
79
- this.underruns++;
80
- this.running = false;
160
+ _renderInput(out, length) {
161
+ if (this.stalled) {
162
+ this._stall(out, 0, length);
163
+ return;
164
+ }
165
+ let offset = 0;
166
+ while (length > 0) {
167
+ const n = Math.min(length, this._samplesUntilNextChange());
168
+ if (n > 0) {
169
+ this.chip.renderAt(this.clock, out, offset, n);
170
+ this.clock += n / this.samplesPerCycle;
171
+ offset += n;
172
+ length -= n;
173
+ continue;
174
+ }
175
+ const head = this.events[this.eventsHead];
176
+ if (head === undefined) {
177
+ this._stall(out, offset, length);
178
+ return;
179
+ }
180
+ if (isResync(head)) this.clock = head.cycle;
181
+ this._applyHead();
81
182
  }
82
- return this._lastSample;
83
183
  }
84
184
 
85
185
  process(inputs, outputs) {
86
- this.cleanQueue();
87
- if (this.queue.length === 0) return true;
88
-
89
- // I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
90
- // but we fiddle the sample rate here to catch up with the target latency, which is harder to do with that API.
91
- const outByMs = this._queueAge() - this.targetLatencyMs;
92
- const maxAdjust = this.inputSampleRate * 0.01;
93
- const adjustment = Math.min(maxAdjust, Math.max(-maxAdjust, outByMs * 100));
94
- const effectiveSampleRate = this.inputSampleRate + adjustment;
95
- const sampleRatio = effectiveSampleRate / sampleRate;
186
+ if (this.stalled && this.upTo - this.clock >= this.targetLeadCycles) this._restart();
96
187
 
97
188
  const channel = outputs[0][0];
189
+ const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
190
+ const sampleRatio = effectiveSampleRate / sampleRate;
191
+
98
192
  const dt = 1 / effectiveSampleRate;
99
193
  const filterAlpha = dt / (RC + dt);
100
194
 
101
- const numInputSamples = Math.round(sampleRatio * channel.length);
102
- const source = new Float32Array(numInputSamples);
195
+ // The fractional read position carries across quanta, so consumption
196
+ // averages exactly sampleRatio and the pitch never steps at a rounding
197
+ // boundary. source[0] is the last input sample of the previous quantum.
198
+ const end = this._phase + channel.length * sampleRatio;
199
+ const numInputSamples = Math.floor(end);
200
+ if (this._source.length <= numInputSamples) {
201
+ this._source = new Float32Array(numInputSamples * 2);
202
+ this._rendered = new Float32Array(numInputSamples * 2);
203
+ }
204
+ const source = this._source;
205
+ const rendered = this._rendered;
206
+ this._renderInput(rendered, numInputSamples);
207
+ source[0] = this._lastFilteredOutput;
103
208
  let prevSample = this._lastFilteredOutput;
104
- for (let i = 0; i < numInputSamples; ++i) {
105
- prevSample += filterAlpha * (this.nextSample() - prevSample);
209
+ for (let i = 1; i <= numInputSamples; ++i) {
210
+ prevSample += filterAlpha * (rendered[i - 1] - prevSample);
106
211
  source[i] = prevSample;
107
212
  }
108
213
  this._lastFilteredOutput = prevSample;
109
214
  for (let i = 0; i < channel.length; i++) {
110
- const pos = (i + 0.5) * sampleRatio;
215
+ const pos = this._phase + i * sampleRatio;
111
216
  const loc = Math.floor(pos);
112
217
  const alpha = pos - loc;
113
218
  channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
114
219
  }
220
+ this._phase = end - numInputSamples;
221
+ this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
115
222
  this.stats(sampleRatio);
116
223
  return true;
117
224
  }