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 +1 -1
- package/src/app/app.js +10 -0
- package/src/jsbeeb.css +6 -0
- package/src/main.js +13 -4
- package/src/resampler.js +92 -0
- package/src/web/audio-renderer.js +18 -16
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.
|
|
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
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
|
-
|
|
799
|
-
|
|
798
|
+
|
|
799
|
+
function pauseIntoDebugger() {
|
|
800
|
+
stop(true);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function resumeFromDebugger() {
|
|
800
804
|
dbgr.hide();
|
|
801
|
-
|
|
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
|
|
package/src/resampler.js
ADDED
|
@@ -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
|
|
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.
|
|
221
|
+
// boundary.
|
|
214
222
|
const end = this._phase + channel.length * sampleRatio;
|
|
215
223
|
const numInputSamples = Math.floor(end);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
220
|
-
|
|
221
|
-
|
|
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);
|