jsbeeb 1.4.0 → 1.5.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/main.js CHANGED
@@ -27,6 +27,7 @@ import { toHfe } from "./disc-hfe.js";
27
27
  import { Keyboard } from "./keyboard.js";
28
28
  import { GamepadSource } from "./gamepad-source.js";
29
29
  import { MicrophoneInput } from "./microphone-input.js";
30
+ import { SpeechOutput } from "./speech-output.js";
30
31
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
31
32
  import { getFilterForMode } from "./canvas.js";
32
33
  import {
@@ -97,6 +98,7 @@ const paramTypes = {
97
98
  logFdcStateChanges: ParamTypes.BOOL,
98
99
  coProcessor: ParamTypes.BOOL,
99
100
  mouseJoystickEnabled: ParamTypes.BOOL,
101
+ speechOutput: ParamTypes.BOOL,
100
102
 
101
103
  // Numeric parameters
102
104
  speed: ParamTypes.INT,
@@ -105,6 +107,7 @@ const paramTypes = {
105
107
  audiofilterfreq: ParamTypes.FLOAT,
106
108
  audiofilterq: ParamTypes.FLOAT,
107
109
  cpuMultiplier: ParamTypes.FLOAT,
110
+ tubeCpuMultiplier: ParamTypes.INT,
108
111
  microphoneChannel: ParamTypes.INT,
109
112
 
110
113
  // String parameters (these are the default but listed for clarity)
@@ -125,7 +128,6 @@ let keyLayout = window.localStorage.keyLayout || "physical";
125
128
 
126
129
  const BBC = utils.BBC;
127
130
  const keyCodes = utils.keyCodes;
128
- const emuKeyHandlers = {};
129
131
  let cpuMultiplier = 1;
130
132
  let fastAsPossible = false;
131
133
  let fastTape = false;
@@ -181,33 +183,22 @@ const printerPort = {
181
183
  },
182
184
  };
183
185
 
184
- let userPort = null;
186
+ // Accessibility switch state — bits 0-7 correspond to switches 1-8.
187
+ // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
188
+ let switchState = 0xff;
185
189
 
186
- const keyswitch = true;
187
- if (keyswitch) {
188
- let switchState = 0xff;
189
-
190
- const switchKey = function (down, code) {
191
- const bit = 1 << (code - utils.keyCodes.K1);
192
- if (down) switchState &= 0xff ^ bit;
193
- else switchState |= bit;
194
- };
195
-
196
- for (let idx = utils.keyCodes.K1; idx <= utils.keyCodes.K8; ++idx) {
197
- emuKeyHandlers[idx] = switchKey;
198
- }
199
- userPort = {
200
- write: function () {},
201
- read: function () {
202
- return switchState;
203
- },
204
- };
205
- }
190
+ const userPort = {
191
+ write() {},
192
+ read() {
193
+ return switchState;
194
+ },
195
+ };
206
196
 
207
197
  const emulationConfig = {
208
198
  keyLayout: keyLayout,
209
199
  coProcessor: parsedQuery.coProcessor,
210
200
  cpuMultiplier: cpuMultiplier,
201
+ tubeCpuMultiplier: parsedQuery.tubeCpuMultiplier || 2,
211
202
  videoCyclesBatch: parsedQuery.videoCyclesBatch,
212
203
  extraRoms: extraRoms,
213
204
  userPort: userPort,
@@ -222,6 +213,11 @@ const emulationConfig = {
222
213
  },
223
214
  };
224
215
 
216
+ // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
217
+ // Must be created before Config so the onClose callback and setSpeechOutput() call can reference it.
218
+ const speechOutput = new SpeechOutput();
219
+ speechOutput.enabled = !!parsedQuery.speechOutput;
220
+
225
221
  const config = new Config(
226
222
  function onChange(changed) {
227
223
  if (changed.displayMode) {
@@ -263,6 +259,16 @@ const config = new Config(
263
259
  setupMicrophone().then(() => {});
264
260
  }
265
261
  }
262
+ if (changed.speechOutput !== undefined) {
263
+ speechOutput.enabled = !!changed.speechOutput;
264
+ }
265
+ if (changed.tubeCpuMultiplier !== undefined) {
266
+ emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
267
+ config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
268
+ if (processor.tube && processor.tube.cpuMultiplier !== undefined) {
269
+ processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
270
+ }
271
+ }
266
272
  updateUrl();
267
273
  },
268
274
  );
@@ -273,11 +279,13 @@ config.mapLegacyModels(parsedQuery);
273
279
  config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
274
280
  config.setKeyLayout(keyLayout);
275
281
  config.set65c02(parsedQuery.coProcessor);
282
+ config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 2);
276
283
  config.setEconet(parsedQuery.hasEconet);
277
284
  config.setMusic5000(parsedQuery.hasMusic5000);
278
285
  config.setTeletext(parsedQuery.hasTeletextAdaptor);
279
286
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
280
287
  config.setMouseJoystickEnabled(parsedQuery.mouseJoystickEnabled);
288
+ config.setSpeechOutput(speechOutput.enabled);
281
289
  let displayMode = parsedQuery.displayMode || "rgb";
282
290
  config.setDisplayMode(displayMode);
283
291
 
@@ -362,7 +370,13 @@ video = new Video(model.isMaster, canvas.fb32, function paint(minx, miny, maxx,
362
370
  if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo();
363
371
 
364
372
  const audioStatsNode = document.getElementById("audio-stats");
365
- const audioHandler = new AudioHandler($("#audio-warning"), audioStatsNode, audioFilterFreq, audioFilterQ, noSeek);
373
+ const audioHandler = new AudioHandler({
374
+ warningNode: $("#audio-warning"),
375
+ statsNode: audioStatsNode,
376
+ audioFilterFreq,
377
+ audioFilterQ,
378
+ noSeek,
379
+ });
366
380
  if (!parsedQuery.audioDebug) audioStatsNode.style.display = "none";
367
381
  // Firefox will report that audio is suspended even when it will
368
382
  // start playing without user interaction, so we need to delay a
@@ -578,21 +592,19 @@ function checkPrinterWindow() {
578
592
  processor.uservia.setca1(true);
579
593
  }
580
594
 
581
- processor = new Cpu6502(
582
- model,
595
+ processor = new Cpu6502(model, {
583
596
  dbgr,
584
597
  video,
585
- audioHandler.soundChip,
586
- audioHandler.ddNoise,
587
- model.hasMusic5000 ? audioHandler.music5000 : null,
598
+ soundChip: audioHandler.soundChip,
599
+ ddNoise: audioHandler.ddNoise,
600
+ music5000: model.hasMusic5000 ? audioHandler.music5000 : null,
588
601
  cmos,
589
- emulationConfig,
602
+ config: emulationConfig,
590
603
  econet,
591
- );
604
+ });
592
605
 
593
606
  // Create input sources
594
607
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
595
-
596
608
  // Create MicrophoneInput but don't enable by default
597
609
  const microphoneInput = new MicrophoneInput();
598
610
  microphoneInput.setErrorCallback((message) => {
@@ -603,9 +615,28 @@ microphoneInput.setErrorCallback((message) => {
603
615
  const screenCanvas = document.getElementById("screen");
604
616
  const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
605
617
 
618
+ /**
619
+ * Attach an RS-423 composite handler to the ACIA that combines the touchscreen
620
+ * (which sends position data to the BBC) with the speech output (which speaks
621
+ * text the BBC sends out). Call this once after processor.initialise() and
622
+ * again whenever speechOutput.enabled changes.
623
+ */
624
+ function setupRs423Handler() {
625
+ const touchScreen = processor.touchScreen;
626
+ processor.acia.setRs423Handler({
627
+ onTransmit(val) {
628
+ touchScreen.onTransmit(val);
629
+ speechOutput.onTransmit(val);
630
+ },
631
+ tryReceive(rts) {
632
+ return touchScreen.tryReceive(rts);
633
+ },
634
+ });
635
+ }
636
+
606
637
  // Helper to manage ADC source configuration
607
638
  function updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
608
- // Default all channels to gamepad
639
+ // Default all channels to the gamepad source.
609
640
  for (let ch = 0; ch < 4; ch++) {
610
641
  processor.adconverter.setChannelSource(ch, gamepadSource);
611
642
  }
@@ -753,6 +784,30 @@ keyboard.registerKeyHandler(
753
784
  { alt: false, ctrl: true },
754
785
  );
755
786
 
787
+ // Register accessibility switch key handlers.
788
+ // Keys 1–8 (K1–K8) and function keys F1–F8 both map to user port bits 0–7
789
+ // (active low: pressing the key clears the corresponding bit in &FE60).
790
+ //
791
+ // On real hardware, the Brilliant Computing switch interface box and special-ed
792
+ // joystick connect to the User Port only — they do not touch the analogue port
793
+ // or the System VIA fire buttons (PB4/PB5), which belong to the standard
794
+ // analogue joystick connector. So we only update switchState here.
795
+ {
796
+ const handleSwitch = (bit) => (down) => {
797
+ if (down) switchState &= ~(1 << bit);
798
+ else switchState |= 1 << bit;
799
+ };
800
+
801
+ // Alt+1–8 and Alt+F1–F8 trigger the switches. Using Alt means the underlying
802
+ // key is never forwarded to the BBC Micro (keyboard.js bails out early when a
803
+ // handler fires), so typing numbers or using function keys works normally.
804
+ const altMod = { alt: true, ctrl: false };
805
+ for (let i = 0; i < 8; i++) {
806
+ keyboard.registerKeyHandler(utils.keyCodes.K1 + i, handleSwitch(i), altMod);
807
+ keyboard.registerKeyHandler(utils.keyCodes.F1 + i, handleSwitch(i), altMod);
808
+ }
809
+ }
810
+
756
811
  // Setup key handlers
757
812
  document.addEventListener("keydown", (evt) => {
758
813
  audioHandler.tryResume().then(() => {});
@@ -825,6 +880,9 @@ async function tapeSthClick(item) {
825
880
  }
826
881
 
827
882
  const $sthModal = new bootstrap.Modal(document.getElementById("sth"));
883
+ document.getElementById("sth").addEventListener("shown.bs.modal", () => {
884
+ document.getElementById("sth-filter").focus();
885
+ });
828
886
 
829
887
  function makeOnCat(onClick) {
830
888
  return function (cat) {
@@ -1340,6 +1398,9 @@ syncLights = function () {
1340
1398
  const startPromise = (async () => {
1341
1399
  await Promise.all([audioHandler.initialise(), processor.initialise()]);
1342
1400
 
1401
+ // Wire up the composite RS-423 handler now that the touchscreen exists.
1402
+ setupRs423Handler();
1403
+
1343
1404
  // Ideally would start the loads first. But their completion needs the FDC from the processor
1344
1405
  const imageLoads = [];
1345
1406
 
package/src/models.js CHANGED
@@ -11,7 +11,7 @@ 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, swram, fdc, tube, cmosOverride } = {}) {
15
15
  this.name = name;
16
16
  this.synonyms = synonyms;
17
17
  this.os = os;
@@ -97,77 +97,81 @@ const masterSwram = [
97
97
  ];
98
98
 
99
99
  export const allModels = [
100
- new Model(
101
- "BBC B with DFS 1.2",
102
- ["B-DFS1.2"],
103
- ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
104
- CpuModel.MOS6502,
105
- false,
106
- beebSwram,
107
- NoiseAwareIntelFdc,
108
- ),
109
- new Model(
110
- "BBC B with DFS 0.9",
111
- ["B-DFS0.9", "B"],
112
- ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
113
- CpuModel.MOS6502,
114
- false,
115
- beebSwram,
116
- NoiseAwareIntelFdc,
117
- ),
118
- new Model(
119
- "BBC B with 1770 (DFS)",
120
- ["B1770"],
121
- ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
122
- CpuModel.MOS6502,
123
- false,
124
- beebSwram,
125
- NoiseAwareWdFdc,
126
- ),
100
+ new Model({
101
+ name: "BBC B with DFS 1.2",
102
+ synonyms: ["B-DFS1.2"],
103
+ os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
104
+ cpuModel: CpuModel.MOS6502,
105
+ isMaster: false,
106
+ swram: beebSwram,
107
+ fdc: NoiseAwareIntelFdc,
108
+ }),
109
+ new Model({
110
+ name: "BBC B with DFS 0.9",
111
+ synonyms: ["B-DFS0.9", "B"],
112
+ os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
113
+ cpuModel: CpuModel.MOS6502,
114
+ isMaster: false,
115
+ swram: beebSwram,
116
+ fdc: NoiseAwareIntelFdc,
117
+ }),
118
+ new Model({
119
+ name: "BBC B with 1770 (DFS)",
120
+ synonyms: ["B1770"],
121
+ os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
122
+ cpuModel: CpuModel.MOS6502,
123
+ isMaster: false,
124
+ swram: beebSwram,
125
+ fdc: NoiseAwareWdFdc,
126
+ }),
127
127
  // putting ADFS in a higher ROM slot gives it priority
128
- new Model(
129
- "BBC B with 1770 (ADFS)",
130
- ["B1770A"],
131
- ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
132
- CpuModel.MOS6502,
133
- false,
134
- beebSwram,
135
- NoiseAwareWdFdc,
136
- ),
137
- new Model(
138
- "BBC Master 128 (DFS)",
139
- ["Master"],
140
- ["master/mos3.20"],
141
- CpuModel.CMOS65C12,
142
- true,
143
- masterSwram,
144
- NoiseAwareWdFdc,
145
- null,
146
- pickDfs,
147
- ),
148
- new Model(
149
- "BBC Master 128 (ADFS)",
150
- ["MasterADFS"],
151
- ["master/mos3.20"],
152
- CpuModel.CMOS65C12,
153
- true,
154
- masterSwram,
155
- NoiseAwareWdFdc,
156
- null,
157
- pickAdfs,
158
- ),
159
- new Model(
160
- "BBC Master 128 (ANFS)",
161
- ["MasterANFS"],
162
- ["master/mos3.20"],
163
- CpuModel.CMOS65C12,
164
- true,
165
- masterSwram,
166
- NoiseAwareWdFdc,
167
- null,
168
- pickAnfs,
169
- ),
170
- new Model("Tube65C02", [], ["tube/6502Tube.rom"], CpuModel.CMOS65C02, false), // Although this can not be explicitly selected as a model, it is required by the configuration builder later
128
+ new Model({
129
+ name: "BBC B with 1770 (ADFS)",
130
+ synonyms: ["B1770A"],
131
+ os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
132
+ cpuModel: CpuModel.MOS6502,
133
+ isMaster: false,
134
+ swram: beebSwram,
135
+ fdc: NoiseAwareWdFdc,
136
+ }),
137
+ new Model({
138
+ name: "BBC Master 128 (DFS)",
139
+ synonyms: ["Master"],
140
+ os: ["master/mos3.20"],
141
+ cpuModel: CpuModel.CMOS65C12,
142
+ isMaster: true,
143
+ swram: masterSwram,
144
+ fdc: NoiseAwareWdFdc,
145
+ cmosOverride: pickDfs,
146
+ }),
147
+ new Model({
148
+ name: "BBC Master 128 (ADFS)",
149
+ synonyms: ["MasterADFS"],
150
+ os: ["master/mos3.20"],
151
+ cpuModel: CpuModel.CMOS65C12,
152
+ isMaster: true,
153
+ swram: masterSwram,
154
+ fdc: NoiseAwareWdFdc,
155
+ cmosOverride: pickAdfs,
156
+ }),
157
+ new Model({
158
+ name: "BBC Master 128 (ANFS)",
159
+ synonyms: ["MasterANFS"],
160
+ os: ["master/mos3.20"],
161
+ cpuModel: CpuModel.CMOS65C12,
162
+ isMaster: true,
163
+ swram: masterSwram,
164
+ fdc: NoiseAwareWdFdc,
165
+ cmosOverride: pickAnfs,
166
+ }),
167
+ // Although this can not be explicitly selected as a model, it is required by the configuration builder later
168
+ new Model({
169
+ name: "Tube65C02",
170
+ synonyms: [],
171
+ os: ["tube/6502Tube.rom"],
172
+ cpuModel: CpuModel.CMOS65C02,
173
+ isMaster: false,
174
+ }),
171
175
  ];
172
176
 
173
177
  export function findModel(name) {
@@ -182,19 +186,43 @@ export function findModel(name) {
182
186
  return null;
183
187
  }
184
188
 
185
- export const TEST_6502 = new Model("TEST", ["TEST"], [], CpuModel.MOS6502, false, beebSwram, NoiseAwareIntelFdc);
189
+ export const TEST_6502 = new Model({
190
+ name: "TEST",
191
+ synonyms: ["TEST"],
192
+ os: [],
193
+ cpuModel: CpuModel.MOS6502,
194
+ isMaster: false,
195
+ swram: beebSwram,
196
+ fdc: NoiseAwareIntelFdc,
197
+ });
186
198
  TEST_6502.isTest = true;
187
- export const TEST_65C02 = new Model("TEST", ["TEST"], [], CpuModel.CMOS65C02, false, masterSwram, NoiseAwareIntelFdc);
199
+ export const TEST_65C02 = new Model({
200
+ name: "TEST",
201
+ synonyms: ["TEST"],
202
+ os: [],
203
+ cpuModel: CpuModel.CMOS65C02,
204
+ isMaster: false,
205
+ swram: masterSwram,
206
+ fdc: NoiseAwareIntelFdc,
207
+ });
188
208
  TEST_65C02.isTest = true;
189
- export const TEST_65C12 = new Model("TEST", ["TEST"], [], CpuModel.CMOS65C12, false, masterSwram, NoiseAwareIntelFdc);
209
+ export const TEST_65C12 = new Model({
210
+ name: "TEST",
211
+ synonyms: ["TEST"],
212
+ os: [],
213
+ cpuModel: CpuModel.CMOS65C12,
214
+ isMaster: false,
215
+ swram: masterSwram,
216
+ fdc: NoiseAwareIntelFdc,
217
+ });
190
218
  TEST_65C12.isTest = true;
191
219
 
192
- export const basicOnly = new Model(
193
- "Basic only",
194
- ["Basic only"],
195
- ["master/mos3.20"],
196
- CpuModel.CMOS65C12,
197
- true,
198
- masterSwram,
199
- NoiseAwareWdFdc,
200
- );
220
+ export const basicOnly = new Model({
221
+ name: "Basic only",
222
+ synonyms: ["Basic only"],
223
+ os: ["master/mos3.20"],
224
+ cpuModel: CpuModel.CMOS65C12,
225
+ isMaster: true,
226
+ swram: masterSwram,
227
+ fdc: NoiseAwareWdFdc,
228
+ });
package/src/soundchip.js CHANGED
@@ -288,6 +288,84 @@ export class SoundChip {
288
288
  }
289
289
  }
290
290
 
291
+ /**
292
+ * InstrumentedSoundChip - wraps a real SoundChip and captures all writes
293
+ * for debugging purposes. Provides the same interface as SoundChip.
294
+ */
295
+ export class InstrumentedSoundChip extends SoundChip {
296
+ constructor() {
297
+ super(() => {}); // no audio output callback needed in headless mode
298
+ this._capturedWrites = [];
299
+ this._capturing = false;
300
+ this._totalCycles = 0;
301
+ }
302
+
303
+ poke(value) {
304
+ if (this._capturing) {
305
+ this._capturedWrites.push({ cycle: this._totalCycles, value });
306
+ }
307
+ super.poke(value);
308
+ }
309
+
310
+ /** Start capturing SN76489 writes. Clears any previous capture. */
311
+ startCapture() {
312
+ this._capturedWrites = [];
313
+ this._capturing = true;
314
+ }
315
+
316
+ /** Stop capturing and return the captured writes. */
317
+ stopCapture() {
318
+ this._capturing = false;
319
+ return this._capturedWrites;
320
+ }
321
+
322
+ /** Get the captured writes without stopping. */
323
+ getCapturedWrites() {
324
+ return this._capturedWrites;
325
+ }
326
+
327
+ /** Clear captured writes. */
328
+ clearCapture() {
329
+ this._capturedWrites = [];
330
+ }
331
+
332
+ /** Track total cycles for timestamps. Called by the scheduler. */
333
+ advance(cycles) {
334
+ this._totalCycles += cycles;
335
+ super.advance(cycles);
336
+ }
337
+
338
+ /** Read current SN76489 register state in a friendly format. */
339
+ getState() {
340
+ return {
341
+ tone: [
342
+ this.registers[0],
343
+ this.registers[1],
344
+ this.registers[2],
345
+ ],
346
+ noise: this.registers[3],
347
+ volume: [
348
+ this._attenuationFromVolume(0),
349
+ this._attenuationFromVolume(1),
350
+ this._attenuationFromVolume(2),
351
+ this._attenuationFromVolume(3),
352
+ ],
353
+ lfsr: this.lfsr,
354
+ latchedRegister: this.latchedRegister,
355
+ };
356
+ }
357
+
358
+ /** Convert internal float volume back to 0-15 attenuation. */
359
+ _attenuationFromVolume(channel) {
360
+ const v = this.volume[channel];
361
+ // volumeTable[15] = 0 (silent), volumeTable[0] = loudest
362
+ for (let i = 0; i < 16; i++) {
363
+ if (Math.abs(volumeTable[i] - v) < 0.001) return i;
364
+ }
365
+ return 15; // silent
366
+ }
367
+ }
368
+
291
369
  export class FakeSoundChip {
292
370
  reset() {}
293
371
 
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * RS-423 handler that routes transmitted bytes to the Web Speech API.
5
+ *
6
+ * BBC programs use *FX3,1 (or *FX3,3) to send OSWRCH output to the RS-423
7
+ * serial port, which on real hardware fed a Votrax Type 'N Talk synthesiser.
8
+ * We intercept at the ACIA hardware boundary and route to speechSynthesis.
9
+ *
10
+ * Byte handling is based on the Votrax Type 'N Talk Operator's Manual (1981):
11
+ * - Printable ASCII 0x20–0x7E: accumulated into the text buffer.
12
+ * - CR (0x0D): "TALK-CLR" — speaks the buffer and clears it. Multiple CR-
13
+ * terminated lines queue naturally via speechSynthesis.speak(). A future
14
+ * improvement could heuristically combine lines that arrive within a single
15
+ * frame (~20 ms) into one utterance, which would give modern TTS engines a
16
+ * better sentence to work with — but the simple per-line queue works well
17
+ * enough and has no surprising pauses or dropped output.
18
+ * - LF (0x0A): explicitly listed as null data in the manual; ignored.
19
+ * - ESC (0x1B): unit-select prefix for daisy-chained TNT units. ESC plus
20
+ * the following byte are consumed silently (not passed to speechSynthesis).
21
+ * - All other bytes: null data per the manual; ignored.
22
+ */
23
+
24
+ // From the TNT Operator's Manual: "The input buffer can hold more than 750
25
+ // characters".
26
+ export const MAX_BUFFER = 750;
27
+
28
+ export class SpeechOutput {
29
+ constructor() {
30
+ this._buffer = "";
31
+ this._escapeNext = false;
32
+ this._enabled = false;
33
+ }
34
+
35
+ get enabled() {
36
+ return this._enabled;
37
+ }
38
+
39
+ set enabled(value) {
40
+ this._enabled = !!value;
41
+ if (!this._enabled) {
42
+ this._buffer = "";
43
+ if (typeof speechSynthesis !== "undefined") speechSynthesis.cancel();
44
+ }
45
+ }
46
+
47
+ /** RS-423 handler interface: called for each byte the BBC transmits. */
48
+ onTransmit(byte) {
49
+ if (!this._enabled) return;
50
+
51
+ if (this._escapeNext) {
52
+ this._escapeNext = false;
53
+ return;
54
+ }
55
+
56
+ switch (byte) {
57
+ case 0x1b: // ESC — next byte is a unit-select code, not text.
58
+ this._escapeNext = true;
59
+ return;
60
+
61
+ case 0x0d: // CR — TALK-CLR: speak current buffer and clear it.
62
+ this._flush();
63
+ return;
64
+
65
+ default:
66
+ if (byte >= 0x20 && byte <= 0x7e) {
67
+ this._buffer += String.fromCharCode(byte);
68
+ if (this._buffer.length >= MAX_BUFFER) this._flush();
69
+ }
70
+ // Everything else is null data — silently ignored.
71
+ }
72
+ }
73
+
74
+ /** RS-423 handler interface: nothing to send back to the BBC. */
75
+ tryReceive() {
76
+ return -1;
77
+ }
78
+
79
+ _flush() {
80
+ const text = this._buffer.trim();
81
+ this._buffer = "";
82
+ if (!text || typeof speechSynthesis === "undefined") return;
83
+ speechSynthesis.speak(new SpeechSynthesisUtterance(text));
84
+ }
85
+ }