jsbeeb 1.9.1 → 1.10.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/package.json CHANGED
@@ -7,10 +7,10 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.9.1",
10
+ "version": "1.10.0",
11
11
  "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
- "node": "22"
13
+ "node": ">=22.12.0"
14
14
  },
15
15
  "type": "module",
16
16
  "main": "./src/app/app.js",
@@ -51,11 +51,11 @@
51
51
  "npm-run-all2": "^8.0.4",
52
52
  "pixelmatch": "^7.1.0",
53
53
  "prettier": "^3.8.1",
54
- "vite": "^7.3.1",
54
+ "vite": "^8.0.3",
55
55
  "vitest": "^4.0.10"
56
56
  },
57
57
  "optionalDependencies": {
58
- "electron": "^40.4.1",
58
+ "electron": "^41.1.1",
59
59
  "electron-builder": "^26.8.1",
60
60
  "electron-store": "^11.0.2"
61
61
  },
package/src/6502.js CHANGED
@@ -9,6 +9,7 @@ import { Scheduler } from "./scheduler.js";
9
9
  import { TouchScreen } from "./touchscreen.js";
10
10
  import { TeletextAdaptor } from "./teletext_adaptor.js";
11
11
  import { Filestore } from "./filestore.js";
12
+ import { FakeRelayNoise } from "./relaynoise.js";
12
13
 
13
14
  const signExtend = utils.signExtend;
14
15
 
