jsbeeb 1.20.0 → 1.20.2
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 +5 -0
- package/package.json +1 -1
- package/src/acia.js +20 -2
- package/src/app/app.js +10 -0
- package/src/biquad.js +36 -0
- package/src/canvas.js +2 -1
- package/src/jsbeeb.css +6 -0
- package/src/main.js +69 -30
- package/src/resampler.js +92 -0
- 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 +5 -14
- package/src/web/audio-renderer.js +44 -34
package/README.md
CHANGED
|
@@ -289,6 +289,11 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
|
|
|
289
289
|
- (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
|
|
290
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
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)).
|
|
292
297
|
|
|
293
298
|
### Atom-specific parameters
|
|
294
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.20.
|
|
10
|
+
"version": "1.20.2",
|
|
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/app/app.js
CHANGED
|
@@ -250,6 +250,16 @@ const template = [
|
|
|
250
250
|
click: sendAction("rewind"),
|
|
251
251
|
},
|
|
252
252
|
{ type: "separator" },
|
|
253
|
+
{
|
|
254
|
+
label: "Pause and Show Debugger",
|
|
255
|
+
accelerator: "CmdOrCtrl+Home",
|
|
256
|
+
click: sendAction("pause"),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
label: "Resume",
|
|
260
|
+
click: sendAction("resume"),
|
|
261
|
+
},
|
|
262
|
+
{ type: "separator" },
|
|
253
263
|
{
|
|
254
264
|
label: "Soft Reset",
|
|
255
265
|
click: sendAction("soft-reset"),
|
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/jsbeeb.css
CHANGED
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",
|
|
@@ -175,12 +180,6 @@ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
|
|
|
175
180
|
let fastAsPossible = false;
|
|
176
181
|
let fastTape = false;
|
|
177
182
|
let noSeek;
|
|
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;
|
|
184
183
|
let stationId = 101;
|
|
185
184
|
let econet = null;
|
|
186
185
|
|
|
@@ -209,8 +208,6 @@ if (parsedQuery.embed) {
|
|
|
209
208
|
fastTape = !!parsedQuery.fasttape;
|
|
210
209
|
noSeek = !!parsedQuery.noseek;
|
|
211
210
|
|
|
212
|
-
if (parsedQuery.audiofilterfreq !== undefined) audioFilterFreq = parsedQuery.audiofilterfreq;
|
|
213
|
-
if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilterq;
|
|
214
211
|
if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
|
|
215
212
|
if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
|
|
216
213
|
|
|
@@ -239,7 +236,15 @@ const userPort = {
|
|
|
239
236
|
// Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
|
|
240
237
|
// Must be created before Config so the onClose callback and the initial checkbox state can reference it.
|
|
241
238
|
const speechOutput = new SpeechOutput();
|
|
242
|
-
|
|
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);
|
|
243
248
|
|
|
244
249
|
const config = new Config(
|
|
245
250
|
function onChange(changed) {
|
|
@@ -266,9 +271,7 @@ const config = new Config(
|
|
|
266
271
|
setupMicrophone();
|
|
267
272
|
}
|
|
268
273
|
}
|
|
269
|
-
if (changed.speechOutput !== undefined)
|
|
270
|
-
speechOutput.enabled = !!changed.speechOutput;
|
|
271
|
-
}
|
|
274
|
+
if (changed.speechOutput !== undefined) setSpeechOutput(!!changed.speechOutput);
|
|
272
275
|
if (changed.tubeCpuMultiplier !== undefined) {
|
|
273
276
|
emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
|
|
274
277
|
config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
|
|
@@ -557,7 +560,15 @@ displayModeFilter = canvas.filterClass;
|
|
|
557
560
|
// frame into the canvas and an animation frame presents it, so a stalled
|
|
558
561
|
// display holds up the picture and not the emulation (issue #885).
|
|
559
562
|
const videoFb32 = new Uint32Array(canvas.fb32.length);
|
|
560
|
-
const pendingFrame = {
|
|
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
|
+
};
|
|
561
572
|
let presentScheduled = false;
|
|
562
573
|
let paintMsThisTick = 0;
|
|
563
574
|
let presentMsMax = 0;
|
|
@@ -581,7 +592,14 @@ video = new Video(
|
|
|
581
592
|
if (pendingFrame.lineGrid.length !== this.lineGrid.length)
|
|
582
593
|
pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
|
|
583
594
|
pendingFrame.lineGrid.set(this.lineGrid);
|
|
584
|
-
Object.assign(pendingFrame, {
|
|
595
|
+
Object.assign(pendingFrame, {
|
|
596
|
+
minx,
|
|
597
|
+
miny,
|
|
598
|
+
maxx,
|
|
599
|
+
maxy,
|
|
600
|
+
lineBaseEven: this.lineBaseEven,
|
|
601
|
+
lineBaseOdd: this.lineBaseOdd,
|
|
602
|
+
});
|
|
585
603
|
paintMsThisTick += performance.now() - start;
|
|
586
604
|
if (!presentScheduled) {
|
|
587
605
|
presentScheduled = true;
|
|
@@ -598,8 +616,8 @@ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
|
|
|
598
616
|
const audioHandler = new AudioHandler({
|
|
599
617
|
warningNode: document.getElementById("audio-warning"),
|
|
600
618
|
statsNode: audioStatsNode,
|
|
601
|
-
audioFilterFreq,
|
|
602
|
-
audioFilterQ,
|
|
619
|
+
audioFilterFreq: parsedQuery.audiofilterfreq,
|
|
620
|
+
audioFilterQ: parsedQuery.audiofilterq,
|
|
603
621
|
audioLatencyMs: parsedQuery.audioLatencyMs,
|
|
604
622
|
noSeek,
|
|
605
623
|
cpuSpeed,
|
|
@@ -704,8 +722,10 @@ pastetext.addEventListener("drop", async function (event) {
|
|
|
704
722
|
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
705
723
|
// Regular UEF tape image (not a BeebEm save state)
|
|
706
724
|
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
725
|
+
toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
|
|
707
726
|
} else {
|
|
708
727
|
await loadHTMLFile(file);
|
|
728
|
+
toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
|
|
709
729
|
}
|
|
710
730
|
} catch (error) {
|
|
711
731
|
reportLoadFailure(file.name, error);
|
|
@@ -757,20 +777,36 @@ window.addEventListener("blur", function () {
|
|
|
757
777
|
});
|
|
758
778
|
window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
|
|
759
779
|
|
|
760
|
-
document.getElementById("fs")
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
+
}
|
|
764
793
|
|
|
765
794
|
let keyboard; // This will be initialised after the processor is created
|
|
766
795
|
|
|
767
796
|
const debugPause = document.getElementById("debug-pause");
|
|
768
797
|
const debugPlay = document.getElementById("debug-play");
|
|
769
|
-
|
|
770
|
-
|
|
798
|
+
|
|
799
|
+
function pauseIntoDebugger() {
|
|
800
|
+
stop(true);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function resumeFromDebugger() {
|
|
771
804
|
dbgr.hide();
|
|
772
|
-
|
|
773
|
-
}
|
|
805
|
+
keyboard.resumeEmulation();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
debugPause.addEventListener("click", pauseIntoDebugger);
|
|
809
|
+
debugPlay.addEventListener("click", resumeFromDebugger);
|
|
774
810
|
|
|
775
811
|
// To lower chance of data loss, only accept drop events in the drop
|
|
776
812
|
// zone in the menu bar.
|
|
@@ -848,6 +884,7 @@ processor = new CpuClass(model, {
|
|
|
848
884
|
printer.attach(processor.uservia);
|
|
849
885
|
|
|
850
886
|
processor.teletextAdaptor?.addEventListener("notice", showNotice);
|
|
887
|
+
processor.acia.addEventListener("notice", showNotice);
|
|
851
888
|
|
|
852
889
|
// Create input sources
|
|
853
890
|
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
@@ -1692,12 +1729,6 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
|
|
|
1692
1729
|
});
|
|
1693
1730
|
|
|
1694
1731
|
async function gdLoad(cat, layout) {
|
|
1695
|
-
// TODO: have a onclose flush event, handle errors
|
|
1696
|
-
/*
|
|
1697
|
-
$(window).bind("beforeunload", function() {
|
|
1698
|
-
return confirm("Do you really want to close?");
|
|
1699
|
-
});
|
|
1700
|
-
*/
|
|
1701
1732
|
popupLoading("Loading '" + cat.name + "' from Google Drive");
|
|
1702
1733
|
try {
|
|
1703
1734
|
const available = await googleDrive.initialise();
|
|
@@ -1718,6 +1749,12 @@ async function gdLoad(cat, layout) {
|
|
|
1718
1749
|
const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
|
|
1719
1750
|
console.log("Google Drive loading finished");
|
|
1720
1751
|
loadingFinished();
|
|
1752
|
+
if (!ssd.savesChanges) {
|
|
1753
|
+
toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
|
|
1754
|
+
title: "Google Drive",
|
|
1755
|
+
quietKey: "quietDriveReadOnly",
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1721
1758
|
return ssd;
|
|
1722
1759
|
} catch (error) {
|
|
1723
1760
|
console.error("Google Drive loading error:", error);
|
|
@@ -2681,6 +2718,8 @@ electron({
|
|
|
2681
2718
|
"hard-reset": hardReset,
|
|
2682
2719
|
"save-state": () => document.getElementById("save-state").click(),
|
|
2683
2720
|
rewind: () => rewindUI.open(),
|
|
2721
|
+
pause: pauseIntoDebugger,
|
|
2722
|
+
resume: resumeFromDebugger,
|
|
2684
2723
|
},
|
|
2685
2724
|
});
|
|
2686
2725
|
|
package/src/resampler.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
function besselI0(x) {
|
|
2
|
+
let sum = 1;
|
|
3
|
+
let term = 1;
|
|
4
|
+
for (let k = 1; k < 50; ++k) {
|
|
5
|
+
term *= (x / (2 * k)) ** 2;
|
|
6
|
+
sum += term;
|
|
7
|
+
if (term < sum * 1e-12) break;
|
|
8
|
+
}
|
|
9
|
+
return sum;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rate conversion by a Kaiser-windowed sinc evaluated only where an output
|
|
14
|
+
* sample falls, so the cost scales with the output rate, not the input rate.
|
|
15
|
+
* The kernel is tabulated at `phases` fractional offsets and interpolated
|
|
16
|
+
* between them. Each quantum the caller reserves room for its input, renders
|
|
17
|
+
* into `buffer` at `inputOffset`, reads the output with `read`, then calls
|
|
18
|
+
* `commit` to carry the last `taps` input samples over as the next quantum's
|
|
19
|
+
* history. Nothing is allocated once the buffer has reached its working size.
|
|
20
|
+
*/
|
|
21
|
+
export class PolyphaseResampler {
|
|
22
|
+
constructor(inputRate, cutoffHz, { taps = 201, phases = 64, beta = 8.5 } = {}) {
|
|
23
|
+
this.taps = taps;
|
|
24
|
+
this.phases = phases;
|
|
25
|
+
this.half = (taps - 1) / 2;
|
|
26
|
+
this.table = new Float32Array((phases + 1) * taps);
|
|
27
|
+
const scale = (2 * cutoffHz) / inputRate;
|
|
28
|
+
const norm = besselI0(beta);
|
|
29
|
+
for (let p = 0; p <= phases; ++p) {
|
|
30
|
+
const row = p * taps;
|
|
31
|
+
let sum = 0;
|
|
32
|
+
for (let k = 0; k < taps; ++k) {
|
|
33
|
+
const t = k - this.half - p / phases;
|
|
34
|
+
const x = Math.PI * scale * t;
|
|
35
|
+
const sinc = x === 0 ? 1 : Math.sin(x) / x;
|
|
36
|
+
const u = t / (this.half + 1);
|
|
37
|
+
const window = u <= -1 || u >= 1 ? 0 : besselI0(beta * Math.sqrt(1 - u * u)) / norm;
|
|
38
|
+
this.table[row + k] = scale * sinc * window;
|
|
39
|
+
sum += this.table[row + k];
|
|
40
|
+
}
|
|
41
|
+
for (let k = 0; k < taps; ++k) this.table[row + k] /= sum;
|
|
42
|
+
}
|
|
43
|
+
this.buffer = new Float32Array(taps);
|
|
44
|
+
this.newSamples = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Make room for `count` new input samples after the history. */
|
|
48
|
+
reserve(count) {
|
|
49
|
+
const needed = this.taps + count + 1;
|
|
50
|
+
if (this.buffer.length < needed) {
|
|
51
|
+
const grown = new Float32Array(needed * 2);
|
|
52
|
+
grown.set(this.buffer.subarray(0, this.taps));
|
|
53
|
+
this.buffer = grown;
|
|
54
|
+
}
|
|
55
|
+
this.newSamples = count;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get inputOffset() {
|
|
59
|
+
return this.taps;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fill `out` with samples taken `ratio` input samples apart, the first at
|
|
64
|
+
* `phase` (0 to 1) past the last sample of the previous quantum. The
|
|
65
|
+
* output lags the input by `half` samples, which the kernel needs ahead.
|
|
66
|
+
*/
|
|
67
|
+
read(out, phase, ratio) {
|
|
68
|
+
const { buffer, table, taps, phases } = this;
|
|
69
|
+
for (let i = 0; i < out.length; ++i) {
|
|
70
|
+
const pos = phase + i * ratio;
|
|
71
|
+
const loc = Math.floor(pos);
|
|
72
|
+
const fracPhase = (pos - loc) * phases;
|
|
73
|
+
const p = Math.min(Math.floor(fracPhase), phases - 1);
|
|
74
|
+
const mix = fracPhase - p;
|
|
75
|
+
const rowA = p * taps;
|
|
76
|
+
const rowB = rowA + taps;
|
|
77
|
+
let a = 0;
|
|
78
|
+
let b = 0;
|
|
79
|
+
for (let k = 0; k < taps; ++k) {
|
|
80
|
+
const x = buffer[loc + k];
|
|
81
|
+
a += table[rowA + k] * x;
|
|
82
|
+
b += table[rowB + k] * x;
|
|
83
|
+
}
|
|
84
|
+
out[i] = a + (b - a) * mix;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Keep the last `taps` input samples as history for the next quantum. */
|
|
89
|
+
commit() {
|
|
90
|
+
this.buffer.copyWithin(0, this.newSamples, this.newSamples + this.taps);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/tapes.js
CHANGED
|
@@ -315,6 +315,5 @@ export async function loadTapeFromData(name, data, model) {
|
|
|
315
315
|
console.log("Detected a UEF tape");
|
|
316
316
|
return new UefTape(stream, model);
|
|
317
317
|
}
|
|
318
|
-
|
|
319
|
-
return null;
|
|
318
|
+
throw new Error(`${name} is not a UEF or tapefile tape image`);
|
|
320
319
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Simulates PAL composite video artifacts by encoding the framebuffer to a
|
|
6
6
|
// composite signal and decoding it back to RGB, mimicking the behavior of
|
|
7
|
-
//
|
|
7
|
+
// a BBC Micro's UHF-modulated picture on a PAL television.
|
|
8
8
|
//
|
|
9
9
|
// REFERENCES:
|
|
10
10
|
// - John Watkinson's "Engineer's Guide to Decoding & Encoding" (Section 3.4)
|
|
@@ -40,7 +40,7 @@ export class PALCompositeFilter {
|
|
|
40
40
|
uFramebuffer: gl.getUniformLocation(this.program, "uFramebuffer"),
|
|
41
41
|
uResolution: gl.getUniformLocation(this.program, "uResolution"),
|
|
42
42
|
uTexelSize: gl.getUniformLocation(this.program, "uTexelSize"),
|
|
43
|
-
|
|
43
|
+
uLineBase: gl.getUniformLocation(this.program, "uLineBase"),
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -55,6 +55,6 @@ export class PALCompositeFilter {
|
|
|
55
55
|
gl.uniform1i(this.locations.uFramebuffer, 0); // Texture unit 0
|
|
56
56
|
gl.uniform2f(this.locations.uResolution, params.width, params.height);
|
|
57
57
|
gl.uniform2f(this.locations.uTexelSize, 1.0 / params.width, 1.0 / params.height);
|
|
58
|
-
gl.
|
|
58
|
+
gl.uniform2f(this.locations.uLineBase, params.lineBaseEven, params.lineBaseOdd);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -5,23 +5,21 @@ varying vec2 vTexCoord;
|
|
|
5
5
|
uniform sampler2D uFramebuffer;
|
|
6
6
|
uniform vec2 uResolution;
|
|
7
7
|
uniform vec2 uTexelSize;
|
|
8
|
-
|
|
8
|
+
// Line numbers of texture rows 0 (x) and 1 (y)
|
|
9
|
+
uniform vec2 uLineBase;
|
|
9
10
|
|
|
10
11
|
const float PI = 3.14159265359;
|
|
11
12
|
|
|
12
13
|
// IMPLEMENTATION (Baseband Blending Method):
|
|
13
14
|
// 1. Encode RGB to PAL composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
|
|
14
15
|
// 2. Demodulate current line (with correct phase) → U_curr, V_curr
|
|
15
|
-
// 3. Demodulate previous
|
|
16
|
+
// 3. Demodulate the previous scanline (two texture rows up) → U_prev, V_prev
|
|
16
17
|
// 4. Blend at baseband: U_final = mix(U_curr, U_prev), V_final = mix(V_curr, V_prev)
|
|
17
|
-
// 5.
|
|
18
|
-
// 6.
|
|
19
|
-
// 7. Combine luma and chroma, convert back to RGB
|
|
18
|
+
// 5. Luma: composite through the set's low-pass and subcarrier trap
|
|
19
|
+
// 6. Combine luma and chroma, convert back to RGB
|
|
20
20
|
//
|
|
21
|
-
// NOTE:
|
|
22
|
-
//
|
|
23
|
-
// the previous scanline from the SAME field, which is 2 texture lines apart. Proper
|
|
24
|
-
// support for non-interlaced modes needs to be added.
|
|
21
|
+
// NOTE: Texture rows are half-lines, so the previous scanline of the same field is two rows
|
|
22
|
+
// up, and the subcarrier phase and V-switch come from uLineBase, not from the row.
|
|
25
23
|
|
|
26
24
|
// Chroma demodulation gain: compensates for sin²(x) = 0.5 - 0.5·cos(2x) amplitude loss
|
|
27
25
|
const float FIR_GAIN = 2.0;
|
|
@@ -35,13 +33,9 @@ const float PAL_FRAME_RATE = 25.0; // Frames per second
|
|
|
35
33
|
const float PAL_SUBCARRIER_MHZ = 4.43361875; // PAL color subcarrier frequency (exact)
|
|
36
34
|
|
|
37
35
|
// Derived PAL parameters
|
|
38
|
-
const float PAL_LINES_PER_FIELD = PAL_TOTAL_LINES / 2.0;
|
|
39
36
|
const float PAL_CYCLES_PER_LINE = PAL_SUBCARRIER_MHZ * 1e6 / (PAL_TOTAL_LINES * PAL_FRAME_RATE);
|
|
40
|
-
|
|
41
|
-
const float
|
|
42
|
-
|
|
43
|
-
// jsbeeb texture parameters
|
|
44
|
-
const float TEXTURE_WIDTH = 1024.0; // Framebuffer width (896 visible + 128 blanking)
|
|
37
|
+
// fract(PAL_CYCLES_PER_LINE), spelt out: float keeps more of it alone than inside 283.7516
|
|
38
|
+
const float PAL_LINE_PHASE_OFFSET = 0.7516;
|
|
45
39
|
|
|
46
40
|
// RGB → YUV conversion with proper PAL signal levels baked in
|
|
47
41
|
// Derived from ITU-R BT.470-6: white at 0.7V, peak at 0.931V
|
|
@@ -63,45 +57,58 @@ vec3 yuv_to_rgb(vec3 yuv) {
|
|
|
63
57
|
);
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
// Subcarrier phase (radians) at offset_pixels from pixel_x
|
|
61
|
+
float carrier_phase(float pixel_x, float offset_pixels, float cycles_per_pixel, float phase_offset) {
|
|
62
|
+
return ((pixel_x + offset_pixels) * cycles_per_pixel + phase_offset) * 2.0 * PI;
|
|
63
|
+
}
|
|
69
64
|
|
|
65
|
+
// Encode the texel at offset_pixels from xy to composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
|
|
66
|
+
float encode_composite(vec2 xy, float offset_pixels, float t, float v_switch) {
|
|
70
67
|
vec2 sample_uv = xy + vec2(offset_pixels * uTexelSize.x, 0.0);
|
|
71
68
|
vec3 rgb = texture2D(uFramebuffer, sample_uv).rgb;
|
|
72
69
|
vec3 yuv = rgb_to_yuv(rgb);
|
|
70
|
+
return yuv.x + yuv.y * sin(t) + yuv.z * cos(t) * v_switch;
|
|
71
|
+
}
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Demodulate composite signal at given position
|
|
74
|
+
vec2 demodulate_uv(vec2 xy, float pixel_x, float offset_pixels, float v_switch, float cycles_per_pixel, float phase_offset) {
|
|
75
|
+
float t = carrier_phase(pixel_x, offset_pixels, cycles_per_pixel, phase_offset);
|
|
76
|
+
float composite = encode_composite(xy, offset_pixels, t, v_switch);
|
|
76
77
|
|
|
77
78
|
// Demodulate: multiply by carrier to shift chroma to baseband
|
|
78
79
|
return vec2(composite * sin(t), composite * cos(t) * v_switch);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
void main() {
|
|
82
|
-
//
|
|
83
|
-
vec2 pixelCoord =
|
|
83
|
+
// Texel column and row, whatever size the drawing buffer is.
|
|
84
|
+
vec2 pixelCoord = floor(vTexCoord * uResolution);
|
|
84
85
|
|
|
85
86
|
// BEGIN_FIR_COEFFICIENTS
|
|
86
87
|
// This section is replaced by the Vite build to include FIR filter coefficients.
|
|
87
88
|
// Change Cutoff (in comment below) or FIRTAPS value to configure.
|
|
88
|
-
// Cutoff: 1.108 MHz (quarter subcarrier)
|
|
89
|
+
// Cutoff: 1.108 MHz (quarter subcarrier; the -6 dB design point, -3 dB at about 0.83 MHz)
|
|
89
90
|
const int FIRTAPS = 21;
|
|
90
91
|
float FIR[FIRTAPS];
|
|
91
92
|
// END_FIR_COEFFICIENTS
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
// BEGIN_LUMA_COEFFICIENTS
|
|
95
|
+
// This section is replaced by the Vite build with the set's luma path, low-pass and
|
|
96
|
+
// subcarrier trap in one symmetric FIR, as designed in tools/luma-fir-generator.js.
|
|
97
|
+
const int LUMA_TAPS = 31;
|
|
98
|
+
float LUMA_FIR[LUMA_TAPS];
|
|
99
|
+
// END_LUMA_COEFFICIENTS
|
|
100
|
+
|
|
101
|
+
float row = pixelCoord.y;
|
|
102
|
+
float line = (mod(row, 2.0) < 1.0 ? uLineBase.x : uLineBase.y) + floor(row / 2.0);
|
|
94
103
|
|
|
95
104
|
// PAL phase alternates each scanline (V component inverts)
|
|
96
105
|
float v_switch = mod(line, 2.0) < 1.0 ? 1.0 : -1.0;
|
|
97
106
|
|
|
98
|
-
// Map PAL subcarrier across
|
|
99
|
-
float cycles_per_pixel = PAL_CYCLES_PER_LINE /
|
|
107
|
+
// Map PAL subcarrier across the full line, blanking included
|
|
108
|
+
float cycles_per_pixel = PAL_CYCLES_PER_LINE / uResolution.x;
|
|
100
109
|
|
|
101
|
-
//
|
|
102
|
-
float
|
|
103
|
-
float frame_phase_offset = uFrameCount * PAL_FIELD_PHASE_OFFSET;
|
|
104
|
-
float phase_offset = line_phase_offset + frame_phase_offset;
|
|
110
|
+
// Subcarrier phase at the start of this line, carried across frames by the line count
|
|
111
|
+
float phase_offset = fract(line * PAL_LINE_PHASE_OFFSET);
|
|
105
112
|
|
|
106
113
|
// Step 1: Demodulate current line with FIR filter
|
|
107
114
|
vec2 filtered_uv_curr = vec2(0.0);
|
|
@@ -111,14 +118,11 @@ void main() {
|
|
|
111
118
|
filtered_uv_curr += FIR_GAIN * uv * FIR[i];
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
// Step 2: Demodulate previous
|
|
115
|
-
//
|
|
116
|
-
// Using 2H (line-2) ensures we sample from the same field (both fresh data).
|
|
117
|
-
// This represents the TV's 1H delay within a single field.
|
|
121
|
+
// Step 2: Demodulate the previous scanline of the same field (two rows up, one line
|
|
122
|
+
// earlier) with FIR filter. This represents the TV's 1H delay line.
|
|
118
123
|
vec2 prev_uv = vTexCoord - vec2(0.0, 2.0 * uTexelSize.y);
|
|
119
|
-
float
|
|
120
|
-
float
|
|
121
|
-
float prev_phase_offset = prev_line * PAL_LINE_PHASE_OFFSET + frame_phase_offset;
|
|
124
|
+
float prev_v_switch = -v_switch;
|
|
125
|
+
float prev_phase_offset = fract((line - 1.0) * PAL_LINE_PHASE_OFFSET);
|
|
122
126
|
|
|
123
127
|
vec2 filtered_uv_prev = vec2(0.0);
|
|
124
128
|
for (int i = 0; i < FIRTAPS; i++) {
|
|
@@ -130,17 +134,13 @@ void main() {
|
|
|
130
134
|
// Step 3: Blend chroma at baseband
|
|
131
135
|
vec2 filtered_uv = mix(filtered_uv_curr, filtered_uv_prev, CHROMA_BLEND_WEIGHT);
|
|
132
136
|
|
|
133
|
-
// Step 4:
|
|
134
|
-
float
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
float remodulated_chroma = filtered_uv.x * sin(t_curr) + filtered_uv.y * cos(t_curr) * v_switch;
|
|
141
|
-
|
|
142
|
-
// Complementary subtraction: luma = composite - chroma
|
|
143
|
-
float y_out = composite_curr - remodulated_chroma;
|
|
137
|
+
// Step 4: Luma is the composite through the set's low-pass and subcarrier trap
|
|
138
|
+
float y_out = 0.0;
|
|
139
|
+
for (int i = 0; i < LUMA_TAPS; i++) {
|
|
140
|
+
float offset = float(i - (LUMA_TAPS - 1) / 2);
|
|
141
|
+
float t = carrier_phase(pixelCoord.x, offset, cycles_per_pixel, phase_offset);
|
|
142
|
+
y_out += encode_composite(vTexCoord, offset, t, v_switch) * LUMA_FIR[i];
|
|
143
|
+
}
|
|
144
144
|
|
|
145
145
|
vec3 rgb_out = yuv_to_rgb(vec3(y_out, filtered_uv.x, filtered_uv.y));
|
|
146
146
|
gl_FragColor = vec4(clamp(rgb_out, 0.0, 1.0), 1.0);
|
package/src/video.js
CHANGED
|
@@ -19,6 +19,10 @@ export const OPAQUE_WHITE = 0xffffffff;
|
|
|
19
19
|
|
|
20
20
|
export const MinPaintedFrameRows = 64;
|
|
21
21
|
|
|
22
|
+
// The PAL subcarrier advances 283.7516 cycles per line (4433618.75 Hz / 15625 Hz), and
|
|
23
|
+
// 0.7516 = 1879 / 2500, so its phase against the line is periodic in 2500 lines.
|
|
24
|
+
export const PalPhasePeriodLines = 2500;
|
|
25
|
+
|
|
22
26
|
////////////////////
|
|
23
27
|
// VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
|
|
24
28
|
// Reference: b-em src/video.c (stardot/b-em).
|
|
@@ -348,6 +352,11 @@ export class Video {
|
|
|
348
352
|
this.bitmapY = 0;
|
|
349
353
|
this.oddClock = false;
|
|
350
354
|
this.frameCount = 0;
|
|
355
|
+
this.hsyncCount = 0;
|
|
356
|
+
// The hsyncCount in effect while framebuffer rows 0 and 1 were drawn: row r was drawn
|
|
357
|
+
// during line lineBase[r & 1] + (r >> 1), which sets its PAL subcarrier phase.
|
|
358
|
+
this.lineBaseEven = 0;
|
|
359
|
+
this.lineBaseOdd = 0;
|
|
351
360
|
this.doEvenFrameLogic = false;
|
|
352
361
|
this.isEvenRender = true;
|
|
353
362
|
this.lastRenderWasEven = false;
|
|
@@ -460,6 +469,9 @@ export class Video {
|
|
|
460
469
|
bitmapY: this.bitmapY,
|
|
461
470
|
oddClock: this.oddClock,
|
|
462
471
|
frameCount: this.frameCount,
|
|
472
|
+
hsyncCount: this.hsyncCount,
|
|
473
|
+
lineBaseEven: this.lineBaseEven,
|
|
474
|
+
lineBaseOdd: this.lineBaseOdd,
|
|
463
475
|
doEvenFrameLogic: this.doEvenFrameLogic,
|
|
464
476
|
isEvenRender: this.isEvenRender,
|
|
465
477
|
lastRenderWasEven: this.lastRenderWasEven,
|
|
@@ -512,6 +524,9 @@ export class Video {
|
|
|
512
524
|
this.bitmapY = state.bitmapY;
|
|
513
525
|
this.oddClock = state.oddClock;
|
|
514
526
|
this.frameCount = state.frameCount;
|
|
527
|
+
this.hsyncCount = state.hsyncCount ?? 0;
|
|
528
|
+
this.lineBaseEven = state.lineBaseEven ?? 0;
|
|
529
|
+
this.lineBaseOdd = state.lineBaseOdd ?? 0;
|
|
515
530
|
this.doEvenFrameLogic = state.doEvenFrameLogic;
|
|
516
531
|
this.isEvenRender = state.isEvenRender;
|
|
517
532
|
this.lastRenderWasEven = state.lastRenderWasEven;
|
|
@@ -606,13 +621,26 @@ export class Video {
|
|
|
606
621
|
this.dispEnabled |= enable;
|
|
607
622
|
this.cursorInvertedOffset = -1;
|
|
608
623
|
|
|
609
|
-
this.bitmapY = 0;
|
|
610
624
|
// Interlace even frame fires vsync midway through a scanline.
|
|
611
|
-
|
|
612
|
-
|
|
625
|
+
const oddField = !!(this.regs[8] & 1) && !!(this.frameCount & 1);
|
|
626
|
+
this.bitmapY = oddField ? -1 : 0;
|
|
627
|
+
|
|
628
|
+
// The even field draws the rest of the vsync line on row 0; the odd field's first row
|
|
629
|
+
// is the first hsync after it.
|
|
630
|
+
const firstRowLine = (this.hsyncCount + (oddField ? 1 : 0)) % PalPhasePeriodLines;
|
|
631
|
+
if (this.doublesLines()) {
|
|
632
|
+
this.lineBaseEven = this.lineBaseOdd = firstRowLine;
|
|
633
|
+
} else if (oddField) {
|
|
634
|
+
this.lineBaseOdd = firstRowLine;
|
|
635
|
+
} else {
|
|
636
|
+
this.lineBaseEven = firstRowLine;
|
|
613
637
|
}
|
|
614
638
|
}
|
|
615
639
|
|
|
640
|
+
doublesLines() {
|
|
641
|
+
return (this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
|
|
642
|
+
}
|
|
643
|
+
|
|
616
644
|
debugOffset(x, y) {
|
|
617
645
|
if (x < 0 || x >= 1024) return -1;
|
|
618
646
|
if (y < 0 || y >= 768) return -1;
|
|
@@ -715,9 +743,7 @@ export class Video {
|
|
|
715
743
|
if (!this.halfClock || !this.oddClock) return;
|
|
716
744
|
if ((this.dispEnabled & EVERYTHINGENABLED) !== EVERYTHINGENABLED) return;
|
|
717
745
|
if (this.bitmapX < 0 || this.bitmapX >= 1024 || this.bitmapY < 0 || this.bitmapY >= 625) return;
|
|
718
|
-
|
|
719
|
-
const doubledLines =
|
|
720
|
-
(this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
|
|
746
|
+
const doubledLines = this.doublesLines();
|
|
721
747
|
const bitmapRow = doubledLines ? this.bitmapY & ~1 : this.bitmapY;
|
|
722
748
|
const offset = bitmapRow * 1024 + this.bitmapX;
|
|
723
749
|
const halfCell = this.pixelsPerChar >>> 1;
|
|
@@ -903,6 +929,7 @@ export class Video {
|
|
|
903
929
|
// The CRT vertical beam speed is constant, so this is actually
|
|
904
930
|
// an approximation that works if hsyncs are spaced evenly.
|
|
905
931
|
this.bitmapY += 2;
|
|
932
|
+
this.hsyncCount = (this.hsyncCount + 1) % PalPhasePeriodLines;
|
|
906
933
|
|
|
907
934
|
// Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
|
|
908
935
|
return this.bitmapY >= 768;
|
|
@@ -1064,7 +1091,8 @@ export class Video {
|
|
|
1064
1091
|
// There's a painting subtlety here: if we're in an
|
|
1065
1092
|
// interlace mode but R6>R4 then we'll get stuck
|
|
1066
1093
|
// painting just an odd or even frame, so we double up
|
|
1067
|
-
// scanlines to avoid a ghost half frame.
|
|
1094
|
+
// scanlines to avoid a ghost half frame. This is
|
|
1095
|
+
// doublesLines(), inlined for speed.
|
|
1068
1096
|
if (
|
|
1069
1097
|
(this.doubledScanlines && !this.interlacedSyncAndVideo) ||
|
|
1070
1098
|
this.isEvenRender === this.lastRenderWasEven
|
package/src/web/audio-handler.js
CHANGED
|
@@ -62,7 +62,7 @@ export class AudioHandler {
|
|
|
62
62
|
this.masterGain.connect(this.audioContext.destination);
|
|
63
63
|
this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
|
|
64
64
|
this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
|
|
65
|
-
this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
|
|
65
|
+
this._setup({ audioFilterFreq, audioFilterQ }).catch((error) => this._audioUnavailable(error));
|
|
66
66
|
} else {
|
|
67
67
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
68
68
|
this.audioContext = null;
|
|
@@ -127,27 +127,18 @@ export class AudioHandler {
|
|
|
127
127
|
this.chart.streamTo(statsNode, 100);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
async _setup(audioFilterFreq, audioFilterQ) {
|
|
130
|
+
async _setup({ audioFilterFreq, audioFilterQ }) {
|
|
131
131
|
await this.audioContext.audioWorklet.addModule(rendererUrl);
|
|
132
|
-
if (audioFilterFreq !== 0) {
|
|
133
|
-
const filterNode = this.audioContext.createBiquadFilter();
|
|
134
|
-
filterNode.type = "lowpass";
|
|
135
|
-
filterNode.frequency.value = audioFilterFreq;
|
|
136
|
-
filterNode.Q.value = audioFilterQ;
|
|
137
|
-
this._audioDestination = filterNode;
|
|
138
|
-
filterNode.connect(this.audioContext.destination);
|
|
139
|
-
} else {
|
|
140
|
-
this._audioDestination = this.audioContext.destination;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
132
|
this._jsAudioNode = new AudioWorkletNode(this.audioContext, "sound-chip-processor", {
|
|
144
133
|
processorOptions: {
|
|
145
134
|
targetLatencyMs: this._targetLatencyMs(),
|
|
146
135
|
isAtom: this.isAtom,
|
|
147
136
|
cpuSpeed: this.cpuSpeed,
|
|
137
|
+
audioFilterFreq,
|
|
138
|
+
audioFilterQ,
|
|
148
139
|
},
|
|
149
140
|
});
|
|
150
|
-
this._jsAudioNode.connect(this.
|
|
141
|
+
this._jsAudioNode.connect(this.audioContext.destination);
|
|
151
142
|
this._jsAudioNode.port.onmessage = (event) => {
|
|
152
143
|
const now = Date.now();
|
|
153
144
|
if (event.data.event) {
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
/* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
|
|
2
2
|
import { SoundChip, AtomSoundChip } from "../soundchip.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { LowPassBiquad } from "../biquad.js";
|
|
4
|
+
import { PolyphaseResampler } from "../resampler.js";
|
|
5
|
+
|
|
6
|
+
// The board's output filter is an equal-component Sallen-Key (Service Manual
|
|
7
|
+
// section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
|
|
8
|
+
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
|
|
9
|
+
// rate, ahead of the resampler.
|
|
10
|
+
const OutputFilterHz = 7234;
|
|
11
|
+
const OutputFilterQ = 0.696;
|
|
12
|
+
|
|
13
|
+
// The resampler's sinc is cut off below the output Nyquist so that its
|
|
14
|
+
// transition band has finished before anything folds; sampled sound rides on
|
|
15
|
+
// a 31 kHz or higher carrier that would otherwise land in the audible band.
|
|
16
|
+
const ResamplerCutoffOfOutputRate = 0.4;
|
|
17
|
+
const ResamplerTaps = 201;
|
|
6
18
|
|
|
7
19
|
const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
|
|
8
20
|
const MaxTargetLatencyMs = 250;
|
|
@@ -22,10 +34,20 @@ const isResync = (event) => event.state !== undefined || event.reset !== undefin
|
|
|
22
34
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
23
35
|
constructor(options) {
|
|
24
36
|
super(options);
|
|
25
|
-
const {
|
|
37
|
+
const {
|
|
38
|
+
isAtom = false,
|
|
39
|
+
cpuSpeed = 1000000,
|
|
40
|
+
targetLatencyMs,
|
|
41
|
+
audioFilterFreq,
|
|
42
|
+
audioFilterQ,
|
|
43
|
+
} = options?.processorOptions ?? {};
|
|
26
44
|
this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
|
|
27
45
|
this.inputSampleRate = this.chip.soundchipFreq;
|
|
28
46
|
this.samplesPerCycle = this.chip.samplesPerCycle;
|
|
47
|
+
this.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
|
|
48
|
+
this.resampler = new PolyphaseResampler(this.inputSampleRate, ResamplerCutoffOfOutputRate * sampleRate, {
|
|
49
|
+
taps: ResamplerTaps,
|
|
50
|
+
});
|
|
29
51
|
|
|
30
52
|
this.events = [];
|
|
31
53
|
this.eventsHead = 0;
|
|
@@ -36,10 +58,7 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
36
58
|
this.skippedMs = 0;
|
|
37
59
|
this.minLeadMs = Infinity;
|
|
38
60
|
|
|
39
|
-
this._lastFilteredOutput = 0;
|
|
40
61
|
this._phase = 0;
|
|
41
|
-
this._source = new Float32Array(0);
|
|
42
|
-
this._rendered = new Float32Array(0);
|
|
43
62
|
this.smoothedLeadError = 0;
|
|
44
63
|
this.setTargetLatency(targetLatencyMs);
|
|
45
64
|
this.port.onmessage = (event) => {
|
|
@@ -49,6 +68,15 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
49
68
|
this.nextStats = 0;
|
|
50
69
|
}
|
|
51
70
|
|
|
71
|
+
// Zero turns it off; a setting the biquad cannot realise (non-finite, at or
|
|
72
|
+
// above Nyquist, non-positive Q) falls back to the board's own values.
|
|
73
|
+
_makeOutputFilter(frequency = OutputFilterHz, q = OutputFilterQ) {
|
|
74
|
+
if (frequency <= 0) return null;
|
|
75
|
+
const usable = frequency < this.inputSampleRate / 2 && q > 0;
|
|
76
|
+
if (!usable) return new LowPassBiquad(this.inputSampleRate, OutputFilterHz, OutputFilterQ);
|
|
77
|
+
return new LowPassBiquad(this.inputSampleRate, frequency, q);
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
setTargetLatency(ms) {
|
|
53
81
|
const valid = Number.isFinite(ms) && ms > 0;
|
|
54
82
|
this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
|
|
@@ -157,12 +185,11 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
157
185
|
return Math.floor((boundary - this.clock) * this.samplesPerCycle);
|
|
158
186
|
}
|
|
159
187
|
|
|
160
|
-
_renderInput(out, length) {
|
|
188
|
+
_renderInput(out, offset, length) {
|
|
161
189
|
if (this.stalled) {
|
|
162
|
-
this._stall(out,
|
|
190
|
+
this._stall(out, offset, length);
|
|
163
191
|
return;
|
|
164
192
|
}
|
|
165
|
-
let offset = 0;
|
|
166
193
|
while (length > 0) {
|
|
167
194
|
const n = Math.min(length, this._samplesUntilNextChange());
|
|
168
195
|
if (n > 0) {
|
|
@@ -189,34 +216,17 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
189
216
|
const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
|
|
190
217
|
const sampleRatio = effectiveSampleRate / sampleRate;
|
|
191
218
|
|
|
192
|
-
const dt = 1 / effectiveSampleRate;
|
|
193
|
-
const filterAlpha = dt / (RC + dt);
|
|
194
|
-
|
|
195
219
|
// The fractional read position carries across quanta, so consumption
|
|
196
220
|
// averages exactly sampleRatio and the pitch never steps at a rounding
|
|
197
|
-
// boundary.
|
|
221
|
+
// boundary.
|
|
198
222
|
const end = this._phase + channel.length * sampleRatio;
|
|
199
223
|
const numInputSamples = Math.floor(end);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this._renderInput(rendered, numInputSamples);
|
|
207
|
-
source[0] = this._lastFilteredOutput;
|
|
208
|
-
let prevSample = this._lastFilteredOutput;
|
|
209
|
-
for (let i = 1; i <= numInputSamples; ++i) {
|
|
210
|
-
prevSample += filterAlpha * (rendered[i - 1] - prevSample);
|
|
211
|
-
source[i] = prevSample;
|
|
212
|
-
}
|
|
213
|
-
this._lastFilteredOutput = prevSample;
|
|
214
|
-
for (let i = 0; i < channel.length; i++) {
|
|
215
|
-
const pos = this._phase + i * sampleRatio;
|
|
216
|
-
const loc = Math.floor(pos);
|
|
217
|
-
const alpha = pos - loc;
|
|
218
|
-
channel[i] = source[loc] * (1 - alpha) + source[loc + 1] * alpha;
|
|
219
|
-
}
|
|
224
|
+
const resampler = this.resampler;
|
|
225
|
+
resampler.reserve(numInputSamples);
|
|
226
|
+
this._renderInput(resampler.buffer, resampler.inputOffset, numInputSamples);
|
|
227
|
+
this.outputFilter?.process(resampler.buffer, resampler.inputOffset, numInputSamples);
|
|
228
|
+
resampler.read(channel, this._phase, sampleRatio);
|
|
229
|
+
resampler.commit();
|
|
220
230
|
this._phase = end - numInputSamples;
|
|
221
231
|
this.minLeadMs = Math.min(this.minLeadMs, this.leadMs());
|
|
222
232
|
this.stats(sampleRatio);
|