jsbeeb 1.19.1 → 1.19.3

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/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.1",
10
+ "version": "1.19.3",
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/main.js CHANGED
@@ -174,8 +174,12 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
174
174
  let fastAsPossible = false;
175
175
  let fastTape = false;
176
176
  let noSeek;
177
- let audioFilterFreq = 7000;
178
- let audioFilterQ = 5;
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;
179
183
  let stationId = 101;
180
184
  let econet = null;
181
185
 
package/src/soundchip.js CHANGED
@@ -5,6 +5,13 @@ 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
+
8
15
  const volumeTable = new Float32Array(16);
9
16
  (() => {
10
17
  let f = 1.0;
@@ -72,6 +79,10 @@ export class SoundChip {
72
79
  this.position = 0;
73
80
  this.buffer = new Float32Array(SoundBufferSamples);
74
81
 
82
+ this.dcAlpha = 1 - (2 * Math.PI * DcRestoreCornerHz) / sampleRate;
83
+ this.dcPrevIn = 0;
84
+ this.dcPrevOut = 0;
85
+
75
86
  this.latchedRegister = 0;
76
87
  this.slowDataBus = 0;
77
88
  this.active = false;
@@ -178,10 +189,22 @@ export class SoundChip {
178
189
  for (let i = 0; i < length; ++i) {
179
190
  out[i + offset] = 0.0;
180
191
  }
181
- if (!this.enabled) return;
182
- for (let i = 0; i < this.generators.length; ++i) {
183
- this.generators[i](i, out, offset, length);
192
+ if (this.enabled) {
193
+ for (let i = 0; i < this.generators.length; ++i) {
194
+ this.generators[i](i, out, offset, length);
195
+ }
196
+ }
197
+ const alpha = this.dcAlpha;
198
+ let prevIn = this.dcPrevIn;
199
+ let prevOut = this.dcPrevOut;
200
+ for (let i = 0; i < length; ++i) {
201
+ const x = out[i + offset];
202
+ prevOut = x - prevIn + alpha * prevOut;
203
+ prevIn = x;
204
+ out[i + offset] = prevOut;
184
205
  }
206
+ this.dcPrevIn = prevIn;
207
+ this.dcPrevOut = prevOut;
185
208
  }
186
209
 
187
210
  catchUp() {
@@ -297,6 +320,8 @@ export class SoundChip {
297
320
  sineOn: this.sineOn,
298
321
  sineStep: this.sineStep,
299
322
  sineTime: this.sineTime,
323
+ dcPrevIn: this.dcPrevIn,
324
+ dcPrevOut: this.dcPrevOut,
300
325
  };
301
326
  }
302
327
 
@@ -322,6 +347,9 @@ export class SoundChip {
322
347
  // Reset output buffer
323
348
  this.position = 0;
324
349
  this.buffer.fill(0);
350
+ // Older snapshots predate the DC blocker
351
+ this.dcPrevIn = state.dcPrevIn ?? 0;
352
+ this.dcPrevOut = state.dcPrevOut ?? 0;
325
353
  }
326
354
 
327
355
  reset(hard) {
package/src/teletext.js CHANGED
@@ -16,7 +16,11 @@ export class Teletext {
16
16
  this.flashTime = 0;
17
17
  this.heldChar = 0;
18
18
  this.holdChar = false;
19
- this.dataQueue = [0, 0, 0, 0];
19
+ this.cellGlyphIndex = 0;
20
+ this.cellFlash = false;
21
+ this.cellConceal = false;
22
+ this.cellPalette = 0;
23
+ this.dataQueue = new Uint8Array(4);
20
24
  this.scanlineCounter = 0;
21
25
  this.levelDEW = false;
22
26
  this.levelDISPTMG = false;
@@ -204,7 +208,7 @@ export class Teletext {
204
208
  this.flashTime = state.flashTime;
205
209
  this.heldChar = state.heldChar;
206
210
  this.holdChar = state.holdChar;
207
- this.dataQueue = [...state.dataQueue];
211
+ this.dataQueue.set(state.dataQueue);
208
212
  this.scanlineCounter = state.scanlineCounter;
209
213
  this.levelDEW = state.levelDEW;
210
214
  this.levelDISPTMG = state.levelDISPTMG;
@@ -308,8 +312,11 @@ export class Teletext {
308
312
  }
309
313
 
310
314
  fetchData(data) {
311
- this.dataQueue.shift();
312
- this.dataQueue.push(data & 0x7f);
315
+ // `copyWithin` over four bytes costs more than the moves it saves.
316
+ this.dataQueue[0] = this.dataQueue[1];
317
+ this.dataQueue[1] = this.dataQueue[2];
318
+ this.dataQueue[2] = this.dataQueue[3];
319
+ this.dataQueue[3] = data & 0x7f;
313
320
  }
314
321
 
315
322
  setDEW(level) {
@@ -386,14 +393,13 @@ export class Teletext {
386
393
  this.levelRA0 = level;
387
394
  }
388
395
 
389
- render(buf, offset) {
396
+ /**
397
+ * Clock one character through the chip: take the oldest byte in the pipeline, apply it
398
+ * to the character state, and latch how the resulting cell should look.
399
+ */
400
+ advance() {
390
401
  let data = this.dataQueue[0];
391
402
 
392
- let scanline = this.scanlineCounter << 1;
393
- if (this.levelRA0) {
394
- scanline++;
395
- }
396
-
397
403
  this.oldDbl = this.dbl;
398
404
 
399
405
  this.prevCol = this.col;
@@ -412,28 +418,49 @@ export class Teletext {
412
418
  this.heldChar = 32;
413
419
  }
414
420
 
421
+ // Flash (code 8) is "Set After" — flashThisCell retains the pre-control-code state.
422
+ // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
423
+ if (flashThisCell && !this.flash) flashThisCell = false;
424
+
425
+ // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
426
+ if (this.conceal) concealThisCell = true;
427
+
428
+ this.cellGlyphIndex = (data - 32) * 20;
429
+ this.cellFlash = flashThisCell;
430
+ this.cellConceal = concealThisCell;
431
+ this.cellPalette = ((this.bg & 7) << 5) | ((this.prevCol & 7) << 2);
432
+ }
433
+
434
+ glyphScanline() {
435
+ let scanline = this.scanlineCounter << 1;
436
+ if (this.levelRA0) {
437
+ scanline++;
438
+ }
439
+
415
440
  if (this.oldDbl) {
416
441
  scanline = scanline >>> 1;
417
442
  if (this.secondHalfOfDouble) {
418
443
  scanline += 10;
419
444
  }
420
445
  }
421
- let chardef = this.curGlyphs[(data - 32) * 20 + scanline];
422
-
423
- // Flash (code 8) is "Set After" — flashThisCell retains the pre-control-code state.
424
- // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
425
- if (flashThisCell && !this.flash) flashThisCell = false;
446
+ return scanline;
447
+ }
426
448
 
427
- // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
428
- if (this.conceal) concealThisCell = true;
449
+ /**
450
+ * Paint the cell most recently latched by `advance`. Everything else read here changes
451
+ * per scanline or per field, not per character.
452
+ */
453
+ emit(buf, offset) {
454
+ const scanline = this.glyphScanline();
429
455
 
430
- if (concealThisCell || (flashThisCell && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
431
- const backgroundColour = this.colour[(this.bg & 7) << 5];
456
+ if (this.cellConceal || (this.cellFlash && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
457
+ const backgroundColour = this.colour[this.cellPalette & 0xe0];
432
458
  for (let i = 0; i < 16; ++i) {
433
459
  buf[offset++] = backgroundColour;
434
460
  }
435
461
  } else {
436
- const paletteIndex = ((this.bg & 7) << 5) | ((this.prevCol & 7) << 2);
462
+ let chardef = this.curGlyphs[this.cellGlyphIndex + scanline];
463
+ const paletteIndex = this.cellPalette;
437
464
 
438
465
  for (let pixel = 0; pixel < 16; ++pixel) {
439
466
  buf[offset + pixel] = this.colour[paletteIndex + (chardef & 3)];
@@ -441,4 +468,27 @@ export class Teletext {
441
468
  }
442
469
  }
443
470
  }
471
+
472
+ // The second half of the cell `emit` last painted, for the ULA's 1MHz repaint.
473
+ emitSecondHalf(buf, offset) {
474
+ const scanline = this.glyphScanline();
475
+ if (this.cellConceal || (this.cellFlash && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
476
+ const backgroundColour = this.colour[this.cellPalette & 0xe0];
477
+ for (let pixel = 8; pixel < 16; ++pixel) {
478
+ buf[offset + pixel] = backgroundColour;
479
+ }
480
+ } else {
481
+ let chardef = this.curGlyphs[this.cellGlyphIndex + scanline] >>> 16;
482
+ const paletteIndex = this.cellPalette;
483
+ for (let pixel = 8; pixel < 16; ++pixel) {
484
+ buf[offset + pixel] = this.colour[paletteIndex + (chardef & 3)];
485
+ chardef >>>= 2;
486
+ }
487
+ }
488
+ }
489
+
490
+ render(buf, offset) {
491
+ this.advance();
492
+ this.emit(buf, offset);
493
+ }
444
494
  }
package/src/video.js CHANGED
@@ -84,6 +84,7 @@ class Ula {
84
84
  this._writeNulaPalette(val);
85
85
  break;
86
86
  }
87
+ this.video.repaintSecondHalfOfCell();
87
88
  }
88
89
 
89
90
  snapshotState() {
@@ -385,7 +386,9 @@ export class Video {
385
386
  this.cursorOff = false;
386
387
  this.cursorOnThisFrame = false;
387
388
  this.cursorDrawIndex = 0;
389
+ this.cursorInvertedOffset = -1;
388
390
  this.cursorPos = 0;
391
+ this.cellData = 0;
389
392
  this.interlacedSyncAndVideo = false;
390
393
  this.doubledScanlines = true;
391
394
  this.frameSkipCount = 0;
@@ -546,6 +549,8 @@ export class Video {
546
549
  this.cursorOn = state.cursorOn;
547
550
  this.cursorOff = state.cursorOff;
548
551
  this.cursorOnThisFrame = state.cursorOnThisFrame;
552
+ this.cursorInvertedOffset = -1;
553
+ this.cellData = 0;
549
554
  this.cursorDrawIndex = state.cursorDrawIndex;
550
555
  this.cursorPos = state.cursorPos;
551
556
  this.interlacedSyncAndVideo = state.interlacedSyncAndVideo;
@@ -599,6 +604,7 @@ export class Video {
599
604
  if (this.frameCount % this.frameSkipCount) enable = 0;
600
605
  }
601
606
  this.dispEnabled |= enable;
607
+ this.cursorInvertedOffset = -1;
602
608
 
603
609
  this.bitmapY = 0;
604
610
  // Interlace even frame fires vsync midway through a scanline.
@@ -650,11 +656,7 @@ export class Video {
650
656
  destOffset |= 0;
651
657
  const offset = table4bppOffset(this.ulaMode, dat);
652
658
  const fb32 = this.fb32;
653
- // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
654
- // pixel colours directly from the NULA 12-bit colour table (collook).
655
- // This skips the XOR-7 logical↔physical colour mapping that the
656
- // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
657
- const colourLookup = this.ula.paletteMode ? this.ula.collook : this.ulaPal;
659
+ const colourLookup = this.pixelColours();
658
660
  const table4bpp = this.table4bpp;
659
661
  // Take advantage of numPixels being either 8 or 16
660
662
  if (numPixels === 8) {
@@ -668,20 +670,70 @@ export class Video {
668
670
  }
669
671
  }
670
672
 
673
+ // The second half of a 16 pixel cell, for the 1MHz repaint.
674
+ blitFbSecondHalf(dat, destOffset) {
675
+ const offset = table4bppOffset(this.ulaMode, dat);
676
+ const colourLookup = this.pixelColours();
677
+ for (let i = 8; i < 16; ++i) {
678
+ this.fb32[destOffset + i] = colourLookup[this.table4bpp[offset + i]];
679
+ }
680
+ }
681
+
682
+ // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
683
+ // pixel colours directly from the NULA 12-bit colour table (collook).
684
+ // This skips the XOR-7 logical↔physical colour mapping that the
685
+ // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
686
+ pixelColours() {
687
+ return this.ula.paletteMode ? this.ula.collook : this.ulaPal;
688
+ }
689
+
671
690
  handleCursor(offset) {
672
691
  if (this.cursorOnThisFrame && this.ulactrl & this.cursorTable[this.cursorDrawIndex]) {
673
- for (let i = 0; i < this.pixelsPerChar; ++i) {
674
- this.fb32[offset + i] ^= 0x00ffffff;
675
- }
676
- if (this.doubledScanlines && !this.interlacedSyncAndVideo) {
677
- for (let i = 0; i < this.pixelsPerChar; ++i) {
678
- this.fb32[offset + 1024 + i] ^= 0x00ffffff;
679
- }
680
- }
692
+ this.invertForCursor(offset, 0);
693
+ this.cursorInvertedOffset = offset;
694
+ } else {
695
+ this.cursorInvertedOffset = -1;
681
696
  }
682
697
  if (++this.cursorDrawIndex === 7) this.cursorDrawIndex = 0;
683
698
  }
684
699
 
700
+ invertForCursor(offset, fromPixel) {
701
+ for (let i = fromPixel; i < this.pixelsPerChar; ++i) {
702
+ this.fb32[offset + i] ^= 0x00ffffff;
703
+ }
704
+ if (this.doubledScanlines && !this.interlacedSyncAndVideo) {
705
+ for (let i = fromPixel; i < this.pixelsPerChar; ++i) {
706
+ this.fb32[offset + 1024 + i] ^= 0x00ffffff;
707
+ }
708
+ }
709
+ }
710
+
711
+ // The render loop paints a whole 1MHz cell on one 2MHz tick and skips the next, but the ULA
712
+ // output stage switches at 2MHz: a register write landing on the skipped tick changes the
713
+ // second half of the cell just painted. See https://github.com/mattgodbolt/jsbeeb/issues/766
714
+ repaintSecondHalfOfCell() {
715
+ if (!this.halfClock || !this.oddClock) return;
716
+ if ((this.dispEnabled & EVERYTHINGENABLED) !== EVERYTHINGENABLED) return;
717
+ if (this.bitmapX < 0 || this.bitmapX >= 1024 || this.bitmapY < 0 || this.bitmapY >= 625) return;
718
+ // The same line doubling decision as the render loop, which inlines it for speed.
719
+ const doubledLines =
720
+ (this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
721
+ const bitmapRow = doubledLines ? this.bitmapY & ~1 : this.bitmapY;
722
+ const offset = bitmapRow * 1024 + this.bitmapX;
723
+ const halfCell = this.pixelsPerChar >>> 1;
724
+ if (this.teletextMode) {
725
+ this.teletext.emitSecondHalf(this.fb32, offset);
726
+ } else {
727
+ this.blitFbSecondHalf(this.cellData, offset);
728
+ }
729
+ if (doubledLines) {
730
+ this.fb32.copyWithin(offset + 1024 + halfCell, offset + halfCell, offset + this.pixelsPerChar);
731
+ }
732
+ if (this.cursorInvertedOffset === offset) {
733
+ this.invertForCursor(offset, halfCell);
734
+ }
735
+ }
736
+
685
737
  setScreenHwScroll(viaScreenHwScroll) {
686
738
  this.screenSubtract = this.screenAddrSubtract[viaScreenHwScroll];
687
739
  }
@@ -984,11 +1036,15 @@ export class Video {
984
1036
  // Read data from address pointer if both horizontal and vertical display enabled.
985
1037
  const dat = this.readVideoMem();
986
1038
  if (insideBorder) {
987
- // Always feed the SAA5050 pipeline: on real hardware IC15
988
- // permanently connects the video bus to the SAA5050 inputs
989
- // regardless of ULA mode. Required for the "TTX trick".
1039
+ // Always clock the SAA5050, whatever the ULA mode: IC15 latches the video bus
1040
+ // into the chip and it is MA13, not the ULA's teletext bit, that gates it. We
1041
+ // do not model the MA13 gate yet, so this feeds unconditionally. The chip is
1042
+ // clocked here and painted later, so a control code seen while the ULA shows
1043
+ // bitmap still takes effect.
990
1044
  // See https://github.com/mattgodbolt/jsbeeb/issues/546
1045
+ // and https://github.com/mattgodbolt/jsbeeb/issues/832
991
1046
  this.teletext.fetchData(dat);
1047
+ this.teletext.advance();
992
1048
 
993
1049
  // Check cursor start.
994
1050
  if (
@@ -1018,6 +1074,7 @@ export class Video {
1018
1074
  }
1019
1075
 
1020
1076
  const offset = bitmapRow * 1024 + this.bitmapX;
1077
+ this.cellData = dat;
1021
1078
 
1022
1079
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
1023
1080
  // Note this row's logical pixel size for display
@@ -1033,24 +1090,24 @@ export class Video {
1033
1090
  if (this.teletextMode) {
1034
1091
  if (this.halfClock) {
1035
1092
  // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
1036
- this.teletext.render(this.fb32, offset);
1093
+ this.teletext.emit(this.fb32, offset);
1037
1094
  } else {
1038
- // 2MHz clock + teletext bit set (the "TTX trick"): the Video ULA
1039
- // forces DISPEN to the SAA5050 to 0 in 2MHz modes (0/1/2/3), so
1040
- // the SAA5050 outputs black. Confirmed by Rich Talbot-Watkins (RTW)
1041
- // at ABUG 2026-03-13.
1095
+ // 2MHz clock + teletext bit set (the "TTX trick"): the SAA5050
1096
+ // outputs black. Behaviour confirmed by Rich Talbot-Watkins (RTW)
1097
+ // at ABUG 2026-03-13; the mechanism is not established, as the
1098
+ // Model B ULA has no connection to the SAA5050 at all.
1042
1099
  // See https://github.com/mattgodbolt/jsbeeb/issues/546
1043
1100
  this.fb32.fill(OPAQUE_BLACK, offset, offset + this.pixelsPerChar);
1044
1101
  }
1045
1102
  } else {
1046
- this.blitFb(dat, offset, this.pixelsPerChar, doubledLines);
1103
+ this.blitFb(dat, offset, this.pixelsPerChar);
1047
1104
  }
1048
1105
  if (doubledLines) {
1049
1106
  this.fb32.copyWithin(offset + 1024, offset, offset + this.pixelsPerChar);
1050
1107
  }
1051
1108
  }
1052
1109
  if (this.cursorDrawIndex) {
1053
- this.handleCursor(offset, doubledLines);
1110
+ this.handleCursor(offset);
1054
1111
  }
1055
1112
  }
1056
1113
  }
@@ -1059,6 +1116,10 @@ export class Video {
1059
1116
  // the SAA5050 pipeline with the video bus data, forcing bit 6 high.
1060
1117
  // On real hardware IC37/IC36 operates regardless of ULA mode —
1061
1118
  // it is wired to the CRTC DISPEN signal, not the ULA teletext bit.
1119
+ // Hardware also clocks the chip here, which we do not: our row reset fires
1120
+ // too early relative to the pipeline for that to come out right, and adding
1121
+ // `advance()` alone moves three of the hardware reference pages. See
1122
+ // https://github.com/mattgodbolt/jsbeeb/issues/874
1062
1123
  if (!(this.dispEnabled & HDISPENABLE) && this.dispEnabled & VDISPENABLE) {
1063
1124
  this.teletext.fetchData(this.readVideoMem() | 0x40);
1064
1125
  }
@@ -8,6 +8,13 @@ const MaxQueuedMs = 250;
8
8
 
9
9
  const samplesFor = (ms) => (InputSampleRate * ms) / 1000;
10
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
13
+ // without audibly bending pitch.
14
+ const OccupancySmoothingTau = 0.5;
15
+ const ProportionalGain = 0.2;
16
+ const MaxAdjust = InputSampleRate * 0.0005;
17
+
11
18
  class SoundChipProcessor extends AudioWorkletProcessor {
12
19
  constructor(...args) {
13
20
  super(...args);
@@ -15,12 +22,15 @@ class SoundChipProcessor extends AudioWorkletProcessor {
15
22
  this.inputSampleRate = InputSampleRate;
16
23
  this._lastSample = 0;
17
24
  this._lastFilteredOutput = 0;
25
+ this._phase = 0;
26
+ this._source = new Float32Array(0);
18
27
  this.queue = [];
19
28
  this._queueSizeSamples = 0;
20
29
  this.dropped = 0;
21
30
  this.underruns = 0;
22
31
  this.targetLatencyMs = 1000 * (1 / 50); // One frame
23
32
  this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
33
+ this.smoothedOccupancyError = 0;
24
34
  this.running = false;
25
35
  this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
26
36
  this.port.onmessage = (event) => {
@@ -50,6 +60,18 @@ class SoundChipProcessor extends AudioWorkletProcessor {
50
60
  return Date.now() - timeInBufferMs;
51
61
  }
52
62
 
63
+ _occupancySamples() {
64
+ return this._queueSizeSamples - (this.queue.length ? this.queue[0].offset : 0);
65
+ }
66
+
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));
73
+ }
74
+
53
75
  onBuffer(time, buffer) {
54
76
  this.queue.push({ offset: 0, time, buffer });
55
77
  this._queueSizeSamples += buffer.length;
@@ -88,30 +110,34 @@ class SoundChipProcessor extends AudioWorkletProcessor {
88
110
 
89
111
  // I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
90
112
  // 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;
113
+ const channel = outputs[0][0];
114
+ const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
95
115
  const sampleRatio = effectiveSampleRate / sampleRate;
96
116
 
97
- const channel = outputs[0][0];
98
117
  const dt = 1 / effectiveSampleRate;
99
118
  const filterAlpha = dt / (RC + dt);
100
119
 
101
- const numInputSamples = Math.round(sampleRatio * channel.length);
102
- const source = new Float32Array(numInputSamples);
120
+ // The fractional read position carries across quanta, so consumption
121
+ // averages exactly sampleRatio and the pitch never steps at a rounding
122
+ // boundary. source[0] is the last input sample of the previous quantum.
123
+ const end = this._phase + channel.length * sampleRatio;
124
+ const numInputSamples = Math.floor(end);
125
+ if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
126
+ const source = this._source;
127
+ source[0] = this._lastFilteredOutput;
103
128
  let prevSample = this._lastFilteredOutput;
104
- for (let i = 0; i < numInputSamples; ++i) {
129
+ for (let i = 1; i <= numInputSamples; ++i) {
105
130
  prevSample += filterAlpha * (this.nextSample() - prevSample);
106
131
  source[i] = prevSample;
107
132
  }
108
133
  this._lastFilteredOutput = prevSample;
109
134
  for (let i = 0; i < channel.length; i++) {
110
- const pos = (i + 0.5) * sampleRatio;
135
+ const pos = this._phase + i * sampleRatio;
111
136
  const loc = Math.floor(pos);
112
137
  const alpha = pos - loc;
113
138
  channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
114
139
  }
140
+ this._phase = end - numInputSamples;
115
141
  this.stats(sampleRatio);
116
142
  return true;
117
143
  }
@@ -1,9 +0,0 @@
1
- 0d=d:IFd VDUd:p=POINT(64*POS,1E3-VPOS*32):RETURN ELSEMODE2:GCOL0,-9:CLG
2
- 1d=9:REPEATVDU30:REPEATGOSUBFALSE:IFPOS=15VDU28,5,VPOS,14;11,26:IF0ELSEIFp=0PRINT:UNTIL0ELSEUNTILVPOS=25
3
- 2b=ABSRND MOD7:k=0:VDU31,9,3
4
- 3REPEATg=9-INKEY6MOD3
5
- 4FORl=TRUE TO1:o=l ANDSGNo
6
- 5IFo=l COLOURb-15:VDUl EORg:k=k+(g=7AND9-6*l)
7
- 6IF0ELSEFORf=0TO11:d=f/3OR2EORd:GOSUBFALSE
8
- 7IF2^((f+k)MOD12)AND975AND&C2590EC/8^b VDU2080*ABSl;:o=o+p:IF0ELSENEXT,
9
- 8VDU20:UNTILo*LOGg:UNTIL0