jsbeeb 1.19.3 → 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.3",
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,
@@ -372,7 +373,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
372
373
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
373
374
  const cpuSpeed = model.cyclesPerSecond;
374
375
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
375
- const MaxCyclesPerFrame = clocksPerSecond / 10;
376
+ const MaxCyclesPerTick = clocksPerSecond / 10;
376
377
 
377
378
  let tryGl = true;
378
379
  if (parsedQuery.glEnabled !== undefined) {
@@ -552,14 +553,40 @@ function swapCanvas(newFilterClass) {
552
553
  const canvas = createCanvasForFilter(displayModeFilter);
553
554
  displayModeFilter = canvas.filterClass;
554
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
+
555
572
  video = new Video(
556
573
  model.isMaster,
557
- canvas.fb32,
574
+ videoFb32,
558
575
  function paint(minx, miny, maxx, maxy) {
559
576
  frames++;
560
577
  if (frames < frameSkip) return;
561
578
  frames = 0;
562
- 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
+ }
563
590
  },
564
591
  { isAtom: model.isAtom },
565
592
  );
@@ -573,9 +600,11 @@ const audioHandler = new AudioHandler({
573
600
  statsNode: audioStatsNode,
574
601
  audioFilterFreq,
575
602
  audioFilterQ,
603
+ audioLatencyMs: parsedQuery.audioLatencyMs,
576
604
  noSeek,
577
605
  cpuSpeed,
578
606
  isAtom: model.isAtom,
607
+ hasMusic5000: config.hasMusic5000,
579
608
  });
580
609
  // Firefox will report that audio is suspended even when it will
581
610
  // start playing without user interaction, so we need to delay a
@@ -724,7 +753,9 @@ setCrtPic(displayModeFilter);
724
753
 
725
754
  window.addEventListener("blur", function () {
726
755
  keyboard.clearKeys();
756
+ setEmulationLead(audioHandler.setWindowFocused(false));
727
757
  });
758
+ window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
728
759
 
729
760
  document.getElementById("fs").addEventListener("click", function (event) {
730
761
  screenCanvas.requestFullscreen();
@@ -1694,20 +1725,16 @@ async function gdLoad(cat, layout) {
1694
1725
  }
1695
1726
  }
1696
1727
 
1697
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1698
- (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 () {
1699
1732
  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
- }
1733
+ await googleDrive.initialise();
1705
1734
  } catch (error) {
1706
- console.log(`Google Drive is unavailable: ${errorText(error)}`);
1735
+ toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
1736
+ return false;
1707
1737
  }
1708
- })();
1709
- const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1710
- document.getElementById("open-drive-link").addEventListener("click", async function () {
1711
1738
  const authed = await gdAuth(false);
1712
1739
  if (authed) {
1713
1740
  googleDriveModal.show();
@@ -2248,6 +2275,7 @@ function profileVideo(arg) {
2248
2275
  }
2249
2276
 
2250
2277
  let last = 0;
2278
+ let lastEnd = 0;
2251
2279
 
2252
2280
  function VirtualSpeedUpdater() {
2253
2281
  this.cycles = 0;
@@ -2281,8 +2309,51 @@ function VirtualSpeedUpdater() {
2281
2309
  const virtualSpeedUpdater = new VirtualSpeedUpdater();
2282
2310
 
2283
2311
  const rewindBuffer = new RewindBuffer(30);
2284
- let rewindFrameCounter = 0;
2285
- 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
+ }
2286
2357
 
2287
2358
  rewindUI = new RewindUI({
2288
2359
  rewindBuffer,
@@ -2315,21 +2386,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
2315
2386
  if (drive) showDriveTracks(driveIndex);
2316
2387
  }
2317
2388
 
2318
- 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() {
2319
2406
  if (!running) {
2320
2407
  last = 0;
2321
2408
  return;
2322
2409
  }
2323
- // If we got here via setTimeout, we don't get passed the time.
2324
- if (now === undefined) {
2325
- now = window.performance.now();
2326
- }
2410
+ const now = performance.now();
2327
2411
 
2328
2412
  const motorOn = processor.acia.motorOn;
2329
- const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
2330
2413
  const speedy = fastAsPossible || (fastTape && motorOn);
2331
- const useTimeout = speedy || motorOn || discOn;
2332
- const timeout = speedy ? 0 : 1000.0 / 50;
2333
2414
 
2334
2415
  // In speedy mode, we still run all the state machines accurately
2335
2416
  // but we paint less often because painting is the most expensive
@@ -2338,25 +2419,16 @@ function draw(now) {
2338
2419
  // modes, i.e. MODE 7, still look ok.
2339
2420
  video.frameSkipCount = speedy ? 9 : 0;
2340
2421
 
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
- }
2422
+ scheduleTick(speedy ? 0 : TickMs);
2349
2423
 
2350
- audioHandler.soundChip.catchUp();
2351
2424
  gamepad.update(processor.sysvia);
2352
2425
  syncLights();
2353
2426
  if (last !== 0) {
2354
2427
  let cycles;
2355
2428
  if (!speedy) {
2356
- // Now and last are DOMHighResTimeStamp, just a double.
2357
- const sinceLast = now - last;
2429
+ const sinceLast = Math.max(0, now - last);
2358
2430
  cycles = (sinceLast * clocksPerSecond) / 1000;
2359
- cycles = Math.min(cycles, MaxCyclesPerFrame);
2431
+ cycles = Math.min(cycles, MaxCyclesPerTick);
2360
2432
  } else {
2361
2433
  cycles = clocksPerSecond / 50;
2362
2434
  }
@@ -2365,14 +2437,20 @@ function draw(now) {
2365
2437
  if (!processor.execute(cycles)) {
2366
2438
  stop(true);
2367
2439
  }
2440
+ audioHandler.flushChipEvents();
2368
2441
  const end = performance.now();
2369
2442
  virtualSpeedUpdater.update(cycles, end - now, speedy);
2370
- // Capture rewind snapshot periodically
2371
- if (++rewindFrameCounter >= RewindCaptureInterval) {
2372
- rewindFrameCounter = 0;
2443
+ let snapshotMs = 0;
2444
+ rewindCycleCounter += cycles;
2445
+ if (rewindCycleCounter >= RewindCaptureCycles) {
2446
+ rewindCycleCounter -= RewindCaptureCycles;
2373
2447
  rewindBuffer.push(processor.snapshotState());
2374
2448
  rewindUI.updateButtonState();
2449
+ snapshotMs = performance.now() - end;
2375
2450
  }
2451
+ if (audioStatsNode)
2452
+ logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
2453
+ paintMsThisTick = 0;
2376
2454
  } catch (e) {
2377
2455
  running = false;
2378
2456
  utils.noteEvent("exception", "thrown", e.stack);
@@ -2383,11 +2461,29 @@ function draw(now) {
2383
2461
  stop(false);
2384
2462
  }
2385
2463
  }
2386
- last = now;
2464
+ last = Math.max(last, now);
2465
+ lastEnd = performance.now();
2387
2466
  }
2388
2467
 
2389
2468
  function run() {
2390
- 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
+ }
2391
2487
  }
2392
2488
 
2393
2489
  let wasPreviouslyRunning = false;
package/src/soundchip.js CHANGED
@@ -12,6 +12,10 @@ export const SoundBufferSamples = 512;
12
12
  // mean level, which a zero-mean output would silence (see issue #863).
13
13
  const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
14
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
+
15
19
  const volumeTable = new Float32Array(16);
16
20
  (() => {
17
21
  let f = 1.0;
@@ -35,9 +39,15 @@ export class SoundChip {
35
39
  * @param {function(Float32Array): void} onBuffer called with each full
36
40
  * SoundBufferSamples-sized buffer of output. Receives the same buffer
37
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).
38
47
  */
39
- constructor(onBuffer) {
48
+ constructor(onBuffer, { onEvent = null } = {}) {
40
49
  this._onBuffer = onBuffer;
50
+ this._onEvent = onEvent;
41
51
  // 4MHz input signal. Internal divide-by-8
42
52
  this.soundchipFreq = 4000000.0 / 8;
43
53
  const sampleRate = this.soundchipFreq;
@@ -91,15 +101,38 @@ export class SoundChip {
91
101
  mute: () => {
92
102
  this.catchUp();
93
103
  this.sineOn = false;
104
+ this._emit({ sine: 0 });
94
105
  },
95
106
  tone: (freq) => {
96
107
  this.catchUp();
97
108
  this.sineOn = true;
98
109
  this.sineStep = (freq / sampleRate) * this.sineTable.length;
110
+ this._emit({ sine: freq });
99
111
  },
100
112
  };
101
113
  }
102
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
+
103
136
  sineChannel(channel, out, offset, length) {
104
137
  if (!this.sineOn) return;
105
138
 
@@ -181,6 +214,7 @@ export class SoundChip {
181
214
  this.volume[2] = volumeTable[v2];
182
215
  this.volume[3] = volumeTable[v3];
183
216
  this.noisePoked();
217
+ this._emit({ state: this.snapshotState() });
184
218
  }
185
219
 
186
220
  generate(out, offset, length) {
@@ -219,6 +253,13 @@ export class SoundChip {
219
253
  this.activeTask = this.scheduler.newTask(() => {
220
254
  if (this.active) this.poke(this.slowDataBus);
221
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
+ }
222
263
  }
223
264
 
224
265
  render(out, offset, length) {
@@ -239,6 +280,7 @@ export class SoundChip {
239
280
  }
240
281
 
241
282
  advance(cycles) {
283
+ if (this._onEvent) return;
242
284
  const num = cycles * this.samplesPerCycle + this.residual;
243
285
  let rounded = num | 0;
244
286
  this.residual = num - rounded;
@@ -270,6 +312,7 @@ export class SoundChip {
270
312
 
271
313
  poke(value) {
272
314
  this.catchUp();
315
+ this._emit({ poke: value });
273
316
 
274
317
  let command;
275
318
  if (value & 0x80) {
@@ -350,10 +393,13 @@ export class SoundChip {
350
393
  // Older snapshots predate the DC blocker
351
394
  this.dcPrevIn = state.dcPrevIn ?? 0;
352
395
  this.dcPrevOut = state.dcPrevOut ?? 0;
396
+ this.progressTask?.ensureScheduled(true, EventProgressCycles);
397
+ this._emit({ state: this.snapshotState() });
353
398
  }
354
399
 
355
400
  reset(hard) {
356
401
  if (!hard) return;
402
+ this._emit({ reset: true });
357
403
  for (let i = 0; i < 4; ++i) {
358
404
  this.counter[i] = 0;
359
405
  this.registers[i] = 0;
@@ -366,14 +412,15 @@ export class SoundChip {
366
412
 
367
413
  enable(e) {
368
414
  this.enabled = e;
415
+ this._emit({ enabled: e });
369
416
  }
370
417
 
371
418
  mute() {
372
- this.enabled = false;
419
+ this.enable(false);
373
420
  }
374
421
 
375
422
  unmute() {
376
- this.enabled = true;
423
+ this.enable(true);
377
424
  }
378
425
  }
379
426
 
@@ -383,8 +430,8 @@ export class SoundChip {
383
430
  * channel with DC-blocking filter.
384
431
  */
385
432
  export class AtomSoundChip extends SoundChip {
386
- constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
387
- super(onBuffer);
433
+ constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
434
+ super(onBuffer, { onEvent });
388
435
  this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
389
436
  this.secondsPerCycle = 1 / cpuSpeed;
390
437
 
@@ -423,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
423
470
  this._speakerCycleOffset = 0;
424
471
  }
425
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
+
426
484
  speakerReset() {
485
+ this._emit({ speakerReset: true });
427
486
  this.bitChange = [];
428
487
  this.currentSpeakerBit = 0.0;
429
488
  this._speakerPrevIn = 0;
@@ -461,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
461
520
 
462
521
  updateSpeaker(value, microCycle, seconds) {
463
522
  const cycles = microCycle + seconds / this.secondsPerCycle;
464
- 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 });
465
526
  }
466
527
  }
467
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,115 +1,190 @@
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
-
11
- // Smoothing rejects the producer's per-frame bursts; proportional only, as
12
- // occupancy already integrates rate error; 0.05% authority covers clock skew
10
+ // Smoothing rejects the producer's per-tick bursts; proportional only, as
11
+ // lead already integrates rate error; 0.05% authority covers clock skew
13
12
  // without audibly bending pitch.
14
- const OccupancySmoothingTau = 0.5;
13
+ const LeadSmoothingTau = 0.5;
15
14
  const ProportionalGain = 0.2;
16
- const MaxAdjust = InputSampleRate * 0.0005;
15
+ const MaxAdjustFraction = 0.0005;
16
+
17
+ const isResync = (event) => event.state !== undefined || event.reset !== undefined;
17
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.
18
22
  class SoundChipProcessor extends AudioWorkletProcessor {
19
- constructor(...args) {
20
- 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;
21
38
 
22
- this.inputSampleRate = InputSampleRate;
23
- this._lastSample = 0;
24
39
  this._lastFilteredOutput = 0;
25
40
  this._phase = 0;
26
41
  this._source = new Float32Array(0);
27
- this.queue = [];
28
- this._queueSizeSamples = 0;
29
- this.dropped = 0;
30
- this.underruns = 0;
31
- this.targetLatencyMs = 1000 * (1 / 50); // One frame
32
- this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
33
- this.smoothedOccupancyError = 0;
34
- this.running = false;
35
- this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
42
+ this._rendered = new Float32Array(0);
43
+ this.smoothedLeadError = 0;
44
+ this.setTargetLatency(targetLatencyMs);
36
45
  this.port.onmessage = (event) => {
37
- // TODO: even better than this, send over register settings/catch up and run the audio work _here_
38
- 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);
39
48
  };
40
49
  this.nextStats = 0;
41
50
  }
42
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
+
43
91
  stats(sampleRatio) {
44
92
  if (currentTime < this.nextStats) return;
45
93
  this.nextStats = currentTime + 0.25;
46
94
  this.port.postMessage({
47
95
  sampleRate: sampleRate,
48
96
  inputSampleRate: this.inputSampleRate,
49
- dropped: this.dropped,
50
- underruns: this.underruns,
51
- queueSize: this.queue.length,
52
- 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,
53
102
  sampleRatio: sampleRatio,
54
103
  });
104
+ this.minLeadMs = Infinity;
55
105
  }
56
106
 
57
- _queueAge() {
58
- if (this.queue.length === 0) return 0;
59
- const timeInBufferMs = 1000 * (this.queue[0].offset / this.inputSampleRate) + this.queue[0].time;
60
- return Date.now() - timeInBufferMs;
107
+ _notify(event, count) {
108
+ this.port.postMessage({ event, count });
61
109
  }
62
110
 
63
- _occupancySamples() {
64
- return this._queueSizeSamples - (this.queue.length ? this.queue[0].offset : 0);
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));
65
118
  }
66
119
 
67
- _effectiveSampleRate(dtSeconds) {
68
- const error = this._occupancySamples() - this.startQueueSizeSamples;
69
- const alpha = Math.min(1, dtSeconds / OccupancySmoothingTau);
70
- this.smoothedOccupancyError += alpha * (error - this.smoothedOccupancyError);
71
- const adjustment = ProportionalGain * this.smoothedOccupancyError;
72
- return this.inputSampleRate + Math.min(MaxAdjust, Math.max(-MaxAdjust, adjustment));
120
+ _applyHead() {
121
+ this.chip.applyEvent(this.events[this.eventsHead++]);
73
122
  }
74
123
 
75
- onBuffer(time, buffer) {
76
- this.queue.push({ offset: 0, time, buffer });
77
- this._queueSizeSamples += buffer.length;
78
- this.cleanQueue();
79
- if (!this.running && this._queueSizeSamples >= this.startQueueSizeSamples) this.running = true;
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;
80
130
  }
81
131
 
82
- _shift() {
83
- const dropped = this.queue.shift();
84
- this._queueSizeSamples -= dropped.buffer.length;
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;
85
139
  }
86
140
 
87
- cleanQueue() {
88
- const maxLatency = this.targetLatencyMs * 2;
89
- while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
90
- this._shift();
91
- this.dropped++;
141
+ _stall(out, offset, length) {
142
+ if (!this.stalled) {
143
+ this.stalled = true;
144
+ this.stalls++;
145
+ this._notify("stall", 1);
92
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);
93
158
  }
94
159
 
95
- nextSample() {
96
- if (this.running && this.queue.length) {
97
- const queueElement = this.queue[0];
98
- this._lastSample = queueElement.buffer[queueElement.offset];
99
- if (++queueElement.offset === queueElement.buffer.length) this._shift();
100
- } else {
101
- this.underruns++;
102
- 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();
103
182
  }
104
- return this._lastSample;
105
183
  }
106
184
 
107
185
  process(inputs, outputs) {
108
- this.cleanQueue();
109
- if (this.queue.length === 0) return true;
186
+ if (this.stalled && this.upTo - this.clock >= this.targetLeadCycles) this._restart();
110
187
 
111
- // I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
112
- // but we fiddle the sample rate here to catch up with the target latency, which is harder to do with that API.
113
188
  const channel = outputs[0][0];
114
189
  const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
115
190
  const sampleRatio = effectiveSampleRate / sampleRate;
@@ -122,12 +197,17 @@ class SoundChipProcessor extends AudioWorkletProcessor {
122
197
  // boundary. source[0] is the last input sample of the previous quantum.
123
198
  const end = this._phase + channel.length * sampleRatio;
124
199
  const numInputSamples = Math.floor(end);
125
- if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
200
+ if (this._source.length <= numInputSamples) {
201
+ this._source = new Float32Array(numInputSamples * 2);
202
+ this._rendered = new Float32Array(numInputSamples * 2);
203
+ }
126
204
  const source = this._source;
205
+ const rendered = this._rendered;
206
+ this._renderInput(rendered, numInputSamples);
127
207
  source[0] = this._lastFilteredOutput;
128
208
  let prevSample = this._lastFilteredOutput;
129
209
  for (let i = 1; i <= numInputSamples; ++i) {
130
- prevSample += filterAlpha * (this.nextSample() - prevSample);
210
+ prevSample += filterAlpha * (rendered[i - 1] - prevSample);
131
211
  source[i] = prevSample;
132
212
  }
133
213
  this._lastFilteredOutput = prevSample;
@@ -138,6 +218,7 @@ class SoundChipProcessor extends AudioWorkletProcessor {
138
218
  channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
139
219
  }
140
220
  this._phase = end - numInputSamples;
221
+ this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
141
222
  this.stats(sampleRatio);
142
223
  return true;
143
224
  }