jsbeeb 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.js CHANGED
@@ -11,12 +11,14 @@ const CpuModel = Object.freeze({
11
11
  });
12
12
 
13
13
  class Model {
14
- constructor({ name, synonyms, os, cpuModel, isMaster, swram, fdc, tube, cmosOverride } = {}) {
14
+ constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, tube, cmosOverride, banks } = {}) {
15
15
  this.name = name;
16
16
  this.synonyms = synonyms;
17
17
  this.os = os;
18
+ this.banks = banks;
18
19
  this._cpuModel = cpuModel;
19
20
  this.isMaster = isMaster;
21
+ this.isAtom = !!isAtom;
20
22
  this.Fdc = fdc;
21
23
  this.swram = swram;
22
24
  this.isTest = false;
@@ -58,6 +60,20 @@ function pickDfs(cmos) {
58
60
  return cmos;
59
61
  }
60
62
 
63
+ function atomModel({ name, synonyms, os, banks }) {
64
+ return new Model({
65
+ name,
66
+ synonyms,
67
+ os,
68
+ cpuModel: CpuModel.MOS6502,
69
+ isMaster: false,
70
+ isAtom: true,
71
+ swram: beebSwram,
72
+ fdc: NoiseAwareIntelFdc,
73
+ banks,
74
+ });
75
+ }
76
+
61
77
  // TODO: semi-bplus-style to get swram for exile hardcoded here
62
78
  const beebSwram = [
63
79
  true,
@@ -164,6 +180,31 @@ export const allModels = [
164
180
  fdc: NoiseAwareWdFdc,
165
181
  cmosOverride: pickAnfs,
166
182
  }),
183
+ // Atom OS ROM layout: Block F (kernel), E (DOS/MMC), D (FP), C (BASIC).
184
+ // Empty strings represent unpopulated ROM slots.
185
+ atomModel({
186
+ name: "Acorn Atom (MMC)",
187
+ synonyms: ["Atom"],
188
+ os: ["atom/Atom_Kernel_E.rom", "atom/ATMMC3E.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
189
+ // Utility ROMs in banks 0 and 1 of Block A (0xA000-0xAFFF), up to 8 banks.
190
+ // Bank selected by Branquart latch at 0xBFFF (bits 0-3 = bank, bit 6 = lock).
191
+ banks: ["atom/PCHARME.ROM", "atom/gags.rom"],
192
+ }),
193
+ atomModel({
194
+ name: "Acorn Atom (Tape)",
195
+ synonyms: ["Atom-Tape"],
196
+ os: ["atom/Atom_Kernel.rom", "", "", "atom/Atom_Basic.rom"],
197
+ }),
198
+ atomModel({
199
+ name: "Acorn Atom (Tape with FP)",
200
+ synonyms: ["Atom-Tape-FP"],
201
+ os: ["atom/Atom_Kernel.rom", "", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
202
+ }),
203
+ atomModel({
204
+ name: "Acorn Atom (DOS)",
205
+ synonyms: ["Atom-DOS"],
206
+ os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
207
+ }),
167
208
  // Although this can not be explicitly selected as a model, it is required by the configuration builder later
168
209
  new Model({
169
210
  name: "Tube65C02",
package/src/ppia.js ADDED
@@ -0,0 +1,477 @@
1
+ import { getKeyMapAtom } from "./utils_atom.js";
2
+
3
+ // 8255 Programmable Peripheral Interface Adapter for the Acorn Atom.
4
+ // Reference: http://mdfs.net/Docs/Comp/Acorn/Atom/atap25.htm
5
+ // Memory map: http://mdfs.net/Docs/Comp/Acorn/Atom/MemoryMap
6
+ //
7
+ // Port A - 0xB000 (output)
8
+ // bits 0-3: Keyboard row select
9
+ // bits 4-7: MC6847 graphics mode
10
+ //
11
+ // Port B - 0xB001 (input)
12
+ // bits 0-5: Keyboard column (active low)
13
+ // bit 6: CTRL key (low when pressed)
14
+ // bit 7: SHIFT key (low when pressed)
15
+ //
16
+ // Port C - 0xB002 (mixed I/O)
17
+ // Output bits:
18
+ // 0: Tape output
19
+ // 1: Enable 2.4 kHz to cassette output
20
+ // 2: Loudspeaker
21
+ // 3: Colour Set Select (CSS)
22
+ // Input bits:
23
+ // 4: 2.4 kHz input
24
+ // 5: Cassette input
25
+ // 6: REPT key (low when pressed)
26
+ // 7: 60 Hz VSync signal (low during flyback)
27
+ //
28
+ // Keyboard matrix (active low, active when key pressed):
29
+ // Port A row → 9 8 7 6 5 4 3 2 1 0
30
+ // Port B col ↓
31
+ // ~b0 : SPC [ \ ] ^ LCK <-> ^-v Lft Rgt
32
+ // ~b1 : Dwn Up CLR ENT CPY DEL 0 1 2 3
33
+ // ~b2 : 4 5 6 7 8 9 : ; < =
34
+ // ~b3 : > ? @ A B C D E F G
35
+ // ~b4 : H I J K L M N O P Q
36
+ // ~b5 : R S T U V W X Y Z ESC
37
+ // ~b6 : Ctrl
38
+ // ~b7 : Shift
39
+
40
+ const PORTA = 0x0,
41
+ PORTB = 0x1,
42
+ PORTC = 0x2,
43
+ CREG = 0x3; // control register
44
+
45
+ class PPIA {
46
+ constructor(cpu) {
47
+ this.cpu = cpu;
48
+
49
+ this.latcha = 0;
50
+ this.latchb = 0;
51
+ this.latchc = 0;
52
+ this.portapins = 0;
53
+ this.portbpins = 0;
54
+ this.portcpins = 0;
55
+ this.creg = 0;
56
+ this.speaker = 0;
57
+ }
58
+
59
+ reset() {
60
+ //http://members.casema.nl/hhaydn/8255_pin.html
61
+ this.latcha = this.latchb = this.latchc = 0x00;
62
+ }
63
+
64
+ setVBlankInt(level) {
65
+ // level == 1 when in the vsync
66
+ // FE66_wait_for_flyback_start will loop until bit 7 (copied into N register using BIT)
67
+ // of B002 is not 0 (i.e until BPL fails when bit 7 is 1)
68
+ // then
69
+ // FE6B_wait_for_flyback will loop until bit 7 of B002 is (copied into N register using BIT)
70
+ // of B002 is not 1 (i.e until BMI fails when bit 7 is 0)
71
+
72
+ //60 Hz sync signal - normally 1 during the frame, but goes 0 at start of flyback (at the end of a frame).
73
+ //opposite of the 'level'
74
+ if (!level) {
75
+ // set bit 7 to 1 - in frame
76
+ this.latchc |= 0x80;
77
+ } else {
78
+ // set bit 7 to 0 - in vsync
79
+ this.latchc &= ~0x80;
80
+ }
81
+ this.recalculatePortCPins();
82
+ }
83
+
84
+ /*
85
+ Port C - #B002
86
+ Output bits: Function:
87
+ 0 Tape output
88
+ 1 Enable 2.4 kHz to cassette output
89
+ 2 Loudspeaker
90
+ 3 Not used
91
+
92
+ Input bits: Function:
93
+ 4 2.4 kHz input
94
+ 5 Cassette input
95
+ 6 REPT key (low when pressed)
96
+ 7 60 Hz sync signal (low during flyback)
97
+ The port C output lines, bits 0 to 3, may be used for user applications when the cassette interface is not being used.
98
+
99
+ */
100
+ write(addr, val) {
101
+ val |= 0;
102
+ // The 8255 only decodes A0-A1; addresses 4-7 mirror 0-3.
103
+ // Addresses 8-15 are outside the 8255's decode range.
104
+ if ((addr & 0xf) >= 0x8) return;
105
+ switch (addr & 0x3) {
106
+ case PORTA:
107
+ this.latcha = val;
108
+ this.recalculatePortAPins();
109
+ break;
110
+
111
+ case PORTB:
112
+ // Port B is input-only; writes are ignored.
113
+ break;
114
+
115
+ case PORTC:
116
+ this.latchc = (this.latchc & 0xf0) | (val & 0x0f);
117
+
118
+ this.recalculatePortCPins();
119
+ break;
120
+ case CREG: {
121
+ this.creg = val & 0xff;
122
+ // 8255 CREG: D7=1 is mode-set (ignored; Atom uses fixed port directions),
123
+ // D7=0 is Bit Set/Reset (BSR) for individual port C output bits.
124
+ if (val & 0x80) break; // mode-set: no action needed
125
+
126
+ // BSR: set or clear a single port C output bit.
127
+ // NOTE: Simplified: only handles bits 2 (speaker) and 3 (CSS),
128
+ // and rebuilds the lower nibble. Works because the Atom ROM
129
+ // only BSR-toggles these two bits.
130
+ let speaker = this.latchc & 0x04;
131
+ let css = this.latchc & 0x08;
132
+ switch (val & 0xe) {
133
+ case 0x4: // port C pin 2 (speaker)
134
+ speaker = (val & 1) << 2;
135
+ break;
136
+ case 0x6: // port C pin 3 (CSS)
137
+ css = (val & 1) << 3;
138
+ break;
139
+ }
140
+ this.latchc = (this.latchc & 0xf0) | css | speaker;
141
+ this.recalculatePortCPins();
142
+ break;
143
+ }
144
+ }
145
+ }
146
+
147
+ read(addr) {
148
+ if ((addr & 0xf) >= 0x8) return addr >>> 8; // open bus
149
+ switch (addr & 0x3) {
150
+ case PORTA:
151
+ this.recalculatePortAPins();
152
+ return this.portapins;
153
+ case PORTB: {
154
+ this.recalculatePortBPins();
155
+ const keyrow = this.portapins & 0x0f;
156
+ const n = this.keys[keyrow];
157
+ let r = 0xff; // all keys unpressed
158
+ for (let b = 0; b <= 9; b++) r &= ~(n[b] << b);
159
+ // CTRL and SHIFT are always readable regardless of row selection
160
+ const ctrl_shift = (this.keys[0][7] << 7) | (this.keys[0][6] << 6);
161
+ r &= ~(ctrl_shift & 0xc0);
162
+ return r;
163
+ }
164
+ case PORTC: {
165
+ this.recalculatePortCPins();
166
+
167
+ // Force HZIN (bit 4) high
168
+ this.portcpins = (this.portcpins & 0xef) | (1 << 4);
169
+
170
+ // Read back full port C state: output bits 0-3 from latch,
171
+ // input bits 4-7 from pins, with REPT key overlay on bit 6.
172
+ let val = this.portcpins;
173
+
174
+ // REPT key: bit 6 is LOW when pressed
175
+ const rept_key = (!this.keys[1][6] << 6) & 0x40;
176
+ val = (val & ~0x40) | rept_key;
177
+
178
+ // Track cassette input transitions. The Atom ROM tape routines:
179
+ // 0xfc0a - OSBGET: get byte from tape (every 3.34ms)
180
+ // 0xfcd2 - test tape input pulse (every 0.033ms / 33 cycles)
181
+ // 0xfcc2 - count duration of tape pulse (<8 loops = '1', >=8 = '0')
182
+ // 0xfe6e, 0xfe9d, 0xfe69 - flyback/VSync routines
183
+ // Between each receiveBit, fcd2 is called ~6 times (33 cycles each).
184
+ return val;
185
+ }
186
+ default:
187
+ return addr >>> 8; // CREG and unmapped registers return open bus
188
+ }
189
+ }
190
+
191
+ recalculatePortAPins() {
192
+ this.portapins = this.latcha;
193
+ this.drivePortA();
194
+ this.portAUpdated();
195
+ }
196
+
197
+ recalculatePortBPins() {
198
+ this.portbpins = this.latchb;
199
+ this.drivePortB();
200
+ this.portBUpdated();
201
+ }
202
+
203
+ recalculatePortCPins() {
204
+ this.portcpins = this.latchc;
205
+ this.drivePortC();
206
+ this.portCUpdated();
207
+ }
208
+
209
+ // No-op hooks for subclass override
210
+ drivePortA() {}
211
+ drivePortB() {}
212
+ drivePortC() {}
213
+ portAUpdated() {}
214
+ portBUpdated() {}
215
+ portCUpdated() {}
216
+ }
217
+
218
+ // On the atom, the PPIA does the keyboard, speaker and tape.
219
+ // On the BBC, sysVIA does the keyboard and pokes the soundchip
220
+ // and the ACIA does the tape
221
+ export class AtomPPIA extends PPIA {
222
+ constructor(cpu, initialLayout, scheduler) {
223
+ super(cpu);
224
+
225
+ this.keys = [];
226
+ for (let i = 0; i < 16; ++i) {
227
+ this.keys[i] = new Uint8Array(16);
228
+ }
229
+
230
+ this.setKeyLayout(initialLayout);
231
+
232
+ this.keyboardEnabled = true;
233
+ this.lastSpeakerBit = 0;
234
+ // The Atom has no lock lights. Report caps lock as "on" and shift
235
+ // lock as "off" so the paste routine's lock-toggle logic is never
236
+ // triggered (it toggles CAPSLOCK when !capsLockLight, and SHIFTLOCK
237
+ // when shiftLockLight).
238
+ this.capsLockLight = true;
239
+ this.shiftLockLight = false;
240
+ this.tapeCarrierCount = 0;
241
+ this.tapeDcdLineLevel = false;
242
+ this.motorOn = false;
243
+
244
+ this.reset();
245
+
246
+ // from ACIA
247
+ this.runTapeTask = scheduler.newTask(() => this.runTape());
248
+ }
249
+
250
+ setKeyLayout(map) {
251
+ this.keycodeToRowCol = getKeyMapAtom(map);
252
+ }
253
+
254
+ // from SysVIA
255
+ clearKeys() {
256
+ for (let i = 0; i < this.keys.length; ++i) {
257
+ for (let j = 0; j < this.keys[i].length; ++j) {
258
+ this.keys[i][j] = 0;
259
+ }
260
+ }
261
+ this.updateKeys();
262
+ }
263
+
264
+ // from SysVIA
265
+ disableKeyboard() {
266
+ this.keyboardEnabled = false;
267
+ this.clearKeys();
268
+ }
269
+
270
+ // from SysVIA
271
+ enableKeyboard() {
272
+ this.keyboardEnabled = true;
273
+ this.clearKeys();
274
+ }
275
+
276
+ // from SysVIA
277
+ set(key, val, shiftDown) {
278
+ if (!this.keyboardEnabled) return;
279
+ const colrow = this.keycodeToRowCol[!!shiftDown][key];
280
+ if (!colrow) return;
281
+
282
+ this.keys[colrow[0]][colrow[1]] = val;
283
+ this.updateKeys();
284
+ }
285
+
286
+ // from SysVIA
287
+ keyDown(key, shiftDown) {
288
+ this.set(key, 1, shiftDown);
289
+ }
290
+
291
+ // from SysVIA
292
+ keyUp(key) {
293
+ // set up for both keymaps
294
+ // (with and without shift)
295
+ this.set(key, 0, true);
296
+ this.set(key, 0, false);
297
+ }
298
+
299
+ // from SysVIA
300
+ keyDownRaw(colrow) {
301
+ this.keys[colrow[0]][colrow[1]] = 1;
302
+ this.updateKeys();
303
+ }
304
+
305
+ // from SysVIA
306
+ keyUpRaw(colrow) {
307
+ this.keys[colrow[0]][colrow[1]] = 0;
308
+ this.updateKeys();
309
+ }
310
+
311
+ // from SysVIA
312
+ keyToggleRaw(colrow) {
313
+ this.keys[colrow[0]][colrow[1]] = 1 - this.keys[colrow[0]][colrow[1]];
314
+ this.updateKeys();
315
+ }
316
+
317
+ // from SysVIA
318
+ hasAnyKeyDown() {
319
+ // 10 for ATOM
320
+ const numCols = 10;
321
+
322
+ for (let i = 0; i < numCols; ++i) {
323
+ for (let j = 0; j < 8; ++j) {
324
+ if (this.keys[i][j]) {
325
+ return true;
326
+ }
327
+ }
328
+ }
329
+ return false;
330
+ }
331
+
332
+ // nothing on ATOM
333
+ updateKeys() {}
334
+
335
+ // nothing on ATOM
336
+ polltime() {}
337
+
338
+ portAUpdated() {
339
+ this.updateKeys();
340
+ }
341
+
342
+ // nothing on ATOM
343
+ portBUpdated() {}
344
+
345
+ portCUpdated() {
346
+ const speakerBit = (this.portcpins & 0x04) >>> 2;
347
+ if (speakerBit !== this.lastSpeakerBit) {
348
+ this.lastSpeakerBit = speakerBit;
349
+ this.cpu.soundChip.speakerGenerator.pushBit(speakerBit, this.cpu.currentCycles, this.cpu.cycleSeconds);
350
+ }
351
+ }
352
+
353
+ drivePortA() {
354
+ this.updateKeys();
355
+ }
356
+
357
+ drivePortB() {
358
+ // Nothing driving here.
359
+ }
360
+
361
+ drivePortC() {
362
+ // Nothing driving here.
363
+ }
364
+
365
+ // ATOM TAPE SUPPORT
366
+ // from ACIA on BBC
367
+
368
+ // set by TAPE
369
+ tone(freq) {
370
+ let toneGen = this.cpu.soundChip.toneGenerator;
371
+ if (!freq) toneGen.mute();
372
+ else toneGen.tone(freq);
373
+ }
374
+
375
+ // nothing on ATOM
376
+ dcdLineUpdated() {}
377
+
378
+ // set by TAPE
379
+ setTapeCarrier(level) {
380
+ if (!level) {
381
+ this.tapeCarrierCount = 0;
382
+ this.tapeDcdLineLevel = false;
383
+ } else {
384
+ this.tapeCarrierCount++;
385
+ // The tape hardware doesn't raise DCD until the carrier tone
386
+ // has persisted for a while. The BBC service manual opines,
387
+ // "The DCD flag in the 6850 should change 0.1 to 0.4 seconds
388
+ // after a continuous tone appears".
389
+ // Star Drifter doesn't load without this.
390
+ // We use 0.174s, measured on an issue 3 model B.
391
+ // Testing on real hardware, DCD is blipped, it lowers about
392
+ // 210us after it raises, even though the carrier tone
393
+ // may be continuing.
394
+ this.tapeDcdLineLevel = this.tapeCarrierCount === 209;
395
+ }
396
+ this.dcdLineUpdated();
397
+ }
398
+
399
+ // Receive bits from tape (called by tape.poll via PPIA, not ACIA like BBC).
400
+ // Called once every ~208 clock cycles (208us at 1 MHz).
401
+ // Recognition: '1' = 4 half-cycles at 1.2 kHz (duration < 8),
402
+ // '0' = 8 half-cycles at 2.4 kHz (duration >= 8).
403
+ // Leader tone is a stream of '1' bits.
404
+ receiveBit(bit) {
405
+ bit |= 0;
406
+ this.latchc = (this.latchc & 0xdf) | (bit << 5);
407
+ this.recalculatePortCPins();
408
+ }
409
+
410
+ // nothing on ATOM
411
+ receive(/*_byte*/) {}
412
+
413
+ setTape(tape) {
414
+ this.tape = tape;
415
+ }
416
+
417
+ rewindTape() {
418
+ if (this.tape) {
419
+ this.tape.rewind();
420
+ }
421
+ }
422
+
423
+ playTape() {
424
+ if (this.tape) {
425
+ this.motorOn = true;
426
+ this.runTape();
427
+ }
428
+ }
429
+
430
+ stopTape() {
431
+ this.motorOn = false;
432
+ if (this.tape) {
433
+ const toneGen = this.cpu.soundChip.toneGenerator;
434
+ toneGen.mute();
435
+ this.runTapeTask.cancel();
436
+ this.setTapeCarrier(false);
437
+ }
438
+ }
439
+
440
+ runTape() {
441
+ if (this.tape) this.runTapeTask.reschedule(this.tape.poll(this));
442
+ }
443
+
444
+ updateIrq() {}
445
+
446
+ snapshotState() {
447
+ return {
448
+ latcha: this.latcha,
449
+ latchb: this.latchb,
450
+ latchc: this.latchc,
451
+ portapins: this.portapins,
452
+ portbpins: this.portbpins,
453
+ portcpins: this.portcpins,
454
+ creg: this.creg,
455
+ keys: this.keys.map((row) => Array.from(row)),
456
+ keyboardEnabled: this.keyboardEnabled,
457
+ lastSpeakerBit: this.lastSpeakerBit,
458
+ };
459
+ }
460
+
461
+ restoreState(state) {
462
+ this.latcha = state.latcha;
463
+ this.latchb = state.latchb;
464
+ this.latchc = state.latchc;
465
+ this.portapins = state.portapins;
466
+ this.portbpins = state.portbpins;
467
+ this.portcpins = state.portcpins;
468
+ this.creg = state.creg;
469
+ if (state.keys) {
470
+ for (let i = 0; i < state.keys.length; i++) {
471
+ this.keys[i].set(state.keys[i]);
472
+ }
473
+ }
474
+ this.keyboardEnabled = state.keyboardEnabled;
475
+ this.lastSpeakerBit = state.lastSpeakerBit;
476
+ }
477
+ }
package/src/soundchip.js CHANGED
@@ -1,3 +1,7 @@
1
+ // Atom speaker volume, scaled to be comparable with BBC tone channels.
2
+ // The BBC volumeTable[0] (loudest) is 0.25 (1.0 / 4 channels).
3
+ const speakerVolume = 0.5;
4
+
1
5
  const volumeTable = new Float32Array(16);
2
6
  (() => {
3
7
  let f = 1.0;
@@ -327,6 +331,94 @@ export class SoundChip {
327
331
  }
328
332
  }
329
333
 
334
+ /**
335
+ * AtomSoundChip -- Acorn Atom sound via a 1-bit speaker driven by the PPIA.
336
+ * Uses only the sine channel (shared with BBC for tape tones) and a speaker
337
+ * channel with DC-blocking filter.
338
+ */
339
+ export class AtomSoundChip extends SoundChip {
340
+ constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
341
+ super(onBuffer);
342
+ this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
343
+ this.secondsPerCycle = 1 / cpuSpeed;
344
+
345
+ // Replace the BBC tone/noise generators with just sine + speaker.
346
+ this.generators = [this.sineChannel.bind(this), this.speakerChannel.bind(this)];
347
+ // Recompute sine attenuation for the Atom's 2-channel mix
348
+ // (parent computed it for 5 BBC channels).
349
+ this.sineTable = makeSineTable(1 / this.generators.length);
350
+
351
+ this.speakerGenerator = {
352
+ mute: () => {
353
+ this.catchUp();
354
+ this.speakerReset();
355
+ },
356
+ pushBit: (bit, cycles, seconds) => {
357
+ this.catchUp();
358
+ this.updateSpeaker(bit, cycles, seconds);
359
+ },
360
+ };
361
+
362
+ this.bitChange = [];
363
+ this.currentSpeakerBit = 0.0;
364
+ this._speakerPrevIn = 0;
365
+ this._speakerPrevOut = 0;
366
+ this._speakerCycleOffset = 0;
367
+ }
368
+
369
+ reset(hard) {
370
+ super.reset(hard);
371
+ if (hard) this.speakerReset();
372
+ }
373
+
374
+ catchUp() {
375
+ this._speakerCycleOffset = 0;
376
+ super.catchUp();
377
+ this._speakerCycleOffset = 0;
378
+ }
379
+
380
+ speakerReset() {
381
+ this.bitChange = [];
382
+ this.currentSpeakerBit = 0.0;
383
+ this._speakerPrevIn = 0;
384
+ this._speakerPrevOut = 0;
385
+ this._speakerCycleOffset = 0;
386
+ }
387
+
388
+ speakerChannel(channel, out, offset, length) {
389
+ const fromCycle = this.lastRunEpoch + this._speakerCycleOffset;
390
+ this._speakerCycleOffset += length / this.samplesPerCycle;
391
+ let bitIndex = 0;
392
+ // DC-blocking high-pass filter: y[n] = x[n] - x[n-1] + alpha * y[n-1]
393
+ // The SoundChip runs at 500 kHz (4 MHz / 8). For a ~20 Hz cutoff:
394
+ // alpha = 1 - 2*pi*fc/fs = 1 - 2*pi*20/500000 ≈ 0.99975
395
+ const alpha = 0.99975;
396
+
397
+ for (let i = 0; i < length; ++i) {
398
+ while (
399
+ bitIndex < this.bitChange.length &&
400
+ this.bitChange[bitIndex].cycles <= fromCycle + i / this.samplesPerCycle
401
+ ) {
402
+ this.currentSpeakerBit = this.bitChange[bitIndex].bit;
403
+ bitIndex++;
404
+ }
405
+ const input = this.currentSpeakerBit * speakerVolume;
406
+ this._speakerPrevOut = input - this._speakerPrevIn + alpha * this._speakerPrevOut;
407
+ this._speakerPrevIn = input;
408
+ out[i + offset] += this._speakerPrevOut;
409
+ }
410
+
411
+ if (bitIndex > 0) {
412
+ this.bitChange.splice(0, bitIndex);
413
+ }
414
+ }
415
+
416
+ updateSpeaker(value, microCycle, seconds) {
417
+ const cycles = microCycle + seconds / this.secondsPerCycle;
418
+ this.bitChange.push({ bit: value ? 1.0 : 0.0, cycles });
419
+ }
420
+ }
421
+
330
422
  /**
331
423
  * InstrumentedSoundChip - wraps a real SoundChip and captures all writes
332
424
  * for debugging purposes. Provides the same interface as SoundChip.
@@ -423,11 +515,17 @@ export class FakeSoundChip {
423
515
  mute: () => {},
424
516
  tone: () => {},
425
517
  };
518
+ this.speakerGenerator = {
519
+ mute: () => {},
520
+ pushBit: () => {},
521
+ };
426
522
  }
427
523
 
428
524
  snapshotState() {
429
525
  return {};
430
526
  }
431
527
 
432
- restoreState() {}
528
+ restoreState() {
529
+ this._speakerCycleOffset = 0;
530
+ }
433
531
  }