jsbeeb 1.20.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/acia.js +20 -2
- package/src/biquad.js +36 -0
- package/src/canvas.js +2 -1
- package/src/main.js +56 -26
- 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 +32 -24
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.1",
|
|
11
11
|
"//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=24.15.0"
|
package/src/acia.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// https://books.google.com/books?id=wUecAQAAQBAJ&pg=PA431&lpg=PA431&dq=acia+tdre&source=bl&ots=mp-yF-mK-P&sig=e6aXkFRfiIOb57WZmrvdIGsCooI&hl=en&sa=X&ei=0g2fVdDyFIXT-QG8-JD4BA&ved=0CCwQ6AEwAw#v=onepage&q=acia%20tdre&f=false
|
|
5
5
|
// http://www.classiccmp.org/dunfield/r/6850.pdf
|
|
6
6
|
|
|
7
|
-
export class Acia {
|
|
7
|
+
export class Acia extends EventTarget {
|
|
8
8
|
constructor(cpu, toneGen, scheduler, relayNoise) {
|
|
9
|
+
super();
|
|
9
10
|
this.cpu = cpu;
|
|
10
11
|
this.toneGen = toneGen;
|
|
11
12
|
this.rs423Handler = null;
|
|
@@ -20,6 +21,7 @@ export class Acia {
|
|
|
20
21
|
this.tapeCarrierCount = 0;
|
|
21
22
|
this.tapeDcdLineLevel = false;
|
|
22
23
|
this.hadDcdHigh = false;
|
|
24
|
+
this.saidOverrun = false;
|
|
23
25
|
this.serialReceiveRate = 0;
|
|
24
26
|
this.serialReceiveCyclesPerByte = 0;
|
|
25
27
|
this.serialTransmitRate = 0;
|
|
@@ -200,8 +202,8 @@ export class Acia {
|
|
|
200
202
|
// TODO: this doesn't match the datasheet:
|
|
201
203
|
// "The Overrun does not occur in the Status Register until the
|
|
202
204
|
// valid character prior to Overrun has been read."
|
|
203
|
-
console.log("Serial overrun");
|
|
204
205
|
this.sr |= 0xa0;
|
|
206
|
+
this.noteOverrun();
|
|
205
207
|
} else {
|
|
206
208
|
// If bit 7 contains parity, mask it off.
|
|
207
209
|
this.dr = byte & (this.cr & 0x10 ? 0xff : 0x7f);
|
|
@@ -210,6 +212,22 @@ export class Acia {
|
|
|
210
212
|
this.updateIrq();
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
noteOverrun() {
|
|
216
|
+
if (this.saidOverrun) return;
|
|
217
|
+
this.saidOverrun = true;
|
|
218
|
+
this.dispatchEvent(
|
|
219
|
+
new CustomEvent("notice", {
|
|
220
|
+
detail: {
|
|
221
|
+
message:
|
|
222
|
+
"A serial byte arrived before the previous one was read, so it was lost. " +
|
|
223
|
+
"A tape load that stops with Data? is usually this.",
|
|
224
|
+
title: "Serial",
|
|
225
|
+
quietKey: "quietSerialOverrun",
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
213
231
|
snapshotState() {
|
|
214
232
|
const scheduler = this.txCompleteTask.scheduler;
|
|
215
233
|
return {
|
package/src/biquad.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Coefficients from the RBJ Audio EQ Cookbook: the second-order prototype
|
|
2
|
+
// under a bilinear transform prewarped so the corner lands on `frequency`.
|
|
3
|
+
export class LowPassBiquad {
|
|
4
|
+
constructor(sampleRate, frequency, q) {
|
|
5
|
+
const w0 = (2 * Math.PI * frequency) / sampleRate;
|
|
6
|
+
const alpha = Math.sin(w0) / (2 * q);
|
|
7
|
+
const cosW0 = Math.cos(w0);
|
|
8
|
+
const a0 = 1 + alpha;
|
|
9
|
+
this.b0 = (1 - cosW0) / (2 * a0);
|
|
10
|
+
this.b1 = (1 - cosW0) / a0;
|
|
11
|
+
this.a1 = (-2 * cosW0) / a0;
|
|
12
|
+
this.a2 = (1 - alpha) / a0;
|
|
13
|
+
this.x1 = 0;
|
|
14
|
+
this.x2 = 0;
|
|
15
|
+
this.y1 = 0;
|
|
16
|
+
this.y2 = 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process(buffer, offset, length) {
|
|
20
|
+
const { b0, b1, a1, a2 } = this;
|
|
21
|
+
let { x1, x2, y1, y2 } = this;
|
|
22
|
+
for (let i = offset; i < offset + length; ++i) {
|
|
23
|
+
const x = buffer[i];
|
|
24
|
+
const y = b0 * x + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2;
|
|
25
|
+
x2 = x1;
|
|
26
|
+
x1 = x;
|
|
27
|
+
y2 = y1;
|
|
28
|
+
y1 = y;
|
|
29
|
+
buffer[i] = y;
|
|
30
|
+
}
|
|
31
|
+
this.x1 = x1;
|
|
32
|
+
this.x2 = x2;
|
|
33
|
+
this.y1 = y1;
|
|
34
|
+
this.y2 = y2;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/canvas.js
CHANGED
|
@@ -240,7 +240,8 @@ export class GlCanvas {
|
|
|
240
240
|
this.filter.setUniforms({
|
|
241
241
|
width,
|
|
242
242
|
height,
|
|
243
|
-
|
|
243
|
+
lineBaseEven: frame.lineBaseEven,
|
|
244
|
+
lineBaseOdd: frame.lineBaseOdd,
|
|
244
245
|
lineGrid: frame.lineGrid,
|
|
245
246
|
// How much of the framebuffer each output pixel covers, which sets
|
|
246
247
|
// how wide an edge-smoothing ramp should be. `extent` holds texel
|
package/src/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,10 +777,19 @@ 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
|
|
|
@@ -848,6 +877,7 @@ processor = new CpuClass(model, {
|
|
|
848
877
|
printer.attach(processor.uservia);
|
|
849
878
|
|
|
850
879
|
processor.teletextAdaptor?.addEventListener("notice", showNotice);
|
|
880
|
+
processor.acia.addEventListener("notice", showNotice);
|
|
851
881
|
|
|
852
882
|
// Create input sources
|
|
853
883
|
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
@@ -1692,12 +1722,6 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
|
|
|
1692
1722
|
});
|
|
1693
1723
|
|
|
1694
1724
|
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
1725
|
popupLoading("Loading '" + cat.name + "' from Google Drive");
|
|
1702
1726
|
try {
|
|
1703
1727
|
const available = await googleDrive.initialise();
|
|
@@ -1718,6 +1742,12 @@ async function gdLoad(cat, layout) {
|
|
|
1718
1742
|
const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
|
|
1719
1743
|
console.log("Google Drive loading finished");
|
|
1720
1744
|
loadingFinished();
|
|
1745
|
+
if (!ssd.savesChanges) {
|
|
1746
|
+
toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
|
|
1747
|
+
title: "Google Drive",
|
|
1748
|
+
quietKey: "quietDriveReadOnly",
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1721
1751
|
return ssd;
|
|
1722
1752
|
} catch (error) {
|
|
1723
1753
|
console.error("Google Drive loading error:", error);
|
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,13 @@
|
|
|
1
1
|
/* global sampleRate, currentTime, registerProcessor, AudioWorkletProcessor */
|
|
2
2
|
import { SoundChip, AtomSoundChip } from "../soundchip.js";
|
|
3
|
+
import { LowPassBiquad } from "../biquad.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
// The board's output filter is an equal-component Sallen-Key (Service Manual
|
|
6
|
+
// section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
|
|
7
|
+
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
|
|
8
|
+
// rate, ahead of decimation, it is also the anti-alias filter.
|
|
9
|
+
const OutputFilterHz = 7234;
|
|
10
|
+
const OutputFilterQ = 0.696;
|
|
6
11
|
|
|
7
12
|
const DefaultTargetLatencyMs = 1000 * (1 / 50); // One frame
|
|
8
13
|
const MaxTargetLatencyMs = 250;
|
|
@@ -22,10 +27,17 @@ const isResync = (event) => event.state !== undefined || event.reset !== undefin
|
|
|
22
27
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
23
28
|
constructor(options) {
|
|
24
29
|
super(options);
|
|
25
|
-
const {
|
|
30
|
+
const {
|
|
31
|
+
isAtom = false,
|
|
32
|
+
cpuSpeed = 1000000,
|
|
33
|
+
targetLatencyMs,
|
|
34
|
+
audioFilterFreq,
|
|
35
|
+
audioFilterQ,
|
|
36
|
+
} = options?.processorOptions ?? {};
|
|
26
37
|
this.chip = isAtom ? new AtomSoundChip(null, { cpuSpeed }) : new SoundChip(null);
|
|
27
38
|
this.inputSampleRate = this.chip.soundchipFreq;
|
|
28
39
|
this.samplesPerCycle = this.chip.samplesPerCycle;
|
|
40
|
+
this.outputFilter = this._makeOutputFilter(audioFilterFreq, audioFilterQ);
|
|
29
41
|
|
|
30
42
|
this.events = [];
|
|
31
43
|
this.eventsHead = 0;
|
|
@@ -36,10 +48,9 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
36
48
|
this.skippedMs = 0;
|
|
37
49
|
this.minLeadMs = Infinity;
|
|
38
50
|
|
|
39
|
-
this.
|
|
51
|
+
this._lastInputSample = 0;
|
|
40
52
|
this._phase = 0;
|
|
41
53
|
this._source = new Float32Array(0);
|
|
42
|
-
this._rendered = new Float32Array(0);
|
|
43
54
|
this.smoothedLeadError = 0;
|
|
44
55
|
this.setTargetLatency(targetLatencyMs);
|
|
45
56
|
this.port.onmessage = (event) => {
|
|
@@ -49,6 +60,15 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
49
60
|
this.nextStats = 0;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
// Zero turns it off; a setting the biquad cannot realise (non-finite, at or
|
|
64
|
+
// above Nyquist, non-positive Q) falls back to the board's own values.
|
|
65
|
+
_makeOutputFilter(frequency = OutputFilterHz, q = OutputFilterQ) {
|
|
66
|
+
if (frequency <= 0) return null;
|
|
67
|
+
const usable = frequency < this.inputSampleRate / 2 && q > 0;
|
|
68
|
+
if (!usable) return new LowPassBiquad(this.inputSampleRate, OutputFilterHz, OutputFilterQ);
|
|
69
|
+
return new LowPassBiquad(this.inputSampleRate, frequency, q);
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
setTargetLatency(ms) {
|
|
53
73
|
const valid = Number.isFinite(ms) && ms > 0;
|
|
54
74
|
this.targetLatencyMs = valid ? Math.min(ms, MaxTargetLatencyMs) : DefaultTargetLatencyMs;
|
|
@@ -157,12 +177,11 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
157
177
|
return Math.floor((boundary - this.clock) * this.samplesPerCycle);
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
_renderInput(out, length) {
|
|
180
|
+
_renderInput(out, offset, length) {
|
|
161
181
|
if (this.stalled) {
|
|
162
|
-
this._stall(out,
|
|
182
|
+
this._stall(out, offset, length);
|
|
163
183
|
return;
|
|
164
184
|
}
|
|
165
|
-
let offset = 0;
|
|
166
185
|
while (length > 0) {
|
|
167
186
|
const n = Math.min(length, this._samplesUntilNextChange());
|
|
168
187
|
if (n > 0) {
|
|
@@ -189,28 +208,17 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
189
208
|
const effectiveSampleRate = this._effectiveSampleRate(channel.length / sampleRate);
|
|
190
209
|
const sampleRatio = effectiveSampleRate / sampleRate;
|
|
191
210
|
|
|
192
|
-
const dt = 1 / effectiveSampleRate;
|
|
193
|
-
const filterAlpha = dt / (RC + dt);
|
|
194
|
-
|
|
195
211
|
// The fractional read position carries across quanta, so consumption
|
|
196
212
|
// averages exactly sampleRatio and the pitch never steps at a rounding
|
|
197
213
|
// boundary. source[0] is the last input sample of the previous quantum.
|
|
198
214
|
const end = this._phase + channel.length * sampleRatio;
|
|
199
215
|
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
|
-
}
|
|
216
|
+
if (this._source.length <= numInputSamples) this._source = new Float32Array(numInputSamples * 2);
|
|
204
217
|
const source = this._source;
|
|
205
|
-
|
|
206
|
-
this._renderInput(
|
|
207
|
-
source
|
|
208
|
-
|
|
209
|
-
for (let i = 1; i <= numInputSamples; ++i) {
|
|
210
|
-
prevSample += filterAlpha * (rendered[i - 1] - prevSample);
|
|
211
|
-
source[i] = prevSample;
|
|
212
|
-
}
|
|
213
|
-
this._lastFilteredOutput = prevSample;
|
|
218
|
+
source[0] = this._lastInputSample;
|
|
219
|
+
this._renderInput(source, 1, numInputSamples);
|
|
220
|
+
this.outputFilter?.process(source, 1, numInputSamples);
|
|
221
|
+
this._lastInputSample = source[numInputSamples];
|
|
214
222
|
for (let i = 0; i < channel.length; i++) {
|
|
215
223
|
const pos = this._phase + i * sampleRatio;
|
|
216
224
|
const loc = Math.floor(pos);
|