@@ -573,7 +574,18 @@ function is1MHzAccess(addr) {
573
574
  export class Cpu6502 extends Base6502 {
574
575
  constructor(
575
576
  model,
576
- { dbgr, video, soundChip, ddNoise, music5000, cmos, config, econet, cycleAccurate = true } = {},
577
+ {
578
+ dbgr,
579
+ video,
580
+ soundChip,
581
+ ddNoise,
582
+ relayNoise = new FakeRelayNoise(),
583
+ music5000,
584
+ cmos,
585
+ config,
586
+ econet,
587
+ cycleAccurate = true,
588
+ } = {},
577
589
  ) {
578
590
  super(model, { cycleAccurate });
579
591
  this.config = fixUpConfig(config);
@@ -587,6 +599,7 @@ export class Cpu6502 extends Base6502 {
587
599
  this.soundChip = soundChip;
588
600
  this.music5000 = music5000;
589
601
  this.ddNoise = ddNoise;
602
+ this.relayNoise = relayNoise;
590
603
  this.memStatOffsetByIFetchBank = 0;
591
604
  this.memStatOffset = 0;
592
605
  this.memStat = new Uint8Array(512);
@@ -633,7 +646,7 @@ export class Cpu6502 extends Base6502 {
633
646
  getGamepads: this.config.getGamepads,
634
647
  });
635
648
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
636
- this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen);
649
+ this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen, this.relayNoise);
637
650
  this.serial = new Serial(this.acia);
638
651
  this.adconverter = new Adc(this.sysvia, this.scheduler);
639
652
  this.soundChip.setScheduler(this.scheduler);
package/src/acia.js CHANGED
@@ -5,10 +5,11 @@
5
5
  // http://www.classiccmp.org/dunfield/r/6850.pdf
6
6
 
7
7
  export class Acia {
8
- constructor(cpu, toneGen, scheduler, rs423Handler) {
8
+ constructor(cpu, toneGen, scheduler, rs423Handler, relayNoise) {
9
9
  this.cpu = cpu;
10
10
  this.toneGen = toneGen;
11
11
  this.rs423Handler = rs423Handler;
12
+ this.relayNoise = relayNoise;
12
13
 
13
14
  this.sr = 0x00;
14
15
  this.cr = 0x00;
@@ -58,10 +59,12 @@ export class Acia {
58
59
  setMotor(on) {
59
60
  if (on && !this.motorOn) {
60
61
  this.runTape();
62
+ this.relayNoise.motorOn();
61
63
  } else if (!on && this.motorOn) {
62
64
  this.toneGen.mute();
63
65
  this.runTapeTask.cancel();
64
66
  this.setTapeCarrier(false);
67
+ this.relayNoise.motorOff();
65
68
  }
66
69
  this.motorOn = on;
67
70
  }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Create an AudioContext with a fallback for older WebKit browsers.
5
+ * @param {AudioContextOptions} [options] - passed to the AudioContext constructor
6
+ * @returns {AudioContext|null}
7
+ */
8
+ export function createAudioContext(options) {
9
+ /*global webkitAudioContext*/
10
+ if (typeof AudioContext !== "undefined") return new AudioContext(options);
11
+ if (typeof webkitAudioContext !== "undefined") return new webkitAudioContext(options);
12
+ return null;
13
+ }
package/src/ddnoise.js CHANGED
@@ -1,26 +1,20 @@
1
1
  "use strict";
2
- import * as utils from "./utils.js";
3
- import _ from "underscore";
2
+ import { SamplePlayer } from "./sample-player.js";
4
3
 
5
- const IDLE = 0;
6
- const SPIN_UP = 1;
7
- const SPINNING = 2;
8
- const VOLUME = 0.25;
4
+ const Idle = 0;
5
+ const SpinUp = 1;
6
+ const Spinning = 2;
7
+ const Volume = 0.25;
9
8
 
10
- export class DdNoise {
11
- constructor(context) {
12
- this.context = context;
13
- this.sounds = {};
14
- this.state = IDLE;
9
+ export class DdNoise extends SamplePlayer {
10
+ constructor(context, destination) {
11
+ super(context, destination, Volume);
12
+ this.state = Idle;
15
13
  this.motor = null;
16
- this.gain = context.createGain();
17
- this.gain.gain.value = VOLUME;
18
- this.gain.connect(context.destination);
19
- // workaround for older safaris that GC sounds when they're playing...
20
- this.playing = [];
21
14
  }
15
+
22
16
  async initialise() {
23
- const sounds = await loadSounds(this.context, {
17
+ await this.loadSounds({
24
18
  motorOn: "sounds/disc525/motoron.wav",
25
19
  motorOff: "sounds/disc525/motoroff.wav",
26
20
  motor: "sounds/disc525/motor.wav",
@@ -29,63 +23,35 @@ export class DdNoise {
29
23
  seek2: "sounds/disc525/seek2.wav",
30
24
  seek3: "sounds/disc525/seek3.wav",
31
25
  });
32
- this.sounds = sounds;
33
- }
34
- oneShot(sound) {
35
- const duration = sound.duration;
36
- const context = this.context;
37
- if (context.state !== "running") return duration;
38
- const source = context.createBufferSource();
39
- source.buffer = sound;
40
- source.connect(this.gain);
41
- source.start();
42
- return duration;
43
- }
44
- play(sound, loop) {
45
- if (this.context.state !== "running") return Promise.reject();
46
- return new Promise((resolve) => {
47
- const source = this.context.createBufferSource();
48
- source.loop = !!loop;
49
- source.buffer = sound;
50
- source.connect(this.gain);
51
- source.onended = () => {
52
- this.playing = _.without(this.playing, source);
53
- if (!source.loop) resolve();
54
- };
55
- source.start();
56
- this.playing.push(source);
57
- if (source.loop) {
58
- resolve(source);
59
- }
60
- });
61
26
  }
27
+
62
28
  spinUp() {
63
- if (this.state === SPINNING || this.state === SPIN_UP) return;
64
- this.state = SPIN_UP;
29
+ if (this.state === Spinning || this.state === SpinUp) return;
30
+ this.state = SpinUp;
65
31
  this.play(this.sounds.motorOn).then(
66
32
  () => {
67
33
  // Handle race: we may have had spinDown() called on us before the
68
34
  // spinUp() initial sound finished playing.
69
- if (this.state === IDLE) {
70
- return;
71
- }
35
+ if (this.state === Idle) return;
72
36
  this.play(this.sounds.motor, true).then((source) => {
73
37
  this.motor = source;
74
- this.state = SPINNING;
38
+ this.state = Spinning;
75
39
  });
76
40
  },
77
41
  () => {},
78
42
  );
79
43
  }
44
+
80
45
  spinDown() {
81
- if (this.state === IDLE) return;
82
- this.state = IDLE;
46
+ if (this.state === Idle) return;
47
+ this.state = Idle;
83
48
  if (this.motor) {
84
49
  this.motor.stop();
85
50
  this.motor = null;
86
51
  this.oneShot(this.sounds.motorOff);
87
52
  }
88
53
  }
54
+
89
55
  seek(diff) {
90
56
  if (diff < 0) diff = -diff;
91
57
  if (diff === 0) return 0;
@@ -94,37 +60,9 @@ export class DdNoise {
94
60
  else if (diff <= 40) return this.oneShot(this.sounds.seek2);
95
61
  else return this.oneShot(this.sounds.seek3);
96
62
  }
97
- mute() {
98
- this.gain.gain.value = 0;
99
- }
100
- unmute() {
101
- this.gain.gain.value = VOLUME;
102
- }
103
- }
104
-
105
- async function loadSounds(context, sounds) {
106
- const loaded = await Promise.all(
107
- _.map(sounds, async (sound) => {
108
- // Safari doesn't support the Promise stuff directly, so we create
109
- // our own Promise here.
110
- const data = await utils.loadData(sound);
111
- return await new Promise((resolve) => {
112
- context.decodeAudioData(data.buffer, (decodedData) => {
113
- resolve(decodedData);
114
- });
115
- });
116
- }),
117
- );
118
- const keys = _.keys(sounds);
119
- const result = {};
120
- for (let i = 0; i < keys.length; ++i) {
121
- result[keys[i]] = loaded[i];
122
- }
123
- return result;
124
63
  }
125
64
 
126
65
  export class FakeDdNoise {
127
- constructor() {}
128
66
  seek() {
129
67
  return 0;
130
68
  }
package/src/fake6502.js CHANGED
@@ -5,6 +5,7 @@ import { FakeVideo } from "./video.js";
5
5
  import { FakeSoundChip } from "./soundchip.js";
6
6
  import { findModel, TEST_6502, TEST_65C02, TEST_65C12 } from "./models.js";
7
7
  import { FakeDdNoise } from "./ddnoise.js";
8
+ import { FakeRelayNoise } from "./relaynoise.js";
8
9
  import { Cpu6502 } from "./6502.js";
9
10
  import { Cmos } from "./cmos.js";
10
11
  import { FakeMusic5000 } from "./music5000.js";
@@ -24,6 +25,7 @@ export function fake6502(model, opts) {
24
25
  video: opts.video || fakeVideo,
25
26
  soundChip: opts.soundChip || soundChip,
26
27
  ddNoise: new FakeDdNoise(),
28
+ relayNoise: new FakeRelayNoise(),
27
29
  music5000: new FakeMusic5000(),
28
30
  cmos: new Cmos(),
29
31
  cycleAccurate: opts.cycleAccurate,
package/src/keyboard.js CHANGED
@@ -40,6 +40,13 @@ export class Keyboard extends EventEmitter {
40
40
  this.lastShiftLocation = 1;
41
41
  this.lastCtrlLocation = 1;
42
42
  this.lastAltLocation = 1;
43
+
44
+ // Paste state — uses a scheduler task instead of a debugInstruction
45
+ // hook so the CPU can remain on the fast execution path during paste.
46
+ this._pasteKeys = [];
47
+ this._pasteLastChar = undefined;
48
+ this._pasteClocksPerMs = 0;
49
+ this._pasteTask = this.processor.scheduler.newTask(() => this._deliverPasteKey());
43
50
  }
44
51
 
45
52
  /**
@@ -210,6 +217,11 @@ export class Keyboard extends EventEmitter {
210
217
  const code = this.keyCode(evt);
211
218
  evt.preventDefault();
212
219
 
220
+ if (this.isPasting && code === utils.keyCodes.ESCAPE) {
221
+ this.cancelPaste();
222
+ return;
223
+ }
224
+
213
225
  // Special handling cases that we always want to keep within keyboard.js
214
226
  const isSpecialHandled = this._handleSpecialKeys(code);
215
227
  if (isSpecialHandled) return;
@@ -314,10 +326,10 @@ export class Keyboard extends EventEmitter {
314
326
  * @param {boolean} checkCapsAndShiftLocks - Whether to check caps and shift locks
315
327
  */
316
328
  sendRawKeyboardToBBC(keysToSend, checkCapsAndShiftLocks) {
317
- let lastChar;
318
- let nextKeyMillis = 0;
329
+ if (this.isPasting) this.cancelPaste();
330
+
319
331
  this.processor.sysvia.disableKeyboard();
320
- const clocksPerSecond = Math.floor(this.processor.cpuMultiplier * 2000000);
332
+ this._pasteClocksPerMs = Math.floor(this.processor.cpuMultiplier * 2000000) / 1000;
321
333
 
322
334
  if (checkCapsAndShiftLocks) {
323
335
  let toggleKey = null;
@@ -329,45 +341,69 @@ export class Keyboard extends EventEmitter {
329
341
  }
330
342
  }
331
343
 
332
- const sendCharHook = this.processor.debugInstruction.add(() => {
333
- const millis = this.processor.cycleSeconds * 1000 + this.processor.currentCycles / (clocksPerSecond / 1000);
334
- if (millis < nextKeyMillis) {
335
- return;
336
- }
344
+ this._pasteKeys = keysToSend;
345
+ this._pasteLastChar = undefined;
346
+ this._pasteTask.schedule(0);
347
+ }
337
348
 
338
- if (lastChar && lastChar !== utils.BBC.SHIFT) {
339
- this.processor.sysvia.keyToggleRaw(lastChar);
340
- }
349
+ /**
350
+ * Scheduler callback that delivers one key per invocation, rescheduling
351
+ * itself for the next key. Replaces the old debugInstruction hook so the
352
+ * CPU stays on the fast execution path during paste.
353
+ * @private
354
+ */
355
+ _deliverPasteKey() {
356
+ if (this._pasteLastChar && this._pasteLastChar !== utils.BBC.SHIFT) {
357
+ this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
358
+ }
341
359
 
342
- if (keysToSend.length === 0) {
343
- // Finished
344
- this.processor.sysvia.enableKeyboard();
345
- sendCharHook.remove();
346
- return;
347
- }
360
+ if (this._pasteKeys.length === 0) {
361
+ this._pasteLastChar = undefined;
362
+ this.processor.sysvia.enableKeyboard();
363
+ return;
364
+ }
348
365
 
349
- const ch = keysToSend[0];
350
- const debounce = lastChar === ch;
351
- lastChar = ch;
352
- if (debounce) {
353
- lastChar = undefined;
354
- nextKeyMillis = millis + 30;
355
- return;
356
- }
366
+ const ch = this._pasteKeys[0];
367
+ const debounce = this._pasteLastChar === ch;
368
+ this._pasteLastChar = ch;
369
+ if (debounce) {
370
+ this._pasteLastChar = undefined;
371
+ this._pasteTask.schedule(30 * this._pasteClocksPerMs);
372
+ return;
373
+ }
357
374
 
358
- let time = 50;
359
- if (typeof lastChar === "number") {
360
- time = lastChar;
361
- lastChar = undefined;
362
- } else {
363
- this.processor.sysvia.keyToggleRaw(lastChar);
364
- }
375
+ let delayMs = 50;
376
+ if (typeof this._pasteLastChar === "number") {
377
+ delayMs = this._pasteLastChar;
378
+ this._pasteLastChar = undefined;
379
+ } else {
380
+ this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
381
+ }
365
382
 
366
- // remove first character
367
- keysToSend.shift();
383
+ this._pasteKeys.shift();
384
+ this._pasteTask.schedule(delayMs * this._pasteClocksPerMs);
385
+ }
368
386
 
369
- nextKeyMillis = millis + time;
370
- });
387
+ /**
388
+ * Cancel any in-progress paste operation.
389
+ */
390
+ cancelPaste() {
391
+ if (!this.isPasting) return;
392
+ this._pasteTask.cancel();
393
+ if (this._pasteLastChar && this._pasteLastChar !== utils.BBC.SHIFT) {
394
+ this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
395
+ }
396
+ this._pasteLastChar = undefined;
397
+ this._pasteKeys = [];
398
+ this.processor.sysvia.enableKeyboard();
399
+ }
400
+
401
+ /**
402
+ * Whether a paste operation is currently in progress.
403
+ * @returns {boolean}
404
+ */
405
+ get isPasting() {
406
+ return this._pasteKeys.length > 0 || this._pasteTask.scheduled();
371
407
  }
372
408
 
373
409
  /**
package/src/main.js CHANGED
@@ -480,6 +480,7 @@ async function loadSCSIFile(file) {
480
480
  }
481
481
 
482
482
  const $pastetext = $("#paste-text");
483
+ $pastetext.closest("form").on("submit", (event) => event.preventDefault());
483
484
  $pastetext.on("paste", function (event) {
484
485
  const text = event.originalEvent.clipboardData.getData("text/plain");
485
486
  sendRawKeyboardToBBC(utils.stringToBBCKeys(text), true);
@@ -619,6 +620,7 @@ processor = new Cpu6502(model, {
619
620
  video,
620
621
  soundChip: audioHandler.soundChip,
621
622
  ddNoise: audioHandler.ddNoise,
623
+ relayNoise: audioHandler.relayNoise,
622
624
  music5000: model.hasMusic5000 ? audioHandler.music5000 : null,
623
625
  cmos,
624
626
  config: emulationConfig,
@@ -1,4 +1,5 @@
1
1
  import { AnalogueSource } from "./analogue-source.js";
2
+ import { createAudioContext } from "./audio-utils.js";
2
3
 
3
4
  /**
4
5
  * Provides microphone input as an analogue source for the BBC Micro's ADC
@@ -38,7 +39,13 @@ export class MicrophoneInput extends AnalogueSource {
38
39
  if (!this.audioContext) {
39
40
  try {
40
41
  console.log("MicrophoneInput: Creating audio context");
41
- this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
42
+ this.audioContext = createAudioContext();
43
+ if (!this.audioContext) {
44
+ this.errorMessage = "WebAudio is not available; could not create audio context";
45
+ console.error("MicrophoneInput:", this.errorMessage);
46
+ if (this.errorCallback) this.errorCallback(this.errorMessage);
47
+ return false;
48
+ }
42
49
  console.log("MicrophoneInput: Audio context created:", this.audioContext.state);
43
50
  } catch (error) {
44
51
  console.error("MicrophoneInput: Error creating audio context:", error);
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ // Cassette motor relay click sound (issue #296).
3
+ // Audio samples recorded from a real BBC Master cassette motor relay.
4
+ import { SamplePlayer } from "./sample-player.js";
5
+
6
+ const Volume = 0.4;
7
+
8
+ export class RelayNoise extends SamplePlayer {
9
+ constructor(context, destination) {
10
+ super(context, destination, Volume);
11
+ }
12
+
13
+ async initialise() {
14
+ await this.loadSounds({
15
+ motorOn: "sounds/tape/motor_on.mp3",
16
+ motorOff: "sounds/tape/motor_off.mp3",
17
+ });
18
+ }
19
+
20
+ motorOn() {
21
+ this.oneShot(this.sounds.motorOn);
22
+ }
23
+
24
+ motorOff() {
25
+ this.oneShot(this.sounds.motorOff);
26
+ }
27
+ }
28
+
29
+ export class FakeRelayNoise {
30
+ initialise() {
31
+ return Promise.resolve();
32
+ }
33
+ motorOn() {}
34
+ motorOff() {}
35
+ mute() {}
36
+ unmute() {}
37
+ }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ import * as utils from "./utils.js";
3
+
4
+ /**
5
+ * Base class for audio components that load and play back sample buffers
6
+ * (e.g. disc drive noise, cassette relay clicks).
7
+ *
8
+ * Provides: gain node setup, sample loading, one-shot playback, and
9
+ * gain-based mute/unmute. Subclasses add domain-specific behaviour.
10
+ */
11
+ export class SamplePlayer {
12
+ constructor(context, destination, volume) {
13
+ this.context = context;
14
+ this.volume = volume;
15
+ this.sounds = {};
16
+ this.gain = context.createGain();
17
+ this.gain.gain.value = volume;
18
+ this.gain.connect(destination);
19
+ // Prevent older Safari from GC-ing in-flight AudioBufferSourceNodes.
20
+ this.playing = [];
21
+ }
22
+
23
+ /**
24
+ * Load a map of {name: path} into decoded AudioBuffers stored in this.sounds.
25
+ */
26
+ async loadSounds(pathMap) {
27
+ const entries = Object.entries(pathMap);
28
+ const decoded = await Promise.all(
29
+ entries.map(async ([, path]) => {
30
+ const data = await utils.loadData(path);
31
+ // Safari doesn't support the promise form of decodeAudioData.
32
+ return new Promise((resolve, reject) => {
33
+ this.context.decodeAudioData(
34
+ data.buffer,
35
+ (buf) => resolve(buf),
36
+ (err) => reject(err),
37
+ );
38
+ });
39
+ }),
40
+ );
41
+ for (let i = 0; i < entries.length; i++) {
42
+ this.sounds[entries[i][0]] = decoded[i];
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Fire-and-forget: play a buffer once, return its duration.
48
+ */
49
+ oneShot(sound) {
50
+ const duration = sound.duration;
51
+ if (this.context.state !== "running") return duration;
52
+ const source = this.context.createBufferSource();
53
+ source.buffer = sound;
54
+ source.connect(this.gain);
55
+ source.onended = () => {
56
+ this.playing = this.playing.filter((s) => s !== source);
57
+ };
58
+ source.start();
59
+ this.playing.push(source);
60
+ return duration;
61
+ }
62
+
63
+ /**
64
+ * Play a buffer, optionally looping. Returns a Promise that resolves
65
+ * with the source node (if looping) or when playback ends (if not).
66
+ */
67
+ play(sound, loop) {
68
+ if (this.context.state !== "running") return Promise.reject();
69
+ return new Promise((resolve) => {
70
+ const source = this.context.createBufferSource();
71
+ source.loop = !!loop;
72
+ source.buffer = sound;
73
+ source.connect(this.gain);
74
+ source.onended = () => {
75
+ this.playing = this.playing.filter((s) => s !== source);
76
+ if (!source.loop) resolve();
77
+ };
78
+ source.start();
79
+ this.playing.push(source);
80
+ if (source.loop) resolve(source);
81
+ });
82
+ }
83
+
84
+ mute() {
85
+ this.gain.gain.value = 0;
86
+ }
87
+
88
+ unmute() {
89
+ this.gain.gain.value = this.volume;
90
+ }
91
+ }
package/src/teletext.js CHANGED
@@ -395,7 +395,7 @@ export class Teletext {
395
395
  this.prevCol = this.col;
396
396
  this.curGlyphs = this.nextGlyphs;
397
397
 
398
- const prevFlash = this.flash;
398
+ let flashThisCell = this.flash;
399
399
  if (data < 0x20) {
400
400
  data = this.handleControlCode(data);
401
401
  } else if (this.gfx) {
@@ -415,7 +415,11 @@ export class Teletext {
415
415
  }
416
416
  let chardef = this.curGlyphs[(data - 32) * 20 + scanline];
417
417
 
418
- if ((prevFlash && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
418
+ // Flash (code 8) is "Set After" — flashThisCell retains the pre-control-code state.
419
+ // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
420
+ if (flashThisCell && !this.flash) flashThisCell = false;
421
+
422
+ if ((flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
419
423
  const backgroundColour = this.colour[(this.bg & 7) << 5];
420
424
  for (let i = 0; i < 16; ++i) {
421
425
  buf[offset++] = backgroundColour;
@@ -1,7 +1,9 @@
1
1
  import { SmoothieChart, TimeSeries } from "smoothie";
2
2
  import { FakeSoundChip, SoundChip } from "../soundchip.js";
3
3
  import { DdNoise, FakeDdNoise } from "../ddnoise.js";
4
+ import { RelayNoise, FakeRelayNoise } from "../relaynoise.js";
4
5
  import { Music5000, FakeMusic5000 } from "../music5000.js";
6
+ import { createAudioContext } from "../audio-utils.js";
5
7
 
6
8
  // Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
7
9
  // See https://github.com/vitejs/vite/discussions/6459
@@ -23,18 +25,16 @@ export class AudioHandler {
23
25
  this._addStat("queueSize", { strokeStyle: "rgb(51,126,108)" });
24
26
  this._addStat("queueAge", { strokeStyle: "rgb(162,119,22)" });
25
27
  this.chart.streamTo(statsNode, 100);
26
- /*global webkitAudioContext*/
27
- this.audioContext =
28
- typeof AudioContext !== "undefined"
29
- ? new AudioContext()
30
- : typeof webkitAudioContext !== "undefined"
31
- ? new webkitAudioContext()
32
- : null;
28
+ this.audioContext = createAudioContext();
33
29
  this._jsAudioNode = null;
34
30
  if (this.audioContext && this.audioContext.audioWorklet) {
35
31
  this.audioContext.onstatechange = () => this.checkStatus();
36
32
  this.soundChip = new SoundChip((buffer, time) => this._onBuffer(buffer, time));
37
- this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext);
33
+ // Master gain node for all sample-based audio (disc, relay, etc.).
34
+ this.masterGain = this.audioContext.createGain();
35
+ this.masterGain.connect(this.audioContext.destination);
36
+ this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
37
+ this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
38
38
  this._setup(audioFilterFreq, audioFilterQ).then();
39
39
  } else {
40
40
  if (this.audioContext && !this.audioContext.audioWorklet) {
@@ -52,18 +52,14 @@ export class AudioHandler {
52
52
  }
53
53
  this.soundChip = new FakeSoundChip();
54
54
  this.ddNoise = new FakeDdNoise();
55
+ this.relayNoise = new FakeRelayNoise();
55
56
  }
56
57
 
57
58
  this.warningNode.on("mousedown", () => this.tryResume());
58
59
  this.warningNode.toggle(false);
59
60
 
60
61
  // Initialise Music 5000 audio context
61
- this.audioContextM5000 =
62
- typeof AudioContext !== "undefined"
63
- ? new AudioContext({ sampleRate: 46875 })
64
- : typeof webkitAudioContext !== "undefined"
65
- ? new webkitAudioContext({ sampleRate: 46875 })
66
- : null;
62
+ this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
67
63
 
68
64
  if (this.audioContextM5000 && this.audioContextM5000.audioWorklet) {
69
65
  this.audioContextM5000.onstatechange = () => this.checkStatus();
@@ -125,22 +121,26 @@ export class AudioHandler {
125
121
  }
126
122
 
127
123
  checkStatus() {
128
- if (!this.audioContext) return;
129
- if (this.audioContext.state === "suspended") this.warningNode.fadeIn();
130
- if (this.audioContext.state === "running") this.warningNode.fadeOut();
124
+ if (!this.audioContext && !this.audioContextM5000) return;
125
+ const suspended =
126
+ (this.audioContext && this.audioContext.state === "suspended") ||
127
+ (this.audioContextM5000 && this.audioContextM5000.state === "suspended");
128
+ if (suspended) this.warningNode.fadeIn();
129
+ else this.warningNode.fadeOut();
131
130
  }
132
131
 
133
132
  async initialise() {
134
133
  await this.ddNoise.initialise();
134
+ await this.relayNoise.initialise();
135
135
  }
136
136
 
137
137
  mute() {
138
138
  this.soundChip.mute();
139
- this.ddNoise.mute();
139
+ if (this.masterGain) this.masterGain.gain.value = 0;
140
140
  }
141
141
 
142
142
  unmute() {
143
143
  this.soundChip.unmute();
144
- this.ddNoise.unmute();
144
+ if (this.masterGain) this.masterGain.gain.value = 1;
145
145
  }
146
146
  }
@@ -100,6 +100,19 @@ export class TestMachine {
100
100
  throw new Error(`Cursor did not reach state ${on} in time (cursorOnThisFrame=${video.cursorOnThisFrame})`);
101
101
  }
102
102
 
103
+ /**
104
+ * Run until the teletext flash state reaches the desired phase.
105
+ * @param {boolean} on - true for flash-on (flashing cells blanked), false for flash-off
106
+ */
107
+ async runToFlashState(on) {
108
+ const teletext = this.processor.video.teletext;
109
+ for (let i = 0; i < 100; i++) {
110
+ if (teletext.flashOn === on) return;
111
+ await this.runFor(40000);
112
+ }
113
+ throw new Error(`Flash did not reach state ${on} in time (flashOn=${teletext.flashOn})`);
114
+ }
115
+
103
116
  async runUntilVblank() {
104
117
  let hit = false;
105
118
  if (this.processor.isMaster) throw new Error("Not yet implemented");