jsbeeb 1.19.2 → 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 +143 -43
- package/src/soundchip.js +98 -9
- package/src/web/audio-handler.js +107 -41
- package/src/web/audio-renderer.js +169 -62
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,
|
|
@@ -174,8 +175,12 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
|
|
|
174
175
|
let fastAsPossible = false;
|
|
175
176
|
let fastTape = false;
|
|
176
177
|
let noSeek;
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
// The board's output filter is an equal-component Sallen-Key (Service Manual
|
|
179
|
+
// section 3.8: 10K and 2n2 twice, gain 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
|
|
180
|
+
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. BiquadFilterNode
|
|
181
|
+
// takes lowpass Q in decibels: 20*log10(0.696) = -3.15.
|
|
182
|
+
let audioFilterFreq = 7234;
|
|
183
|
+
let audioFilterQ = -3.15;
|
|
179
184
|
let stationId = 101;
|
|
180
185
|
let econet = null;
|
|
181
186
|
|
|
@@ -368,7 +373,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
368
373
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
369
374
|
const cpuSpeed = model.cyclesPerSecond;
|
|
370
375
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
371
|
-
const
|
|
376
|
+
const MaxCyclesPerTick = clocksPerSecond / 10;
|
|
372
377
|
|
|
373
378
|
let tryGl = true;
|
|
374
379
|
if (parsedQuery.glEnabled !== undefined) {
|
|
@@ -548,14 +553,40 @@ function swapCanvas(newFilterClass) {
|
|
|
548
553
|
const canvas = createCanvasForFilter(displayModeFilter);
|
|
549
554
|
displayModeFilter = canvas.filterClass;
|
|
550
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
|
+
|
|
551
572
|
video = new Video(
|
|
552
573
|
model.isMaster,
|
|
553
|
-
|
|
574
|
+
videoFb32,
|
|
554
575
|
function paint(minx, miny, maxx, maxy) {
|
|
555
576
|
frames++;
|
|
556
577
|
if (frames < frameSkip) return;
|
|
557
578
|
frames = 0;
|
|
558
|
-
|
|
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
|
+
}
|
|
559
590
|
},
|
|
560
591
|
{ isAtom: model.isAtom },
|
|
561
592
|
);
|
|
@@ -569,9 +600,11 @@ const audioHandler = new AudioHandler({
|
|
|
569
600
|
statsNode: audioStatsNode,
|
|
570
601
|
audioFilterFreq,
|
|
571
602
|
audioFilterQ,
|
|
603
|
+
audioLatencyMs: parsedQuery.audioLatencyMs,
|
|
572
604
|
noSeek,
|
|
573
605
|
cpuSpeed,
|
|
574
606
|
isAtom: model.isAtom,
|
|
607
|
+
hasMusic5000: config.hasMusic5000,
|
|
575
608
|
});
|
|
576
609
|
// Firefox will report that audio is suspended even when it will
|
|
577
610
|
// start playing without user interaction, so we need to delay a
|
|
@@ -720,7 +753,9 @@ setCrtPic(displayModeFilter);
|
|
|
720
753
|
|
|
721
754
|
window.addEventListener("blur", function () {
|
|
722
755
|
keyboard.clearKeys();
|
|
756
|
+
setEmulationLead(audioHandler.setWindowFocused(false));
|
|
723
757
|
});
|
|
758
|
+
window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
|
|
724
759
|
|
|
725
760
|
document.getElementById("fs").addEventListener("click", function (event) {
|
|
726
761
|
screenCanvas.requestFullscreen();
|
|
@@ -1690,20 +1725,16 @@ async function gdLoad(cat, layout) {
|
|
|
1690
1725
|
}
|
|
1691
1726
|
}
|
|
1692
1727
|
|
|
1693
|
-
|
|
1694
|
-
|
|
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 () {
|
|
1695
1732
|
try {
|
|
1696
|
-
|
|
1697
|
-
if (available) {
|
|
1698
|
-
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
|
|
1699
|
-
await gdAuth(true);
|
|
1700
|
-
}
|
|
1733
|
+
await googleDrive.initialise();
|
|
1701
1734
|
} catch (error) {
|
|
1702
|
-
|
|
1735
|
+
toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
|
|
1736
|
+
return false;
|
|
1703
1737
|
}
|
|
1704
|
-
})();
|
|
1705
|
-
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
1706
|
-
document.getElementById("open-drive-link").addEventListener("click", async function () {
|
|
1707
1738
|
const authed = await gdAuth(false);
|
|
1708
1739
|
if (authed) {
|
|
1709
1740
|
googleDriveModal.show();
|
|
@@ -2244,6 +2275,7 @@ function profileVideo(arg) {
|
|
|
2244
2275
|
}
|
|
2245
2276
|
|
|
2246
2277
|
let last = 0;
|
|
2278
|
+
let lastEnd = 0;
|
|
2247
2279
|
|
|
2248
2280
|
function VirtualSpeedUpdater() {
|
|
2249
2281
|
this.cycles = 0;
|
|
@@ -2277,8 +2309,51 @@ function VirtualSpeedUpdater() {
|
|
|
2277
2309
|
const virtualSpeedUpdater = new VirtualSpeedUpdater();
|
|
2278
2310
|
|
|
2279
2311
|
const rewindBuffer = new RewindBuffer(30);
|
|
2280
|
-
let
|
|
2281
|
-
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
|
+
}
|
|
2282
2357
|
|
|
2283
2358
|
rewindUI = new RewindUI({
|
|
2284
2359
|
rewindBuffer,
|
|
@@ -2311,21 +2386,31 @@ for (const item of document.querySelectorAll(".drive-tracks")) {
|
|
|
2311
2386
|
if (drive) showDriveTracks(driveIndex);
|
|
2312
2387
|
}
|
|
2313
2388
|
|
|
2314
|
-
|
|
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() {
|
|
2315
2406
|
if (!running) {
|
|
2316
2407
|
last = 0;
|
|
2317
2408
|
return;
|
|
2318
2409
|
}
|
|
2319
|
-
|
|
2320
|
-
if (now === undefined) {
|
|
2321
|
-
now = window.performance.now();
|
|
2322
|
-
}
|
|
2410
|
+
const now = performance.now();
|
|
2323
2411
|
|
|
2324
2412
|
const motorOn = processor.acia.motorOn;
|
|
2325
|
-
const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
2326
2413
|
const speedy = fastAsPossible || (fastTape && motorOn);
|
|
2327
|
-
const useTimeout = speedy || motorOn || discOn;
|
|
2328
|
-
const timeout = speedy ? 0 : 1000.0 / 50;
|
|
2329
2414
|
|
|
2330
2415
|
// In speedy mode, we still run all the state machines accurately
|
|
2331
2416
|
// but we paint less often because painting is the most expensive
|
|
@@ -2334,25 +2419,16 @@ function draw(now) {
|
|
|
2334
2419
|
// modes, i.e. MODE 7, still look ok.
|
|
2335
2420
|
video.frameSkipCount = speedy ? 9 : 0;
|
|
2336
2421
|
|
|
2337
|
-
|
|
2338
|
-
// a) We're trying to run as fast as possible.
|
|
2339
|
-
// b) Tape is playing, normal speed but backgrounded tab should run.
|
|
2340
|
-
if (useTimeout) {
|
|
2341
|
-
window.setTimeout(draw, timeout);
|
|
2342
|
-
} else {
|
|
2343
|
-
window.requestAnimationFrame(draw);
|
|
2344
|
-
}
|
|
2422
|
+
scheduleTick(speedy ? 0 : TickMs);
|
|
2345
2423
|
|
|
2346
|
-
audioHandler.soundChip.catchUp();
|
|
2347
2424
|
gamepad.update(processor.sysvia);
|
|
2348
2425
|
syncLights();
|
|
2349
2426
|
if (last !== 0) {
|
|
2350
2427
|
let cycles;
|
|
2351
2428
|
if (!speedy) {
|
|
2352
|
-
|
|
2353
|
-
const sinceLast = now - last;
|
|
2429
|
+
const sinceLast = Math.max(0, now - last);
|
|
2354
2430
|
cycles = (sinceLast * clocksPerSecond) / 1000;
|
|
2355
|
-
cycles = Math.min(cycles,
|
|
2431
|
+
cycles = Math.min(cycles, MaxCyclesPerTick);
|
|
2356
2432
|
} else {
|
|
2357
2433
|
cycles = clocksPerSecond / 50;
|
|
2358
2434
|
}
|
|
@@ -2361,14 +2437,20 @@ function draw(now) {
|
|
|
2361
2437
|
if (!processor.execute(cycles)) {
|
|
2362
2438
|
stop(true);
|
|
2363
2439
|
}
|
|
2440
|
+
audioHandler.flushChipEvents();
|
|
2364
2441
|
const end = performance.now();
|
|
2365
2442
|
virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2443
|
+
let snapshotMs = 0;
|
|
2444
|
+
rewindCycleCounter += cycles;
|
|
2445
|
+
if (rewindCycleCounter >= RewindCaptureCycles) {
|
|
2446
|
+
rewindCycleCounter -= RewindCaptureCycles;
|
|
2369
2447
|
rewindBuffer.push(processor.snapshotState());
|
|
2370
2448
|
rewindUI.updateButtonState();
|
|
2449
|
+
snapshotMs = performance.now() - end;
|
|
2371
2450
|
}
|
|
2451
|
+
if (audioStatsNode)
|
|
2452
|
+
logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
|
|
2453
|
+
paintMsThisTick = 0;
|
|
2372
2454
|
} catch (e) {
|
|
2373
2455
|
running = false;
|
|
2374
2456
|
utils.noteEvent("exception", "thrown", e.stack);
|
|
@@ -2379,11 +2461,29 @@ function draw(now) {
|
|
|
2379
2461
|
stop(false);
|
|
2380
2462
|
}
|
|
2381
2463
|
}
|
|
2382
|
-
last = now;
|
|
2464
|
+
last = Math.max(last, now);
|
|
2465
|
+
lastEnd = performance.now();
|
|
2383
2466
|
}
|
|
2384
2467
|
|
|
2385
2468
|
function run() {
|
|
2386
|
-
|
|
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
|
+
}
|
|
2387
2487
|
}
|
|
2388
2488
|
|
|
2389
2489
|
let wasPreviouslyRunning = false;
|
package/src/soundchip.js
CHANGED
|
@@ -5,6 +5,17 @@ const speakerVolume = 0.5;
|
|
|
5
5
|
// Samples per output chunk handed to the onBuffer callback.
|
|
6
6
|
export const SoundBufferSamples = 512;
|
|
7
7
|
|
|
8
|
+
// The BBC's DC restoration circuit (Service Manual section 3.8: R8 10K, C2
|
|
9
|
+
// 4u7) cancels the SN76489's unipolar pedestal. Modelled as a first-order DC
|
|
10
|
+
// blocker with the same corner, 1/(2*pi*R*C). The generators must stay
|
|
11
|
+
// unipolar: sampled speech is amplitude modulation of a 125 kHz carrier's
|
|
12
|
+
// mean level, which a zero-mean output would silence (see issue #863).
|
|
13
|
+
const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
|
|
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
|
+
|
|
8
19
|
const volumeTable = new Float32Array(16);
|
|
9
20
|
(() => {
|
|
10
21
|
let f = 1.0;
|
|
@@ -28,9 +39,15 @@ export class SoundChip {
|
|
|
28
39
|
* @param {function(Float32Array): void} onBuffer called with each full
|
|
29
40
|
* SoundBufferSamples-sized buffer of output. Receives the same buffer
|
|
30
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).
|
|
31
47
|
*/
|
|
32
|
-
constructor(onBuffer) {
|
|
48
|
+
constructor(onBuffer, { onEvent = null } = {}) {
|
|
33
49
|
this._onBuffer = onBuffer;
|
|
50
|
+
this._onEvent = onEvent;
|
|
34
51
|
// 4MHz input signal. Internal divide-by-8
|
|
35
52
|
this.soundchipFreq = 4000000.0 / 8;
|
|
36
53
|
const sampleRate = this.soundchipFreq;
|
|
@@ -72,6 +89,10 @@ export class SoundChip {
|
|
|
72
89
|
this.position = 0;
|
|
73
90
|
this.buffer = new Float32Array(SoundBufferSamples);
|
|
74
91
|
|
|
92
|
+
this.dcAlpha = 1 - (2 * Math.PI * DcRestoreCornerHz) / sampleRate;
|
|
93
|
+
this.dcPrevIn = 0;
|
|
94
|
+
this.dcPrevOut = 0;
|
|
95
|
+
|
|
75
96
|
this.latchedRegister = 0;
|
|
76
97
|
this.slowDataBus = 0;
|
|
77
98
|
this.active = false;
|
|
@@ -80,15 +101,38 @@ export class SoundChip {
|
|
|
80
101
|
mute: () => {
|
|
81
102
|
this.catchUp();
|
|
82
103
|
this.sineOn = false;
|
|
104
|
+
this._emit({ sine: 0 });
|
|
83
105
|
},
|
|
84
106
|
tone: (freq) => {
|
|
85
107
|
this.catchUp();
|
|
86
108
|
this.sineOn = true;
|
|
87
109
|
this.sineStep = (freq / sampleRate) * this.sineTable.length;
|
|
110
|
+
this._emit({ sine: freq });
|
|
88
111
|
},
|
|
89
112
|
};
|
|
90
113
|
}
|
|
91
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
|
+
|
|
92
136
|
sineChannel(channel, out, offset, length) {
|
|
93
137
|
if (!this.sineOn) return;
|
|
94
138
|
|
|
@@ -170,6 +214,7 @@ export class SoundChip {
|
|
|
170
214
|
this.volume[2] = volumeTable[v2];
|
|
171
215
|
this.volume[3] = volumeTable[v3];
|
|
172
216
|
this.noisePoked();
|
|
217
|
+
this._emit({ state: this.snapshotState() });
|
|
173
218
|
}
|
|
174
219
|
|
|
175
220
|
generate(out, offset, length) {
|
|
@@ -178,10 +223,22 @@ export class SoundChip {
|
|
|
178
223
|
for (let i = 0; i < length; ++i) {
|
|
179
224
|
out[i + offset] = 0.0;
|
|
180
225
|
}
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
226
|
+
if (this.enabled) {
|
|
227
|
+
for (let i = 0; i < this.generators.length; ++i) {
|
|
228
|
+
this.generators[i](i, out, offset, length);
|
|
229
|
+
}
|
|
184
230
|
}
|
|
231
|
+
const alpha = this.dcAlpha;
|
|
232
|
+
let prevIn = this.dcPrevIn;
|
|
233
|
+
let prevOut = this.dcPrevOut;
|
|
234
|
+
for (let i = 0; i < length; ++i) {
|
|
235
|
+
const x = out[i + offset];
|
|
236
|
+
prevOut = x - prevIn + alpha * prevOut;
|
|
237
|
+
prevIn = x;
|
|
238
|
+
out[i + offset] = prevOut;
|
|
239
|
+
}
|
|
240
|
+
this.dcPrevIn = prevIn;
|
|
241
|
+
this.dcPrevOut = prevOut;
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
catchUp() {
|
|
@@ -196,6 +253,13 @@ export class SoundChip {
|
|
|
196
253
|
this.activeTask = this.scheduler.newTask(() => {
|
|
197
254
|
if (this.active) this.poke(this.slowDataBus);
|
|
198
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
|
+
}
|
|
199
263
|
}
|
|
200
264
|
|
|
201
265
|
render(out, offset, length) {
|
|
@@ -216,6 +280,7 @@ export class SoundChip {
|
|
|
216
280
|
}
|
|
217
281
|
|
|
218
282
|
advance(cycles) {
|
|
283
|
+
if (this._onEvent) return;
|
|
219
284
|
const num = cycles * this.samplesPerCycle + this.residual;
|
|
220
285
|
let rounded = num | 0;
|
|
221
286
|
this.residual = num - rounded;
|
|
@@ -247,6 +312,7 @@ export class SoundChip {
|
|
|
247
312
|
|
|
248
313
|
poke(value) {
|
|
249
314
|
this.catchUp();
|
|
315
|
+
this._emit({ poke: value });
|
|
250
316
|
|
|
251
317
|
let command;
|
|
252
318
|
if (value & 0x80) {
|
|
@@ -297,6 +363,8 @@ export class SoundChip {
|
|
|
297
363
|
sineOn: this.sineOn,
|
|
298
364
|
sineStep: this.sineStep,
|
|
299
365
|
sineTime: this.sineTime,
|
|
366
|
+
dcPrevIn: this.dcPrevIn,
|
|
367
|
+
dcPrevOut: this.dcPrevOut,
|
|
300
368
|
};
|
|
301
369
|
}
|
|
302
370
|
|
|
@@ -322,10 +390,16 @@ export class SoundChip {
|
|
|
322
390
|
// Reset output buffer
|
|
323
391
|
this.position = 0;
|
|
324
392
|
this.buffer.fill(0);
|
|
393
|
+
// Older snapshots predate the DC blocker
|
|
394
|
+
this.dcPrevIn = state.dcPrevIn ?? 0;
|
|
395
|
+
this.dcPrevOut = state.dcPrevOut ?? 0;
|
|
396
|
+
this.progressTask?.ensureScheduled(true, EventProgressCycles);
|
|
397
|
+
this._emit({ state: this.snapshotState() });
|
|
325
398
|
}
|
|
326
399
|
|
|
327
400
|
reset(hard) {
|
|
328
401
|
if (!hard) return;
|
|
402
|
+
this._emit({ reset: true });
|
|
329
403
|
for (let i = 0; i < 4; ++i) {
|
|
330
404
|
this.counter[i] = 0;
|
|
331
405
|
this.registers[i] = 0;
|
|
@@ -338,14 +412,15 @@ export class SoundChip {
|
|
|
338
412
|
|
|
339
413
|
enable(e) {
|
|
340
414
|
this.enabled = e;
|
|
415
|
+
this._emit({ enabled: e });
|
|
341
416
|
}
|
|
342
417
|
|
|
343
418
|
mute() {
|
|
344
|
-
this.
|
|
419
|
+
this.enable(false);
|
|
345
420
|
}
|
|
346
421
|
|
|
347
422
|
unmute() {
|
|
348
|
-
this.
|
|
423
|
+
this.enable(true);
|
|
349
424
|
}
|
|
350
425
|
}
|
|
351
426
|
|
|
@@ -355,8 +430,8 @@ export class SoundChip {
|
|
|
355
430
|
* channel with DC-blocking filter.
|
|
356
431
|
*/
|
|
357
432
|
export class AtomSoundChip extends SoundChip {
|
|
358
|
-
constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
|
|
359
|
-
super(onBuffer);
|
|
433
|
+
constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
|
|
434
|
+
super(onBuffer, { onEvent });
|
|
360
435
|
this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
|
|
361
436
|
this.secondsPerCycle = 1 / cpuSpeed;
|
|
362
437
|
|
|
@@ -395,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
|
|
|
395
470
|
this._speakerCycleOffset = 0;
|
|
396
471
|
}
|
|
397
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
|
+
|
|
398
484
|
speakerReset() {
|
|
485
|
+
this._emit({ speakerReset: true });
|
|
399
486
|
this.bitChange = [];
|
|
400
487
|
this.currentSpeakerBit = 0.0;
|
|
401
488
|
this._speakerPrevIn = 0;
|
|
@@ -433,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
|
|
|
433
520
|
|
|
434
521
|
updateSpeaker(value, microCycle, seconds) {
|
|
435
522
|
const cycles = microCycle + seconds / this.secondsPerCycle;
|
|
436
|
-
|
|
523
|
+
const bit = value ? 1.0 : 0.0;
|
|
524
|
+
if (this._onEvent) this._onEvent({ cycle: cycles, bit });
|
|
525
|
+
else this.bitChange.push({ bit, cycles });
|
|
437
526
|
}
|
|
438
527
|
}
|
|
439
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,117 +1,224 @@
|
|
|
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
|
+
// Smoothing rejects the producer's per-tick bursts; proportional only, as
|
|
11
|
+
// lead already integrates rate error; 0.05% authority covers clock skew
|
|
12
|
+
// without audibly bending pitch.
|
|
13
|
+
const LeadSmoothingTau = 0.5;
|
|
14
|
+
const ProportionalGain = 0.2;
|
|
15
|
+
const MaxAdjustFraction = 0.0005;
|
|
10
16
|
|
|
17
|
+
const isResync = (event) => event.state !== undefined || event.reset !== undefined;
|
|
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.
|
|
11
22
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
12
|
-
constructor(
|
|
13
|
-
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;
|
|
14
38
|
|
|
15
|
-
this.inputSampleRate = InputSampleRate;
|
|
16
|
-
this._lastSample = 0;
|
|
17
39
|
this._lastFilteredOutput = 0;
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.targetLatencyMs
|
|
23
|
-
this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
|
|
24
|
-
this.running = false;
|
|
25
|
-
this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
|
|
40
|
+
this._phase = 0;
|
|
41
|
+
this._source = new Float32Array(0);
|
|
42
|
+
this._rendered = new Float32Array(0);
|
|
43
|
+
this.smoothedLeadError = 0;
|
|
44
|
+
this.setTargetLatency(targetLatencyMs);
|
|
26
45
|
this.port.onmessage = (event) => {
|
|
27
|
-
|
|
28
|
-
this.
|
|
46
|
+
if (event.data.command === "setTargetLatency") this.setTargetLatency(event.data.targetLatencyMs);
|
|
47
|
+
else this.onProduced(event.data.upTo, event.data.events);
|
|
29
48
|
};
|
|
30
49
|
this.nextStats = 0;
|
|
31
50
|
}
|
|
32
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
|
+
|
|
33
91
|
stats(sampleRatio) {
|
|
34
92
|
if (currentTime < this.nextStats) return;
|
|
35
93
|
this.nextStats = currentTime + 0.25;
|
|
36
94
|
this.port.postMessage({
|
|
37
95
|
sampleRate: sampleRate,
|
|
38
96
|
inputSampleRate: this.inputSampleRate,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
97
|
+
stalls: this.stalls,
|
|
98
|
+
skippedMs: this.skippedMs,
|
|
99
|
+
leadMs: this.leadMs(),
|
|
100
|
+
leadMinMs: this.minLeadMs,
|
|
101
|
+
queuedEvents: this.events.length - this.eventsHead,
|
|
43
102
|
sampleRatio: sampleRatio,
|
|
44
103
|
});
|
|
104
|
+
this.minLeadMs = Infinity;
|
|
45
105
|
}
|
|
46
106
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const timeInBufferMs = 1000 * (this.queue[0].offset / this.inputSampleRate) + this.queue[0].time;
|
|
50
|
-
return Date.now() - timeInBufferMs;
|
|
107
|
+
_notify(event, count) {
|
|
108
|
+
this.port.postMessage({ event, count });
|
|
51
109
|
}
|
|
52
110
|
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
|
|
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));
|
|
58
118
|
}
|
|
59
119
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this._queueSizeSamples -= dropped.buffer.length;
|
|
120
|
+
_applyHead() {
|
|
121
|
+
this.chip.applyEvent(this.events[this.eventsHead++]);
|
|
63
122
|
}
|
|
64
123
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
130
|
+
}
|
|
131
|
+
|
|
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;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_stall(out, offset, length) {
|
|
142
|
+
if (!this.stalled) {
|
|
143
|
+
this.stalled = true;
|
|
144
|
+
this.stalls++;
|
|
145
|
+
this._notify("stall", 1);
|
|
70
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);
|
|
71
158
|
}
|
|
72
159
|
|
|
73
|
-
|
|
74
|
-
if (this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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();
|
|
81
182
|
}
|
|
82
|
-
return this._lastSample;
|
|
83
183
|
}
|
|
84
184
|
|
|
85
185
|
process(inputs, outputs) {
|
|
86
|
-
this.
|
|
87
|
-
if (this.queue.length === 0) return true;
|
|
88
|
-
|
|
89
|
-
// I looked into using https://www.npmjs.com/package/@alexanderolsen/libsamplerate-js or similar (the full API),
|
|
90
|
-
// but we fiddle the sample rate here to catch up with the target latency, which is harder to do with that API.
|
|
91
|
-
const outByMs = this._queueAge() - this.targetLatencyMs;
|
|
92
|
-
const maxAdjust = this.inputSampleRate * 0.01;
|
|
93
|
-
const adjustment = Math.min(maxAdjust, Math.max(-maxAdjust, outByMs * 100));
|
|
94
|
-
const effectiveSampleRate = this.inputSampleRate + adjustment;
|
|
95
|
-
const sampleRatio = effectiveSampleRate / sampleRate;
|
|
186
|
+
if (this.stalled && this.upTo - this.clock >= this.targetLeadCycles) this._restart();
|
|
96
187
|
|
|
97
188
|
const channel = outputs[0][0];
|
|
189
|
+
const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
|
|
190
|
+
const sampleRatio = effectiveSampleRate / sampleRate;
|
|
191
|
+
|
|
98
192
|
const dt = 1 / effectiveSampleRate;
|
|
99
193
|
const filterAlpha = dt / (RC + dt);
|
|
100
194
|
|
|
101
|
-
|
|
102
|
-
|
|
195
|
+
// The fractional read position carries across quanta, so consumption
|
|
196
|
+
// averages exactly sampleRatio and the pitch never steps at a rounding
|
|
197
|
+
// boundary. source[0] is the last input sample of the previous quantum.
|
|
198
|
+
const end = this._phase + channel.length * sampleRatio;
|
|
199
|
+
const numInputSamples = Math.floor(end);
|
|
200
|
+
if (this._source.length <= numInputSamples) {
|
|
201
|
+
this._source = new Float32Array(numInputSamples * 2);
|
|
202
|
+
this._rendered = new Float32Array(numInputSamples * 2);
|
|
203
|
+
}
|
|
204
|
+
const source = this._source;
|
|
205
|
+
const rendered = this._rendered;
|
|
206
|
+
this._renderInput(rendered, numInputSamples);
|
|
207
|
+
source[0] = this._lastFilteredOutput;
|
|
103
208
|
let prevSample = this._lastFilteredOutput;
|
|
104
|
-
for (let i =
|
|
105
|
-
prevSample += filterAlpha * (
|
|
209
|
+
for (let i = 1; i <= numInputSamples; ++i) {
|
|
210
|
+
prevSample += filterAlpha * (rendered[i - 1] - prevSample);
|
|
106
211
|
source[i] = prevSample;
|
|
107
212
|
}
|
|
108
213
|
this._lastFilteredOutput = prevSample;
|
|
109
214
|
for (let i = 0; i < channel.length; i++) {
|
|
110
|
-
const pos =
|
|
215
|
+
const pos = this._phase + i * sampleRatio;
|
|
111
216
|
const loc = Math.floor(pos);
|
|
112
217
|
const alpha = pos - loc;
|
|
113
218
|
channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
|
|
114
219
|
}
|
|
220
|
+
this._phase = end - numInputSamples;
|
|
221
|
+
this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
|
|
115
222
|
this.stats(sampleRatio);
|
|
116
223
|
return true;
|
|
117
224
|
}
|