jsbeeb 1.19.3 → 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.
@@ -6,19 +6,39 @@ import { createAudioContext } from "../audio-utils.js";
6
6
  import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
7
7
  import { toast } from "./toast.js";
8
8
 
9
- // Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
10
- // See https://github.com/vitejs/vite/discussions/6459
11
- const rendererUrl = new URL("./audio-renderer.js", import.meta.url).href;
12
- const music5000WorkletUrl = new URL("../music5000-worklet.js", import.meta.url).href;
9
+ import rendererUrl from "./audio-renderer.js?worker&url";
10
+ import music5000WorkletUrl from "../music5000-worklet.js?worker&url";
11
+
12
+ // Skips plot as the milliseconds skipped, on the lead scale; stalls as a fixed spike.
13
+ const StallSpikeHeight = 20;
14
+
15
+ // Nobody is watching an unfocused window, so its sound can run far behind
16
+ // the picture, deep enough to ride out the browser starving the tab.
17
+ export const UnfocusedLatencyMs = 200;
18
+ export const DefaultLatencyMs = 20;
13
19
 
14
20
  export class AudioHandler {
15
- constructor({ warningNode, statsNode, audioFilterFreq, audioFilterQ, noSeek, cpuSpeed, isAtom } = {}) {
21
+ constructor({
22
+ warningNode,
23
+ statsNode,
24
+ audioFilterFreq,
25
+ audioFilterQ,
26
+ audioLatencyMs,
27
+ noSeek,
28
+ cpuSpeed,
29
+ isAtom,
30
+ hasMusic5000,
31
+ } = {}) {
16
32
  this.cpuSpeed = cpuSpeed;
17
33
  this.isAtom = isAtom;
34
+ this.audioLatencyMs = audioLatencyMs ?? DefaultLatencyMs;
35
+ this.windowFocused = document.hasFocus();
18
36
  this.warningNode = warningNode;
19
37
  this.noAudio = false;
20
38
  toggle(this.warningNode, false);
21
39
  this.stats = {};
40
+ this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
41
+ this._chipEvents = [];
22
42
  if (statsNode) {
23
43
  this._initStats(statsNode).catch((error) => {
24
44
  console.error("Unable to initialise audio stats", error);
@@ -30,16 +50,19 @@ export class AudioHandler {
30
50
  this._jsAudioNode = null;
31
51
  if (this.audioContext && this.audioContext.audioWorklet) {
32
52
  this.audioContext.onstatechange = () => this.checkStatus();
33
- const onBuffer = (buffer, time) => this._onBuffer(buffer, time);
53
+ const onEvent = (event) => {
54
+ if (event.progress) this.flushChipEvents();
55
+ else this._chipEvents.push(event);
56
+ };
34
57
  this.soundChip = this.isAtom
35
- ? new AtomSoundChip(onBuffer, { cpuSpeed: this.cpuSpeed })
36
- : new SoundChip(onBuffer);
58
+ ? new AtomSoundChip(null, { cpuSpeed: this.cpuSpeed, onEvent })
59
+ : new SoundChip(null, { onEvent });
37
60
  // Master gain node for all sample-based audio (disc, relay, etc.).
38
61
  this.masterGain = this.audioContext.createGain();
39
62
  this.masterGain.connect(this.audioContext.destination);
40
63
  this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
41
64
  this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
42
- this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
65
+ this._setup({ audioFilterFreq, audioFilterQ }).catch((error) => this._audioUnavailable(error));
43
66
  } else {
44
67
  if (this.audioContext && !this.audioContext.audioWorklet) {
45
68
  this.audioContext = null;
@@ -60,31 +83,31 @@ export class AudioHandler {
60
83
 
61
84
  this.warningNode.addEventListener("mousedown", () => this.tryResume());
62
85
 
63
- // Initialise Music 5000 audio context
64
- this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
65
-
66
- if (this.audioContextM5000 && this.audioContextM5000.audioWorklet) {
67
- this.audioContextM5000.onstatechange = () => this.checkStatus();
68
- this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
69
-
70
- this.audioContextM5000.audioWorklet
71
- .addModule(music5000WorkletUrl)
72
- .then(() => {
73
- this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
74
- outputChannelCount: [2],
75
- });
76
- this._music5000workletnode.connect(this.audioContextM5000.destination);
77
- })
78
- .catch((error) => {
79
- console.error("Unable to initialise Music 5000 audio", error);
80
- toast(
81
- `The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
82
- { title: "Music 5000", quietKey: "quietMusic5000Audio" },
83
- );
84
- });
85
- } else {
86
- this.music5000 = new FakeMusic5000();
87
- }
86
+ this.audioContextM5000 = null;
87
+ this._music5000workletnode = null;
88
+ this.music5000 = hasMusic5000 ? this._createMusic5000() : new FakeMusic5000();
89
+ }
90
+
91
+ // The Music 5000 gets its own context, running at the board's own sample rate.
92
+ _createMusic5000() {
93
+ if (!this.audioContext?.audioWorklet) return new FakeMusic5000();
94
+ const context = createAudioContext({ sampleRate: 46875 });
95
+ this.audioContextM5000 = context;
96
+ context.onstatechange = () => this.checkStatus();
97
+ context.audioWorklet
98
+ .addModule(music5000WorkletUrl)
99
+ .then(() => {
100
+ this._music5000workletnode = new AudioWorkletNode(context, "music5000", { outputChannelCount: [2] });
101
+ this._music5000workletnode.connect(context.destination);
102
+ })
103
+ .catch((error) => {
104
+ console.error("Unable to initialise Music 5000 audio", error);
105
+ toast(
106
+ `The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
107
+ { title: "Music 5000", quietKey: "quietMusic5000Audio" },
108
+ );
109
+ });
110
+ return new Music5000((buffer) => this._onBufferMusic5000(buffer));
88
111
  }
89
112
 
90
113
  // Lazily load smoothie and set up the audio stats chart.
@@ -98,34 +121,64 @@ export class AudioHandler {
98
121
  return { min: 0, max: range.max };
99
122
  },
100
123
  });
101
- this._addStat("queueSize", { strokeStyle: "rgb(51,126,108)" });
102
- this._addStat("queueAge", { strokeStyle: "rgb(162,119,22)" });
124
+ this._addStat("leadMs", { strokeStyle: "rgb(51,126,108)" });
125
+ this._addStat("stall", { strokeStyle: "rgb(220,50,50)", lineWidth: 2 });
126
+ this._addStat("skip", { strokeStyle: "rgb(120,80,200)", lineWidth: 2 });
103
127
  this.chart.streamTo(statsNode, 100);
104
128
  }
105
129
 
106
- async _setup(audioFilterFreq, audioFilterQ) {
130
+ async _setup({ audioFilterFreq, audioFilterQ }) {
107
131
  await this.audioContext.audioWorklet.addModule(rendererUrl);
108
- if (audioFilterFreq !== 0) {
109
- const filterNode = this.audioContext.createBiquadFilter();
110
- filterNode.type = "lowpass";
111
- filterNode.frequency.value = audioFilterFreq;
112
- filterNode.Q.value = audioFilterQ;
113
- this._audioDestination = filterNode;
114
- filterNode.connect(this.audioContext.destination);
115
- } else {
116
- this._audioDestination = this.audioContext.destination;
117
- }
118
-
119
- this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor");
120
- this._jsAudioNode.connect(this._audioDestination);
132
+ this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
133
+ processorOptions: {
134
+ targetLatencyMs: this._targetLatencyMs(),
135
+ isAtom: this.isAtom,
136
+ cpuSpeed: this.cpuSpeed,
137
+ audioFilterFreq,
138
+ audioFilterQ,
139
+ },
140
+ });
141
+ this._jsAudioNode.connect(this.audioContext.destination);
121
142
  this._jsAudioNode.port.onmessage = (event) => {
122
143
  const now = Date.now();
144
+ if (event.data.event) {
145
+ this._onAudioEvent(now, event.data);
146
+ return;
147
+ }
148
+ this.eventCounts.leadMinMs = Math.min(this.eventCounts.leadMinMs, event.data.leadMinMs);
123
149
  for (const stat of Object.keys(event.data)) {
124
150
  if (this.stats[stat]) this.stats[stat].append(now, event.data[stat]);
125
151
  }
126
152
  };
127
153
  }
128
154
 
155
+ _onAudioEvent(now, { event, count }) {
156
+ this.eventCounts[event] += count;
157
+ const series = this.stats[event];
158
+ if (!series) return;
159
+ series.append(now - 1, 0);
160
+ series.append(now, event === "stall" ? StallSpikeHeight : count);
161
+ series.append(now + 1, 0);
162
+ }
163
+
164
+ _targetLatencyMs() {
165
+ return this.windowFocused ? this.audioLatencyMs : UnfocusedLatencyMs;
166
+ }
167
+
168
+ // Returns how far ahead of the sound the picture should now run, in ms.
169
+ setWindowFocused(focused) {
170
+ this.windowFocused = focused;
171
+ const targetLatencyMs = this._targetLatencyMs();
172
+ this._jsAudioNode?.port.postMessage({ command: "setTargetLatency", targetLatencyMs });
173
+ return targetLatencyMs - this.audioLatencyMs;
174
+ }
175
+
176
+ takeEventCounts() {
177
+ const counts = this.eventCounts;
178
+ this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
179
+ return counts;
180
+ }
181
+
129
182
  _audioUnavailable(error) {
130
183
  console.error("Unable to initialise audio", error);
131
184
  this.noAudio = true;
@@ -140,11 +193,12 @@ export class AudioHandler {
140
193
  this.chart.addTimeSeries(timeSeries, info);
141
194
  }
142
195
 
143
- _onBuffer(buffer) {
144
- // No transfer list, deliberately: the chip reuses this buffer, and
145
- // transferring would detach it and trip crbug.com/537801199. The clone
146
- // costs little (512 floats per 1.024ms of chip output, ~2MB/s).
147
- if (this._jsAudioNode) this._jsAudioNode.port.postMessage({ time: Date.now(), buffer });
196
+ // Ships the chip's state changes since the last call, and how far the
197
+ // emulator has got, so the worklet knows its lead even when nothing changed.
198
+ flushChipEvents() {
199
+ if (!this._jsAudioNode) return;
200
+ this._jsAudioNode.port.postMessage({ upTo: this.soundChip.scheduler.epoch, events: this._chipEvents });
201
+ this._chipEvents = [];
148
202
  }
149
203
 
150
204
  // Recent browsers, particularly Safari and Chrome, require a user interaction in order to enable sound playback.
@@ -177,13 +231,16 @@ export class AudioHandler {
177
231
  await this.relayNoise.initialise();
178
232
  }
179
233
 
234
+ // The emulator is stopping, so no tick will ship the change; send it now.
180
235
  mute() {
181
236
  this.soundChip.mute();
237
+ this.flushChipEvents();
182
238
  if (this.masterGain) this.masterGain.gain.value = 0;
183
239
  }
184
240
 
185
241
  unmute() {
186
242
  this.soundChip.unmute();
243
+ this.flushChipEvents();
187
244
  if (this.masterGain) this.masterGain.gain.value = 1;
188
245
  }
189
246
  }
@@ -1,122 +1,213 @@
1
1
  /* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
2
+ import { SoundChip, AtomSoundChip } from "../soundchip.js";
3
+ import { LowPassBiquad } from "../biquad.js";
2
4
 
3
- const lowPassFilterFreq = sampleRate / 2;
4
- 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;
5
11
 
6
- const InputSampleRate = 4000000.0 / 8;
7
- const MaxQueuedMs = 250;
12
+ const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
13
+ const MaxTargetLatencyMs = 250;
8
14
 
9
- const samplesFor = (ms) => (InputSampleRate * ms) / 1000;
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
15
+ // Smoothing rejects the producer's per-tick bursts; proportional only, as
16
+ // lead already integrates rate error; 0.05% authority covers clock skew
13
17
  // without audibly bending pitch.
14
- const OccupancySmoothingTau = 0.5;
18
+ const LeadSmoothingTau = 0.5;
15
19
  const ProportionalGain = 0.2;
16
- const MaxAdjust = InputSampleRate * 0.0005;
20
+ const MaxAdjustFraction = 0.0005;
17
21
 
18
- class SoundChipProcessor extends AudioWorkletProcessor {
19
- constructor(...args) {
20
- super(...args);
22
+ const isResync = (event) => event.state !== undefined || event.reset !== undefined;
21
23
 
22
- this.inputSampleRate = InputSampleRate;
23
- this._lastSample = 0;
24
- this._lastFilteredOutput = 0;
24
+ // Renders the chip from its timestamped state changes, so a producer that
25
+ // falls behind leaves the chip sounding its current state (a stall) rather
26
+ // than silent; the missed time is skipped once the producer is ahead again.
27
+ class SoundChipProcessor extends AudioWorkletProcessor {
28
+ constructor(options) {
29
+ super(options);
30
+ const {
31
+ isAtom = false,
32
+ cpuSpeed = 1000000,
33
+ targetLatencyMs,
34
+ audioFilterFreq,
35
+ audioFilterQ,
36
+ } = options?.processorOptions ?? {};
37
+ this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
38
+ this.inputSampleRate = this.chip.soundchipFreq;
39
+ this.samplesPerCycle = this.chip.samplesPerCycle;
40
+ this.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
41
+
42
+ this.events = [];
43
+ this.eventsHead = 0;
44
+ this.clock = 0;
45
+ this.upTo = 0;
46
+ this.stalled = true;
47
+ this.stalls = 0;
48
+ this.skippedMs = 0;
49
+ this.minLeadMs = Infinity;
50
+
51
+ this._lastInputSample = 0;
25
52
  this._phase = 0;
26
53
  this._source = new Float32Array(0);
27
- this.queue = [];
28
- this._queueSizeSamples = 0;
29
- this.dropped = 0;
30
- this.underruns = 0;
31
- this.targetLatencyMs = 1000 * (1 / 50); // One frame
32
- this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
33
- this.smoothedOccupancyError = 0;
34
- this.running = false;
35
- this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
54
+ this.smoothedLeadError = 0;
55
+ this.setTargetLatency(targetLatencyMs);
36
56
  this.port.onmessage = (event) => {
37
- // TODO: even better than this, send over register settings/catch up and run the audio work _here_
38
- this.onBuffer(event.data.time, event.data.buffer);
57
+ if (event.data.command === "setTargetLatency") this.setTargetLatency(event.data.targetLatencyMs);
58
+ else this.onProduced(event.data.upTo, event.data.events);
39
59
  };
40
60
  this.nextStats = 0;
41
61
  }
42
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
+
72
+ setTargetLatency(ms) {
73
+ const valid = Number.isFinite(ms) && ms > 0;
74
+ this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
75
+ this.targetLeadCycles = this._cycles(this.targetLatencyMs);
76
+ this.smoothedLeadError = 0;
77
+ }
78
+
79
+ _cycles(ms) {
80
+ return ((ms / 1000) * this.inputSampleRate) / this.samplesPerCycle;
81
+ }
82
+
83
+ _ms(cycles) {
84
+ return (1000 * cycles * this.samplesPerCycle) / this.inputSampleRate;
85
+ }
86
+
87
+ leadMs() {
88
+ return this._ms(this.upTo - this.clock);
89
+ }
90
+
91
+ // A resync (restore or reset) starts a new timeline; anything queued
92
+ // before it belongs to the old one.
93
+ onProduced(upTo, events) {
94
+ let from = 0;
95
+ for (let i = events.length - 1; i >= 0; --i) {
96
+ if (isResync(events[i])) {
97
+ from = i;
98
+ this.events = [];
99
+ this.eventsHead = 0;
100
+ break;
101
+ }
102
+ }
103
+ if (this.eventsHead > 0) {
104
+ this.events = this.events.slice(this.eventsHead);
105
+ this.eventsHead = 0;
106
+ }
107
+ for (let i = from; i < events.length; ++i) this.events.push(events[i]);
108
+ this.upTo = upTo;
109
+ }
110
+
43
111
  stats(sampleRatio) {
44
112
  if (currentTime < this.nextStats) return;
45
113
  this.nextStats = currentTime + 0.25;
46
114
  this.port.postMessage({
47
115
  sampleRate: sampleRate,
48
116
  inputSampleRate: this.inputSampleRate,
49
- dropped: this.dropped,
50
- underruns: this.underruns,
51
- queueSize: this.queue.length,
52
- queueAge: this._queueAge(),
117
+ stalls: this.stalls,
118
+ skippedMs: this.skippedMs,
119
+ leadMs: this.leadMs(),
120
+ leadMinMs: this.minLeadMs,
121
+ queuedEvents: this.events.length - this.eventsHead,
53
122
  sampleRatio: sampleRatio,
54
123
  });
124
+ this.minLeadMs = Infinity;
55
125
  }
56
126
 
57
- _queueAge() {
58
- if (this.queue.length === 0) return 0;
59
- const timeInBufferMs = 1000 * (this.queue[0].offset / this.inputSampleRate) + this.queue[0].time;
60
- return Date.now() - timeInBufferMs;
127
+ _notify(event, count) {
128
+ this.port.postMessage({ event, count });
61
129
  }
62
130
 
63
- _occupancySamples() {
64
- return this._queueSizeSamples - (this.queue.length ? this.queue[0].offset : 0);
131
+ _effectiveSampleRate(dtSeconds) {
132
+ const error = this.upTo - this.clock - this.targetLeadCycles;
133
+ const alpha = Math.min(1, dtSeconds / LeadSmoothingTau);
134
+ this.smoothedLeadError += alpha * (error - this.smoothedLeadError);
135
+ const adjustment = ProportionalGain * this.smoothedLeadError * this.samplesPerCycle;
136
+ const maxAdjust = this.inputSampleRate * MaxAdjustFraction;
137
+ return this.inputSampleRate + Math.min(maxAdjust, Math.max(-maxAdjust, adjustment));
65
138
  }
66
139
 
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));
140
+ _applyHead() {
141
+ this.chip.applyEvent(this.events[this.eventsHead++]);
73
142
  }
74
143
 
75
- onBuffer(time, buffer) {
76
- this.queue.push({ offset: 0, time, buffer });
77
- this._queueSizeSamples += buffer.length;
78
- this.cleanQueue();
79
- if (!this.running && this._queueSizeSamples >= this.startQueueSizeSamples) this.running = true;
144
+ // Applies every queued change up to `cycle` at once and moves the clock
145
+ // there, so the sound continues from the producer's state without a gap.
146
+ _skipTo(cycle) {
147
+ while (this.eventsHead < this.events.length && this.events[this.eventsHead].cycle <= cycle) this._applyHead();
148
+ this.skippedMs += this._ms(cycle - this.clock);
149
+ this.clock = cycle;
80
150
  }
81
151
 
82
- _shift() {
83
- const dropped = this.queue.shift();
84
- this._queueSizeSamples -= dropped.buffer.length;
152
+ _restart() {
153
+ const skipTo = this.upTo - this.targetLeadCycles;
154
+ if (skipTo > this.clock) {
155
+ this._notify("skip", this._ms(skipTo - this.clock));
156
+ this._skipTo(skipTo);
157
+ }
158
+ this.stalled = false;
85
159
  }
86
160
 
87
- cleanQueue() {
88
- const maxLatency = this.targetLatencyMs * 2;
89
- while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
90
- this._shift();
91
- this.dropped++;
161
+ _stall(out, offset, length) {
162
+ if (!this.stalled) {
163
+ this.stalled = true;
164
+ this.stalls++;
165
+ this._notify("stall", 1);
92
166
  }
167
+ this.chip.renderAt(this.clock, out, offset, length);
168
+ }
169
+
170
+ // Input samples before the next change of state: the head event, or
171
+ // the producer's position, whichever the clock reaches first. A resync
172
+ // takes effect at once, since it starts a new timeline.
173
+ _samplesUntilNextChange() {
174
+ const head = this.events[this.eventsHead];
175
+ if (head !== undefined && isResync(head)) return 0;
176
+ const boundary = head === undefined ? this.upTo : Math.min(head.cycle, this.upTo);
177
+ return Math.floor((boundary - this.clock) * this.samplesPerCycle);
93
178
  }
94
179
 
95
- nextSample() {
96
- if (this.running && this.queue.length) {
97
- const queueElement = this.queue[0];
98
- this._lastSample = queueElement.buffer[queueElement.offset];
99
- if (++queueElement.offset === queueElement.buffer.length) this._shift();
100
- } else {
101
- this.underruns++;
102
- this.running = false;
180
+ _renderInput(out, offset, length) {
181
+ if (this.stalled) {
182
+ this._stall(out, offset, length);
183
+ return;
184
+ }
185
+ while (length > 0) {
186
+ const n = Math.min(length, this._samplesUntilNextChange());
187
+ if (n > 0) {
188
+ this.chip.renderAt(this.clock, out, offset, n);
189
+ this.clock += n / this.samplesPerCycle;
190
+ offset += n;
191
+ length -= n;
192
+ continue;
193
+ }
194
+ const head = this.events[this.eventsHead];
195
+ if (head === undefined) {
196
+ this._stall(out, offset, length);
197
+ return;
198
+ }
199
+ if (isResync(head)) this.clock = head.cycle;
200
+ this._applyHead();
103
201
  }
104
- return this._lastSample;
105
202
  }
106
203
 
107
204
  process(inputs, outputs) {
108
- this.cleanQueue();
109
- if (this.queue.length === 0) return true;
205
+ if (this.stalled && this.upTo - this.clock >= this.targetLeadCycles) this._restart();
110
206
 
111
- // I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
112
- // but we fiddle the sample rate here to catch up with the target latency, which is harder to do with that API.
113
207
  const channel = outputs[0][0];
114
208
  const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
115
209
  const sampleRatio = effectiveSampleRate / sampleRate;
116
210
 
117
- const dt = 1 / effectiveSampleRate;
118
- const filterAlpha = dt / (RC + dt);
119
-
120
211
  // The fractional read position carries across quanta, so consumption
121
212
  // averages exactly sampleRatio and the pitch never steps at a rounding
122
213
  // boundary. source[0] is the last input sample of the previous quantum.
@@ -124,13 +215,10 @@ class SoundChipProcessor extends AudioWorkletProcessor {
124
215
  const numInputSamples = Math.floor(end);
125
216
  if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
126
217
  const source = this._source;
127
- source[0] = this._lastFilteredOutput;
128
- let prevSample = this._lastFilteredOutput;
129
- for (let i = 1; i <= numInputSamples; ++i) {
130
- prevSample += filterAlpha * (this.nextSample() - prevSample);
131
- source[i] = prevSample;
132
- }
133
- 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];
134
222
  for (let i = 0; i < channel.length; i++) {
135
223
  const pos = this._phase + i * sampleRatio;
136
224
  const loc = Math.floor(pos);
@@ -138,6 +226,7 @@ class SoundChipProcessor extends AudioWorkletProcessor {
138
226
  channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
139
227
  }
140
228
  this._phase = end - numInputSamples;
229
+ this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
141
230
  this.stats(sampleRatio);
142
231
  return true;
143
232
  }