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 +1 -1
- package/src/main.js +6 -2
- package/src/soundchip.js +31 -3
- package/src/teletext.js +70 -20
- package/src/video.js +84 -23
- package/src/web/audio-renderer.js +35 -9
- package/src/basic/multiline-tetris +0 -9
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.
|
|
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
|
-
|
|
178
|
-
|
|
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 (
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
312
|
-
this.dataQueue
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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 (
|
|
431
|
-
const backgroundColour = this.colour[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
988
|
-
//
|
|
989
|
-
//
|
|
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.
|
|
1093
|
+
this.teletext.emit(this.fb32, offset);
|
|
1037
1094
|
} else {
|
|
1038
|
-
// 2MHz clock + teletext bit set (the "TTX trick"): the
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
// at
|
|
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
|
|
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
|
|
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
|
|
92
|
-
const
|
|
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
|
-
|
|
102
|
-
|
|
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 =
|
|
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 =
|
|
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
|