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.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/acia.js +20 -2
- package/src/biquad.js +36 -0
- package/src/canvas.js +2 -1
- package/src/google-drive.js +11 -1
- package/src/main.js +191 -65
- package/src/soundchip.js +67 -6
- package/src/tapes.js +1 -2
- package/src/video-filters/pal-composite.js +3 -3
- package/src/video-filters/shaders/pal-composite.frag.glsl +48 -48
- package/src/video.js +35 -7
- package/src/web/audio-handler.js +112 -55
- package/src/web/audio-renderer.js +168 -79
package/src/web/audio-handler.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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({
|
|
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
|
|
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(
|
|
36
|
-
: new SoundChip(
|
|
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
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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("
|
|
102
|
-
this._addStat("
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
const
|
|
12
|
+
const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
|
|
13
|
+
const MaxTargetLatencyMs = 250;
|
|
8
14
|
|
|
9
|
-
|
|
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
|
|
18
|
+
const LeadSmoothingTau = 0.5;
|
|
15
19
|
const ProportionalGain = 0.2;
|
|
16
|
-
const
|
|
20
|
+
const MaxAdjustFraction = 0.0005;
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
constructor(...args) {
|
|
20
|
-
super(...args);
|
|
22
|
+
const isResync = (event) => event.state !== undefined || event.reset !== undefined;
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
28
|
-
this.
|
|
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
|
-
|
|
38
|
-
this.
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
const
|
|
84
|
-
this.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
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
|
-
|
|
96
|
-
if (this.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.
|
|
102
|
-
|
|
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.
|
|
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.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
}
|