jsbeeb 1.20.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -289,6 +289,11 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
289
289
  - (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
290
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
291
  - `audioLatencyMs` - how far the sound runs behind the emulator, in milliseconds (default 20). Raising it lets the sound ride out longer stalls of the emulator, at the cost of lagging the picture by that much.
292
+ - `audiofilterfreq` / `audiofilterq` - the corner frequency in Hz and the Q of the lowpass modelling the board's output
293
+ filter, applied to the sound chip before it is resampled. The defaults, 7234 and 0.696, are the Beeb's own.
294
+ `audiofilterfreq=0` turns the filter off.
295
+ - `displayMode=X` - picks the display: `rgb` (the default, a plain monitor), `pal` (a television fed by the Beeb's UHF modulator)
296
+ or `xbr` (an upscaler, see [docs/xbr-display-mode.md](docs/xbr-display-mode.md)).
292
297
 
293
298
  ### Atom-specific parameters
294
299
 
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.20.0",
10
+ "version": "1.20.1",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
package/src/acia.js CHANGED
@@ -4,8 +4,9 @@
4
4
  // https://books.google.com/books?id=wUecAQAAQBAJ&pg=PA431&lpg=PA431&dq=acia+tdre&source=bl&ots=mp-yF-mK-P&sig=e6aXkFRfiIOb57WZmrvdIGsCooI&hl=en&sa=X&ei=0g2fVdDyFIXT-QG8-JD4BA&ved=0CCwQ6AEwAw#v=onepage&q=acia%20tdre&f=false
5
5
  // http://www.classiccmp.org/dunfield/r/6850.pdf
6
6
 
7
- export class Acia {
7
+ export class Acia extends EventTarget {
8
8
  constructor(cpu, toneGen, scheduler, relayNoise) {
9
+ super();
9
10
  this.cpu = cpu;
10
11
  this.toneGen = toneGen;
11
12
  this.rs423Handler = null;
@@ -20,6 +21,7 @@ export class Acia {
20
21
  this.tapeCarrierCount = 0;
21
22
  this.tapeDcdLineLevel = false;
22
23
  this.hadDcdHigh = false;
24
+ this.saidOverrun = false;
23
25
  this.serialReceiveRate = 0;
24
26
  this.serialReceiveCyclesPerByte = 0;
25
27
  this.serialTransmitRate = 0;
@@ -200,8 +202,8 @@ export class Acia {
200
202
  // TODO: this doesn't match the datasheet:
201
203
  // "The Overrun does not occur in the Status Register until the
202
204
  // valid character prior to Overrun has been read."
203
- console.log("Serial overrun");
204
205
  this.sr |= 0xa0;
206
+ this.noteOverrun();
205
207
  } else {
206
208
  // If bit 7 contains parity, mask it off.
207
209
  this.dr = byte & (this.cr & 0x10 ? 0xff : 0x7f);
@@ -210,6 +212,22 @@ export class Acia {
210
212
  this.updateIrq();
211
213
  }
212
214
 
215
+ noteOverrun() {
216
+ if (this.saidOverrun) return;
217
+ this.saidOverrun = true;
218
+ this.dispatchEvent(
219
+ new CustomEvent("notice", {
220
+ detail: {
221
+ message:
222
+ "A serial byte arrived before the previous one was read, so it was lost. " +
223
+ "A tape load that stops with Data? is usually this.",
224
+ title: "Serial",
225
+ quietKey: "quietSerialOverrun",
226
+ },
227
+ }),
228
+ );
229
+ }
230
+
213
231
  snapshotState() {
214
232
  const scheduler = this.txCompleteTask.scheduler;
215
233
  return {
package/src/biquad.js ADDED
@@ -0,0 +1,36 @@
1
+ // Coefficients from the RBJ Audio EQ Cookbook: the second-order prototype
2
+ // under a bilinear transform prewarped so the corner lands on `frequency`.
3
+ export class LowPassBiquad {
4
+ constructor(sampleRate, frequency, q) {
5
+ const w0 = (2 * Math.PI * frequency) / sampleRate;
6
+ const alpha = Math.sin(w0) / (2 * q);
7
+ const cosW0 = Math.cos(w0);
8
+ const a0 = 1 + alpha;
9
+ this.b0 = (1 - cosW0) / (2 * a0);
10
+ this.b1 = (1 - cosW0) / a0;
11
+ this.a1 = (-2 * cosW0) / a0;
12
+ this.a2 = (1 - alpha) / a0;
13
+ this.x1 = 0;
14
+ this.x2 = 0;
15
+ this.y1 = 0;
16
+ this.y2 = 0;
17
+ }
18
+
19
+ process(buffer, offset, length) {
20
+ const { b0, b1, a1, a2 } = this;
21
+ let { x1, x2, y1, y2 } = this;
22
+ for (let i = offset; i < offset + length; ++i) {
23
+ const x = buffer[i];
24
+ const y = b0 * x + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2;
25
+ x2 = x1;
26
+ x1 = x;
27
+ y2 = y1;
28
+ y1 = y;
29
+ buffer[i] = y;
30
+ }
31
+ this.x1 = x1;
32
+ this.x2 = x2;
33
+ this.y1 = y1;
34
+ this.y2 = y2;
35
+ }
36
+ }
package/src/canvas.js CHANGED
@@ -240,7 +240,8 @@ export class GlCanvas {
240
240
  this.filter.setUniforms({
241
241
  width,
242
242
  height,
243
- frameCount: frame.frameCount,
243
+ lineBaseEven: frame.lineBaseEven,
244
+ lineBaseOdd: frame.lineBaseOdd,
244
245
  lineGrid: frame.lineGrid,
245
246
  // How much of the framebuffer each output pixel covers, which sets
246
247
  // how wide an edge-smoothing ramp should be. `extent` holds texel
package/src/main.js CHANGED
@@ -89,6 +89,11 @@ function stringToMachineKeys(text) {
89
89
  }
90
90
 
91
91
  const gamepad = new GamePad();
92
+ if (!window.isSecureContext)
93
+ toast("Gamepads only work over https, so any joystick plugged in here is not seen.", {
94
+ title: "Gamepads",
95
+ quietKey: "quietInsecureGamepads",
96
+ });
92
97
  const availableImages = [
93
98
  {
94
99
  name: "Elite",
@@ -175,12 +180,6 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
175
180
  let fastAsPossible = false;
176
181
  let fastTape = false;
177
182
  let noSeek;
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;
184
183
  let stationId = 101;
185
184
  let econet = null;
186
185
 
@@ -209,8 +208,6 @@ if (parsedQuery.embed) {
209
208
  fastTape = !!parsedQuery.fasttape;
210
209
  noSeek = !!parsedQuery.noseek;
211
210
 
212
- if (parsedQuery.audiofilterfreq !== undefined) audioFilterFreq = parsedQuery.audiofilterfreq;
213
- if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilterq;
214
211
  if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
215
212
  if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
216
213
 
@@ -239,7 +236,15 @@ const userPort = {
239
236
  // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
240
237
  // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
241
238
  const speechOutput = new SpeechOutput();
242
- speechOutput.enabled = !!parsedQuery.speechOutput;
239
+
240
+ function setSpeechOutput(enabled) {
241
+ speechOutput.enabled = enabled;
242
+ if (enabled && typeof speechSynthesis === "undefined")
243
+ toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
244
+ title: "Speech",
245
+ });
246
+ }
247
+ setSpeechOutput(!!parsedQuery.speechOutput);
243
248
 
244
249
  const config = new Config(
245
250
  function onChange(changed) {
@@ -266,9 +271,7 @@ const config = new Config(
266
271
  setupMicrophone();
267
272
  }
268
273
  }
269
- if (changed.speechOutput !== undefined) {
270
- speechOutput.enabled = !!changed.speechOutput;
271
- }
274
+ if (changed.speechOutput !== undefined) setSpeechOutput(!!changed.speechOutput);
272
275
  if (changed.tubeCpuMultiplier !== undefined) {
273
276
  emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
274
277
  config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
@@ -557,7 +560,15 @@ displayModeFilter = canvas.filterClass;
557
560
  // frame into the canvas and an animation frame presents it, so a stalled
558
561
  // display holds up the picture and not the emulation (issue #885).
559
562
  const videoFb32 = new Uint32Array(canvas.fb32.length);
560
- const pendingFrame = { minx: 0, miny: 0, maxx: 0, maxy: 0, frameCount: 0, lineGrid: new Uint8Array(0) };
563
+ const pendingFrame = {
564
+ minx: 0,
565
+ miny: 0,
566
+ maxx: 0,
567
+ maxy: 0,
568
+ lineBaseEven: 0,
569
+ lineBaseOdd: 0,
570
+ lineGrid: new Uint8Array(0),
571
+ };
561
572
  let presentScheduled = false;
562
573
  let paintMsThisTick = 0;
563
574
  let presentMsMax = 0;
@@ -581,7 +592,14 @@ video = new Video(
581
592
  if (pendingFrame.lineGrid.length !== this.lineGrid.length)
582
593
  pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
583
594
  pendingFrame.lineGrid.set(this.lineGrid);
584
- Object.assign(pendingFrame, { minx, miny, maxx, maxy, frameCount: this.frameCount });
595
+ Object.assign(pendingFrame, {
596
+ minx,
597
+ miny,
598
+ maxx,
599
+ maxy,
600
+ lineBaseEven: this.lineBaseEven,
601
+ lineBaseOdd: this.lineBaseOdd,
602
+ });
585
603
  paintMsThisTick += performance.now() - start;
586
604
  if (!presentScheduled) {
587
605
  presentScheduled = true;
@@ -598,8 +616,8 @@ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
598
616
  const audioHandler = new AudioHandler({
599
617
  warningNode: document.getElementById("audio-warning"),
600
618
  statsNode: audioStatsNode,
601
- audioFilterFreq,
602
- audioFilterQ,
619
+ audioFilterFreq: parsedQuery.audiofilterfreq,
620
+ audioFilterQ: parsedQuery.audiofilterq,
603
621
  audioLatencyMs: parsedQuery.audioLatencyMs,
604
622
  noSeek,
605
623
  cpuSpeed,
@@ -704,8 +722,10 @@ pastetext.addEventListener("drop", async function (event) {
704
722
  } else if (file.name.toLowerCase().endsWith(".uef")) {
705
723
  // Regular UEF tape image (not a BeebEm save state)
706
724
  setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
725
+ toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
707
726
  } else {
708
727
  await loadHTMLFile(file);
728
+ toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
709
729
  }
710
730
  } catch (error) {
711
731
  reportLoadFailure(file.name, error);
@@ -757,10 +777,19 @@ window.addEventListener("blur", function () {
757
777
  });
758
778
  window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
759
779
 
760
- document.getElementById("fs").addEventListener("click", function (event) {
761
- screenCanvas.requestFullscreen();
762
- event.preventDefault();
763
- });
780
+ const fullscreenItem = document.getElementById("fs");
781
+ if (document.fullscreenEnabled) {
782
+ fullscreenItem.addEventListener("click", async (event) => {
783
+ event.preventDefault();
784
+ try {
785
+ await screenCanvas.requestFullscreen();
786
+ } catch (error) {
787
+ toast(`Could not go fullscreen: ${errorText(error)}`, { title: "Fullscreen" });
788
+ }
789
+ });
790
+ } else {
791
+ fullscreenItem.closest("li").hidden = true;
792
+ }
764
793
 
765
794
  let keyboard; // This will be initialised after the processor is created
766
795
 
@@ -848,6 +877,7 @@ processor = new CpuClass(model, {
848
877
  printer.attach(processor.uservia);
849
878
 
850
879
  processor.teletextAdaptor?.addEventListener("notice", showNotice);
880
+ processor.acia.addEventListener("notice", showNotice);
851
881
 
852
882
  // Create input sources
853
883
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
@@ -1692,12 +1722,6 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
1692
1722
  });
1693
1723
 
1694
1724
  async function gdLoad(cat, layout) {
1695
- // TODO: have a onclose flush event, handle errors
1696
- /*
1697
- $(window).bind("beforeunload", function() {
1698
- return confirm("Do you really want to close?");
1699
- });
1700
- */
1701
1725
  popupLoading("Loading '" + cat.name + "' from Google Drive");
1702
1726
  try {
1703
1727
  const available = await googleDrive.initialise();
@@ -1718,6 +1742,12 @@ async function gdLoad(cat, layout) {
1718
1742
  const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1719
1743
  console.log("Google Drive loading finished");
1720
1744
  loadingFinished();
1745
+ if (!ssd.savesChanges) {
1746
+ toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
1747
+ title: "Google Drive",
1748
+ quietKey: "quietDriveReadOnly",
1749
+ });
1750
+ }
1721
1751
  return ssd;
1722
1752
  } catch (error) {
1723
1753
  console.error("Google Drive loading error:", error);
package/src/tapes.js CHANGED
@@ -315,6 +315,5 @@ export async function loadTapeFromData(name, data, model) {
315
315
  console.log("Detected a UEF tape");
316
316
  return new UefTape(stream, model);
317
317
  }
318
- console.log("Unknown tape format");
319
- return null;
318
+ throw new Error(`${name} is not a UEF or tapefile tape image`);
320
319
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Simulates PAL composite video artifacts by encoding the framebuffer to a
6
6
  // composite signal and decoding it back to RGB, mimicking the behavior of
7
- // connecting a BBC Micro to a PAL television via composite cable.
7
+ // a BBC Micro's UHF-modulated picture on a PAL television.
8
8
  //
9
9
  // REFERENCES:
10
10
  // - John Watkinson's "Engineer's Guide to Decoding & Encoding" (Section 3.4)
@@ -40,7 +40,7 @@ export class PALCompositeFilter {
40
40
  uFramebuffer: gl.getUniformLocation(this.program, "uFramebuffer"),
41
41
  uResolution: gl.getUniformLocation(this.program, "uResolution"),
42
42
  uTexelSize: gl.getUniformLocation(this.program, "uTexelSize"),
43
- uFrameCount: gl.getUniformLocation(this.program, "uFrameCount"),
43
+ uLineBase: gl.getUniformLocation(this.program, "uLineBase"),
44
44
  };
45
45
  }
46
46
 
@@ -55,6 +55,6 @@ export class PALCompositeFilter {
55
55
  gl.uniform1i(this.locations.uFramebuffer, 0); // Texture unit 0
56
56
  gl.uniform2f(this.locations.uResolution, params.width, params.height);
57
57
  gl.uniform2f(this.locations.uTexelSize, 1.0 / params.width, 1.0 / params.height);
58
- gl.uniform1f(this.locations.uFrameCount, params.frameCount % 8); // 8-field temporal phase sequence
58
+ gl.uniform2f(this.locations.uLineBase, params.lineBaseEven, params.lineBaseOdd);
59
59
  }
60
60
  }
@@ -5,23 +5,21 @@ varying vec2 vTexCoord;
5
5
  uniform sampler2D uFramebuffer;
6
6
  uniform vec2 uResolution;
7
7
  uniform vec2 uTexelSize;
8
- uniform float uFrameCount;
8
+ // Line numbers of texture rows 0 (x) and 1 (y)
9
+ uniform vec2 uLineBase;
9
10
 
10
11
  const float PI = 3.14159265359;
11
12
 
12
13
  // IMPLEMENTATION (Baseband Blending Method):
13
14
  // 1. Encode RGB to PAL composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
14
15
  // 2. Demodulate current line (with correct phase) → U_curr, V_curr
15
- // 3. Demodulate previous line (2H for interlaced, same field) → U_prev, V_prev
16
+ // 3. Demodulate the previous scanline (two texture rows up) → U_prev, V_prev
16
17
  // 4. Blend at baseband: U_final = mix(U_curr, U_prev), V_final = mix(V_curr, V_prev)
17
- // 5. Remodulate blended chroma back to composite frequency
18
- // 6. Extract luma via complementary subtraction: Y = composite - remodulated_chroma
19
- // 7. Combine luma and chroma, convert back to RGB
18
+ // 5. Luma: composite through the set's low-pass and subcarrier trap
19
+ // 6. Combine luma and chroma, convert back to RGB
20
20
  //
21
- // NOTE: Uses 2H delay (line-2) not 1H (line-1) because jsbeeb simulates interlacing by
22
- // rendering only odd or even lines per frame. A real PAL TV's 1H delay line would contain
23
- // the previous scanline from the SAME field, which is 2 texture lines apart. Proper
24
- // support for non-interlaced modes needs to be added.
21
+ // NOTE: Texture rows are half-lines, so the previous scanline of the same field is two rows
22
+ // up, and the subcarrier phase and V-switch come from uLineBase, not from the row.
25
23
 
26
24
  // Chroma demodulation gain: compensates for sin²(x) = 0.5 - 0.5·cos(2x) amplitude loss
27
25
  const float FIR_GAIN = 2.0;
@@ -35,13 +33,9 @@ const float PAL_FRAME_RATE = 25.0; // Frames per second
35
33
  const float PAL_SUBCARRIER_MHZ = 4.43361875; // PAL color subcarrier frequency (exact)
36
34
 
37
35
  // Derived PAL parameters
38
- const float PAL_LINES_PER_FIELD = PAL_TOTAL_LINES / 2.0;
39
36
  const float PAL_CYCLES_PER_LINE = PAL_SUBCARRIER_MHZ * 1e6 / (PAL_TOTAL_LINES * PAL_FRAME_RATE);
40
- const float PAL_LINE_PHASE_OFFSET = fract(PAL_CYCLES_PER_LINE);
41
- const float PAL_FIELD_PHASE_OFFSET = PAL_LINE_PHASE_OFFSET * PAL_LINES_PER_FIELD;
42
-
43
- // jsbeeb texture parameters
44
- const float TEXTURE_WIDTH = 1024.0; // Framebuffer width (896 visible + 128 blanking)
37
+ // fract(PAL_CYCLES_PER_LINE), spelt out: float keeps more of it alone than inside 283.7516
38
+ const float PAL_LINE_PHASE_OFFSET = 0.7516;
45
39
 
46
40
  // RGB → YUV conversion with proper PAL signal levels baked in
47
41
  // Derived from ITU-R BT.470-6: white at 0.7V, peak at 0.931V
@@ -63,45 +57,58 @@ vec3 yuv_to_rgb(vec3 yuv) {
63
57
  );
64
58
  }
65
59
 
66
- // Demodulate composite signal at given position
67
- vec2 demodulate_uv(vec2 xy, float pixel_x, float offset_pixels, float v_switch, float cycles_per_pixel, float phase_offset) {
68
- float t = ((pixel_x + offset_pixels) * cycles_per_pixel + phase_offset) * 2.0 * PI;
60
+ // Subcarrier phase (radians) at offset_pixels from pixel_x
61
+ float carrier_phase(float pixel_x, float offset_pixels, float cycles_per_pixel, float phase_offset) {
62
+ return ((pixel_x + offset_pixels) * cycles_per_pixel + phase_offset) * 2.0 * PI;
63
+ }
69
64
 
65
+ // Encode the texel at offset_pixels from xy to composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
66
+ float encode_composite(vec2 xy, float offset_pixels, float t, float v_switch) {
70
67
  vec2 sample_uv = xy + vec2(offset_pixels * uTexelSize.x, 0.0);
71
68
  vec3 rgb = texture2D(uFramebuffer, sample_uv).rgb;
72
69
  vec3 yuv = rgb_to_yuv(rgb);
70
+ return yuv.x + yuv.y * sin(t) + yuv.z * cos(t) * v_switch;
71
+ }
73
72
 
74
- // Encode to composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
75
- float composite = yuv.x + yuv.y * sin(t) + yuv.z * cos(t) * v_switch;
73
+ // Demodulate composite signal at given position
74
+ vec2 demodulate_uv(vec2 xy, float pixel_x, float offset_pixels, float v_switch, float cycles_per_pixel, float phase_offset) {
75
+ float t = carrier_phase(pixel_x, offset_pixels, cycles_per_pixel, phase_offset);
76
+ float composite = encode_composite(xy, offset_pixels, t, v_switch);
76
77
 
77
78
  // Demodulate: multiply by carrier to shift chroma to baseband
78
79
  return vec2(composite * sin(t), composite * cos(t) * v_switch);
79
80
  }
80
81
 
81
82
  void main() {
82
- // Use gl_FragCoord for pixel coordinates - it's hardware-provided and avoids interpolation artifacts
83
- vec2 pixelCoord = vec2(gl_FragCoord.x, uResolution.y - gl_FragCoord.y);
83
+ // Texel column and row, whatever size the drawing buffer is.
84
+ vec2 pixelCoord = floor(vTexCoord * uResolution);
84
85
 
85
86
  // BEGIN_FIR_COEFFICIENTS
86
87
  // This section is replaced by the Vite build to include FIR filter coefficients.
87
88
  // Change Cutoff (in comment below) or FIRTAPS value to configure.
88
- // Cutoff: 1.108 MHz (quarter subcarrier)
89
+ // Cutoff: 1.108 MHz (quarter subcarrier; the -6 dB design point, -3 dB at about 0.83 MHz)
89
90
  const int FIRTAPS = 21;
90
91
  float FIR[FIRTAPS];
91
92
  // END_FIR_COEFFICIENTS
92
93
 
93
- float line = floor(pixelCoord.y);
94
+ // BEGIN_LUMA_COEFFICIENTS
95
+ // This section is replaced by the Vite build with the set's luma path, low-pass and
96
+ // subcarrier trap in one symmetric FIR, as designed in tools/luma-fir-generator.js.
97
+ const int LUMA_TAPS = 31;
98
+ float LUMA_FIR[LUMA_TAPS];
99
+ // END_LUMA_COEFFICIENTS
100
+
101
+ float row = pixelCoord.y;
102
+ float line = (mod(row, 2.0) < 1.0 ? uLineBase.x : uLineBase.y) + floor(row / 2.0);
94
103
 
95
104
  // PAL phase alternates each scanline (V component inverts)
96
105
  float v_switch = mod(line, 2.0) < 1.0 ? 1.0 : -1.0;
97
106
 
98
- // Map PAL subcarrier across texture width
99
- float cycles_per_pixel = PAL_CYCLES_PER_LINE / TEXTURE_WIDTH;
107
+ // Map PAL subcarrier across the full line, blanking included
108
+ float cycles_per_pixel = PAL_CYCLES_PER_LINE / uResolution.x;
100
109
 
101
- // PAL temporal phase (8-field sequence creates animated dot crawl)
102
- float line_phase_offset = line * PAL_LINE_PHASE_OFFSET;
103
- float frame_phase_offset = uFrameCount * PAL_FIELD_PHASE_OFFSET;
104
- float phase_offset = line_phase_offset + frame_phase_offset;
110
+ // Subcarrier phase at the start of this line, carried across frames by the line count
111
+ float phase_offset = fract(line * PAL_LINE_PHASE_OFFSET);
105
112
 
106
113
  // Step 1: Demodulate current line with FIR filter
107
114
  vec2 filtered_uv_curr = vec2(0.0);
@@ -111,14 +118,11 @@ void main() {
111
118
  filtered_uv_curr += FIR_GAIN * uv * FIR[i];
112
119
  }
113
120
 
114
- // Step 2: Demodulate previous line (2H for interlaced, same field) with FIR filter
115
- // In interlaced mode, only odd OR even lines are rendered per frame.
116
- // Using 2H (line-2) ensures we sample from the same field (both fresh data).
117
- // This represents the TV's 1H delay within a single field.
121
+ // Step 2: Demodulate the previous scanline of the same field (two rows up, one line
122
+ // earlier) with FIR filter. This represents the TV's 1H delay line.
118
123
  vec2 prev_uv = vTexCoord - vec2(0.0, 2.0 * uTexelSize.y);
119
- float prev_line = line - 2.0;
120
- float prev_v_switch = v_switch * -1.0;
121
- float prev_phase_offset = prev_line * PAL_LINE_PHASE_OFFSET + frame_phase_offset;
124
+ float prev_v_switch = -v_switch;
125
+ float prev_phase_offset = fract((line - 1.0) * PAL_LINE_PHASE_OFFSET);
122
126
 
123
127
  vec2 filtered_uv_prev = vec2(0.0);
124
128
  for (int i = 0; i < FIRTAPS; i++) {
@@ -130,17 +134,13 @@ void main() {
130
134
  // Step 3: Blend chroma at baseband
131
135
  vec2 filtered_uv = mix(filtered_uv_curr, filtered_uv_prev, CHROMA_BLEND_WEIGHT);
132
136
 
133
- // Step 4: Get luma via complementary subtraction
134
- float t_curr = (pixelCoord.x * cycles_per_pixel + phase_offset) * 2.0 * PI;
135
- vec3 rgb_curr = texture2D(uFramebuffer, vTexCoord).rgb;
136
- vec3 yuv_curr = rgb_to_yuv(rgb_curr);
137
- float composite_curr = yuv_curr.x + yuv_curr.y * sin(t_curr) + yuv_curr.z * cos(t_curr) * v_switch;
138
-
139
- // Remodulate blended chroma back to composite frequency
140
- float remodulated_chroma = filtered_uv.x * sin(t_curr) + filtered_uv.y * cos(t_curr) * v_switch;
141
-
142
- // Complementary subtraction: luma = composite - chroma
143
- float y_out = composite_curr - remodulated_chroma;
137
+ // Step 4: Luma is the composite through the set's low-pass and subcarrier trap
138
+ float y_out = 0.0;
139
+ for (int i = 0; i < LUMA_TAPS; i++) {
140
+ float offset = float(i - (LUMA_TAPS - 1) / 2);
141
+ float t = carrier_phase(pixelCoord.x, offset, cycles_per_pixel, phase_offset);
142
+ y_out += encode_composite(vTexCoord, offset, t, v_switch) * LUMA_FIR[i];
143
+ }
144
144
 
145
145
  vec3 rgb_out = yuv_to_rgb(vec3(y_out, filtered_uv.x, filtered_uv.y));
146
146
  gl_FragColor = vec4(clamp(rgb_out, 0.0, 1.0), 1.0);
package/src/video.js CHANGED
@@ -19,6 +19,10 @@ export const OPAQUE_WHITE = 0xffffffff;
19
19
 
20
20
  export const MinPaintedFrameRows = 64;
21
21
 
22
+ // The PAL subcarrier advances 283.7516 cycles per line (4433618.75 Hz / 15625 Hz), and
23
+ // 0.7516 = 1879 / 2500, so its phase against the line is periodic in 2500 lines.
24
+ export const PalPhasePeriodLines = 2500;
25
+
22
26
  ////////////////////
23
27
  // VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
24
28
  // Reference: b-em src/video.c (stardot/b-em).
@@ -348,6 +352,11 @@ export class Video {
348
352
  this.bitmapY = 0;
349
353
  this.oddClock = false;
350
354
  this.frameCount = 0;
355
+ this.hsyncCount = 0;
356
+ // The hsyncCount in effect while framebuffer rows 0 and 1 were drawn: row r was drawn
357
+ // during line lineBase[r & 1] + (r >> 1), which sets its PAL subcarrier phase.
358
+ this.lineBaseEven = 0;
359
+ this.lineBaseOdd = 0;
351
360
  this.doEvenFrameLogic = false;
352
361
  this.isEvenRender = true;
353
362
  this.lastRenderWasEven = false;
@@ -460,6 +469,9 @@ export class Video {
460
469
  bitmapY: this.bitmapY,
461
470
  oddClock: this.oddClock,
462
471
  frameCount: this.frameCount,
472
+ hsyncCount: this.hsyncCount,
473
+ lineBaseEven: this.lineBaseEven,
474
+ lineBaseOdd: this.lineBaseOdd,
463
475
  doEvenFrameLogic: this.doEvenFrameLogic,
464
476
  isEvenRender: this.isEvenRender,
465
477
  lastRenderWasEven: this.lastRenderWasEven,
@@ -512,6 +524,9 @@ export class Video {
512
524
  this.bitmapY = state.bitmapY;
513
525
  this.oddClock = state.oddClock;
514
526
  this.frameCount = state.frameCount;
527
+ this.hsyncCount = state.hsyncCount ?? 0;
528
+ this.lineBaseEven = state.lineBaseEven ?? 0;
529
+ this.lineBaseOdd = state.lineBaseOdd ?? 0;
515
530
  this.doEvenFrameLogic = state.doEvenFrameLogic;
516
531
  this.isEvenRender = state.isEvenRender;
517
532
  this.lastRenderWasEven = state.lastRenderWasEven;
@@ -606,13 +621,26 @@ export class Video {
606
621
  this.dispEnabled |= enable;
607
622
  this.cursorInvertedOffset = -1;
608
623
 
609
- this.bitmapY = 0;
610
624
  // Interlace even frame fires vsync midway through a scanline.
611
- if (!!(this.regs[8] & 1) && !!(this.frameCount & 1)) {
612
- this.bitmapY = -1;
625
+ const oddField = !!(this.regs[8] & 1) && !!(this.frameCount & 1);
626
+ this.bitmapY = oddField ? -1 : 0;
627
+
628
+ // The even field draws the rest of the vsync line on row 0; the odd field's first row
629
+ // is the first hsync after it.
630
+ const firstRowLine = (this.hsyncCount + (oddField ? 1 : 0)) % PalPhasePeriodLines;
631
+ if (this.doublesLines()) {
632
+ this.lineBaseEven = this.lineBaseOdd = firstRowLine;
633
+ } else if (oddField) {
634
+ this.lineBaseOdd = firstRowLine;
635
+ } else {
636
+ this.lineBaseEven = firstRowLine;
613
637
  }
614
638
  }
615
639
 
640
+ doublesLines() {
641
+ return (this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
642
+ }
643
+
616
644
  debugOffset(x, y) {
617
645
  if (x < 0 || x >= 1024) return -1;
618
646
  if (y < 0 || y >= 768) return -1;
@@ -715,9 +743,7 @@ export class Video {
715
743
  if (!this.halfClock || !this.oddClock) return;
716
744
  if ((this.dispEnabled & EVERYTHINGENABLED) !== EVERYTHINGENABLED) return;
717
745
  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;
746
+ const doubledLines = this.doublesLines();
721
747
  const bitmapRow = doubledLines ? this.bitmapY & ~1 : this.bitmapY;
722
748
  const offset = bitmapRow * 1024 + this.bitmapX;
723
749
  const halfCell = this.pixelsPerChar >>> 1;
@@ -903,6 +929,7 @@ export class Video {
903
929
  // The CRT vertical beam speed is constant, so this is actually
904
930
  // an approximation that works if hsyncs are spaced evenly.
905
931
  this.bitmapY += 2;
932
+ this.hsyncCount = (this.hsyncCount + 1) % PalPhasePeriodLines;
906
933
 
907
934
  // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
908
935
  return this.bitmapY >= 768;
@@ -1064,7 +1091,8 @@ export class Video {
1064
1091
  // There's a painting subtlety here: if we're in an
1065
1092
  // interlace mode but R6>R4 then we'll get stuck
1066
1093
  // painting just an odd or even frame, so we double up
1067
- // scanlines to avoid a ghost half frame.
1094
+ // scanlines to avoid a ghost half frame. This is
1095
+ // doublesLines(), inlined for speed.
1068
1096
  if (
1069
1097
  (this.doubledScanlines && !this.interlacedSyncAndVideo) ||
1070
1098
  this.isEvenRender === this.lastRenderWasEven
@@ -62,7 +62,7 @@ export class AudioHandler {
62
62
  this.masterGain.connect(this.audioContext.destination);
63
63
  this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
64
64
  this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
65
- this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
65
+ this._setup({ audioFilterFreq, audioFilterQ }).catch((error) => this._audioUnavailable(error));
66
66
  } else {
67
67
  if (this.audioContext && !this.audioContext.audioWorklet) {
68
68
  this.audioContext = null;
@@ -127,27 +127,18 @@ export class AudioHandler {
127
127
  this.chart.streamTo(statsNode, 100);
128
128
  }
129
129
 
130
- async _setup(audioFilterFreq, audioFilterQ) {
130
+ async _setup({ audioFilterFreq, audioFilterQ }) {
131
131
  await this.audioContext.audioWorklet.addModule(rendererUrl);
132
- if (audioFilterFreq !== 0) {
133
- const filterNode = this.audioContext.createBiquadFilter();
134
- filterNode.type = "lowpass";
135
- filterNode.frequency.value = audioFilterFreq;
136
- filterNode.Q.value = audioFilterQ;
137
- this._audioDestination = filterNode;
138
- filterNode.connect(this.audioContext.destination);
139
- } else {
140
- this._audioDestination = this.audioContext.destination;
141
- }
142
-
143
132
  this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
144
133
  processorOptions: {
145
134
  targetLatencyMs: this._targetLatencyMs(),
146
135
  isAtom: this.isAtom,
147
136
  cpuSpeed: this.cpuSpeed,
137
+ audioFilterFreq,
138
+ audioFilterQ,
148
139
  },
149
140
  });
150
- this._jsAudioNode.connect(this._audioDestination);
141
+ this._jsAudioNode.connect(this.audioContext.destination);
151
142
  this._jsAudioNode.port.onmessage = (event) => {
152
143
  const now = Date.now();
153
144
  if (event.data.event) {
@@ -1,8 +1,13 @@
1
1
  /* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
2
2
  import { SoundChip, AtomSoundChip } from "../soundchip.js";
3
+ import { LowPassBiquad } from "../biquad.js";
3
4
 
4
- const lowPassFilterFreq = sampleRate / 2;
5
- const RC = 1 / (2 * Math.PI * lowPassFilterFreq);
5
+ // The board's output filter is an equal-component Sallen-Key (Service Manual
6
+ // section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
7
+ // Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
8
+ // rate, ahead of decimation, it is also the anti-alias filter.
9
+ const OutputFilterHz = 7234;
10
+ const OutputFilterQ = 0.696;
6
11
 
7
12
  const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
8
13
  const MaxTargetLatencyMs = 250;
@@ -22,10 +27,17 @@ const isResync = (event) => event.state !== undefined || event.reset !== undefin
22
27
  class SoundChipProcessor extends AudioWorkletProcessor {
23
28
  constructor(options) {
24
29
  super(options);
25
- const { isAtom = false, cpuSpeed = 1000000, targetLatencyMs } = options?.processorOptions ?? {};
30
+ const {
31
+ isAtom = false,
32
+ cpuSpeed = 1000000,
33
+ targetLatencyMs,
34
+ audioFilterFreq,
35
+ audioFilterQ,
36
+ } = options?.processorOptions ?? {};
26
37
  this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
27
38
  this.inputSampleRate = this.chip.soundchipFreq;
28
39
  this.samplesPerCycle = this.chip.samplesPerCycle;
40
+ this.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
29
41
 
30
42
  this.events = [];
31
43
  this.eventsHead = 0;
@@ -36,10 +48,9 @@ class SoundChipProcessor extends AudioWorkletProcessor {
36
48
  this.skippedMs = 0;
37
49
  this.minLeadMs = Infinity;
38
50
 
39
- this._lastFilteredOutput = 0;
51
+ this._lastInputSample = 0;
40
52
  this._phase = 0;
41
53
  this._source = new Float32Array(0);
42
- this._rendered = new Float32Array(0);
43
54
  this.smoothedLeadError = 0;
44
55
  this.setTargetLatency(targetLatencyMs);
45
56
  this.port.onmessage = (event) => {
@@ -49,6 +60,15 @@ class SoundChipProcessor extends AudioWorkletProcessor {
49
60
  this.nextStats = 0;
50
61
  }
51
62
 
63
+ // Zero turns it off; a setting the biquad cannot realise (non-finite, at or
64
+ // above Nyquist, non-positive Q) falls back to the board's own values.
65
+ _makeOutputFilter(frequency = OutputFilterHz, q = OutputFilterQ) {
66
+ if (frequency <= 0) return null;
67
+ const usable = frequency < this.inputSampleRate / 2 && q > 0;
68
+ if (!usable) return new LowPassBiquad(this.inputSampleRate, OutputFilterHz, OutputFilterQ);
69
+ return new LowPassBiquad(this.inputSampleRate, frequency, q);
70
+ }
71
+
52
72
  setTargetLatency(ms) {
53
73
  const valid = Number.isFinite(ms) && ms > 0;
54
74
  this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
@@ -157,12 +177,11 @@ class SoundChipProcessor extends AudioWorkletProcessor {
157
177
  return Math.floor((boundary - this.clock) * this.samplesPerCycle);
158
178
  }
159
179
 
160
- _renderInput(out, length) {
180
+ _renderInput(out, offset, length) {
161
181
  if (this.stalled) {
162
- this._stall(out, 0, length);
182
+ this._stall(out, offset, length);
163
183
  return;
164
184
  }
165
- let offset = 0;
166
185
  while (length > 0) {
167
186
  const n = Math.min(length, this._samplesUntilNextChange());
168
187
  if (n > 0) {
@@ -189,28 +208,17 @@ class SoundChipProcessor extends AudioWorkletProcessor {
189
208
  const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
190
209
  const sampleRatio = effectiveSampleRate / sampleRate;
191
210
 
192
- const dt = 1 / effectiveSampleRate;
193
- const filterAlpha = dt / (RC + dt);
194
-
195
211
  // The fractional read position carries across quanta, so consumption
196
212
  // averages exactly sampleRatio and the pitch never steps at a rounding
197
213
  // boundary. source[0] is the last input sample of the previous quantum.
198
214
  const end = this._phase + channel.length * sampleRatio;
199
215
  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
- }
216
+ if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
204
217
  const source = this._source;
205
- const rendered = this._rendered;
206
- this._renderInput(rendered, numInputSamples);
207
- source[0] = this._lastFilteredOutput;
208
- let prevSample = this._lastFilteredOutput;
209
- for (let i = 1; i <= numInputSamples; ++i) {
210
- prevSample += filterAlpha * (rendered[i - 1] - prevSample);
211
- source[i] = prevSample;
212
- }
213
- this._lastFilteredOutput = prevSample;
218
+ source[0] = this._lastInputSample;
219
+ this._renderInput(source, 1, numInputSamples);
220
+ this.outputFilter?.process(source, 1, numInputSamples);
221
+ this._lastInputSample = source[numInputSamples];
214
222
  for (let i = 0; i < channel.length; i++) {
215
223
  const pos = this._phase + i * sampleRatio;
216
224
  const loc = Math.floor(pos);