jsbeeb 1.19.2 → 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/web/audio-renderer.js +35 -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) {
|
|
@@ -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
|
}
|