jsbeeb 1.20.1 → 1.20.2

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.20.1",
10
+ "version": "1.20.2",
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/app/app.js CHANGED
@@ -250,6 +250,16 @@ const template = [
250
250
  click: sendAction("rewind"),
251
251
  },
252
252
  { type: "separator" },
253
+ {
254
+ label: "Pause and Show Debugger",
255
+ accelerator: "CmdOrCtrl+Home",
256
+ click: sendAction("pause"),
257
+ },
258
+ {
259
+ label: "Resume",
260
+ click: sendAction("resume"),
261
+ },
262
+ { type: "separator" },
253
263
  {
254
264
  label: "Soft Reset",
255
265
  click: sendAction("soft-reset"),
package/src/jsbeeb.css CHANGED
@@ -81,6 +81,12 @@ body {
81
81
  width: 90px;
82
82
  }
83
83
 
84
+ #debug-keys {
85
+ clear: both;
86
+ padding-top: 3px;
87
+ color: #aaaaaa;
88
+ }
89
+
84
90
  #crtc_debug {
85
91
  background-color: #333333;
86
92
  border: 1px solid #555555;
package/src/main.js CHANGED
@@ -795,11 +795,18 @@ let keyboard; // This will be initialised after the processor is created
795
795
 
796
796
  const debugPause = document.getElementById("debug-pause");
797
797
  const debugPlay = document.getElementById("debug-play");
798
- debugPause.addEventListener("click", () => stop(true));
799
- debugPlay.addEventListener("click", () => {
798
+
799
+ function pauseIntoDebugger() {
800
+ stop(true);
801
+ }
802
+
803
+ function resumeFromDebugger() {
800
804
  dbgr.hide();
801
- go();
802
- });
805
+ keyboard.resumeEmulation();
806
+ }
807
+
808
+ debugPause.addEventListener("click", pauseIntoDebugger);
809
+ debugPlay.addEventListener("click", resumeFromDebugger);
803
810
 
804
811
  // To lower chance of data loss, only accept drop events in the drop
805
812
  // zone in the menu bar.
@@ -2711,6 +2718,8 @@ electron({
2711
2718
  "hard-reset": hardReset,
2712
2719
  "save-state": () => document.getElementById("save-state").click(),
2713
2720
  rewind: () => rewindUI.open(),
2721
+ pause: pauseIntoDebugger,
2722
+ resume: resumeFromDebugger,
2714
2723
  },
2715
2724
  });
2716
2725
 
