jsbeeb 1.20.2 → 1.21.0
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 +10 -2
- package/package.json +1 -1
- package/src/audio-output.js +124 -0
- package/src/biquad.js +86 -13
- package/src/config.js +28 -3
- package/src/main.js +48 -10
- package/src/soundchip.js +27 -25
- package/src/web/audio-handler.js +30 -5
- package/src/web/audio-renderer.js +37 -27
- package/src/web/quick-settings.js +51 -0
package/README.md
CHANGED
|
@@ -289,11 +289,19 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
|
|
|
289
289
|
- (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
|
|
290
290
|
- `audioDebug` - show the audio lead chart, and log one console line per second in which the emulator tick ran late or the sound stalled or skipped.
|
|
291
291
|
- `audioLatencyMs` - how far the sound runs behind the emulator, in milliseconds (default 20). Raising it lets the sound ride out longer stalls of the emulator, at the cost of lagging the picture by that much.
|
|
292
|
+
- `audioOutput` - what the sound chip is heard through: `speaker` (the default: the board's output stage and the
|
|
293
|
+
internal speaker and case, fitted to recordings of a Master 128), `board` (the board's output stage alone, as at
|
|
294
|
+
its line-level socket) or `off` (the chip resampled and nothing else). The top bar and the configuration dialog
|
|
295
|
+
have the same choice, and remember it. See [docs/audio-path.md](docs/audio-path.md) for the path, the Master's circuit and what a real
|
|
296
|
+
machine measures.
|
|
297
|
+
- `speakerAmount` - how much of the speaker's character to apply, from 0 (the same as `board`) to 1 (as measured,
|
|
298
|
+
the default). The slider next to the sound output, on the top bar and in the configuration dialog, sets it live
|
|
299
|
+
and remembers it.
|
|
292
300
|
- `audiofilterfreq` / `audiofilterq` - the corner frequency in Hz and the Q of the lowpass modelling the board's output
|
|
293
301
|
filter, applied to the sound chip before it is resampled. The defaults, 7234 and 0.696, are the Beeb's own.
|
|
294
|
-
`audiofilterfreq=0` turns the
|
|
302
|
+
`audiofilterfreq=0` turns the whole output path off.
|
|
295
303
|
- `displayMode=X` - picks the display: `rgb` (the default, a plain monitor), `pal` (a television fed by the Beeb's UHF modulator)
|
|
296
|
-
or `xbr` (an upscaler, see [docs/xbr-display-mode.md](docs/xbr-display-mode.md)).
|
|
304
|
+
or `xbr` (an upscaler, see [docs/xbr-display-mode.md](docs/xbr-display-mode.md)). The top bar has the same choice, and remembers it.
|
|
297
305
|
|
|
298
306
|
### Atom-specific parameters
|
|
299
307
|
|
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.
|
|
10
|
+
"version": "1.21.0",
|
|
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"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The stages between the sound chip and the device, shared by the worklet and
|
|
2
|
+
// by tools that replay the same path headlessly. Values and their sources are
|
|
3
|
+
// in docs/audio-path.md.
|
|
4
|
+
|
|
5
|
+
import { Biquad } from "./biquad.js";
|
|
6
|
+
|
|
7
|
+
// The board's output filter is an equal-component Sallen-Key (Service Manual
|
|
8
|
+
// section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
|
|
9
|
+
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
|
|
10
|
+
// rate, ahead of the resampler.
|
|
11
|
+
export const OutputFilterHz = 7234;
|
|
12
|
+
export const OutputFilterQ = 0.696;
|
|
13
|
+
|
|
14
|
+
// The coupling capacitors after the filter, each a first-order high-pass:
|
|
15
|
+
// C10 100nF into the LM386's 50K input, C79 330nF into 4K7 + 1K (Master only),
|
|
16
|
+
// and C18 47uF into the 8 ohm speaker.
|
|
17
|
+
export const BoardHighPassHz = [32, 85, 423];
|
|
18
|
+
|
|
19
|
+
// The internal speaker and case, fitted to microphone captures of a Master 128
|
|
20
|
+
// (the driver's resonance, a presence bump, and the roll-off above 6 kHz).
|
|
21
|
+
export const SpeakerStages = [
|
|
22
|
+
{ type: "highPass", frequency: 550, q: 1.56 },
|
|
23
|
+
{ type: "peaking", frequency: 460, q: 3.5, gainDb: 16.5 },
|
|
24
|
+
{ type: "peaking", frequency: 2750, q: 0.5, gainDb: 13.9 },
|
|
25
|
+
{ type: "highShelf", frequency: 5600, q: 1.3, gainDb: -16.7 },
|
|
26
|
+
{ type: "lowPass", frequency: 11700, q: 1.7 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The resampler's sinc is cut off below the output Nyquist so that its
|
|
30
|
+
// transition band has finished before anything folds; sampled sound rides on
|
|
31
|
+
// a 31 kHz or higher carrier that would otherwise land in the audible band.
|
|
32
|
+
export const ResamplerCutoffOfOutputRate = 0.4;
|
|
33
|
+
export const ResamplerTaps = 201;
|
|
34
|
+
|
|
35
|
+
export const AudioOutputs = Object.freeze({
|
|
36
|
+
speaker: "speaker",
|
|
37
|
+
board: "board",
|
|
38
|
+
off: "off",
|
|
39
|
+
});
|
|
40
|
+
export const DefaultAudioOutput = AudioOutputs.speaker;
|
|
41
|
+
|
|
42
|
+
export function isAudioOutput(value) {
|
|
43
|
+
return Object.values(AudioOutputs).includes(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stageFactories = {
|
|
47
|
+
lowPass: (rate, s) => Biquad.lowPass(rate, s.frequency, s.q),
|
|
48
|
+
highPass: (rate, s) => Biquad.highPass(rate, s.frequency, s.q),
|
|
49
|
+
peaking: (rate, s) => Biquad.peaking(rate, s.frequency, s.q, s.gainDb),
|
|
50
|
+
highShelf: (rate, s) => Biquad.highShelf(rate, s.frequency, s.q, s.gainDb),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const GainProbeHz = [...Array(400).keys()].map((i) => 20 * 2 ** (i / 40));
|
|
54
|
+
|
|
55
|
+
function chainMagnitude(stages, sampleRate, hz) {
|
|
56
|
+
return stages.reduce((gain, stage) => gain * stage.magnitudeAt(sampleRate, hz), 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FlatQ = Math.SQRT1_2;
|
|
60
|
+
const FlatHighPassHz = 20;
|
|
61
|
+
const FlatLowPassHz = 20000;
|
|
62
|
+
const lerp = (from, to, t) => from + (to - from) * t;
|
|
63
|
+
const lerpLog = (from, to, t) => from * (to / from) ** t;
|
|
64
|
+
|
|
65
|
+
// The fit scaled toward flat: gains shrink, the resonant Q falls to
|
|
66
|
+
// Butterworth, and the band edges move out of the way.
|
|
67
|
+
function scaledStage(stage, amount) {
|
|
68
|
+
switch (stage.type) {
|
|
69
|
+
case "peaking":
|
|
70
|
+
case "highShelf":
|
|
71
|
+
return { ...stage, gainDb: stage.gainDb * amount };
|
|
72
|
+
case "highPass":
|
|
73
|
+
return {
|
|
74
|
+
...stage,
|
|
75
|
+
frequency: lerpLog(FlatHighPassHz, stage.frequency, amount),
|
|
76
|
+
q: lerp(FlatQ, stage.q, amount),
|
|
77
|
+
};
|
|
78
|
+
case "lowPass":
|
|
79
|
+
return {
|
|
80
|
+
...stage,
|
|
81
|
+
frequency: lerpLog(FlatLowPassHz, stage.frequency, amount),
|
|
82
|
+
q: lerp(FlatQ, stage.q, amount),
|
|
83
|
+
};
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`Unknown stage type ${stage.type}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The speaker's presence bump would push loud chords past full scale, so the
|
|
90
|
+
// chain is scaled to peak at unity across the band.
|
|
91
|
+
function speakerStages(sampleRate, amount) {
|
|
92
|
+
const stages = SpeakerStages.map((stage) => stageFactories[stage.type](sampleRate, scaledStage(stage, amount)));
|
|
93
|
+
const peak = Math.max(...GainProbeHz.map((hz) => chainMagnitude(stages, sampleRate, hz)));
|
|
94
|
+
return [...stages, Biquad.gain(1 / peak)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Zero turns the whole path off; a setting the biquad cannot realise
|
|
98
|
+
// (non-finite, at or above Nyquist, non-positive Q) falls back to the board's
|
|
99
|
+
// own values.
|
|
100
|
+
function boardFilter(sampleRate, frequency = OutputFilterHz, q = OutputFilterQ) {
|
|
101
|
+
const usable = frequency < sampleRate / 2 && q > 0;
|
|
102
|
+
if (!usable) return Biquad.lowPass(sampleRate, OutputFilterHz, OutputFilterQ);
|
|
103
|
+
return Biquad.lowPass(sampleRate, frequency, q);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The filters to run on the chip's output, in order, for the chosen output.
|
|
108
|
+
*
|
|
109
|
+
* @param {number} sampleRate the chip's rate
|
|
110
|
+
* @param {string} output one of AudioOutputs
|
|
111
|
+
* @param {{filterHz?: number, filterQ?: number, speakerAmount?: number}} options overrides for the
|
|
112
|
+
* board's low-pass, and how much of the speaker fit to apply (1 is the fit, 0 is flat)
|
|
113
|
+
* @returns {Biquad[]}
|
|
114
|
+
*/
|
|
115
|
+
export function outputStages(sampleRate, output, { filterHz, filterQ, speakerAmount = 1 } = {}) {
|
|
116
|
+
if (output === AudioOutputs.off || filterHz <= 0) return [];
|
|
117
|
+
const board = [
|
|
118
|
+
boardFilter(sampleRate, filterHz, filterQ),
|
|
119
|
+
...BoardHighPassHz.map((hz) => Biquad.firstOrderHighPass(sampleRate, hz)),
|
|
120
|
+
];
|
|
121
|
+
if (output === AudioOutputs.board) return board;
|
|
122
|
+
const amount = Number.isFinite(speakerAmount) ? Math.min(Math.max(speakerAmount, 0), 1) : 1;
|
|
123
|
+
return [...board, ...speakerStages(sampleRate, amount)];
|
|
124
|
+
}
|
package/src/biquad.js
CHANGED
|
@@ -1,27 +1,100 @@
|
|
|
1
|
-
// Coefficients from the RBJ Audio EQ Cookbook: the second-order
|
|
1
|
+
// Coefficients from the RBJ Audio EQ Cookbook: the second-order prototypes
|
|
2
2
|
// under a bilinear transform prewarped so the corner lands on `frequency`.
|
|
3
|
-
export class
|
|
4
|
-
constructor(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
this.
|
|
10
|
-
this.b1 = (1 - cosW0) / a0;
|
|
11
|
-
this.a1 = (-2 * cosW0) / a0;
|
|
12
|
-
this.a2 = (1 - alpha) / a0;
|
|
3
|
+
export class Biquad {
|
|
4
|
+
constructor(b0, b1, b2, a1, a2) {
|
|
5
|
+
this.b0 = b0;
|
|
6
|
+
this.b1 = b1;
|
|
7
|
+
this.b2 = b2;
|
|
8
|
+
this.a1 = a1;
|
|
9
|
+
this.a2 = a2;
|
|
13
10
|
this.x1 = 0;
|
|
14
11
|
this.x2 = 0;
|
|
15
12
|
this.y1 = 0;
|
|
16
13
|
this.y2 = 0;
|
|
17
14
|
}
|
|
18
15
|
|
|
16
|
+
static _fromUnnormalised(b0, b1, b2, a0, a1, a2) {
|
|
17
|
+
return new Biquad(b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static _prototype(sampleRate, frequency, q) {
|
|
21
|
+
const w0 = (2 * Math.PI * frequency) / sampleRate;
|
|
22
|
+
return { alpha: Math.sin(w0) / (2 * q), cosW0: Math.cos(w0) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static lowPass(sampleRate, frequency, q) {
|
|
26
|
+
const { alpha, cosW0 } = Biquad._prototype(sampleRate, frequency, q);
|
|
27
|
+
return Biquad._fromUnnormalised((1 - cosW0) / 2, 1 - cosW0, (1 - cosW0) / 2, 1 + alpha, -2 * cosW0, 1 - alpha);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static highPass(sampleRate, frequency, q) {
|
|
31
|
+
const { alpha, cosW0 } = Biquad._prototype(sampleRate, frequency, q);
|
|
32
|
+
return Biquad._fromUnnormalised(
|
|
33
|
+
(1 + cosW0) / 2,
|
|
34
|
+
-(1 + cosW0),
|
|
35
|
+
(1 + cosW0) / 2,
|
|
36
|
+
1 + alpha,
|
|
37
|
+
-2 * cosW0,
|
|
38
|
+
1 - alpha,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A single RC high-pass: 6 dB per octave below the corner. */
|
|
43
|
+
static firstOrderHighPass(sampleRate, frequency) {
|
|
44
|
+
const k = Math.tan((Math.PI * frequency) / sampleRate);
|
|
45
|
+
return Biquad._fromUnnormalised(1, -1, 0, 1 + k, k - 1, 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static peaking(sampleRate, frequency, q, gainDb) {
|
|
49
|
+
const a = 10 ** (gainDb / 40);
|
|
50
|
+
const { alpha, cosW0 } = Biquad._prototype(sampleRate, frequency, q);
|
|
51
|
+
return Biquad._fromUnnormalised(
|
|
52
|
+
1 + alpha * a,
|
|
53
|
+
-2 * cosW0,
|
|
54
|
+
1 - alpha * a,
|
|
55
|
+
1 + alpha / a,
|
|
56
|
+
-2 * cosW0,
|
|
57
|
+
1 - alpha / a,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static highShelf(sampleRate, frequency, q, gainDb) {
|
|
62
|
+
const a = 10 ** (gainDb / 40);
|
|
63
|
+
const { alpha, cosW0 } = Biquad._prototype(sampleRate, frequency, q);
|
|
64
|
+
const root = 2 * Math.sqrt(a) * alpha;
|
|
65
|
+
return Biquad._fromUnnormalised(
|
|
66
|
+
a * (a + 1 + (a - 1) * cosW0 + root),
|
|
67
|
+
-2 * a * (a - 1 + (a + 1) * cosW0),
|
|
68
|
+
a * (a + 1 + (a - 1) * cosW0 - root),
|
|
69
|
+
a + 1 - (a - 1) * cosW0 + root,
|
|
70
|
+
2 * (a - 1 - (a + 1) * cosW0),
|
|
71
|
+
a + 1 - (a - 1) * cosW0 - root,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static gain(factor) {
|
|
76
|
+
return new Biquad(factor, 0, 0, 0, 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Magnitude response at `frequency`, as a linear factor. */
|
|
80
|
+
magnitudeAt(sampleRate, frequency) {
|
|
81
|
+
const w = (2 * Math.PI * frequency) / sampleRate;
|
|
82
|
+
const { b0, b1, b2, a1, a2 } = this;
|
|
83
|
+
const cos1 = Math.cos(w);
|
|
84
|
+
const sin1 = Math.sin(w);
|
|
85
|
+
const cos2 = Math.cos(2 * w);
|
|
86
|
+
const sin2 = Math.sin(2 * w);
|
|
87
|
+
const numerator = Math.hypot(b0 + b1 * cos1 + b2 * cos2, b1 * sin1 + b2 * sin2);
|
|
88
|
+
const denominator = Math.hypot(1 + a1 * cos1 + a2 * cos2, a1 * sin1 + a2 * sin2);
|
|
89
|
+
return numerator / denominator;
|
|
90
|
+
}
|
|
91
|
+
|
|
19
92
|
process(buffer, offset, length) {
|
|
20
|
-
const { b0, b1, a1, a2 } = this;
|
|
93
|
+
const { b0, b1, b2, a1, a2 } = this;
|
|
21
94
|
let { x1, x2, y1, y2 } = this;
|
|
22
95
|
for (let i = offset; i < offset + length; ++i) {
|
|
23
96
|
const x = buffer[i];
|
|
24
|
-
const y = b0 * x + b1 * x1 +
|
|
97
|
+
const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
|
25
98
|
x2 = x1;
|
|
26
99
|
x1 = x;
|
|
27
100
|
y2 = y1;
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
import { allModels, findModel, tubeModelFor } from "./models.js";
|
|
3
3
|
import { getFilterForMode } from "./canvas.js";
|
|
4
|
+
import { AudioOutputs } from "./audio-output.js";
|
|
4
5
|
|
|
5
6
|
const round = (value) => Number(value.toFixed(2));
|
|
6
7
|
|
|
@@ -128,7 +129,8 @@ export class Config extends EventTarget {
|
|
|
128
129
|
|
|
129
130
|
for (const link of document.querySelectorAll(".keyboard-menu a")) {
|
|
130
131
|
link.addEventListener("click", (e) => {
|
|
131
|
-
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
const keyLayout = e.currentTarget.dataset.target;
|
|
132
134
|
this.changed.keyLayout = keyLayout;
|
|
133
135
|
this.setKeyLayout(keyLayout);
|
|
134
136
|
});
|
|
@@ -136,16 +138,31 @@ export class Config extends EventTarget {
|
|
|
136
138
|
|
|
137
139
|
for (const option of document.querySelectorAll(".mic-channel-option")) {
|
|
138
140
|
option.addEventListener("click", (e) => {
|
|
139
|
-
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const channelString = e.currentTarget.dataset.channel;
|
|
140
143
|
const channel = channelString === "" ? undefined : parseInt(channelString, 10);
|
|
141
144
|
this.changed.microphoneChannel = channel;
|
|
142
145
|
this.setMicrophoneChannel(channel);
|
|
143
146
|
});
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
for (const option of document.querySelectorAll(".audio-output-option")) {
|
|
150
|
+
option.addEventListener("click", (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
const audioOutput = e.currentTarget.dataset.output;
|
|
153
|
+
this.changed.audioOutput = audioOutput;
|
|
154
|
+
this.onChange({ audioOutput });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
document.getElementById("speakerAmountSetting").addEventListener("input", (e) => {
|
|
159
|
+
this.onChange({ speakerAmount: parseFloat(e.currentTarget.value) });
|
|
160
|
+
});
|
|
161
|
+
|
|
146
162
|
for (const option of document.querySelectorAll(".display-mode-option")) {
|
|
147
163
|
option.addEventListener("click", (e) => {
|
|
148
|
-
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
const mode = e.currentTarget.dataset.mode;
|
|
149
166
|
this.changed.displayMode = mode;
|
|
150
167
|
this.setDisplayMode(mode);
|
|
151
168
|
this.onChange({ displayMode: mode });
|
|
@@ -178,6 +195,14 @@ export class Config extends EventTarget {
|
|
|
178
195
|
}
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
setAudioOutput(audioOutput) {
|
|
199
|
+
const option = document.querySelector(`.audio-output-option[data-output="${audioOutput}"]`);
|
|
200
|
+
for (const el of document.querySelectorAll(".audio-output-text")) el.textContent = option.textContent;
|
|
201
|
+
document.getElementById("speakerAmountSetting").disabled = audioOutput !== AudioOutputs.speaker;
|
|
202
|
+
}
|
|
203
|
+
setSpeakerAmount(speakerAmount) {
|
|
204
|
+
document.getElementById("speakerAmountSetting").value = speakerAmount;
|
|
205
|
+
}
|
|
181
206
|
setMicrophoneChannel(channel) {
|
|
182
207
|
const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
|
|
183
208
|
for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
|
package/src/main.js
CHANGED
|
@@ -23,6 +23,8 @@ import { Config } from "./config.js";
|
|
|
23
23
|
import { DefaultModel, findModel, tubeModelFor } from "./models.js";
|
|
24
24
|
import { initialise as electron } from "./app/electron.js";
|
|
25
25
|
import { AudioHandler } from "./web/audio-handler.js";
|
|
26
|
+
import { DefaultAudioOutput, isAudioOutput } from "./audio-output.js";
|
|
27
|
+
import { QuickSettings } from "./web/quick-settings.js";
|
|
26
28
|
import { Econet } from "./econet.js";
|
|
27
29
|
import { DiscLayout, toSsdOrDsd } from "./disc.js";
|
|
28
30
|
import { toHfe } from "./disc-hfe.js";
|
|
@@ -143,6 +145,7 @@ const paramTypes = {
|
|
|
143
145
|
mouseJoystickEnabled: ParamTypes.BOOL,
|
|
144
146
|
speechOutput: ParamTypes.BOOL,
|
|
145
147
|
audioDebug: ParamTypes.BOOL,
|
|
148
|
+
audioOutput: ParamTypes.STRING,
|
|
146
149
|
|
|
147
150
|
// Numeric parameters
|
|
148
151
|
speed: ParamTypes.INT,
|
|
@@ -150,6 +153,7 @@ const paramTypes = {
|
|
|
150
153
|
frameSkip: ParamTypes.INT,
|
|
151
154
|
audiofilterfreq: ParamTypes.FLOAT,
|
|
152
155
|
audiofilterq: ParamTypes.FLOAT,
|
|
156
|
+
speakerAmount: ParamTypes.FLOAT,
|
|
153
157
|
audioLatencyMs: ParamTypes.FLOAT,
|
|
154
158
|
cpuMultiplier: ParamTypes.FLOAT,
|
|
155
159
|
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
@@ -248,14 +252,9 @@ setSpeechOutput(!!parsedQuery.speechOutput);
|
|
|
248
252
|
|
|
249
253
|
const config = new Config(
|
|
250
254
|
function onChange(changed) {
|
|
251
|
-
if (changed.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
swapCanvas(getFilterForMode(changed.displayMode));
|
|
255
|
-
setCrtPic(displayModeFilter);
|
|
256
|
-
// Trigger window resize to recalculate layout with new dimensions
|
|
257
|
-
window.dispatchEvent(new Event("resize"));
|
|
258
|
-
}
|
|
255
|
+
if (changed.audioOutput) applyAudioOutput(changed.audioOutput);
|
|
256
|
+
if (changed.speakerAmount !== undefined) applySpeakerAmount(changed.speakerAmount);
|
|
257
|
+
if (changed.displayMode) applyDisplayMode(changed.displayMode);
|
|
259
258
|
},
|
|
260
259
|
function onClose(changed) {
|
|
261
260
|
parsedQuery = Object.assign(parsedQuery, changed);
|
|
@@ -314,8 +313,41 @@ config.setCheckboxes({
|
|
|
314
313
|
mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
|
|
315
314
|
speechOutput: speechOutput.enabled,
|
|
316
315
|
});
|
|
317
|
-
|
|
316
|
+
const displayMode = parsedQuery.displayMode || window.localStorage.displayMode || "rgb";
|
|
318
317
|
config.setDisplayMode(displayMode);
|
|
318
|
+
const audioOutput =
|
|
319
|
+
[parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
|
|
320
|
+
const speakerAmount =
|
|
321
|
+
[parsedQuery.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
|
|
322
|
+
|
|
323
|
+
config.setAudioOutput(audioOutput);
|
|
324
|
+
config.setSpeakerAmount(speakerAmount);
|
|
325
|
+
|
|
326
|
+
function applyAudioOutput(output) {
|
|
327
|
+
audioHandler.setAudioOutput(output);
|
|
328
|
+
config.setAudioOutput(output);
|
|
329
|
+
quickSettings?.showAudioOutput(output);
|
|
330
|
+
window.localStorage.audioOutput = output;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function applySpeakerAmount(amount) {
|
|
334
|
+
audioHandler.setSpeakerAmount(amount);
|
|
335
|
+
config.setSpeakerAmount(amount);
|
|
336
|
+
quickSettings?.showSpeakerAmount(amount);
|
|
337
|
+
window.localStorage.speakerAmount = amount;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function applyDisplayMode(mode) {
|
|
341
|
+
// swapCanvas settles displayModeFilter on whatever was really
|
|
342
|
+
// built, so take the picture from that rather than the request.
|
|
343
|
+
swapCanvas(getFilterForMode(mode));
|
|
344
|
+
setCrtPic(displayModeFilter);
|
|
345
|
+
// Trigger window resize to recalculate layout with new dimensions
|
|
346
|
+
window.dispatchEvent(new Event("resize"));
|
|
347
|
+
config.setDisplayMode(mode);
|
|
348
|
+
quickSettings?.showDisplayMode(mode);
|
|
349
|
+
window.localStorage.displayMode = mode;
|
|
350
|
+
}
|
|
319
351
|
|
|
320
352
|
model = config.model;
|
|
321
353
|
|
|
@@ -535,7 +567,7 @@ function createCanvasForFilter(filterClass) {
|
|
|
535
567
|
return newCanvas;
|
|
536
568
|
}
|
|
537
569
|
|
|
538
|
-
let displayModeFilter = canvasLib.getFilterForMode(
|
|
570
|
+
let displayModeFilter = canvasLib.getFilterForMode(displayMode);
|
|
539
571
|
function swapCanvas(newFilterClass) {
|
|
540
572
|
// Everything but the filter is the same whatever the mode: the framebuffer
|
|
541
573
|
// texture, the vertex buffers and fb32 all carry over untouched.
|
|
@@ -616,8 +648,10 @@ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
|
|
|
616
648
|
const audioHandler = new AudioHandler({
|
|
617
649
|
warningNode: document.getElementById("audio-warning"),
|
|
618
650
|
statsNode: audioStatsNode,
|
|
651
|
+
audioOutput,
|
|
619
652
|
audioFilterFreq: parsedQuery.audiofilterfreq,
|
|
620
653
|
audioFilterQ: parsedQuery.audiofilterq,
|
|
654
|
+
speakerAmount,
|
|
621
655
|
audioLatencyMs: parsedQuery.audioLatencyMs,
|
|
622
656
|
noSeek,
|
|
623
657
|
cpuSpeed,
|
|
@@ -628,6 +662,10 @@ const audioHandler = new AudioHandler({
|
|
|
628
662
|
// start playing without user interaction, so we need to delay a
|
|
629
663
|
// little to get a reliable indication.
|
|
630
664
|
window.setTimeout(() => audioHandler.checkStatus(), 1000);
|
|
665
|
+
const quickSettings = new QuickSettings(
|
|
666
|
+
{ onAudioOutput: applyAudioOutput, onSpeakerAmount: applySpeakerAmount, onDisplayMode: applyDisplayMode },
|
|
667
|
+
{ audioOutput, speakerAmount, displayMode },
|
|
668
|
+
);
|
|
631
669
|
|
|
632
670
|
for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
|
|
633
671
|
|
package/src/soundchip.js
CHANGED
|
@@ -101,30 +101,34 @@ export class SoundChip {
|
|
|
101
101
|
mute: () => {
|
|
102
102
|
this.catchUp();
|
|
103
103
|
this.sineOn = false;
|
|
104
|
-
this._emit(
|
|
104
|
+
this._emit("sine", 0);
|
|
105
105
|
},
|
|
106
106
|
tone: (freq) => {
|
|
107
107
|
this.catchUp();
|
|
108
108
|
this.sineOn = true;
|
|
109
109
|
this.sineStep = (freq / sampleRate) * this.sineTable.length;
|
|
110
|
-
this._emit(
|
|
110
|
+
this._emit("sine", freq);
|
|
111
111
|
},
|
|
112
112
|
};
|
|
113
|
+
|
|
114
|
+
this.eventHandlers = {
|
|
115
|
+
progress: () => {},
|
|
116
|
+
poke: (value) => this.poke(value),
|
|
117
|
+
sine: (freq) => (freq ? this.toneGenerator.tone(freq) : this.toneGenerator.mute()),
|
|
118
|
+
state: (state) => this.restoreState(state),
|
|
119
|
+
reset: (hard) => this.reset(hard),
|
|
120
|
+
};
|
|
113
121
|
}
|
|
114
122
|
|
|
115
|
-
_emit(
|
|
116
|
-
if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch,
|
|
123
|
+
_emit(kind, value) {
|
|
124
|
+
if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, kind, value });
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
/** Applies an event from another chip's onEvent, at the cycle this chip is rendering. */
|
|
120
128
|
applyEvent(event) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else this.toneGenerator.mute();
|
|
125
|
-
} else if (event.enabled !== undefined) this.enabled = event.enabled;
|
|
126
|
-
else if (event.state !== undefined) this.restoreState(event.state);
|
|
127
|
-
else if (event.reset !== undefined) this.reset(event.reset);
|
|
129
|
+
const handler = this.eventHandlers[event.kind];
|
|
130
|
+
if (!handler) throw new Error(`Unknown sound event ${event.kind}`);
|
|
131
|
+
handler(event.value, event);
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
/** Renders `length` samples of output from `cycle`, for a chip that is driven by events. */
|
|
@@ -214,7 +218,7 @@ export class SoundChip {
|
|
|
214
218
|
this.volume[2] = volumeTable[v2];
|
|
215
219
|
this.volume[3] = volumeTable[v3];
|
|
216
220
|
this.noisePoked();
|
|
217
|
-
this._emit(
|
|
221
|
+
this._emit("state", this.snapshotState());
|
|
218
222
|
}
|
|
219
223
|
|
|
220
224
|
generate(out, offset, length) {
|
|
@@ -255,7 +259,7 @@ export class SoundChip {
|
|
|
255
259
|
});
|
|
256
260
|
if (this._onEvent) {
|
|
257
261
|
this.progressTask = this.scheduler.newTask(() => {
|
|
258
|
-
this._emit(
|
|
262
|
+
this._emit("progress", true);
|
|
259
263
|
this.progressTask.schedule(EventProgressCycles);
|
|
260
264
|
});
|
|
261
265
|
this.progressTask.schedule(EventProgressCycles);
|
|
@@ -312,7 +316,7 @@ export class SoundChip {
|
|
|
312
316
|
|
|
313
317
|
poke(value) {
|
|
314
318
|
this.catchUp();
|
|
315
|
-
this._emit(
|
|
319
|
+
this._emit("poke", value);
|
|
316
320
|
|
|
317
321
|
let command;
|
|
318
322
|
if (value & 0x80) {
|
|
@@ -394,12 +398,12 @@ export class SoundChip {
|
|
|
394
398
|
this.dcPrevIn = state.dcPrevIn ?? 0;
|
|
395
399
|
this.dcPrevOut = state.dcPrevOut ?? 0;
|
|
396
400
|
this.progressTask?.ensureScheduled(true, EventProgressCycles);
|
|
397
|
-
this._emit(
|
|
401
|
+
this._emit("state", this.snapshotState());
|
|
398
402
|
}
|
|
399
403
|
|
|
400
404
|
reset(hard) {
|
|
401
405
|
if (!hard) return;
|
|
402
|
-
this._emit(
|
|
406
|
+
this._emit("reset", true);
|
|
403
407
|
for (let i = 0; i < 4; ++i) {
|
|
404
408
|
this.counter[i] = 0;
|
|
405
409
|
this.registers[i] = 0;
|
|
@@ -412,7 +416,6 @@ export class SoundChip {
|
|
|
412
416
|
|
|
413
417
|
enable(e) {
|
|
414
418
|
this.enabled = e;
|
|
415
|
-
this._emit({ enabled: e });
|
|
416
419
|
}
|
|
417
420
|
|
|
418
421
|
mute() {
|
|
@@ -457,6 +460,11 @@ export class AtomSoundChip extends SoundChip {
|
|
|
457
460
|
this._speakerPrevIn = 0;
|
|
458
461
|
this._speakerPrevOut = 0;
|
|
459
462
|
this._speakerCycleOffset = 0;
|
|
463
|
+
|
|
464
|
+
Object.assign(this.eventHandlers, {
|
|
465
|
+
bit: (bit, { cycle }) => this.bitChange.push({ bit, cycles: cycle }),
|
|
466
|
+
speakerReset: () => this.speakerReset(),
|
|
467
|
+
});
|
|
460
468
|
}
|
|
461
469
|
|
|
462
470
|
reset(hard) {
|
|
@@ -470,19 +478,13 @@ export class AtomSoundChip extends SoundChip {
|
|
|
470
478
|
this._speakerCycleOffset = 0;
|
|
471
479
|
}
|
|
472
480
|
|
|
473
|
-
applyEvent(event) {
|
|
474
|
-
if (event.bit !== undefined) this.bitChange.push({ bit: event.bit, cycles: event.cycle });
|
|
475
|
-
else if (event.speakerReset !== undefined) this.speakerReset();
|
|
476
|
-
else super.applyEvent(event);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
481
|
renderAt(cycle, out, offset, length) {
|
|
480
482
|
this._speakerCycleOffset = 0;
|
|
481
483
|
super.renderAt(cycle, out, offset, length);
|
|
482
484
|
}
|
|
483
485
|
|
|
484
486
|
speakerReset() {
|
|
485
|
-
this._emit(
|
|
487
|
+
this._emit("speakerReset", true);
|
|
486
488
|
this.bitChange = [];
|
|
487
489
|
this.currentSpeakerBit = 0.0;
|
|
488
490
|
this._speakerPrevIn = 0;
|
|
@@ -521,7 +523,7 @@ export class AtomSoundChip extends SoundChip {
|
|
|
521
523
|
updateSpeaker(value, microCycle, seconds) {
|
|
522
524
|
const cycles = microCycle + seconds / this.secondsPerCycle;
|
|
523
525
|
const bit = value ? 1.0 : 0.0;
|
|
524
|
-
if (this._onEvent) this._onEvent({ cycle: cycles, bit });
|
|
526
|
+
if (this._onEvent) this._onEvent({ cycle: cycles, kind: "bit", value: bit });
|
|
525
527
|
else this.bitChange.push({ bit, cycles });
|
|
526
528
|
}
|
|
527
529
|
}
|
package/src/web/audio-handler.js
CHANGED
|
@@ -21,8 +21,10 @@ export class AudioHandler {
|
|
|
21
21
|
constructor({
|
|
22
22
|
warningNode,
|
|
23
23
|
statsNode,
|
|
24
|
+
audioOutput,
|
|
24
25
|
audioFilterFreq,
|
|
25
26
|
audioFilterQ,
|
|
27
|
+
speakerAmount,
|
|
26
28
|
audioLatencyMs,
|
|
27
29
|
noSeek,
|
|
28
30
|
cpuSpeed,
|
|
@@ -51,7 +53,7 @@ export class AudioHandler {
|
|
|
51
53
|
if (this.audioContext && this.audioContext.audioWorklet) {
|
|
52
54
|
this.audioContext.onstatechange = () => this.checkStatus();
|
|
53
55
|
const onEvent = (event) => {
|
|
54
|
-
if (event.progress) this.flushChipEvents();
|
|
56
|
+
if (event.kind === "progress") this.flushChipEvents();
|
|
55
57
|
else this._chipEvents.push(event);
|
|
56
58
|
};
|
|
57
59
|
this.soundChip = this.isAtom
|
|
@@ -62,7 +64,9 @@ export class AudioHandler {
|
|
|
62
64
|
this.masterGain.connect(this.audioContext.destination);
|
|
63
65
|
this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
|
|
64
66
|
this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
|
|
65
|
-
this._setup({ audioFilterFreq, audioFilterQ }).catch((error) =>
|
|
67
|
+
this._setup({ audioOutput, audioFilterFreq, audioFilterQ, speakerAmount }).catch((error) =>
|
|
68
|
+
this._audioUnavailable(error),
|
|
69
|
+
);
|
|
66
70
|
} else {
|
|
67
71
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
68
72
|
this.audioContext = null;
|
|
@@ -127,18 +131,21 @@ export class AudioHandler {
|
|
|
127
131
|
this.chart.streamTo(statsNode, 100);
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
async _setup({ audioFilterFreq, audioFilterQ }) {
|
|
134
|
+
async _setup({ audioOutput, audioFilterFreq, audioFilterQ, speakerAmount }) {
|
|
131
135
|
await this.audioContext.audioWorklet.addModule(rendererUrl);
|
|
132
136
|
this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
|
|
133
137
|
processorOptions: {
|
|
134
138
|
targetLatencyMs: this._targetLatencyMs(),
|
|
135
139
|
isAtom: this.isAtom,
|
|
136
140
|
cpuSpeed: this.cpuSpeed,
|
|
141
|
+
audioOutput,
|
|
137
142
|
audioFilterFreq,
|
|
138
143
|
audioFilterQ,
|
|
144
|
+
speakerAmount,
|
|
139
145
|
},
|
|
140
146
|
});
|
|
141
147
|
this._jsAudioNode.connect(this.audioContext.destination);
|
|
148
|
+
if (!this.soundChip.enabled) this._setEnabled(false);
|
|
142
149
|
this._jsAudioNode.port.onmessage = (event) => {
|
|
143
150
|
const now = Date.now();
|
|
144
151
|
if (event.data.event) {
|
|
@@ -165,6 +172,18 @@ export class AudioHandler {
|
|
|
165
172
|
return this.windowFocused ? this.audioLatencyMs : UnfocusedLatencyMs;
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
_setEnabled(enabled) {
|
|
176
|
+
this._jsAudioNode?.port.postMessage({ command: "setEnabled", enabled });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setAudioOutput(audioOutput) {
|
|
180
|
+
this._jsAudioNode?.port.postMessage({ command: "setAudioOutput", audioOutput });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setSpeakerAmount(speakerAmount) {
|
|
184
|
+
this._jsAudioNode?.port.postMessage({ command: "setSpeakerAmount", speakerAmount });
|
|
185
|
+
}
|
|
186
|
+
|
|
168
187
|
// Returns how far ahead of the sound the picture should now run, in ms.
|
|
169
188
|
setWindowFocused(focused) {
|
|
170
189
|
this.windowFocused = focused;
|
|
@@ -197,7 +216,11 @@ export class AudioHandler {
|
|
|
197
216
|
// emulator has got, so the worklet knows its lead even when nothing changed.
|
|
198
217
|
flushChipEvents() {
|
|
199
218
|
if (!this._jsAudioNode) return;
|
|
200
|
-
this._jsAudioNode.port.postMessage({
|
|
219
|
+
this._jsAudioNode.port.postMessage({
|
|
220
|
+
command: "produced",
|
|
221
|
+
upTo: this.soundChip.scheduler.epoch,
|
|
222
|
+
events: this._chipEvents,
|
|
223
|
+
});
|
|
201
224
|
this._chipEvents = [];
|
|
202
225
|
}
|
|
203
226
|
|
|
@@ -231,15 +254,17 @@ export class AudioHandler {
|
|
|
231
254
|
await this.relayNoise.initialise();
|
|
232
255
|
}
|
|
233
256
|
|
|
234
|
-
// The emulator is stopping, so no tick will ship
|
|
257
|
+
// The emulator is stopping, so no tick will ship its last writes; send them now.
|
|
235
258
|
mute() {
|
|
236
259
|
this.soundChip.mute();
|
|
260
|
+
this._setEnabled(false);
|
|
237
261
|
this.flushChipEvents();
|
|
238
262
|
if (this.masterGain) this.masterGain.gain.value = 0;
|
|
239
263
|
}
|
|
240
264
|
|
|
241
265
|
unmute() {
|
|
242
266
|
this.soundChip.unmute();
|
|
267
|
+
this._setEnabled(true);
|
|
243
268
|
this.flushChipEvents();
|
|
244
269
|
if (this.masterGain) this.masterGain.gain.value = 1;
|
|
245
270
|
}
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
/* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
|
|
2
2
|
import { SoundChip, AtomSoundChip } from "../soundchip.js";
|
|
3
|
-
import { LowPassBiquad } from "../biquad.js";
|
|
4
3
|
import { PolyphaseResampler } from "../resampler.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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;
|
|
4
|
+
import {
|
|
5
|
+
AudioOutputs,
|
|
6
|
+
DefaultAudioOutput,
|
|
7
|
+
ResamplerCutoffOfOutputRate,
|
|
8
|
+
ResamplerTaps,
|
|
9
|
+
outputStages,
|
|
10
|
+
} from "../audio-output.js";
|
|
18
11
|
|
|
19
12
|
const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
|
|
20
13
|
const MaxTargetLatencyMs = 250;
|
|
@@ -26,7 +19,8 @@ const LeadSmoothingTau = 0.5;
|
|
|
26
19
|
const ProportionalGain = 0.2;
|
|
27
20
|
const MaxAdjustFraction = 0.0005;
|
|
28
21
|
|
|
29
|
-
const
|
|
22
|
+
const ResyncKinds = new Set(["state", "reset"]);
|
|
23
|
+
const isResync = (event) => ResyncKinds.has(event.kind);
|
|
30
24
|
|
|
31
25
|
// Renders the chip from its timestamped state changes, so a producer that
|
|
32
26
|
// falls behind leaves the chip sounding its current state (a stall) rather
|
|
@@ -38,13 +32,16 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
38
32
|
isAtom = false,
|
|
39
33
|
cpuSpeed = 1000000,
|
|
40
34
|
targetLatencyMs,
|
|
35
|
+
audioOutput = DefaultAudioOutput,
|
|
41
36
|
audioFilterFreq,
|
|
42
37
|
audioFilterQ,
|
|
38
|
+
speakerAmount,
|
|
43
39
|
} = options?.processorOptions ?? {};
|
|
44
40
|
this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
|
|
45
41
|
this.inputSampleRate = this.chip.soundchipFreq;
|
|
46
42
|
this.samplesPerCycle = this.chip.samplesPerCycle;
|
|
47
|
-
this.
|
|
43
|
+
this.boardFilter = { filterHz: audioFilterFreq, filterQ: audioFilterQ, speakerAmount };
|
|
44
|
+
this.setAudioOutput(audioOutput);
|
|
48
45
|
this.resampler = new PolyphaseResampler(this.inputSampleRate, ResamplerCutoffOfOutputRate * sampleRate, {
|
|
49
46
|
taps: ResamplerTaps,
|
|
50
47
|
});
|
|
@@ -61,20 +58,32 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
61
58
|
this._phase = 0;
|
|
62
59
|
this.smoothedLeadError = 0;
|
|
63
60
|
this.setTargetLatency(targetLatencyMs);
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
this.commands = {
|
|
62
|
+
produced: (m) => this.onProduced(m.upTo, m.events),
|
|
63
|
+
setEnabled: (m) => (this.chip.enabled = m.enabled),
|
|
64
|
+
setTargetLatency: (m) => this.setTargetLatency(m.targetLatencyMs),
|
|
65
|
+
setAudioOutput: (m) => this.setAudioOutput(m.audioOutput),
|
|
66
|
+
setSpeakerAmount: (m) => this.setSpeakerAmount(m.speakerAmount),
|
|
67
67
|
};
|
|
68
|
+
this.port.onmessage = (event) => this.onMessage(event.data);
|
|
68
69
|
this.nextStats = 0;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
onMessage(message) {
|
|
73
|
+
const command = this.commands[message.command];
|
|
74
|
+
if (!command) throw new Error(`Unknown sound command ${message.command}`);
|
|
75
|
+
command(message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setAudioOutput(audioOutput) {
|
|
79
|
+
this.audioOutput = audioOutput;
|
|
80
|
+
this.outputFilters = outputStages(this.inputSampleRate, audioOutput, this.boardFilter);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setSpeakerAmount(speakerAmount) {
|
|
84
|
+
if (speakerAmount === this.boardFilter.speakerAmount) return;
|
|
85
|
+
this.boardFilter.speakerAmount = speakerAmount;
|
|
86
|
+
if (this.audioOutput === AudioOutputs.speaker) this.setAudioOutput(this.audioOutput);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
setTargetLatency(ms) {
|
|
@@ -224,7 +233,8 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
224
233
|
const resampler = this.resampler;
|
|
225
234
|
resampler.reserve(numInputSamples);
|
|
226
235
|
this._renderInput(resampler.buffer, resampler.inputOffset, numInputSamples);
|
|
227
|
-
|
|
236
|
+
for (const filter of this.outputFilters)
|
|
237
|
+
filter.process(resampler.buffer, resampler.inputOffset, numInputSamples);
|
|
228
238
|
resampler.read(channel, this._phase, sampleRatio);
|
|
229
239
|
resampler.commit();
|
|
230
240
|
this._phase = end - numInputSamples;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AudioOutputs } from "../audio-output.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sound output, speaker amount and display mode controls on the top bar.
|
|
5
|
+
* Choices go to the callbacks; what is shown follows the show methods, so the
|
|
6
|
+
* bar can be kept in step with the same settings elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
export class QuickSettings {
|
|
9
|
+
constructor({ onAudioOutput, onSpeakerAmount, onDisplayMode }, { audioOutput, speakerAmount, displayMode }) {
|
|
10
|
+
this.output = document.getElementById("audio-output");
|
|
11
|
+
this.amount = document.getElementById("speaker-amount");
|
|
12
|
+
this.display = document.getElementById("display-mode");
|
|
13
|
+
if (!this.output || !this.amount || !this.display) return;
|
|
14
|
+
|
|
15
|
+
this.showAudioOutput(audioOutput);
|
|
16
|
+
this.showSpeakerAmount(speakerAmount);
|
|
17
|
+
this.showDisplayMode(displayMode);
|
|
18
|
+
|
|
19
|
+
this.output.addEventListener("click", (e) => {
|
|
20
|
+
const value = e.target.closest("[data-output]")?.dataset.output;
|
|
21
|
+
if (value) onAudioOutput(value);
|
|
22
|
+
});
|
|
23
|
+
this.amount.addEventListener("input", () => onSpeakerAmount(parseFloat(this.amount.value)));
|
|
24
|
+
this.display.addEventListener("click", (e) => {
|
|
25
|
+
const mode = e.target.closest("[data-mode]")?.dataset.mode;
|
|
26
|
+
if (mode) onDisplayMode(mode);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
showAudioOutput(audioOutput) {
|
|
31
|
+
if (!this.output) return;
|
|
32
|
+
select(this.output.querySelectorAll("[data-output]"), (b) => b.dataset.output === audioOutput);
|
|
33
|
+
this.amount.disabled = audioOutput !== AudioOutputs.speaker;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
showSpeakerAmount(speakerAmount) {
|
|
37
|
+
if (this.amount) this.amount.value = speakerAmount;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
showDisplayMode(mode) {
|
|
41
|
+
if (this.display) select(this.display.querySelectorAll("[data-mode]"), (b) => b.dataset.mode === mode);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function select(buttons, isChosen) {
|
|
46
|
+
for (const button of buttons) {
|
|
47
|
+
const chosen = isChosen(button);
|
|
48
|
+
button.classList.toggle("active", chosen);
|
|
49
|
+
button.setAttribute("aria-pressed", chosen);
|
|
50
|
+
}
|
|
51
|
+
}
|