jsbeeb 1.19.3 → 1.20.1
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 +7 -1
- package/package.json +1 -1
- package/src/acia.js +20 -2
- package/src/biquad.js +36 -0
- package/src/canvas.js +2 -1
- package/src/google-drive.js +11 -1
- package/src/main.js +191 -65
- package/src/soundchip.js +67 -6
- package/src/tapes.js +1 -2
- package/src/video-filters/pal-composite.js +3 -3
- package/src/video-filters/shaders/pal-composite.frag.glsl +48 -48
- package/src/video.js +35 -7
- package/src/web/audio-handler.js +112 -55
- package/src/web/audio-renderer.js +168 -79
package/README.md
CHANGED
|
@@ -287,7 +287,13 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
|
|
|
287
287
|
Doesn't support the sth: pseudo URL unlike `disc` and `tape`, but if given a ZIP file will attempt to use the `.rom`
|
|
288
288
|
file assumed to be within.
|
|
289
289
|
- (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
|
|
290
|
-
- `audioDebug` - show audio
|
|
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
|
+
- `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
|
+
- `audiofilterfreq` / `audiofilterq` - the corner frequency in Hz and the Q of the lowpass modelling the board's output
|
|
293
|
+
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.
|
|
295
|
+
- `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)).
|
|
291
297
|
|
|
292
298
|
### Atom-specific parameters
|
|
293
299
|
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "jsbeeb",
|
|
8
8
|
"description": "Emulate a BBC Micro",
|
|
9
9
|
"repository": "git@github.com:mattgodbolt/jsbeeb.git",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.20.1",
|
|
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"
|
package/src/acia.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// https://books.google.com/books?id=wUecAQAAQBAJ&pg=PA431&lpg=PA431&dq=acia+tdre&source=bl&ots=mp-yF-mK-P&sig=e6aXkFRfiIOb57WZmrvdIGsCooI&hl=en&sa=X&ei=0g2fVdDyFIXT-QG8-JD4BA&ved=0CCwQ6AEwAw#v=onepage&q=acia%20tdre&f=false
|
|
5
5
|
// http://www.classiccmp.org/dunfield/r/6850.pdf
|
|
6
6
|
|
|
7
|
-
export class Acia {
|
|
7
|
+
export class Acia extends EventTarget {
|
|
8
8
|
constructor(cpu, toneGen, scheduler, relayNoise) {
|
|
9
|
+
super();
|
|
9
10
|
this.cpu = cpu;
|
|
10
11
|
this.toneGen = toneGen;
|
|
11
12
|
this.rs423Handler = null;
|
|
@@ -20,6 +21,7 @@ export class Acia {
|
|
|
20
21
|
this.tapeCarrierCount = 0;
|
|
21
22
|
this.tapeDcdLineLevel = false;
|
|
22
23
|
this.hadDcdHigh = false;
|
|
24
|
+
this.saidOverrun = false;
|
|
23
25
|
this.serialReceiveRate = 0;
|
|
24
26
|
this.serialReceiveCyclesPerByte = 0;
|
|
25
27
|
this.serialTransmitRate = 0;
|
|
@@ -200,8 +202,8 @@ export class Acia {
|
|
|
200
202
|
// TODO: this doesn't match the datasheet:
|
|
201
203
|
// "The Overrun does not occur in the Status Register until the
|
|
202
204
|
// valid character prior to Overrun has been read."
|
|
203
|
-
console.log("Serial overrun");
|
|
204
205
|
this.sr |= 0xa0;
|
|
206
|
+
this.noteOverrun();
|
|
205
207
|
} else {
|
|
206
208
|
// If bit 7 contains parity, mask it off.
|
|
207
209
|
this.dr = byte & (this.cr & 0x10 ? 0xff : 0x7f);
|
|
@@ -210,6 +212,22 @@ export class Acia {
|
|
|
210
212
|
this.updateIrq();
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
noteOverrun() {
|
|
216
|
+
if (this.saidOverrun) return;
|
|
217
|
+
this.saidOverrun = true;
|
|
218
|
+
this.dispatchEvent(
|
|
219
|
+
new CustomEvent("notice", {
|
|
220
|
+
detail: {
|
|
221
|
+
message:
|
|
222
|
+
"A serial byte arrived before the previous one was read, so it was lost. " +
|
|
223
|
+
"A tape load that stops with Data? is usually this.",
|
|
224
|
+
title: "Serial",
|
|
225
|
+
quietKey: "quietSerialOverrun",
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
213
231
|
snapshotState() {
|
|
214
232
|
const scheduler = this.txCompleteTask.scheduler;
|
|
215
233
|
return {
|
package/src/biquad.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Coefficients from the RBJ Audio EQ Cookbook: the second-order prototype
|
|
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;
|
|
13
|
+
this.x1 = 0;
|
|
14
|
+
this.x2 = 0;
|
|
15
|
+
this.y1 = 0;
|
|
16
|
+
this.y2 = 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process(buffer, offset, length) {
|
|
20
|
+
const { b0, b1, a1, a2 } = this;
|
|
21
|
+
let { x1, x2, y1, y2 } = this;
|
|
22
|
+
for (let i = offset; i < offset + length; ++i) {
|
|
23
|
+
const x = buffer[i];
|
|
24
|
+
const y = b0 * x + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2;
|
|
25
|
+
x2 = x1;
|
|
26
|
+
x1 = x;
|
|
27
|
+
y2 = y1;
|
|
28
|
+
y1 = y;
|
|
29
|
+
buffer[i] = y;
|
|
30
|
+
}
|
|
31
|
+
this.x1 = x1;
|
|
32
|
+
this.x2 = x2;
|
|
33
|
+
this.y1 = y1;
|
|
34
|
+
this.y2 = y2;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/canvas.js
CHANGED
|
@@ -240,7 +240,8 @@ export class GlCanvas {
|
|
|
240
240
|
this.filter.setUniforms({
|
|
241
241
|
width,
|
|
242
242
|
height,
|
|
243
|
-
|
|
243
|
+
lineBaseEven: frame.lineBaseEven,
|
|
244
|
+
lineBaseOdd: frame.lineBaseOdd,
|
|
244
245
|
lineGrid: frame.lineGrid,
|
|
245
246
|
// How much of the framebuffer each output pixel covers, which sets
|
|
246
247
|
// how wide an edge-smoothing ramp should be. `extent` holds texel
|
package/src/google-drive.js
CHANGED
|
@@ -23,7 +23,17 @@ export class GoogleDriveLoader {
|
|
|
23
23
|
this.driveClient = undefined;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
initialise() {
|
|
27
|
+
if (!this._initialising) {
|
|
28
|
+
this._initialising = this._initialise().catch((error) => {
|
|
29
|
+
this._initialising = undefined;
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return this._initialising;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async _initialise() {
|
|
27
37
|
console.log("Creating GAPI");
|
|
28
38
|
await this._loadScript("https://apis.google.com/js/api.js");
|
|
29
39
|
console.log("Got GAPI, creating token client");
|
package/src/main.js
CHANGED
|
@@ -89,6 +89,11 @@ function stringToMachineKeys(text) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const gamepad = new GamePad();
|
|
92
|
+
if (!window.isSecureContext)
|
|
93
|
+
toast("Gamepads only work over https, so any joystick plugged in here is not seen.", {
|
|
94
|
+
title: "Gamepads",
|
|
95
|
+
quietKey: "quietInsecureGamepads",
|
|
96
|
+
});
|
|
92
97
|
const availableImages = [
|
|
93
98
|
{
|
|
94
99
|
name: "Elite",
|
|
@@ -145,6 +150,7 @@ const paramTypes = {
|
|
|
145
150
|
frameSkip: ParamTypes.INT,
|
|
146
151
|
audiofilterfreq: ParamTypes.FLOAT,
|
|
147
152
|
audiofilterq: ParamTypes.FLOAT,
|
|
153
|
+
audioLatencyMs: ParamTypes.FLOAT,
|
|
148
154
|
cpuMultiplier: ParamTypes.FLOAT,
|
|
149
155
|
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
150
156
|
microphoneChannel: ParamTypes.INT,
|
|
@@ -174,12 +180,6 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
|
|
|
174
180
|
let fastAsPossible = false;
|
|
175
181
|
let fastTape = false;
|
|
176
182
|
let noSeek;
|
|
177
|
-
// The board's output filter is an equal-component Sallen-Key (Service Manual
|
|
178
|
-
// section 3.8: 10K and 2n2 twice, gain 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
|
|
179
|
-
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. BiquadFilterNode
|
|
180
|
-
// takes lowpass Q in decibels: 20*log10(0.696) = -3.15.
|
|
181
|
-
let audioFilterFreq = 7234;
|
|
182
|
-
let audioFilterQ = -3.15;
|
|
183
183
|
let stationId = 101;
|
|
184
184
|
let econet = null;
|
|
185
185
|
|
|
@@ -208,8 +208,6 @@ if (parsedQuery.embed) {
|
|
|
208
208
|
fastTape = !!parsedQuery.fasttape;
|
|
209
209
|
noSeek = !!parsedQuery.noseek;
|
|
210
210
|
|
|
211
|
-
if (parsedQuery.audiofilterfreq !== undefined) audioFilterFreq = parsedQuery.audiofilterfreq;
|
|
212
|
-
if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilterq;
|
|
213
211
|
if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
|
|
214
212
|
if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
|
|
215
213
|
|
|
@@ -238,7 +236,15 @@ const userPort = {
|
|
|
238
236
|
// Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
|
|
239
237
|
// Must be created before Config so the onClose callback and the initial checkbox state can reference it.
|
|
240
238
|
const speechOutput = new SpeechOutput();
|
|
241
|
-
|
|
239
|
+
|
|
240
|
+
function setSpeechOutput(enabled) {
|
|
241
|
+
speechOutput.enabled = enabled;
|
|
242
|
+
if (enabled && typeof speechSynthesis === "undefined")
|
|
243
|
+
toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
|
|
244
|
+
title: "Speech",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
setSpeechOutput(!!parsedQuery.speechOutput);
|
|
242
248
|
|
|
243
249
|
const config = new Config(
|
|
244
250
|
function onChange(changed) {
|
|
@@ -265,9 +271,7 @@ const config = new Config(
|
|
|
265
271
|
setupMicrophone();
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
|
-
if (changed.speechOutput !== undefined)
|
|
269
|
-
speechOutput.enabled = !!changed.speechOutput;
|
|
270
|
-
}
|
|
274
|
+
if (changed.speechOutput !== undefined) setSpeechOutput(!!changed.speechOutput);
|
|
271
275
|
if (changed.tubeCpuMultiplier !== undefined) {
|
|
272
276
|
emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
|
|
273
277
|
config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
|
|
@@ -372,7 +376,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
372
376
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
373
377
|
const cpuSpeed = model.cyclesPerSecond;
|
|
374
378
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
375
|
-
const
|
|
379
|
+
const MaxCyclesPerTick = clocksPerSecond / 10;
|
|
376
380
|
|
|
377
381
|
let tryGl = true;
|
|
378
382
|
if (parsedQuery.glEnabled !== undefined) {
|
|
@@ -552,14 +556,55 @@ function swapCanvas(newFilterClass) {
|
|
|
552
556
|
const canvas = createCanvasForFilter(displayModeFilter);
|
|
553
557
|
displayModeFilter = canvas.filterClass;
|
|
554
558
|
|
|
559
|
+
// The emulator paints into its own framebuffer; flyback copies the finished
|
|
560
|
+
// frame into the canvas and an animation frame presents it, so a stalled
|
|
561
|
+
// display holds up the picture and not the emulation (issue #885).
|
|
562
|
+
const videoFb32 = new Uint32Array(canvas.fb32.length);
|
|
563
|
+
const pendingFrame = {
|
|
564
|
+
minx: 0,
|
|
565
|
+
miny: 0,
|
|
566
|
+
maxx: 0,
|
|
567
|
+
maxy: 0,
|
|
568
|
+
lineBaseEven: 0,
|
|
569
|
+
lineBaseOdd: 0,
|
|
570
|
+
lineGrid: new Uint8Array(0),
|
|
571
|
+
};
|
|
572
|
+
let presentScheduled = false;
|
|
573
|
+
let paintMsThisTick = 0;
|
|
574
|
+
let presentMsMax = 0;
|
|
575
|
+
|
|
576
|
+
function present() {
|
|
577
|
+
presentScheduled = false;
|
|
578
|
+
const start = performance.now();
|
|
579
|
+
canvas.paint(pendingFrame.minx, pendingFrame.miny, pendingFrame.maxx, pendingFrame.maxy, pendingFrame);
|
|
580
|
+
presentMsMax = Math.max(presentMsMax, performance.now() - start);
|
|
581
|
+
}
|
|
582
|
+
|
|
555
583
|
video = new Video(
|
|
556
584
|
model.isMaster,
|
|
557
|
-
|
|
585
|
+
videoFb32,
|
|
558
586
|
function paint(minx, miny, maxx, maxy) {
|
|
559
587
|
frames++;
|
|
560
588
|
if (frames < frameSkip) return;
|
|
561
589
|
frames = 0;
|
|
562
|
-
|
|
590
|
+
const start = performance.now();
|
|
591
|
+
canvas.fb32.set(videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
|
|
592
|
+
if (pendingFrame.lineGrid.length !== this.lineGrid.length)
|
|
593
|
+
pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
|
|
594
|
+
pendingFrame.lineGrid.set(this.lineGrid);
|
|
595
|
+
Object.assign(pendingFrame, {
|
|
596
|
+
minx,
|
|
597
|
+
miny,
|
|
598
|
+
maxx,
|
|
599
|
+
maxy,
|
|
600
|
+
lineBaseEven: this.lineBaseEven,
|
|
601
|
+
lineBaseOdd: this.lineBaseOdd,
|
|
602
|
+
});
|
|
603
|
+
paintMsThisTick += performance.now() - start;
|
|
604
|
+
if (!presentScheduled) {
|
|
605
|
+
presentScheduled = true;
|
|
606
|
+
window.requestAnimationFrame(present);
|
|
607
|
+
}
|
|
563
608
|
},
|
|
564
609
|
{ isAtom: model.isAtom },
|
|
565
610
|
);
|
|
@@ -571,11 +616,13 @@ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
|
|
|
571
616
|
const audioHandler = new AudioHandler({
|
|
572
617
|
warningNode: document.getElementById("audio-warning"),
|
|
573
618
|
statsNode: audioStatsNode,
|
|
574
|
-
audioFilterFreq,
|
|
575
|
-
audioFilterQ,
|
|
619
|
+
audioFilterFreq: parsedQuery.audiofilterfreq,
|
|
620
|
+
audioFilterQ: parsedQuery.audiofilterq,
|
|
621
|
+
audioLatencyMs: parsedQuery.audioLatencyMs,
|
|
576
622
|
noSeek,
|
|
577
623
|
cpuSpeed,
|
|
578
624
|
isAtom: model.isAtom,
|
|
625
|
+
hasMusic5000: config.hasMusic5000,
|
|
579
626
|
});
|
|
580
627
|
// Firefox will report that audio is suspended even when it will
|
|
581
628
|
// start playing without user interaction, so we need to delay a
|
|
@@ -675,8 +722,10 @@ pastetext.addEventListener("drop", async function (event) {
|
|
|
675
722
|
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
676
723
|
// Regular UEF tape image (not a BeebEm save state)
|
|
677
724
|
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
725
|
+
toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
|
|
678
726
|
} else {
|
|
679
727
|
await loadHTMLFile(file);
|
|
728
|
+
toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
|
|
680
729
|
}
|
|
681
730
|
} catch (error) {
|
|
682
731
|
reportLoadFailure(file.name, error);
|
|
@@ -724,12 +773,23 @@ setCrtPic(displayModeFilter);
|
|
|
724
773
|
|
|
725
774
|
window.addEventListener("blur", function () {
|
|
726
775
|
keyboard.clearKeys();
|
|
776
|
+
setEmulationLead(audioHandler.setWindowFocused(false));
|
|
727
777
|
});
|
|
778
|
+
window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
|
|
728
779
|
|
|
729
|
-
document.getElementById("fs")
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
780
|
+
const fullscreenItem = document.getElementById("fs");
|
|
781
|
+
if (document.fullscreenEnabled) {
|
|
782
|
+
fullscreenItem.addEventListener("click", async (event) => {
|
|
783
|
+
event.preventDefault();
|
|
784
|
+
try {
|
|
785
|
+
await screenCanvas.requestFullscreen();
|
|
786
|
+
} catch (error) {
|
|
787
|
+
toast(`Could not go fullscreen: ${errorText(error)}`, { title: "Fullscreen" });
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
} else {
|
|
791
|
+
fullscreenItem.closest("li").hidden = true;
|
|
792
|
+
}
|
|
733
793
|
|
|
734
794
|
let keyboard; // This will be initialised after the processor is created
|
|
735
795
|
|
|
@@ -817,6 +877,7 @@ processor = new CpuClass(model, {
|
|
|
817
877
|
printer.attach(processor.uservia);
|
|
818
878
|
|
|
819
879
|
processor.teletextAdaptor?.addEventListener("notice", showNotice);
|
|
880
|
+
processor.acia.addEventListener("notice", showNotice);
|
|
820
881
|
|
|
821
882
|
// Create input sources
|
|
822
883
|
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
@@ -1661,12 +1722,6 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
|
|
|
1661
1722
|
});
|
|
1662
1723
|
|
|
1663
1724
|
async function gdLoad(cat, layout) {
|
|
1664
|
-
// TODO: have a onclose flush event, handle errors
|
|
1665
|
-
/*
|
|
1666
|
-
$(window).bind("beforeunload", function() {
|
|
1667
|
-
return confirm("Do you really want to close?");
|
|
1668
|
-
});
|
|
1669
|
-
*/
|
|
1670
1725
|
popupLoading("Loading '" + cat.name + "' from Google Drive");
|
|
1671
1726
|
try {
|
|
1672
1727
|
const available = await googleDrive.initialise();
|
|
@@ -1687,6 +1742,12 @@ async function gdLoad(cat, layout) {
|
|
|
1687
1742
|
const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
|
|
1688
1743
|
console.log("Google Drive loading finished");
|
|
1689
1744
|
loadingFinished();
|
|
1745
|
+
if (!ssd.savesChanges) {
|
|
1746
|
+
toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
|
|
1747
|
+
title: "Google Drive",
|
|
1748
|
+
quietKey: "quietDriveReadOnly",
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1690
1751
|
return ssd;
|
|
1691
1752
|
} catch (error) {
|
|
1692
1753
|
console.error("Google Drive loading error:", error);
|
|
@@ -1694,20 +1755,16 @@ async function gdLoad(cat, layout) {
|
|
|
1694
1755
|
}
|
|
1695
1756
|
}
|
|
1696
1757
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1758
|
+
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
1759
|
+
// Loading the Google client holds the main thread for ~100ms, so it waits for
|
|
1760
|
+
// someone to ask for Drive.
|
|
1761
|
+
document.getElementById("open-drive-link").addEventListener("click", async function () {
|
|
1699
1762
|
try {
|
|
1700
|
-
|
|
1701
|
-
if (available) {
|
|
1702
|
-
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
|
|
1703
|
-
await gdAuth(true);
|
|
1704
|
-
}
|
|
1763
|
+
await googleDrive.initialise();
|
|
1705
1764
|
} catch (error) {
|
|
1706
|
-
|
|
1765
|
+
toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
|
|
1766
|
+
return false;
|
|
1707
1767
|
}
|
|
1708
|
-
})();
|
|
1709
|
-
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
1710
|
-
document.getElementById("open-drive-link").addEventListener("click", async function () {
|
|
1711
1768
|
const authed = await gdAuth(false);
|
|
1712
1769
|
if (authed) {
|
|
1713
1770
|
googleDriveModal.show();
|
|
@@ -2248,6 +2305,7 @@ function profileVideo(arg) {
|
|
|
2248
2305
|
}
|
|
2249
2306
|
|
|
2250
2307
|
let last = 0;
|
|
2308
|
+
let lastEnd = 0;
|
|
2251
2309
|
|
|
2252
2310
|
function VirtualSpeedUpdater() {
|
|
2253
2311
|
this.cycles = 0;
|
|
@@ -2281,8 +2339,51 @@ function VirtualSpeedUpdater() {
|
|
|
2281
2339
|
const virtualSpeedUpdater = new VirtualSpeedUpdater();
|
|
2282
2340
|
|
|
2283
2341
|
const rewindBuffer = new RewindBuffer(30);
|
|
2284
|
-
let
|
|
2285
|
-
const RewindCaptureInterval = 50; // ~1 second
|
|
2342
|
+
let rewindCycleCounter = 0;
|
|
2343
|
+
const RewindCaptureInterval = 50; // emulated frames, ~1 second
|
|
2344
|
+
const RewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
|
|
2345
|
+
|
|
2346
|
+
// Under ?audioDebug, one console line per second in which the emulator sat
|
|
2347
|
+
// idle between ticks or a tick ran long, or the audio queue underran or
|
|
2348
|
+
// dropped, so a click can be matched to a cause. The sound chip posts samples
|
|
2349
|
+
// throughout execute(), so only the idle time starves the audio queue.
|
|
2350
|
+
const AudioDebugLogIntervalMs = 1000;
|
|
2351
|
+
const AudioDebugSlowTickMs = 30;
|
|
2352
|
+
const audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
|
|
2353
|
+
const AudioDebugSlowPresentMs = 30;
|
|
2354
|
+
|
|
2355
|
+
function logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
|
|
2356
|
+
const log = audioDebugLog;
|
|
2357
|
+
if (log.start === 0) log.start = now;
|
|
2358
|
+
log.ticks++;
|
|
2359
|
+
log.cycles += cycles;
|
|
2360
|
+
log.maxIdle = Math.max(log.maxIdle, idleMs);
|
|
2361
|
+
log.maxExecute = Math.max(log.maxExecute, executeMs);
|
|
2362
|
+
log.maxPaint = Math.max(log.maxPaint, paintMs);
|
|
2363
|
+
log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
|
|
2364
|
+
if (now - log.start < AudioDebugLogIntervalMs) return;
|
|
2365
|
+
const audio = audioHandler.takeEventCounts();
|
|
2366
|
+
const present = presentMsMax;
|
|
2367
|
+
presentMsMax = 0;
|
|
2368
|
+
const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
|
|
2369
|
+
if (
|
|
2370
|
+
log.maxIdle > AudioDebugSlowTickMs ||
|
|
2371
|
+
log.maxExecute > AudioDebugSlowTickMs ||
|
|
2372
|
+
present > AudioDebugSlowPresentMs ||
|
|
2373
|
+
audio.stall ||
|
|
2374
|
+
audio.skip
|
|
2375
|
+
) {
|
|
2376
|
+
console.log(
|
|
2377
|
+
`${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / clocksPerSecond).toFixed(0)}ms, ` +
|
|
2378
|
+
`idle max ${log.maxIdle.toFixed(0)}ms, ` +
|
|
2379
|
+
`execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
|
|
2380
|
+
`present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
|
|
2381
|
+
`audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
log.start = now;
|
|
2385
|
+
log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
|
|
2386
|
+
}
|
|
2286
2387
|
|
|
2287
2388
|
rewindUI = new RewindUI({
|
|
2288
2389
|
rewindBuffer,
|
|
@@ -2315,21 +2416,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
|
|
|
2315
2416
|
if (drive) showDriveTracks(driveIndex);
|
|
2316
2417
|
}
|
|
2317
2418
|
|
|
2318
|
-
|
|
2419
|
+
// A timer, not requestAnimationFrame: a display presentation stall withholds
|
|
2420
|
+
// animation frames, and with them the sound chip's samples (issue #885).
|
|
2421
|
+
const TickMs = 10;
|
|
2422
|
+
let tickToken = null;
|
|
2423
|
+
|
|
2424
|
+
// A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
|
|
2425
|
+
// compositor does not hold the tick off too.
|
|
2426
|
+
function scheduleTick(delayMs) {
|
|
2427
|
+
const token = (tickToken = {});
|
|
2428
|
+
const fire = () => {
|
|
2429
|
+
if (tickToken === token) tick();
|
|
2430
|
+
};
|
|
2431
|
+
if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
|
|
2432
|
+
else window.setTimeout(fire, delayMs);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function tick() {
|
|
2319
2436
|
if (!running) {
|
|
2320
2437
|
last = 0;
|
|
2321
2438
|
return;
|
|
2322
2439
|
}
|
|
2323
|
-
|
|
2324
|
-
if (now === undefined) {
|
|
2325
|
-
now = window.performance.now();
|
|
2326
|
-
}
|
|
2440
|
+
const now = performance.now();
|
|
2327
2441
|
|
|
2328
2442
|
const motorOn = processor.acia.motorOn;
|
|
2329
|
-
const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
2330
2443
|
const speedy = fastAsPossible || (fastTape && motorOn);
|
|
2331
|
-
const useTimeout = speedy || motorOn || discOn;
|
|
2332
|
-
const timeout = speedy ? 0 : 1000.0 / 50;
|
|
2333
2444
|
|
|
2334
2445
|
// In speedy mode, we still run all the state machines accurately
|
|
2335
2446
|
// but we paint less often because painting is the most expensive
|
|
@@ -2338,25 +2449,16 @@ function draw(now) {
|
|
|
2338
2449
|
// modes, i.e. MODE 7, still look ok.
|
|
2339
2450
|
video.frameSkipCount = speedy ? 9 : 0;
|
|
2340
2451
|
|
|
2341
|
-
|
|
2342
|
-
// a) We're trying to run as fast as possible.
|
|
2343
|
-
// b) Tape is playing, normal speed but backgrounded tab should run.
|
|
2344
|
-
if (useTimeout) {
|
|
2345
|
-
window.setTimeout(draw, timeout);
|
|
2346
|
-
} else {
|
|
2347
|
-
window.requestAnimationFrame(draw);
|
|
2348
|
-
}
|
|
2452
|
+
scheduleTick(speedy ? 0 : TickMs);
|
|
2349
2453
|
|
|
2350
|
-
audioHandler.soundChip.catchUp();
|
|
2351
2454
|
gamepad.update(processor.sysvia);
|
|
2352
2455
|
syncLights();
|
|
2353
2456
|
if (last !== 0) {
|
|
2354
2457
|
let cycles;
|
|
2355
2458
|
if (!speedy) {
|
|
2356
|
-
|
|
2357
|
-
const sinceLast = now - last;
|
|
2459
|
+
const sinceLast = Math.max(0, now - last);
|
|
2358
2460
|
cycles = (sinceLast * clocksPerSecond) / 1000;
|
|
2359
|
-
cycles = Math.min(cycles,
|
|
2461
|
+
cycles = Math.min(cycles, MaxCyclesPerTick);
|
|
2360
2462
|
} else {
|
|
2361
2463
|
cycles = clocksPerSecond / 50;
|
|
2362
2464
|
}
|
|
@@ -2365,14 +2467,20 @@ function draw(now) {
|
|
|
2365
2467
|
if (!processor.execute(cycles)) {
|
|
2366
2468
|
stop(true);
|
|
2367
2469
|
}
|
|
2470
|
+
audioHandler.flushChipEvents();
|
|
2368
2471
|
const end = performance.now();
|
|
2369
2472
|
virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2473
|
+
let snapshotMs = 0;
|
|
2474
|
+
rewindCycleCounter += cycles;
|
|
2475
|
+
if (rewindCycleCounter >= RewindCaptureCycles) {
|
|
2476
|
+
rewindCycleCounter -= RewindCaptureCycles;
|
|
2373
2477
|
rewindBuffer.push(processor.snapshotState());
|
|
2374
2478
|
rewindUI.updateButtonState();
|
|
2479
|
+
snapshotMs = performance.now() - end;
|
|
2375
2480
|
}
|
|
2481
|
+
if (audioStatsNode)
|
|
2482
|
+
logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
|
|
2483
|
+
paintMsThisTick = 0;
|
|
2376
2484
|
} catch (e) {
|
|
2377
2485
|
running = false;
|
|
2378
2486
|
utils.noteEvent("exception", "thrown", e.stack);
|
|
@@ -2383,11 +2491,29 @@ function draw(now) {
|
|
|
2383
2491
|
stop(false);
|
|
2384
2492
|
}
|
|
2385
2493
|
}
|
|
2386
|
-
last = now;
|
|
2494
|
+
last = Math.max(last, now);
|
|
2495
|
+
lastEnd = performance.now();
|
|
2387
2496
|
}
|
|
2388
2497
|
|
|
2389
2498
|
function run() {
|
|
2390
|
-
|
|
2499
|
+
scheduleTick(0);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// A change of audio buffer depth is taken by the picture, not the sound:
|
|
2503
|
+
// gaining lead emulates ahead at once; losing it moves `last` forward so the
|
|
2504
|
+
// ticks emulate nothing until the queue has drained by that much.
|
|
2505
|
+
let emulationLeadMs = 0;
|
|
2506
|
+
|
|
2507
|
+
function setEmulationLead(leadMs) {
|
|
2508
|
+
if (!running) return;
|
|
2509
|
+
const aheadMs = leadMs - emulationLeadMs;
|
|
2510
|
+
emulationLeadMs = leadMs;
|
|
2511
|
+
if (aheadMs > 0) {
|
|
2512
|
+
if (!processor.execute((aheadMs * clocksPerSecond) / 1000)) stop(true);
|
|
2513
|
+
audioHandler.flushChipEvents();
|
|
2514
|
+
} else {
|
|
2515
|
+
last -= aheadMs;
|
|
2516
|
+
}
|
|
2391
2517
|
}
|
|
2392
2518
|
|
|
2393
2519
|
let wasPreviouslyRunning = false;
|