@@ -0,0 +1,92 @@
1
+ function besselI0(x) {
2
+ let sum = 1;
3
+ let term = 1;
4
+ for (let k = 1; k < 50; ++k) {
5
+ term *= (x / (2 * k)) ** 2;
6
+ sum += term;
7
+ if (term < sum * 1e-12) break;
8
+ }
9
+ return sum;
10
+ }
11
+
12
+ /**
13
+ * Rate conversion by a Kaiser-windowed sinc evaluated only where an output
14
+ * sample falls, so the cost scales with the output rate, not the input rate.
15
+ * The kernel is tabulated at `phases` fractional offsets and interpolated
16
+ * between them. Each quantum the caller reserves room for its input, renders
17
+ * into `buffer` at `inputOffset`, reads the output with `read`, then calls
18
+ * `commit` to carry the last `taps` input samples over as the next quantum's
19
+ * history. Nothing is allocated once the buffer has reached its working size.
20
+ */
21
+ export class PolyphaseResampler {
22
+ constructor(inputRate, cutoffHz, { taps = 201, phases = 64, beta = 8.5 } = {}) {
23
+ this.taps = taps;
24
+ this.phases = phases;
25
+ this.half = (taps - 1) / 2;
26
+ this.table = new Float32Array((phases + 1) * taps);
27
+ const scale = (2 * cutoffHz) / inputRate;
28
+ const norm = besselI0(beta);
29
+ for (let p = 0; p <= phases; ++p) {
30
+ const row = p * taps;
31
+ let sum = 0;
32
+ for (let k = 0; k < taps; ++k) {
33
+ const t = k - this.half - p / phases;
34
+ const x = Math.PI * scale * t;
35
+ const sinc = x === 0 ? 1 : Math.sin(x) / x;
36
+ const u = t / (this.half + 1);
37
+ const window = u <= -1 || u >= 1 ? 0 : besselI0(beta * Math.sqrt(1 - u * u)) / norm;
38
+ this.table[row + k] = scale * sinc * window;
39
+ sum += this.table[row + k];
40
+ }
41
+ for (let k = 0; k < taps; ++k) this.table[row + k] /= sum;
42
+ }
43
+ this.buffer = new Float32Array(taps);
44
+ this.newSamples = 0;
45
+ }
46
+
47
+ /** Make room for `count` new input samples after the history. */
48
+ reserve(count) {
49
+ const needed = this.taps + count + 1;
50
+ if (this.buffer.length < needed) {
51
+ const grown = new Float32Array(needed * 2);
52
+ grown.set(this.buffer.subarray(0, this.taps));
53
+ this.buffer = grown;
54
+ }
55
+ this.newSamples = count;
56
+ }
57
+
58
+ get inputOffset() {
59
+ return this.taps;
60
+ }
61
+
62
+ /**
63
+ * Fill `out` with samples taken `ratio` input samples apart, the first at
64
+ * `phase` (0 to 1) past the last sample of the previous quantum. The
65
+ * output lags the input by `half` samples, which the kernel needs ahead.
66
+ */
67
+ read(out, phase, ratio) {
68
+ const { buffer, table, taps, phases } = this;
69
+ for (let i = 0; i < out.length; ++i) {
70
+ const pos = phase + i * ratio;
71
+ const loc = Math.floor(pos);
72
+ const fracPhase = (pos - loc) * phases;
73
+ const p = Math.min(Math.floor(fracPhase), phases - 1);
74
+ const mix = fracPhase - p;
75
+ const rowA = p * taps;
76
+ const rowB = rowA + taps;
77
+ let a = 0;
78
+ let b = 0;
79
+ for (let k = 0; k < taps; ++k) {
80
+ const x = buffer[loc + k];
81
+ a += table[rowA + k] * x;
82
+ b += table[rowB + k] * x;
83
+ }
84
+ out[i] = a + (b - a) * mix;
85
+ }
86
+ }
87
+
88
+ /** Keep the last `taps` input samples as history for the next quantum. */
89
+ commit() {
90
+ this.buffer.copyWithin(0, this.newSamples, this.newSamples + this.taps);
91
+ }
92
+ }
@@ -1,14 +1,21 @@
1
1
  /* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
2
2
  import { SoundChip, AtomSoundChip } from "../soundchip.js";
3
3
  import { LowPassBiquad } from "../biquad.js";
4
+ import { PolyphaseResampler } from "../resampler.js";
4
5
 
5
6
  // The board's output filter is an equal-component Sallen-Key (Service Manual
6
7
  // section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
7
8
  // 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
+ // rate, ahead of the resampler.
9
10
  const OutputFilterHz = 7234;
10
11
  const OutputFilterQ = 0.696;
11
12
 
13
+ // The resampler's sinc is cut off below the output Nyquist so that its
14
+ // transition band has finished before anything folds; sampled sound rides on
15
+ // a 31 kHz or higher carrier that would otherwise land in the audible band.
16
+ const ResamplerCutoffOfOutputRate = 0.4;
17
+ const ResamplerTaps = 201;
18
+
12
19
  const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
13
20
  const MaxTargetLatencyMs = 250;
14
21
 
@@ -38,6 +45,9 @@ class SoundChipProcessor extends AudioWorkletProcessor {
38
45
  this.inputSampleRate = this.chip.soundchipFreq;
39
46
  this.samplesPerCycle = this.chip.samplesPerCycle;
40
47
  this.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
48
+ this.resampler = new PolyphaseResampler(this.inputSampleRate, ResamplerCutoffOfOutputRate * sampleRate, {
49
+ taps: ResamplerTaps,
50
+ });
41
51
 
42
52
  this.events = [];
43
53
  this.eventsHead = 0;
@@ -48,9 +58,7 @@ class SoundChipProcessor extends AudioWorkletProcessor {
48
58
  this.skippedMs = 0;
49
59
  this.minLeadMs = Infinity;
50
60
 
51
- this._lastInputSample = 0;
52
61
  this._phase = 0;
53
- this._source = new Float32Array(0);
54
62
  this.smoothedLeadError = 0;
55
63
  this.setTargetLatency(targetLatencyMs);
56
64
  this.port.onmessage = (event) => {
@@ -210,21 +218,15 @@ class SoundChipProcessor extends AudioWorkletProcessor {
210
218
 
211
219
  // The fractional read position carries across quanta, so consumption
212
220
  // averages exactly sampleRatio and the pitch never steps at a rounding
213
- // boundary. source[0] is the last input sample of the previous quantum.
221
+ // boundary.
214
222
  const end = this._phase + channel.length * sampleRatio;
215
223
  const numInputSamples = Math.floor(end);
216
- if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
217
- const source = this._source;
218
- source[0] = this._lastInputSample;
219
- this._renderInput(source, 1, numInputSamples);
220
- this.outputFilter?.process(source, 1, numInputSamples);
221
- this._lastInputSample = source[numInputSamples];
222
- for (let i = 0; i < channel.length; i++) {
223
- const pos = this._phase + i * sampleRatio;
224
- const loc = Math.floor(pos);
225
- const alpha = pos - loc;
226
- channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
227
- }
224
+ const resampler = this.resampler;
225
+ resampler.reserve(numInputSamples);
226
+ this._renderInput(resampler.buffer, resampler.inputOffset, numInputSamples);
227
+ this.outputFilter?.process(resampler.buffer, resampler.inputOffset, numInputSamples);
228
+ resampler.read(channel, this._phase, sampleRatio);
229
+ resampler.commit();
228
230
  this._phase = end - numInputSamples;
229
231
  this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
230
232
  this.stats(sampleRatio);