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 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 filter off.
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.20.2",
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 prototype
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 LowPassBiquad {
4
- constructor(sampleRate, frequency, q) {
5
- const w0 = (2 * Math.PI * frequency) / sampleRate;
6
- const alpha = Math.sin(w0) / (2 * q);
7
- const cosW0 = Math.cos(w0);
8
- const a0 = 1 + alpha;
9
- this.b0 = (1 - cosW0) / (2 * a0);
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 + b0 * x2 - a1 * y1 - a2 * y2;
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
- const keyLayout = e.target.dataset.target;
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
- const channelString = e.target.dataset.channel;
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
- const mode = e.target.dataset.mode;
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.displayMode) {
252
- // swapCanvas settles displayModeFilter on whatever was really
253
- // built, so take the picture from that rather than the request.
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
- let displayMode = parsedQuery.displayMode || "rgb";
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(parsedQuery.displayMode || "rgb");
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({ sine: 0 });
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({ sine: freq });
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(event) {
116
- if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, ...event });
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
- if (event.poke !== undefined) this.poke(event.poke);
122
- else if (event.sine !== undefined) {
123
- if (event.sine) this.toneGenerator.tone(event.sine);
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({ state: this.snapshotState() });
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({ progress: true });
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({ poke: value });
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({ state: this.snapshotState() });
401
+ this._emit("state", this.snapshotState());
398
402
  }
399
403
 
400
404
  reset(hard) {
401
405
  if (!hard) return;
402
- this._emit({ reset: true });
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({ speakerReset: true });
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
  }
@@ -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) => this._audioUnavailable(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({ upTo: this.soundChip.scheduler.epoch, events: this._chipEvents });
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 the change; send it now.
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
- // The board's output filter is an equal-component Sallen-Key (Service Manual
7
- // section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
8
- // Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
9
- // rate, ahead of the resampler.
10
- const OutputFilterHz = 7234;
11
- const OutputFilterQ = 0.696;
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 isResync = (event) => event.state !== undefined || event.reset !== undefined;
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.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
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.port.onmessage = (event) => {
65
- if (event.data.command === "setTargetLatency") this.setTargetLatency(event.data.targetLatencyMs);
66
- else this.onProduced(event.data.upTo, event.data.events);
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
- // Zero turns it off; a setting the biquad cannot realise (non-finite, at or
72
- // above Nyquist, non-positive Q) falls back to the board's own values.
73
- _makeOutputFilter(frequency = OutputFilterHz, q = OutputFilterQ) {
74
- if (frequency <= 0) return null;
75
- const usable = frequency < this.inputSampleRate / 2 && q > 0;
76
- if (!usable) return new LowPassBiquad(this.inputSampleRate, OutputFilterHz, OutputFilterQ);
77
- return new LowPassBiquad(this.inputSampleRate, frequency, q);
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
- this.outputFilter?.process(resampler.buffer, resampler.inputOffset, numInputSamples);
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
+ }