jsbeeb 1.19.3 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/google-drive.js +11 -1
- package/src/main.js +137 -41
- package/src/soundchip.js +67 -6
- package/src/web/audio-handler.js +107 -41
- package/src/web/audio-renderer.js +148 -67
package/README.md
CHANGED
|
@@ -287,7 +287,8 @@ 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.
|
|
291
292
|
|
|
292
293
|
### Atom-specific parameters
|
|
293
294
|
|
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.0",
|
|
11
11
|
"//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=24.15.0"
|
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
|
@@ -145,6 +145,7 @@ const paramTypes = {
|
|
|
145
145
|
frameSkip: ParamTypes.INT,
|
|
146
146
|
audiofilterfreq: ParamTypes.FLOAT,
|
|
147
147
|
audiofilterq: ParamTypes.FLOAT,
|
|
148
|
+
audioLatencyMs: ParamTypes.FLOAT,
|
|
148
149
|
cpuMultiplier: ParamTypes.FLOAT,
|
|
149
150
|
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
150
151
|
microphoneChannel: ParamTypes.INT,
|
|
@@ -372,7 +373,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
372
373
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
373
374
|
const cpuSpeed = model.cyclesPerSecond;
|
|
374
375
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
375
|
-
const
|
|
376
|
+
const MaxCyclesPerTick = clocksPerSecond / 10;
|
|
376
377
|
|
|
377
378
|
let tryGl = true;
|
|
378
379
|
if (parsedQuery.glEnabled !== undefined) {
|
|
@@ -552,14 +553,40 @@ function swapCanvas(newFilterClass) {
|
|
|
552
553
|
const canvas = createCanvasForFilter(displayModeFilter);
|
|
553
554
|
displayModeFilter = canvas.filterClass;
|
|
554
555
|
|
|
556
|
+
// The emulator paints into its own framebuffer; flyback copies the finished
|
|
557
|
+
// frame into the canvas and an animation frame presents it, so a stalled
|
|
558
|
+
// display holds up the picture and not the emulation (issue #885).
|
|
559
|
+
const videoFb32 = new Uint32Array(canvas.fb32.length);
|
|
560
|
+
const pendingFrame = { minx: 0, miny: 0, maxx: 0, maxy: 0, frameCount: 0, lineGrid: new Uint8Array(0) };
|
|
561
|
+
let presentScheduled = false;
|
|
562
|
+
let paintMsThisTick = 0;
|
|
563
|
+
let presentMsMax = 0;
|
|
564
|
+
|
|
565
|
+
function present() {
|
|
566
|
+
presentScheduled = false;
|
|
567
|
+
const start = performance.now();
|
|
568
|
+
canvas.paint(pendingFrame.minx, pendingFrame.miny, pendingFrame.maxx, pendingFrame.maxy, pendingFrame);
|
|
569
|
+
presentMsMax = Math.max(presentMsMax, performance.now() - start);
|
|
570
|
+
}
|
|
571
|
+
|
|
555
572
|
video = new Video(
|
|
556
573
|
model.isMaster,
|
|
557
|
-
|
|
574
|
+
videoFb32,
|
|
558
575
|
function paint(minx, miny, maxx, maxy) {
|
|
559
576
|
frames++;
|
|
560
577
|
if (frames < frameSkip) return;
|
|
561
578
|
frames = 0;
|
|
562
|
-
|
|
579
|
+
const start = performance.now();
|
|
580
|
+
canvas.fb32.set(videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
|
|
581
|
+
if (pendingFrame.lineGrid.length !== this.lineGrid.length)
|
|
582
|
+
pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
|
|
583
|
+
pendingFrame.lineGrid.set(this.lineGrid);
|
|
584
|
+
Object.assign(pendingFrame, { minx, miny, maxx, maxy, frameCount: this.frameCount });
|
|
585
|
+
paintMsThisTick += performance.now() - start;
|
|
586
|
+
if (!presentScheduled) {
|
|
587
|
+
presentScheduled = true;
|
|
588
|
+
window.requestAnimationFrame(present);
|
|
589
|
+
}
|
|
563
590
|
},
|
|
564
591
|
{ isAtom: model.isAtom },
|
|
565
592
|
);
|
|
@@ -573,9 +600,11 @@ const audioHandler = new AudioHandler({
|
|
|
573
600
|
statsNode: audioStatsNode,
|
|
574
601
|
audioFilterFreq,
|
|
575
602
|
audioFilterQ,
|
|
603
|
+
audioLatencyMs: parsedQuery.audioLatencyMs,
|
|
576
604
|
noSeek,
|
|
577
605
|
cpuSpeed,
|
|
578
606
|
isAtom: model.isAtom,
|
|
607
|
+
hasMusic5000: config.hasMusic5000,
|
|
579
608
|
});
|
|
580
609
|
// Firefox will report that audio is suspended even when it will
|
|
581
610
|
// start playing without user interaction, so we need to delay a
|
|
@@ -724,7 +753,9 @@ setCrtPic(displayModeFilter);
|
|
|
724
753
|
|
|
725
754
|
window.addEventListener("blur", function () {
|
|
726
755
|
keyboard.clearKeys();
|
|
756
|
+
setEmulationLead(audioHandler.setWindowFocused(false));
|
|
727
757
|
});
|
|
758
|
+
window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
|
|
728
759
|
|
|
729
760
|
document.getElementById("fs").addEventListener("click", function (event) {
|
|
730
761
|
screenCanvas.requestFullscreen();
|
|
@@ -1694,20 +1725,16 @@ async function gdLoad(cat, layout) {
|
|
|
1694
1725
|
}
|
|
1695
1726
|
}
|
|
1696
1727
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1728
|
+
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
1729
|
+
// Loading the Google client holds the main thread for ~100ms, so it waits for
|
|
1730
|
+
// someone to ask for Drive.
|
|
1731
|
+
document.getElementById("open-drive-link").addEventListener("click", async function () {
|
|
1699
1732
|
try {
|
|
1700
|
-
|
|
1701
|
-
if (available) {
|
|
1702
|
-
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
|
|
1703
|
-
await gdAuth(true);
|
|
1704
|
-
}
|
|
1733
|
+
await googleDrive.initialise();
|
|
1705
1734
|
} catch (error) {
|
|
1706
|
-
|
|
1735
|
+
toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
|
|
1736
|
+
return false;
|
|
1707
1737
|
}
|
|
1708
|
-
})();
|
|
1709
|
-
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
1710
|
-
document.getElementById("open-drive-link").addEventListener("click", async function () {
|
|
1711
1738
|
const authed = await gdAuth(false);
|
|
1712
1739
|
if (authed) {
|
|
1713
1740
|
googleDriveModal.show();
|
|
@@ -2248,6 +2275,7 @@ function profileVideo(arg) {
|
|
|
2248
2275
|
}
|
|
2249
2276
|
|
|
2250
2277
|
let last = 0;
|
|
2278
|
+
let lastEnd = 0;
|
|
2251
2279
|
|
|
2252
2280
|
function VirtualSpeedUpdater() {
|
|
2253
2281
|
this.cycles = 0;
|
|
@@ -2281,8 +2309,51 @@ function VirtualSpeedUpdater() {
|
|
|
2281
2309
|
const virtualSpeedUpdater = new VirtualSpeedUpdater();
|
|
2282
2310
|
|
|
2283
2311
|
const rewindBuffer = new RewindBuffer(30);
|
|
2284
|
-
let
|
|
2285
|
-
const RewindCaptureInterval = 50; // ~1 second
|
|
2312
|
+
let rewindCycleCounter = 0;
|
|
2313
|
+
const RewindCaptureInterval = 50; // emulated frames, ~1 second
|
|
2314
|
+
const RewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
|
|
2315
|
+
|
|
2316
|
+
// Under ?audioDebug, one console line per second in which the emulator sat
|
|
2317
|
+
// idle between ticks or a tick ran long, or the audio queue underran or
|
|
2318
|
+
// dropped, so a click can be matched to a cause. The sound chip posts samples
|
|
2319
|
+
// throughout execute(), so only the idle time starves the audio queue.
|
|
2320
|
+
const AudioDebugLogIntervalMs = 1000;
|
|
2321
|
+
const AudioDebugSlowTickMs = 30;
|
|
2322
|
+
const audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
|
|
2323
|
+
const AudioDebugSlowPresentMs = 30;
|
|
2324
|
+
|
|
2325
|
+
function logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
|
|
2326
|
+
const log = audioDebugLog;
|
|
2327
|
+
if (log.start === 0) log.start = now;
|
|
2328
|
+
log.ticks++;
|
|
2329
|
+
log.cycles += cycles;
|
|
2330
|
+
log.maxIdle = Math.max(log.maxIdle, idleMs);
|
|
2331
|
+
log.maxExecute = Math.max(log.maxExecute, executeMs);
|
|
2332
|
+
log.maxPaint = Math.max(log.maxPaint, paintMs);
|
|
2333
|
+
log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
|
|
2334
|
+
if (now - log.start < AudioDebugLogIntervalMs) return;
|
|
2335
|
+
const audio = audioHandler.takeEventCounts();
|
|
2336
|
+
const present = presentMsMax;
|
|
2337
|
+
presentMsMax = 0;
|
|
2338
|
+
const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
|
|
2339
|
+
if (
|
|
2340
|
+
log.maxIdle > AudioDebugSlowTickMs ||
|
|
2341
|
+
log.maxExecute > AudioDebugSlowTickMs ||
|
|
2342
|
+
present > AudioDebugSlowPresentMs ||
|
|
2343
|
+
audio.stall ||
|
|
2344
|
+
audio.skip
|
|
2345
|
+
) {
|
|
2346
|
+
console.log(
|
|
2347
|
+
`${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / clocksPerSecond).toFixed(0)}ms, ` +
|
|
2348
|
+
`idle max ${log.maxIdle.toFixed(0)}ms, ` +
|
|
2349
|
+
`execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
|
|
2350
|
+
`present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
|
|
2351
|
+
`audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
log.start = now;
|
|
2355
|
+
log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
|
|
2356
|
+
}
|
|
2286
2357
|
|
|
2287
2358
|
rewindUI = new RewindUI({
|
|
2288
2359
|
rewindBuffer,
|
|
@@ -2315,21 +2386,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
|
|
|
2315
2386
|
if (drive) showDriveTracks(driveIndex);
|
|
2316
2387
|
}
|
|
2317
2388
|
|
|
2318
|
-
|
|
2389
|
+
// A timer, not requestAnimationFrame: a display presentation stall withholds
|
|
2390
|
+
// animation frames, and with them the sound chip's samples (issue #885).
|
|
2391
|
+
const TickMs = 10;
|
|
2392
|
+
let tickToken = null;
|
|
2393
|
+
|
|
2394
|
+
// A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
|
|
2395
|
+
// compositor does not hold the tick off too.
|
|
2396
|
+
function scheduleTick(delayMs) {
|
|
2397
|
+
const token = (tickToken = {});
|
|
2398
|
+
const fire = () => {
|
|
2399
|
+
if (tickToken === token) tick();
|
|
2400
|
+
};
|
|
2401
|
+
if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
|
|
2402
|
+
else window.setTimeout(fire, delayMs);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function tick() {
|
|
2319
2406
|
if (!running) {
|
|
2320
2407
|
last = 0;
|
|
2321
2408
|
return;
|
|
2322
2409
|
}
|
|
2323
|
-
|
|
2324
|
-
if (now === undefined) {
|
|
2325
|
-
now = window.performance.now();
|
|
2326
|
-
}
|
|
2410
|
+
const now = performance.now();
|
|
2327
2411
|
|
|
2328
2412
|
const motorOn = processor.acia.motorOn;
|
|
2329
|
-
const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
2330
2413
|
const speedy = fastAsPossible || (fastTape && motorOn);
|
|
2331
|
-
const useTimeout = speedy || motorOn || discOn;
|
|
2332
|
-
const timeout = speedy ? 0 : 1000.0 / 50;
|
|
2333
2414
|
|
|
2334
2415
|
// In speedy mode, we still run all the state machines accurately
|
|
2335
2416
|
// but we paint less often because painting is the most expensive
|
|
@@ -2338,25 +2419,16 @@ function draw(now) {
|
|
|
2338
2419
|
// modes, i.e. MODE 7, still look ok.
|
|
2339
2420
|
video.frameSkipCount = speedy ? 9 : 0;
|
|
2340
2421
|
|
|
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
|
-
}
|
|
2422
|
+
scheduleTick(speedy ? 0 : TickMs);
|
|
2349
2423
|
|
|
2350
|
-
audioHandler.soundChip.catchUp();
|
|
2351
2424
|
gamepad.update(processor.sysvia);
|
|
2352
2425
|
syncLights();
|
|
2353
2426
|
if (last !== 0) {
|
|
2354
2427
|
let cycles;
|
|
2355
2428
|
if (!speedy) {
|
|
2356
|
-
|
|
2357
|
-
const sinceLast = now - last;
|
|
2429
|
+
const sinceLast = Math.max(0, now - last);
|
|
2358
2430
|
cycles = (sinceLast * clocksPerSecond) / 1000;
|
|
2359
|
-
cycles = Math.min(cycles,
|
|
2431
|
+
cycles = Math.min(cycles, MaxCyclesPerTick);
|
|
2360
2432
|
} else {
|
|
2361
2433
|
cycles = clocksPerSecond / 50;
|
|
2362
2434
|
}
|
|
@@ -2365,14 +2437,20 @@ function draw(now) {
|
|
|
2365
2437
|
if (!processor.execute(cycles)) {
|
|
2366
2438
|
stop(true);
|
|
2367
2439
|
}
|
|
2440
|
+
audioHandler.flushChipEvents();
|
|
2368
2441
|
const end = performance.now();
|
|
2369
2442
|
virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2443
|
+
let snapshotMs = 0;
|
|
2444
|
+
rewindCycleCounter += cycles;
|
|
2445
|
+
if (rewindCycleCounter >= RewindCaptureCycles) {
|
|
2446
|
+
rewindCycleCounter -= RewindCaptureCycles;
|
|
2373
2447
|
rewindBuffer.push(processor.snapshotState());
|
|
2374
2448
|
rewindUI.updateButtonState();
|
|
2449
|
+
snapshotMs = performance.now() - end;
|
|
2375
2450
|
}
|
|
2451
|
+
if (audioStatsNode)
|
|
2452
|
+
logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
|
|
2453
|
+
paintMsThisTick = 0;
|
|
2376
2454
|
} catch (e) {
|
|
2377
2455
|
running = false;
|
|
2378
2456
|
utils.noteEvent("exception", "thrown", e.stack);
|
|
@@ -2383,11 +2461,29 @@ function draw(now) {
|
|
|
2383
2461
|
stop(false);
|
|
2384
2462
|
}
|
|
2385
2463
|
}
|
|
2386
|
-
last = now;
|
|
2464
|
+
last = Math.max(last, now);
|
|
2465
|
+
lastEnd = performance.now();
|
|
2387
2466
|
}
|
|
2388
2467
|
|
|
2389
2468
|
function run() {
|
|
2390
|
-
|
|
2469
|
+
scheduleTick(0);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// A change of audio buffer depth is taken by the picture, not the sound:
|
|
2473
|
+
// gaining lead emulates ahead at once; losing it moves `last` forward so the
|
|
2474
|
+
// ticks emulate nothing until the queue has drained by that much.
|
|
2475
|
+
let emulationLeadMs = 0;
|
|
2476
|
+
|
|
2477
|
+
function setEmulationLead(leadMs) {
|
|
2478
|
+
if (!running) return;
|
|
2479
|
+
const aheadMs = leadMs - emulationLeadMs;
|
|
2480
|
+
emulationLeadMs = leadMs;
|
|
2481
|
+
if (aheadMs > 0) {
|
|
2482
|
+
if (!processor.execute((aheadMs * clocksPerSecond) / 1000)) stop(true);
|
|
2483
|
+
audioHandler.flushChipEvents();
|
|
2484
|
+
} else {
|
|
2485
|
+
last -= aheadMs;
|
|
2486
|
+
}
|
|
2391
2487
|
}
|
|
2392
2488
|
|
|
2393
2489
|
let wasPreviouslyRunning = false;
|
package/src/soundchip.js
CHANGED
|
@@ -12,6 +12,10 @@ export const SoundBufferSamples = 512;
|
|
|
12
12
|
// mean level, which a zero-mean output would silence (see issue #863).
|
|
13
13
|
const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
|
|
14
14
|
|
|
15
|
+
// A chip with an event sink reports progress this often, so its events can
|
|
16
|
+
// stream through a long execute() rather than all arriving at its end.
|
|
17
|
+
const EventProgressCycles = 4000;
|
|
18
|
+
|
|
15
19
|
const volumeTable = new Float32Array(16);
|
|
16
20
|
(() => {
|
|
17
21
|
let f = 1.0;
|
|
@@ -35,9 +39,15 @@ export class SoundChip {
|
|
|
35
39
|
* @param {function(Float32Array): void} onBuffer called with each full
|
|
36
40
|
* SoundBufferSamples-sized buffer of output. Receives the same buffer
|
|
37
41
|
* every call, overwritten afterwards: copy the contents if they are kept.
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {function(object): void} [options.onEvent] called with each
|
|
44
|
+
* change to the chip's state, stamped with the cycle it takes effect at.
|
|
45
|
+
* A chip with an event sink does not render; something else renders
|
|
46
|
+
* from the events (see audio-renderer.js).
|
|
38
47
|
*/
|
|
39
|
-
constructor(onBuffer) {
|
|
48
|
+
constructor(onBuffer, { onEvent = null } = {}) {
|
|
40
49
|
this._onBuffer = onBuffer;
|
|
50
|
+
this._onEvent = onEvent;
|
|
41
51
|
// 4MHz input signal. Internal divide-by-8
|
|
42
52
|
this.soundchipFreq = 4000000.0 / 8;
|
|
43
53
|
const sampleRate = this.soundchipFreq;
|
|
@@ -91,15 +101,38 @@ export class SoundChip {
|
|
|
91
101
|
mute: () => {
|
|
92
102
|
this.catchUp();
|
|
93
103
|
this.sineOn = false;
|
|
104
|
+
this._emit({ sine: 0 });
|
|
94
105
|
},
|
|
95
106
|
tone: (freq) => {
|
|
96
107
|
this.catchUp();
|
|
97
108
|
this.sineOn = true;
|
|
98
109
|
this.sineStep = (freq / sampleRate) * this.sineTable.length;
|
|
110
|
+
this._emit({ sine: freq });
|
|
99
111
|
},
|
|
100
112
|
};
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
_emit(event) {
|
|
116
|
+
if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, ...event });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Applies an event from another chip's onEvent, at the cycle this chip is rendering. */
|
|
120
|
+
applyEvent(event) {
|
|
121
|
+
if (event.poke !== undefined) this.poke(event.poke);
|
|
122
|
+
else if (event.sine !== undefined) {
|
|
123
|
+
if (event.sine) this.toneGenerator.tone(event.sine);
|
|
124
|
+
else this.toneGenerator.mute();
|
|
125
|
+
} else if (event.enabled !== undefined) this.enabled = event.enabled;
|
|
126
|
+
else if (event.state !== undefined) this.restoreState(event.state);
|
|
127
|
+
else if (event.reset !== undefined) this.reset(event.reset);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Renders `length` samples of output from `cycle`, for a chip that is driven by events. */
|
|
131
|
+
renderAt(cycle, out, offset, length) {
|
|
132
|
+
this.scheduler.epoch = this.lastRunEpoch = cycle;
|
|
133
|
+
this.generate(out, offset, length);
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
sineChannel(channel, out, offset, length) {
|
|
104
137
|
if (!this.sineOn) return;
|
|
105
138
|
|
|
@@ -181,6 +214,7 @@ export class SoundChip {
|
|
|
181
214
|
this.volume[2] = volumeTable[v2];
|
|
182
215
|
this.volume[3] = volumeTable[v3];
|
|
183
216
|
this.noisePoked();
|
|
217
|
+
this._emit({ state: this.snapshotState() });
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
generate(out, offset, length) {
|
|
@@ -219,6 +253,13 @@ export class SoundChip {
|
|
|
219
253
|
this.activeTask = this.scheduler.newTask(() => {
|
|
220
254
|
if (this.active) this.poke(this.slowDataBus);
|
|
221
255
|
});
|
|
256
|
+
if (this._onEvent) {
|
|
257
|
+
this.progressTask = this.scheduler.newTask(() => {
|
|
258
|
+
this._emit({ progress: true });
|
|
259
|
+
this.progressTask.schedule(EventProgressCycles);
|
|
260
|
+
});
|
|
261
|
+
this.progressTask.schedule(EventProgressCycles);
|
|
262
|
+
}
|
|
222
263
|
}
|
|
223
264
|
|
|
224
265
|
render(out, offset, length) {
|
|
@@ -239,6 +280,7 @@ export class SoundChip {
|
|
|
239
280
|
}
|
|
240
281
|
|
|
241
282
|
advance(cycles) {
|
|
283
|
+
if (this._onEvent) return;
|
|
242
284
|
const num = cycles * this.samplesPerCycle + this.residual;
|
|
243
285
|
let rounded = num | 0;
|
|
244
286
|
this.residual = num - rounded;
|
|
@@ -270,6 +312,7 @@ export class SoundChip {
|
|
|
270
312
|
|
|
271
313
|
poke(value) {
|
|
272
314
|
this.catchUp();
|
|
315
|
+
this._emit({ poke: value });
|
|
273
316
|
|
|
274
317
|
let command;
|
|
275
318
|
if (value & 0x80) {
|
|
@@ -350,10 +393,13 @@ export class SoundChip {
|
|
|
350
393
|
// Older snapshots predate the DC blocker
|
|
351
394
|
this.dcPrevIn = state.dcPrevIn ?? 0;
|
|
352
395
|
this.dcPrevOut = state.dcPrevOut ?? 0;
|
|
396
|
+
this.progressTask?.ensureScheduled(true, EventProgressCycles);
|
|
397
|
+
this._emit({ state: this.snapshotState() });
|
|
353
398
|
}
|
|
354
399
|
|
|
355
400
|
reset(hard) {
|
|
356
401
|
if (!hard) return;
|
|
402
|
+
this._emit({ reset: true });
|
|
357
403
|
for (let i = 0; i < 4; ++i) {
|
|
358
404
|
this.counter[i] = 0;
|
|
359
405
|
this.registers[i] = 0;
|
|
@@ -366,14 +412,15 @@ export class SoundChip {
|
|
|
366
412
|
|
|
367
413
|
enable(e) {
|
|
368
414
|
this.enabled = e;
|
|
415
|
+
this._emit({ enabled: e });
|
|
369
416
|
}
|
|
370
417
|
|
|
371
418
|
mute() {
|
|
372
|
-
this.
|
|
419
|
+
this.enable(false);
|
|
373
420
|
}
|
|
374
421
|
|
|
375
422
|
unmute() {
|
|
376
|
-
this.
|
|
423
|
+
this.enable(true);
|
|
377
424
|
}
|
|
378
425
|
}
|
|
379
426
|
|
|
@@ -383,8 +430,8 @@ export class SoundChip {
|
|
|
383
430
|
* channel with DC-blocking filter.
|
|
384
431
|
*/
|
|
385
432
|
export class AtomSoundChip extends SoundChip {
|
|
386
|
-
constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
|
|
387
|
-
super(onBuffer);
|
|
433
|
+
constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
|
|
434
|
+
super(onBuffer, { onEvent });
|
|
388
435
|
this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
|
|
389
436
|
this.secondsPerCycle = 1 / cpuSpeed;
|
|
390
437
|
|
|
@@ -423,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
|
|
|
423
470
|
this._speakerCycleOffset = 0;
|
|
424
471
|
}
|
|
425
472
|
|
|
473
|
+
applyEvent(event) {
|
|
474
|
+
if (event.bit !== undefined) this.bitChange.push({ bit: event.bit, cycles: event.cycle });
|
|
475
|
+
else if (event.speakerReset !== undefined) this.speakerReset();
|
|
476
|
+
else super.applyEvent(event);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
renderAt(cycle, out, offset, length) {
|
|
480
|
+
this._speakerCycleOffset = 0;
|
|
481
|
+
super.renderAt(cycle, out, offset, length);
|
|
482
|
+
}
|
|
483
|
+
|
|
426
484
|
speakerReset() {
|
|
485
|
+
this._emit({ speakerReset: true });
|
|
427
486
|
this.bitChange = [];
|
|
428
487
|
this.currentSpeakerBit = 0.0;
|
|
429
488
|
this._speakerPrevIn = 0;
|
|
@@ -461,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
|
|
|
461
520
|
|
|
462
521
|
updateSpeaker(value, microCycle, seconds) {
|
|
463
522
|
const cycles = microCycle + seconds / this.secondsPerCycle;
|
|
464
|
-
|
|
523
|
+
const bit = value ? 1.0 : 0.0;
|
|
524
|
+
if (this._onEvent) this._onEvent({ cycle: cycles, bit });
|
|
525
|
+
else this.bitChange.push({ bit, cycles });
|
|
465
526
|
}
|
|
466
527
|
}
|
|
467
528
|
|
package/src/web/audio-handler.js
CHANGED
|
@@ -6,19 +6,39 @@ import { createAudioContext } from "../audio-utils.js";
|
|
|
6
6
|
import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
|
|
7
7
|
import { toast } from "./toast.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import rendererUrl from "./audio-renderer.js?worker&url";
|
|
10
|
+
import music5000WorkletUrl from "../music5000-worklet.js?worker&url";
|
|
11
|
+
|
|
12
|
+
// Skips plot as the milliseconds skipped, on the lead scale; stalls as a fixed spike.
|
|
13
|
+
const StallSpikeHeight = 20;
|
|
14
|
+
|
|
15
|
+
// Nobody is watching an unfocused window, so its sound can run far behind
|
|
16
|
+
// the picture, deep enough to ride out the browser starving the tab.
|
|
17
|
+
export const UnfocusedLatencyMs = 200;
|
|
18
|
+
export const DefaultLatencyMs = 20;
|
|
13
19
|
|
|
14
20
|
export class AudioHandler {
|
|
15
|
-
constructor({
|
|
21
|
+
constructor({
|
|
22
|
+
warningNode,
|
|
23
|
+
statsNode,
|
|
24
|
+
audioFilterFreq,
|
|
25
|
+
audioFilterQ,
|
|
26
|
+
audioLatencyMs,
|
|
27
|
+
noSeek,
|
|
28
|
+
cpuSpeed,
|
|
29
|
+
isAtom,
|
|
30
|
+
hasMusic5000,
|
|
31
|
+
} = {}) {
|
|
16
32
|
this.cpuSpeed = cpuSpeed;
|
|
17
33
|
this.isAtom = isAtom;
|
|
34
|
+
this.audioLatencyMs = audioLatencyMs ?? DefaultLatencyMs;
|
|
35
|
+
this.windowFocused = document.hasFocus();
|
|
18
36
|
this.warningNode = warningNode;
|
|
19
37
|
this.noAudio = false;
|
|
20
38
|
toggle(this.warningNode, false);
|
|
21
39
|
this.stats = {};
|
|
40
|
+
this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
|
|
41
|
+
this._chipEvents = [];
|
|
22
42
|
if (statsNode) {
|
|
23
43
|
this._initStats(statsNode).catch((error) => {
|
|
24
44
|
console.error("Unable to initialise audio stats", error);
|
|
@@ -30,10 +50,13 @@ export class AudioHandler {
|
|
|
30
50
|
this._jsAudioNode = null;
|
|
31
51
|
if (this.audioContext && this.audioContext.audioWorklet) {
|
|
32
52
|
this.audioContext.onstatechange = () => this.checkStatus();
|
|
33
|
-
const
|
|
53
|
+
const onEvent = (event) => {
|
|
54
|
+
if (event.progress) this.flushChipEvents();
|
|
55
|
+
else this._chipEvents.push(event);
|
|
56
|
+
};
|
|
34
57
|
this.soundChip = this.isAtom
|
|
35
|
-
? new AtomSoundChip(
|
|
36
|
-
: new SoundChip(
|
|
58
|
+
? new AtomSoundChip(null, { cpuSpeed: this.cpuSpeed, onEvent })
|
|
59
|
+
: new SoundChip(null, { onEvent });
|
|
37
60
|
// Master gain node for all sample-based audio (disc, relay, etc.).
|
|
38
61
|
this.masterGain = this.audioContext.createGain();
|
|
39
62
|
this.masterGain.connect(this.audioContext.destination);
|
|
@@ -60,31 +83,31 @@ export class AudioHandler {
|
|
|
60
83
|
|
|
61
84
|
this.warningNode.addEventListener("mousedown", () => this.tryResume());
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
this.audioContextM5000 = null;
|
|
87
|
+
this._music5000workletnode = null;
|
|
88
|
+
this.music5000 = hasMusic5000 ? this._createMusic5000() : new FakeMusic5000();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The Music 5000 gets its own context, running at the board's own sample rate.
|
|
92
|
+
_createMusic5000() {
|
|
93
|
+
if (!this.audioContext?.audioWorklet) return new FakeMusic5000();
|
|
94
|
+
const context = createAudioContext({ sampleRate: 46875 });
|
|
95
|
+
this.audioContextM5000 = context;
|
|
96
|
+
context.onstatechange = () => this.checkStatus();
|
|
97
|
+
context.audioWorklet
|
|
98
|
+
.addModule(music5000WorkletUrl)
|
|
99
|
+
.then(() => {
|
|
100
|
+
this._music5000workletnode = new AudioWorkletNode(context, "music5000", { outputChannelCount: [2] });
|
|
101
|
+
this._music5000workletnode.connect(context.destination);
|
|
102
|
+
})
|
|
103
|
+
.catch((error) => {
|
|
104
|
+
console.error("Unable to initialise Music 5000 audio", error);
|
|
105
|
+
toast(
|
|
106
|
+
`The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
|
|
107
|
+
{ title: "Music 5000", quietKey: "quietMusic5000Audio" },
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
return new Music5000((buffer) => this._onBufferMusic5000(buffer));
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
// Lazily load smoothie and set up the audio stats chart.
|
|
@@ -98,8 +121,9 @@ export class AudioHandler {
|
|
|
98
121
|
return { min: 0, max: range.max };
|
|
99
122
|
},
|
|
100
123
|
});
|
|
101
|
-
this._addStat("
|
|
102
|
-
this._addStat("
|
|
124
|
+
this._addStat("leadMs", { strokeStyle: "rgb(51,126,108)" });
|
|
125
|
+
this._addStat("stall", { strokeStyle: "rgb(220,50,50)", lineWidth: 2 });
|
|
126
|
+
this._addStat("skip", { strokeStyle: "rgb(120,80,200)", lineWidth: 2 });
|
|
103
127
|
this.chart.streamTo(statsNode, 100);
|
|
104
128
|
}
|
|
105
129
|
|
|
@@ -116,16 +140,54 @@ export class AudioHandler {
|
|
|
116
140
|
this._audioDestination = this.audioContext.destination;
|
|
117
141
|
}
|
|
118
142
|
|
|
119
|
-
this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor"
|
|
143
|
+
this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
|
|
144
|
+
processorOptions: {
|
|
145
|
+
targetLatencyMs: this._targetLatencyMs(),
|
|
146
|
+
isAtom: this.isAtom,
|
|
147
|
+
cpuSpeed: this.cpuSpeed,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
120
150
|
this._jsAudioNode.connect(this._audioDestination);
|
|
121
151
|
this._jsAudioNode.port.onmessage = (event) => {
|
|
122
152
|
const now = Date.now();
|
|
153
|
+
if (event.data.event) {
|
|
154
|
+
this._onAudioEvent(now, event.data);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.eventCounts.leadMinMs = Math.min(this.eventCounts.leadMinMs, event.data.leadMinMs);
|
|
123
158
|
for (const stat of Object.keys(event.data)) {
|
|
124
159
|
if (this.stats[stat]) this.stats[stat].append(now, event.data[stat]);
|
|
125
160
|
}
|
|
126
161
|
};
|
|
127
162
|
}
|
|
128
163
|
|
|
164
|
+
_onAudioEvent(now, { event, count }) {
|
|
165
|
+
this.eventCounts[event] += count;
|
|
166
|
+
const series = this.stats[event];
|
|
167
|
+
if (!series) return;
|
|
168
|
+
series.append(now - 1, 0);
|
|
169
|
+
series.append(now, event === "stall" ? StallSpikeHeight : count);
|
|
170
|
+
series.append(now + 1, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_targetLatencyMs() {
|
|
174
|
+
return this.windowFocused ? this.audioLatencyMs : UnfocusedLatencyMs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Returns how far ahead of the sound the picture should now run, in ms.
|
|
178
|
+
setWindowFocused(focused) {
|
|
179
|
+
this.windowFocused = focused;
|
|
180
|
+
const targetLatencyMs = this._targetLatencyMs();
|
|
181
|
+
this._jsAudioNode?.port.postMessage({ command: "setTargetLatency", targetLatencyMs });
|
|
182
|
+
return targetLatencyMs - this.audioLatencyMs;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
takeEventCounts() {
|
|
186
|
+
const counts = this.eventCounts;
|
|
187
|
+
this.eventCounts = { stall: 0, skip: 0, leadMinMs: Infinity };
|
|
188
|
+
return counts;
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
_audioUnavailable(error) {
|
|
130
192
|
console.error("Unable to initialise audio", error);
|
|
131
193
|
this.noAudio = true;
|
|
@@ -140,11 +202,12 @@ export class AudioHandler {
|
|
|
140
202
|
this.chart.addTimeSeries(timeSeries, info);
|
|
141
203
|
}
|
|
142
204
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
205
|
+
// Ships the chip's state changes since the last call, and how far the
|
|
206
|
+
// emulator has got, so the worklet knows its lead even when nothing changed.
|
|
207
|
+
flushChipEvents() {
|
|
208
|
+
if (!this._jsAudioNode) return;
|
|
209
|
+
this._jsAudioNode.port.postMessage({ upTo: this.soundChip.scheduler.epoch, events: this._chipEvents });
|
|
210
|
+
this._chipEvents = [];
|
|
148
211
|
}
|
|
149
212
|
|
|
150
213
|
// Recent browsers, particularly Safari and Chrome, require a user interaction in order to enable sound playback.
|
|
@@ -177,13 +240,16 @@ export class AudioHandler {
|
|
|
177
240
|
await this.relayNoise.initialise();
|
|
178
241
|
}
|
|
179
242
|
|
|
243
|
+
// The emulator is stopping, so no tick will ship the change; send it now.
|
|
180
244
|
mute() {
|
|
181
245
|
this.soundChip.mute();
|
|
246
|
+
this.flushChipEvents();
|
|
182
247
|
if (this.masterGain) this.masterGain.gain.value = 0;
|
|
183
248
|
}
|
|
184
249
|
|
|
185
250
|
unmute() {
|
|
186
251
|
this.soundChip.unmute();
|
|
252
|
+
this.flushChipEvents();
|
|
187
253
|
if (this.masterGain) this.masterGain.gain.value = 1;
|
|
188
254
|
}
|
|
189
255
|
}
|
|
@@ -1,115 +1,190 @@
|
|
|
1
1
|
/* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
|
|
2
|
+
import { SoundChip, AtomSoundChip } from "../soundchip.js";
|
|
2
3
|
|
|
3
4
|
const lowPassFilterFreq = sampleRate / 2;
|
|
4
5
|
const RC = 1 / (2 * Math.PI * lowPassFilterFreq);
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
7
|
+
const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
|
|
8
|
+
const MaxTargetLatencyMs = 250;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// Smoothing rejects the producer's per-frame bursts; proportional only, as
|
|
12
|
-
// occupancy already integrates rate error; 0.05% authority covers clock skew
|
|
10
|
+
// Smoothing rejects the producer's per-tick bursts; proportional only, as
|
|
11
|
+
// lead already integrates rate error; 0.05% authority covers clock skew
|
|
13
12
|
// without audibly bending pitch.
|
|
14
|
-
const
|
|
13
|
+
const LeadSmoothingTau = 0.5;
|
|
15
14
|
const ProportionalGain = 0.2;
|
|
16
|
-
const
|
|
15
|
+
const MaxAdjustFraction = 0.0005;
|
|
16
|
+
|
|
17
|
+
const isResync = (event) => event.state !== undefined || event.reset !== undefined;
|
|
17
18
|
|
|
19
|
+
// Renders the chip from its timestamped state changes, so a producer that
|
|
20
|
+
// falls behind leaves the chip sounding its current state (a stall) rather
|
|
21
|
+
// than silent; the missed time is skipped once the producer is ahead again.
|
|
18
22
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
19
|
-
constructor(
|
|
20
|
-
super(
|
|
23
|
+
constructor(options) {
|
|
24
|
+
super(options);
|
|
25
|
+
const { isAtom = false, cpuSpeed = 1000000, targetLatencyMs } = options?.processorOptions ?? {};
|
|
26
|
+
this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
|
|
27
|
+
this.inputSampleRate = this.chip.soundchipFreq;
|
|
28
|
+
this.samplesPerCycle = this.chip.samplesPerCycle;
|
|
29
|
+
|
|
30
|
+
this.events = [];
|
|
31
|
+
this.eventsHead = 0;
|
|
32
|
+
this.clock = 0;
|
|
33
|
+
this.upTo = 0;
|
|
34
|
+
this.stalled = true;
|
|
35
|
+
this.stalls = 0;
|
|
36
|
+
this.skippedMs = 0;
|
|
37
|
+
this.minLeadMs = Infinity;
|
|
21
38
|
|
|
22
|
-
this.inputSampleRate = InputSampleRate;
|
|
23
|
-
this._lastSample = 0;
|
|
24
39
|
this._lastFilteredOutput = 0;
|
|
25
40
|
this._phase = 0;
|
|
26
41
|
this._source = new Float32Array(0);
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
30
|
-
this.underruns = 0;
|
|
31
|
-
this.targetLatencyMs = 1000 * (1 / 50); // One frame
|
|
32
|
-
this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
|
|
33
|
-
this.smoothedOccupancyError = 0;
|
|
34
|
-
this.running = false;
|
|
35
|
-
this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
|
|
42
|
+
this._rendered = new Float32Array(0);
|
|
43
|
+
this.smoothedLeadError = 0;
|
|
44
|
+
this.setTargetLatency(targetLatencyMs);
|
|
36
45
|
this.port.onmessage = (event) => {
|
|
37
|
-
|
|
38
|
-
this.
|
|
46
|
+
if (event.data.command === "setTargetLatency") this.setTargetLatency(event.data.targetLatencyMs);
|
|
47
|
+
else this.onProduced(event.data.upTo, event.data.events);
|
|
39
48
|
};
|
|
40
49
|
this.nextStats = 0;
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
setTargetLatency(ms) {
|
|
53
|
+
const valid = Number.isFinite(ms) && ms > 0;
|
|
54
|
+
this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
|
|
55
|
+
this.targetLeadCycles = this._cycles(this.targetLatencyMs);
|
|
56
|
+
this.smoothedLeadError = 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_cycles(ms) {
|
|
60
|
+
return ((ms / 1000) * this.inputSampleRate) / this.samplesPerCycle;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_ms(cycles) {
|
|
64
|
+
return (1000 * cycles * this.samplesPerCycle) / this.inputSampleRate;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
leadMs() {
|
|
68
|
+
return this._ms(this.upTo - this.clock);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A resync (restore or reset) starts a new timeline; anything queued
|
|
72
|
+
// before it belongs to the old one.
|
|
73
|
+
onProduced(upTo, events) {
|
|
74
|
+
let from = 0;
|
|
75
|
+
for (let i = events.length - 1; i >= 0; --i) {
|
|
76
|
+
if (isResync(events[i])) {
|
|
77
|
+
from = i;
|
|
78
|
+
this.events = [];
|
|
79
|
+
this.eventsHead = 0;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (this.eventsHead > 0) {
|
|
84
|
+
this.events = this.events.slice(this.eventsHead);
|
|
85
|
+
this.eventsHead = 0;
|
|
86
|
+
}
|
|
87
|
+
for (let i = from; i < events.length; ++i) this.events.push(events[i]);
|
|
88
|
+
this.upTo = upTo;
|
|
89
|
+
}
|
|
90
|
+
|
|
43
91
|
stats(sampleRatio) {
|
|
44
92
|
if (currentTime < this.nextStats) return;
|
|
45
93
|
this.nextStats = currentTime + 0.25;
|
|
46
94
|
this.port.postMessage({
|
|
47
95
|
sampleRate: sampleRate,
|
|
48
96
|
inputSampleRate: this.inputSampleRate,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
97
|
+
stalls: this.stalls,
|
|
98
|
+
skippedMs: this.skippedMs,
|
|
99
|
+
leadMs: this.leadMs(),
|
|
100
|
+
leadMinMs: this.minLeadMs,
|
|
101
|
+
queuedEvents: this.events.length - this.eventsHead,
|
|
53
102
|
sampleRatio: sampleRatio,
|
|
54
103
|
});
|
|
104
|
+
this.minLeadMs = Infinity;
|
|
55
105
|
}
|
|
56
106
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const timeInBufferMs = 1000 * (this.queue[0].offset / this.inputSampleRate) + this.queue[0].time;
|
|
60
|
-
return Date.now() - timeInBufferMs;
|
|
107
|
+
_notify(event, count) {
|
|
108
|
+
this.port.postMessage({ event, count });
|
|
61
109
|
}
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
111
|
+
_effectiveSampleRate(dtSeconds) {
|
|
112
|
+
const error = this.upTo - this.clock - this.targetLeadCycles;
|
|
113
|
+
const alpha = Math.min(1, dtSeconds / LeadSmoothingTau);
|
|
114
|
+
this.smoothedLeadError += alpha * (error - this.smoothedLeadError);
|
|
115
|
+
const adjustment = ProportionalGain * this.smoothedLeadError * this.samplesPerCycle;
|
|
116
|
+
const maxAdjust = this.inputSampleRate * MaxAdjustFraction;
|
|
117
|
+
return this.inputSampleRate + Math.min(maxAdjust, Math.max(-maxAdjust, adjustment));
|
|
65
118
|
}
|
|
66
119
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const alpha = Math.min(1, dtSeconds / OccupancySmoothingTau);
|
|
70
|
-
this.smoothedOccupancyError += alpha * (error - this.smoothedOccupancyError);
|
|
71
|
-
const adjustment = ProportionalGain * this.smoothedOccupancyError;
|
|
72
|
-
return this.inputSampleRate + Math.min(MaxAdjust, Math.max(-MaxAdjust, adjustment));
|
|
120
|
+
_applyHead() {
|
|
121
|
+
this.chip.applyEvent(this.events[this.eventsHead++]);
|
|
73
122
|
}
|
|
74
123
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
124
|
+
// Applies every queued change up to `cycle` at once and moves the clock
|
|
125
|
+
// there, so the sound continues from the producer's state without a gap.
|
|
126
|
+
_skipTo(cycle) {
|
|
127
|
+
while (this.eventsHead < this.events.length && this.events[this.eventsHead].cycle <= cycle) this._applyHead();
|
|
128
|
+
this.skippedMs += this._ms(cycle - this.clock);
|
|
129
|
+
this.clock = cycle;
|
|
80
130
|
}
|
|
81
131
|
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
this.
|
|
132
|
+
_restart() {
|
|
133
|
+
const skipTo = this.upTo - this.targetLeadCycles;
|
|
134
|
+
if (skipTo > this.clock) {
|
|
135
|
+
this._notify("skip", this._ms(skipTo - this.clock));
|
|
136
|
+
this._skipTo(skipTo);
|
|
137
|
+
}
|
|
138
|
+
this.stalled = false;
|
|
85
139
|
}
|
|
86
140
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
141
|
+
_stall(out, offset, length) {
|
|
142
|
+
if (!this.stalled) {
|
|
143
|
+
this.stalled = true;
|
|
144
|
+
this.stalls++;
|
|
145
|
+
this._notify("stall", 1);
|
|
92
146
|
}
|
|
147
|
+
this.chip.renderAt(this.clock, out, offset, length);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Input samples before the next change of state: the head event, or
|
|
151
|
+
// the producer's position, whichever the clock reaches first. A resync
|
|
152
|
+
// takes effect at once, since it starts a new timeline.
|
|
153
|
+
_samplesUntilNextChange() {
|
|
154
|
+
const head = this.events[this.eventsHead];
|
|
155
|
+
if (head !== undefined && isResync(head)) return 0;
|
|
156
|
+
const boundary = head === undefined ? this.upTo : Math.min(head.cycle, this.upTo);
|
|
157
|
+
return Math.floor((boundary - this.clock) * this.samplesPerCycle);
|
|
93
158
|
}
|
|
94
159
|
|
|
95
|
-
|
|
96
|
-
if (this.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
_renderInput(out, length) {
|
|
161
|
+
if (this.stalled) {
|
|
162
|
+
this._stall(out, 0, length);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let offset = 0;
|
|
166
|
+
while (length > 0) {
|
|
167
|
+
const n = Math.min(length, this._samplesUntilNextChange());
|
|
168
|
+
if (n > 0) {
|
|
169
|
+
this.chip.renderAt(this.clock, out, offset, n);
|
|
170
|
+
this.clock += n / this.samplesPerCycle;
|
|
171
|
+
offset += n;
|
|
172
|
+
length -= n;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const head = this.events[this.eventsHead];
|
|
176
|
+
if (head === undefined) {
|
|
177
|
+
this._stall(out, offset, length);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (isResync(head)) this.clock = head.cycle;
|
|
181
|
+
this._applyHead();
|
|
103
182
|
}
|
|
104
|
-
return this._lastSample;
|
|
105
183
|
}
|
|
106
184
|
|
|
107
185
|
process(inputs, outputs) {
|
|
108
|
-
this.
|
|
109
|
-
if (this.queue.length === 0) return true;
|
|
186
|
+
if (this.stalled && this.upTo - this.clock >= this.targetLeadCycles) this._restart();
|
|
110
187
|
|
|
111
|
-
// I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
|
|
112
|
-
// but we fiddle the sample rate here to catch up with the target latency, which is harder to do with that API.
|
|
113
188
|
const channel = outputs[0][0];
|
|
114
189
|
const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
|
|
115
190
|
const sampleRatio = effectiveSampleRate / sampleRate;
|
|
@@ -122,12 +197,17 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
122
197
|
// boundary. source[0] is the last input sample of the previous quantum.
|
|
123
198
|
const end = this._phase + channel.length * sampleRatio;
|
|
124
199
|
const numInputSamples = Math.floor(end);
|
|
125
|
-
if (this._source.length <= numInputSamples)
|
|
200
|
+
if (this._source.length <= numInputSamples) {
|
|
201
|
+
this._source = new Float32Array(numInputSamples * 2);
|
|
202
|
+
this._rendered = new Float32Array(numInputSamples * 2);
|
|
203
|
+
}
|
|
126
204
|
const source = this._source;
|
|
205
|
+
const rendered = this._rendered;
|
|
206
|
+
this._renderInput(rendered, numInputSamples);
|
|
127
207
|
source[0] = this._lastFilteredOutput;
|
|
128
208
|
let prevSample = this._lastFilteredOutput;
|
|
129
209
|
for (let i = 1; i <= numInputSamples; ++i) {
|
|
130
|
-
prevSample += filterAlpha * (
|
|
210
|
+
prevSample += filterAlpha * (rendered[i - 1] - prevSample);
|
|
131
211
|
source[i] = prevSample;
|
|
132
212
|
}
|
|
133
213
|
this._lastFilteredOutput = prevSample;
|
|
@@ -138,6 +218,7 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
138
218
|
channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
|
|
139
219
|
}
|
|
140
220
|
this._phase = end - numInputSamples;
|
|
221
|
+
this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
|
|
141
222
|
this.stats(sampleRatio);
|
|
142
223
|
return true;
|
|
143
224
|
}
|