jsbeeb 1.19.3 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/acia.js +20 -2
- package/src/biquad.js +36 -0
- package/src/canvas.js +2 -1
- package/src/google-drive.js +11 -1
- package/src/main.js +191 -65
- package/src/soundchip.js +67 -6
- package/src/tapes.js +1 -2
- package/src/video-filters/pal-composite.js +3 -3
- package/src/video-filters/shaders/pal-composite.frag.glsl +48 -48
- package/src/video.js +35 -7
- package/src/web/audio-handler.js +112 -55
- package/src/web/audio-renderer.js +168 -79
package/src/soundchip.js
CHANGED
|
@@ -12,6 +12,10 @@ export const SoundBufferSamples = 512;
|
|
|
12
12
|
// mean level, which a zero-mean output would silence (see issue #863).
|
|
13
13
|
const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
|
|
14
14
|
|
|
15
|
+
// A chip with an event sink reports progress this often, so its events can
|
|
16
|
+
// stream through a long execute() rather than all arriving at its end.
|
|
17
|
+
const EventProgressCycles = 4000;
|
|
18
|
+
|
|
15
19
|
const volumeTable = new Float32Array(16);
|
|
16
20
|
(() => {
|
|
17
21
|
let f = 1.0;
|
|
@@ -35,9 +39,15 @@ export class SoundChip {
|
|
|
35
39
|
* @param {function(Float32Array): void} onBuffer called with each full
|
|
36
40
|
* SoundBufferSamples-sized buffer of output. Receives the same buffer
|
|
37
41
|
* every call, overwritten afterwards: copy the contents if they are kept.
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {function(object): void} [options.onEvent] called with each
|
|
44
|
+
* change to the chip's state, stamped with the cycle it takes effect at.
|
|
45
|
+
* A chip with an event sink does not render; something else renders
|
|
46
|
+
* from the events (see audio-renderer.js).
|
|
38
47
|
*/
|
|
39
|
-
constructor(onBuffer) {
|
|
48
|
+
constructor(onBuffer, { onEvent = null } = {}) {
|
|
40
49
|
this._onBuffer = onBuffer;
|
|
50
|
+
this._onEvent = onEvent;
|
|
41
51
|
// 4MHz input signal. Internal divide-by-8
|
|
42
52
|
this.soundchipFreq = 4000000.0 / 8;
|
|
43
53
|
const sampleRate = this.soundchipFreq;
|
|
@@ -91,15 +101,38 @@ export class SoundChip {
|
|
|
91
101
|
mute: () => {
|
|
92
102
|
this.catchUp();
|
|
93
103
|
this.sineOn = false;
|
|
104
|
+
this._emit({ sine: 0 });
|
|
94
105
|
},
|
|
95
106
|
tone: (freq) => {
|
|
96
107
|
this.catchUp();
|
|
97
108
|
this.sineOn = true;
|
|
98
109
|
this.sineStep = (freq / sampleRate) * this.sineTable.length;
|
|
110
|
+
this._emit({ sine: freq });
|
|
99
111
|
},
|
|
100
112
|
};
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
_emit(event) {
|
|
116
|
+
if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, ...event });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Applies an event from another chip's onEvent, at the cycle this chip is rendering. */
|
|
120
|
+
applyEvent(event) {
|
|
121
|
+
if (event.poke !== undefined) this.poke(event.poke);
|
|
122
|
+
else if (event.sine !== undefined) {
|
|
123
|
+
if (event.sine) this.toneGenerator.tone(event.sine);
|
|
124
|
+
else this.toneGenerator.mute();
|
|
125
|
+
} else if (event.enabled !== undefined) this.enabled = event.enabled;
|
|
126
|
+
else if (event.state !== undefined) this.restoreState(event.state);
|
|
127
|
+
else if (event.reset !== undefined) this.reset(event.reset);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Renders `length` samples of output from `cycle`, for a chip that is driven by events. */
|
|
131
|
+
renderAt(cycle, out, offset, length) {
|
|
132
|
+
this.scheduler.epoch = this.lastRunEpoch = cycle;
|
|
133
|
+
this.generate(out, offset, length);
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
sineChannel(channel, out, offset, length) {
|
|
104
137
|
if (!this.sineOn) return;
|
|
105
138
|
|
|
@@ -181,6 +214,7 @@ export class SoundChip {
|
|
|
181
214
|
this.volume[2] = volumeTable[v2];
|
|
182
215
|
this.volume[3] = volumeTable[v3];
|
|
183
216
|
this.noisePoked();
|
|
217
|
+
this._emit({ state: this.snapshotState() });
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
generate(out, offset, length) {
|
|
@@ -219,6 +253,13 @@ export class SoundChip {
|
|
|
219
253
|
this.activeTask = this.scheduler.newTask(() => {
|
|
220
254
|
if (this.active) this.poke(this.slowDataBus);
|
|
221
255
|
});
|
|
256
|
+
if (this._onEvent) {
|
|
257
|
+
this.progressTask = this.scheduler.newTask(() => {
|
|
258
|
+
this._emit({ progress: true });
|
|
259
|
+
this.progressTask.schedule(EventProgressCycles);
|
|
260
|
+
});
|
|
261
|
+
this.progressTask.schedule(EventProgressCycles);
|
|
262
|
+
}
|
|
222
263
|
}
|
|
223
264
|
|
|
224
265
|
render(out, offset, length) {
|
|
@@ -239,6 +280,7 @@ export class SoundChip {
|
|
|
239
280
|
}
|
|
240
281
|
|
|
241
282
|
advance(cycles) {
|
|
283
|
+
if (this._onEvent) return;
|
|
242
284
|
const num = cycles * this.samplesPerCycle + this.residual;
|
|
243
285
|
let rounded = num | 0;
|
|
244
286
|
this.residual = num - rounded;
|
|
@@ -270,6 +312,7 @@ export class SoundChip {
|
|
|
270
312
|
|
|
271
313
|
poke(value) {
|
|
272
314
|
this.catchUp();
|
|
315
|
+
this._emit({ poke: value });
|
|
273
316
|
|
|
274
317
|
let command;
|
|
275
318
|
if (value & 0x80) {
|
|
@@ -350,10 +393,13 @@ export class SoundChip {
|
|
|
350
393
|
// Older snapshots predate the DC blocker
|
|
351
394
|
this.dcPrevIn = state.dcPrevIn ?? 0;
|
|
352
395
|
this.dcPrevOut = state.dcPrevOut ?? 0;
|
|
396
|
+
this.progressTask?.ensureScheduled(true, EventProgressCycles);
|
|
397
|
+
this._emit({ state: this.snapshotState() });
|
|
353
398
|
}
|
|
354
399
|
|
|
355
400
|
reset(hard) {
|
|
356
401
|
if (!hard) return;
|
|
402
|
+
this._emit({ reset: true });
|
|
357
403
|
for (let i = 0; i < 4; ++i) {
|
|
358
404
|
this.counter[i] = 0;
|
|
359
405
|
this.registers[i] = 0;
|
|
@@ -366,14 +412,15 @@ export class SoundChip {
|
|
|
366
412
|
|
|
367
413
|
enable(e) {
|
|
368
414
|
this.enabled = e;
|
|
415
|
+
this._emit({ enabled: e });
|
|
369
416
|
}
|
|
370
417
|
|
|
371
418
|
mute() {
|
|
372
|
-
this.
|
|
419
|
+
this.enable(false);
|
|
373
420
|
}
|
|
374
421
|
|
|
375
422
|
unmute() {
|
|
376
|
-
this.
|
|
423
|
+
this.enable(true);
|
|
377
424
|
}
|
|
378
425
|
}
|
|
379
426
|
|
|
@@ -383,8 +430,8 @@ export class SoundChip {
|
|
|
383
430
|
* channel with DC-blocking filter.
|
|
384
431
|
*/
|
|
385
432
|
export class AtomSoundChip extends SoundChip {
|
|
386
|
-
constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
|
|
387
|
-
super(onBuffer);
|
|
433
|
+
constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
|
|
434
|
+
super(onBuffer, { onEvent });
|
|
388
435
|
this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
|
|
389
436
|
this.secondsPerCycle = 1 / cpuSpeed;
|
|
390
437
|
|
|
@@ -423,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
|
|
|
423
470
|
this._speakerCycleOffset = 0;
|
|
424
471
|
}
|
|
425
472
|
|
|
473
|
+
applyEvent(event) {
|
|
474
|
+
if (event.bit !== undefined) this.bitChange.push({ bit: event.bit, cycles: event.cycle });
|
|
475
|
+
else if (event.speakerReset !== undefined) this.speakerReset();
|
|
476
|
+
else super.applyEvent(event);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
renderAt(cycle, out, offset, length) {
|
|
480
|
+
this._speakerCycleOffset = 0;
|
|
481
|
+
super.renderAt(cycle, out, offset, length);
|
|
482
|
+
}
|
|
483
|
+
|
|
426
484
|
speakerReset() {
|
|
485
|
+
this._emit({ speakerReset: true });
|
|
427
486
|
this.bitChange = [];
|
|
428
487
|
this.currentSpeakerBit = 0.0;
|
|
429
488
|
this._speakerPrevIn = 0;
|
|
@@ -461,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
|
|
|
461
520
|
|
|
462
521
|
updateSpeaker(value, microCycle, seconds) {
|
|
463
522
|
const cycles = microCycle + seconds / this.secondsPerCycle;
|
|
464
|
-
|
|
523
|
+
const bit = value ? 1.0 : 0.0;
|
|
524
|
+
if (this._onEvent) this._onEvent({ cycle: cycles, bit });
|
|
525
|
+
else this.bitChange.push({ bit, cycles });
|
|
465
526
|
}
|
|
466
527
|
}
|
|
467
528
|
|
package/src/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
|