jsbeeb 1.9.1 → 1.11.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 +5 -9
- package/src/6502.js +16 -3
- package/src/acia.js +4 -1
- package/src/app/electron.js +4 -4
- package/src/audio-utils.js +13 -0
- package/src/bem-snapshot.js +2 -34
- package/src/config.js +65 -59
- package/src/ddnoise.js +20 -82
- package/src/dom-utils.js +32 -0
- package/src/fake6502.js +2 -0
- package/src/google-drive.js +3 -4
- package/src/keyboard.js +72 -36
- package/src/main.js +224 -202
- package/src/microphone-input.js +8 -1
- package/src/relaynoise.js +37 -0
- package/src/sample-player.js +91 -0
- package/src/sth.js +1 -1
- package/src/tapes.js +2 -2
- package/src/teletext.js +6 -2
- package/src/utils.js +140 -15
- package/src/web/audio-handler.js +27 -28
- package/src/web/debug.js +100 -71
- 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.11.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",
|
|
@@ -31,12 +31,8 @@
|
|
|
31
31
|
"bootstrap": "^5.3.8",
|
|
32
32
|
"bootswatch": "^5.3.8",
|
|
33
33
|
"event-emitter-es6": "^1.1.5",
|
|
34
|
-
"fflate": "^0.8.2",
|
|
35
|
-
"jquery": "^4.0.0",
|
|
36
|
-
"pako": "^2.1.0",
|
|
37
34
|
"sharp": "^0.34.5",
|
|
38
|
-
"smoothie": "^1.36.1"
|
|
39
|
-
"underscore": "^1.13.7"
|
|
35
|
+
"smoothie": "^1.36.1"
|
|
40
36
|
},
|
|
41
37
|
"devDependencies": {
|
|
42
38
|
"@eslint/js": "^10.0.1",
|
|
@@ -51,11 +47,11 @@
|
|
|
51
47
|
"npm-run-all2": "^8.0.4",
|
|
52
48
|
"pixelmatch": "^7.1.0",
|
|
53
49
|
"prettier": "^3.8.1",
|
|
54
|
-
"vite": "^
|
|
50
|
+
"vite": "^8.0.3",
|
|
55
51
|
"vitest": "^4.0.10"
|
|
56
52
|
},
|
|
57
53
|
"optionalDependencies": {
|
|
58
|
-
"electron": "^
|
|
54
|
+
"electron": "^41.1.1",
|
|
59
55
|
"electron-builder": "^26.8.1",
|
|
60
56
|
"electron-store": "^11.0.2"
|
|
61
57
|
},
|
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);
|
|
@@ -1093,7 +1106,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1093
1106
|
const ramRomOs = this.ramRomOs;
|
|
1094
1107
|
let data = await utils.loadData(name);
|
|
1095
1108
|
if (/\.zip/i.test(name)) {
|
|
1096
|
-
data = utils.unzipRomImage(data).data;
|
|
1109
|
+
data = (await utils.unzipRomImage(data)).data;
|
|
1097
1110
|
}
|
|
1098
1111
|
ramRomOs.set(data, offset);
|
|
1099
1112
|
}
|
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
|
}
|
package/src/app/electron.js
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
// Electron integration for jsbeeb desktop application.
|
|
4
4
|
// Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
|
|
5
5
|
|
|
6
|
-
export let initialise = function () {};
|
|
7
|
-
|
|
8
6
|
function init(args) {
|
|
9
7
|
const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
|
|
10
8
|
const api = window.electronAPI;
|
|
@@ -66,6 +64,8 @@ function init(args) {
|
|
|
66
64
|
}
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
export function initialise(args) {
|
|
68
|
+
if (typeof window.electronAPI !== "undefined") {
|
|
69
|
+
init(args);
|
|
70
|
+
}
|
|
71
71
|
}
|
|
@@ -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/bem-snapshot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
import { decompress } from "./utils.js";
|
|
2
3
|
|
|
3
4
|
// B-em snapshot format parser (versions 1 and 3).
|
|
4
5
|
// v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
|
|
@@ -434,39 +435,6 @@ function readString(data, pos) {
|
|
|
434
435
|
return str;
|
|
435
436
|
}
|
|
436
437
|
|
|
437
|
-
async function inflateRaw(compressedData) {
|
|
438
|
-
// Use DecompressionStream (available in modern browsers and Node 18+)
|
|
439
|
-
if (typeof DecompressionStream !== "undefined") {
|
|
440
|
-
const ds = new DecompressionStream("deflate");
|
|
441
|
-
const writer = ds.writable.getWriter();
|
|
442
|
-
const reader = ds.readable.getReader();
|
|
443
|
-
const chunks = [];
|
|
444
|
-
|
|
445
|
-
// Start reading before writing to avoid backpressure deadlock
|
|
446
|
-
const readPromise = (async () => {
|
|
447
|
-
for (;;) {
|
|
448
|
-
const { done, value } = await reader.read();
|
|
449
|
-
if (done) break;
|
|
450
|
-
chunks.push(value);
|
|
451
|
-
}
|
|
452
|
-
})();
|
|
453
|
-
|
|
454
|
-
await writer.write(compressedData);
|
|
455
|
-
await writer.close();
|
|
456
|
-
await readPromise;
|
|
457
|
-
|
|
458
|
-
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
459
|
-
const result = new Uint8Array(totalLength);
|
|
460
|
-
let offset = 0;
|
|
461
|
-
for (const chunk of chunks) {
|
|
462
|
-
result.set(chunk, offset);
|
|
463
|
-
offset += chunk.length;
|
|
464
|
-
}
|
|
465
|
-
return result;
|
|
466
|
-
}
|
|
467
|
-
throw new Error("Zlib decompression not available (need DecompressionStream API)");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
438
|
function parseBemV3(buffer) {
|
|
471
439
|
const bytes = new Uint8Array(buffer);
|
|
472
440
|
let offset = 8; // Skip "BEMSNAP3" signature
|
|
@@ -523,7 +491,7 @@ function parseBemV3(buffer) {
|
|
|
523
491
|
if (memSection) {
|
|
524
492
|
// Memory is zlib-compressed; decompression is async.
|
|
525
493
|
// Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
|
|
526
|
-
return
|
|
494
|
+
return decompress(memSection.data, "deflate").then((memData) => {
|
|
527
495
|
cpuState.fe30 = memData[0];
|
|
528
496
|
cpuState.fe34 = memData[1];
|
|
529
497
|
const ramStart = 2;
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import $ from "jquery";
|
|
3
2
|
import EventEmitter from "event-emitter-es6";
|
|
4
3
|
import { findModel } from "./models.js";
|
|
5
4
|
import { getFilterForMode } from "./canvas.js";
|
|
@@ -12,8 +11,8 @@ export class Config extends EventEmitter {
|
|
|
12
11
|
this.changed = {};
|
|
13
12
|
this.model = null;
|
|
14
13
|
this.coProcessor = null;
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
const configuration = document.getElementById("configuration");
|
|
15
|
+
configuration.addEventListener("show.bs.modal", () => {
|
|
17
16
|
this.changed = {};
|
|
18
17
|
this.setDropdownText(this.model.name);
|
|
19
18
|
this.set65c02(this.model.tube);
|
|
@@ -23,116 +22,122 @@ export class Config extends EventEmitter {
|
|
|
23
22
|
this.setEconet(this.model.hasEconet);
|
|
24
23
|
});
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
configuration.addEventListener("hide.bs.modal", () => {
|
|
27
26
|
this.onClose(this.changed);
|
|
28
27
|
if (Object.keys(this.changed).length > 0) {
|
|
29
28
|
this.emit("settings-changed", this.changed);
|
|
30
29
|
}
|
|
31
30
|
});
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
for (const link of document.querySelectorAll(".model-menu a")) {
|
|
33
|
+
link.addEventListener("click", (e) => {
|
|
34
|
+
this.changed.model = e.target.dataset.target;
|
|
35
|
+
this.setDropdownText(e.target.textContent);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
this.changed.coProcessor =
|
|
40
|
-
|
|
39
|
+
document.getElementById("65c02").addEventListener("click", () => {
|
|
40
|
+
this.changed.coProcessor = document.getElementById("65c02").checked;
|
|
41
|
+
document.getElementById("tubeCpuMultiplier").disabled = !document.getElementById("65c02").checked;
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
const val = parseInt(
|
|
45
|
-
|
|
44
|
+
document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
|
|
45
|
+
const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
|
|
46
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = val;
|
|
46
47
|
this.changed.tubeCpuMultiplier = val;
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
this.changed.hasTeletextAdaptor =
|
|
50
|
+
document.getElementById("hasTeletextAdaptor").addEventListener("click", () => {
|
|
51
|
+
this.changed.hasTeletextAdaptor = document.getElementById("hasTeletextAdaptor").checked;
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
this.changed.hasEconet =
|
|
54
|
+
document.getElementById("hasEconet").addEventListener("click", () => {
|
|
55
|
+
this.changed.hasEconet = document.getElementById("hasEconet").checked;
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
this.changed.hasMusic5000 =
|
|
58
|
+
document.getElementById("hasMusic5000").addEventListener("click", () => {
|
|
59
|
+
this.changed.hasMusic5000 = document.getElementById("hasMusic5000").checked;
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
for (const link of document.querySelectorAll(".keyboard-menu a")) {
|
|
63
|
+
link.addEventListener("click", (e) => {
|
|
64
|
+
const keyLayout = e.target.dataset.target;
|
|
65
|
+
this.changed.keyLayout = keyLayout;
|
|
66
|
+
this.setKeyLayout(keyLayout);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
for (const option of document.querySelectorAll(".mic-channel-option")) {
|
|
71
|
+
option.addEventListener("click", (e) => {
|
|
72
|
+
const channelString = e.target.dataset.channel;
|
|
73
|
+
const channel = channelString === "" ? undefined : parseInt(channelString, 10);
|
|
74
|
+
this.changed.microphoneChannel = channel;
|
|
75
|
+
this.setMicrophoneChannel(channel);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
this.changed.mouseJoystickEnabled =
|
|
79
|
+
document.getElementById("mouseJoystickEnabled").addEventListener("click", () => {
|
|
80
|
+
this.changed.mouseJoystickEnabled = document.getElementById("mouseJoystickEnabled").checked;
|
|
76
81
|
});
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
this.changed.speechOutput =
|
|
83
|
+
document.getElementById("speechOutput").addEventListener("click", () => {
|
|
84
|
+
this.changed.speechOutput = document.getElementById("speechOutput").checked;
|
|
80
85
|
});
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
for (const option of document.querySelectorAll(".display-mode-option")) {
|
|
88
|
+
option.addEventListener("click", (e) => {
|
|
89
|
+
const mode = e.target.dataset.mode;
|
|
90
|
+
this.changed.displayMode = mode;
|
|
91
|
+
this.setDisplayMode(mode);
|
|
92
|
+
this.onChange({ displayMode: mode });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
setMicrophoneChannel(channel) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
} else {
|
|
94
|
-
$(".mic-channel-text").text("Disabled");
|
|
95
|
-
}
|
|
98
|
+
const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
|
|
99
|
+
for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
setMouseJoystickEnabled(enabled) {
|
|
99
|
-
|
|
103
|
+
document.getElementById("mouseJoystickEnabled").checked = !!enabled;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
setSpeechOutput(enabled) {
|
|
103
|
-
|
|
107
|
+
document.getElementById("speechOutput").checked = !!enabled;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
setDisplayMode(mode) {
|
|
107
111
|
const config = getFilterForMode(mode).getDisplayConfig();
|
|
108
|
-
|
|
112
|
+
for (const el of document.querySelectorAll(".display-mode-text")) el.textContent = config.name;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
setModel(modelName) {
|
|
112
116
|
this.model = findModel(modelName);
|
|
113
|
-
|
|
117
|
+
for (const el of document.querySelectorAll(".bbc-model")) el.textContent = this.model.name;
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
setKeyLayout(keyLayout) {
|
|
117
|
-
|
|
121
|
+
const text = keyLayout[0].toUpperCase() + keyLayout.substring(1);
|
|
122
|
+
for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
set65c02(enabled) {
|
|
121
126
|
enabled = !!enabled;
|
|
122
|
-
|
|
127
|
+
document.getElementById("65c02").checked = enabled;
|
|
123
128
|
this.model.tube = enabled ? findModel("Tube65c02") : null;
|
|
124
|
-
|
|
129
|
+
document.getElementById("tubeCpuMultiplier").disabled = !enabled;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
setTubeCpuMultiplier(value) {
|
|
128
133
|
this.tubeCpuMultiplier = value;
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
document.getElementById("tubeCpuMultiplier").value = value;
|
|
135
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = value;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
setEconet(enabled) {
|
|
134
139
|
enabled = !!enabled;
|
|
135
|
-
|
|
140
|
+
document.getElementById("hasEconet").checked = enabled;
|
|
136
141
|
this.model.hasEconet = enabled;
|
|
137
142
|
|
|
138
143
|
if (enabled && this.model.isMaster) {
|
|
@@ -142,20 +147,21 @@ export class Config extends EventEmitter {
|
|
|
142
147
|
|
|
143
148
|
setMusic5000(enabled) {
|
|
144
149
|
enabled = !!enabled;
|
|
145
|
-
|
|
150
|
+
document.getElementById("hasMusic5000").checked = enabled;
|
|
146
151
|
this.model.hasMusic5000 = enabled;
|
|
147
152
|
this.addRemoveROM("ample.rom", enabled);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
setTeletext(enabled) {
|
|
151
156
|
enabled = !!enabled;
|
|
152
|
-
|
|
157
|
+
document.getElementById("hasTeletextAdaptor").checked = enabled;
|
|
153
158
|
this.model.hasTeletextAdaptor = enabled;
|
|
154
159
|
this.addRemoveROM("ats-3.0.rom", enabled);
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
setDropdownText(modelName) {
|
|
158
|
-
|
|
163
|
+
const el = document.querySelector("#bbc-model-dropdown .bbc-model");
|
|
164
|
+
if (el) el.textContent = modelName;
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
addRemoveROM(romName, required) {
|
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/dom-utils.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Minimal DOM helpers to replace jQuery usage.
|
|
4
|
+
|
|
5
|
+
export function show(el) {
|
|
6
|
+
el.style.display = "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hide(el) {
|
|
10
|
+
el.style.display = "none";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toggle(el, visible) {
|
|
14
|
+
el.style.display = visible ? "" : "none";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fadeIn(el, duration = 400) {
|
|
18
|
+
el.style.display = "";
|
|
19
|
+
el.style.transition = `opacity ${duration}ms`;
|
|
20
|
+
el.style.opacity = "0";
|
|
21
|
+
// Force reflow so the transition triggers.
|
|
22
|
+
void el.offsetHeight;
|
|
23
|
+
el.style.opacity = "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fadeOut(el, duration = 400) {
|
|
27
|
+
el.style.transition = `opacity ${duration}ms`;
|
|
28
|
+
el.style.opacity = "0";
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
if (el.style.opacity === "0") el.style.display = "none";
|
|
31
|
+
}, duration);
|
|
32
|
+
}
|
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/google-drive.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import * as utils from "./utils.js";
|
|
3
|
+
import { debounce, uint8ArrayToString } from "./utils.js";
|
|
5
4
|
import { discFor } from "./fdc.js";
|
|
6
5
|
|
|
7
6
|
const MIME_TYPE = "application/vnd.jsbeeb.disc-image";
|
|
@@ -112,7 +111,7 @@ export class GoogleDriveLoader {
|
|
|
112
111
|
const metadata = { name, mimeType: MIME_TYPE };
|
|
113
112
|
if (!idOrNone) metadata.parents = [this.parentFolderId];
|
|
114
113
|
|
|
115
|
-
const base64Data = btoa(
|
|
114
|
+
const base64Data = btoa(uint8ArrayToString(data));
|
|
116
115
|
const multipartRequestBody =
|
|
117
116
|
`${delimiter}Content-Type: application/json\r\n\r\n` +
|
|
118
117
|
`${JSON.stringify(metadata)}${delimiter}` +
|
|
@@ -141,7 +140,7 @@ export class GoogleDriveLoader {
|
|
|
141
140
|
const id = meta.id;
|
|
142
141
|
if (meta.capabilities.canEdit) {
|
|
143
142
|
console.log("Making editable disc");
|
|
144
|
-
flusher =
|
|
143
|
+
flusher = debounce(async (changedData) => {
|
|
145
144
|
console.log("Data changed...");
|
|
146
145
|
await this.saveFile(name, changedData, id);
|
|
147
146
|
console.log("Saved ok");
|