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 +4 -4
- package/src/6502.js +15 -2
- package/src/acia.js +4 -1
- package/src/audio-utils.js +13 -0
- package/src/ddnoise.js +20 -82
- package/src/fake6502.js +2 -0
- package/src/keyboard.js +72 -36
- package/src/main.js +2 -0
- package/src/microphone-input.js +8 -1
- package/src/relaynoise.js +37 -0
- package/src/sample-player.js +91 -0
- package/src/teletext.js +6 -2
- package/src/web/audio-handler.js +19 -19
- package/tests/test-machine.js +13 -0
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.
|
|
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": "^
|
|
54
|
+
"vite": "^8.0.3",
|
|
55
55
|
"vitest": "^4.0.10"
|
|
56
56
|
},
|
|
57
57
|
"optionalDependencies": {
|
|
58
|
-
"electron": "^
|
|
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
|
-
{
|
|
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
|
|
3
|
-
import _ from "underscore";
|
|
2
|
+
import { SamplePlayer } from "./sample-player.js";
|
|
4
3
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
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
|
-
|
|
13
|
-
this.
|
|
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
|
-
|
|
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 ===
|
|
64
|
-
this.state =
|
|
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 ===
|
|
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 =
|
|
38
|
+
this.state = Spinning;
|
|
75
39
|
});
|
|
76
40
|
},
|
|
77
41
|
() => {},
|
|
78
42
|
);
|
|
79
43
|
}
|
|
44
|
+
|
|
80
45
|
spinDown() {
|
|
81
|
-
if (this.state ===
|
|
82
|
-
this.state =
|
|
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
|
-
|
|
318
|
-
|
|
329
|
+
if (this.isPasting) this.cancelPaste();
|
|
330
|
+
|
|
319
331
|
this.processor.sysvia.disableKeyboard();
|
|
320
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
344
|
+
this._pasteKeys = keysToSend;
|
|
345
|
+
this._pasteLastChar = undefined;
|
|
346
|
+
this._pasteTask.schedule(0);
|
|
347
|
+
}
|
|
337
348
|
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
360
|
+
if (this._pasteKeys.length === 0) {
|
|
361
|
+
this._pasteLastChar = undefined;
|
|
362
|
+
this.processor.sysvia.enableKeyboard();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
348
365
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
383
|
+
this._pasteKeys.shift();
|
|
384
|
+
this._pasteTask.schedule(delayMs * this._pasteClocksPerMs);
|
|
385
|
+
}
|
|
368
386
|
|
|
369
|
-
|
|
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,
|
package/src/microphone-input.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/web/audio-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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.
|
|
139
|
+
if (this.masterGain) this.masterGain.gain.value = 0;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
unmute() {
|
|
143
143
|
this.soundChip.unmute();
|
|
144
|
-
this.
|
|
144
|
+
if (this.masterGain) this.masterGain.gain.value = 1;
|
|
145
145
|
}
|
|
146
146
|
}
|
package/tests/test-machine.js
CHANGED
|
@@ -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");
|