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 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.2",
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
- let audioFilterFreq = 7000;
178
- let audioFilterQ = 5;
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 (!this.enabled) return;
182
- for (let i = 0; i < this.generators.length; ++i) {
183
- this.generators[i](i, out, offset, length);
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 outByMs = this._queueAge() - this.targetLatencyMs;
92
- const maxAdjust = this.inputSampleRate * 0.01;
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
- const numInputSamples = Math.round(sampleRatio * channel.length);
102
- const source = new Float32Array(numInputSamples);
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 = 0; i < numInputSamples; ++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 = (i + 0.5) * sampleRatio;
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
  }