All files / src/web speech-output.js

96.66% Statements 29/30
95.23% Branches 20/21
83.33% Functions 5/6
96.15% Lines 25/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84                                              1x       10x 10x 10x               13x 13x 2x 2x           851x   846x 1x 1x     845x   1x 1x     9x 9x     835x 831x 831x               1x       10x 10x 10x 9x      
/**
 * RS-423 handler that routes transmitted bytes to the Web Speech API.
 *
 * BBC programs use *FX3,1 (or *FX3,3) to send OSWRCH output to the RS-423
 * serial port, which on real hardware fed a Votrax Type 'N Talk synthesiser.
 * We intercept at the ACIA hardware boundary and route to speechSynthesis.
 *
 * Byte handling is based on the Votrax Type 'N Talk Operator's Manual (1981):
 *  - Printable ASCII 0x20–0x7E: accumulated into the text buffer.
 *  - CR (0x0D): "TALK-CLR" — speaks the buffer and clears it.  Multiple CR-
 *    terminated lines queue naturally via speechSynthesis.speak().  A future
 *    improvement could heuristically combine lines that arrive within a single
 *    frame (~20 ms) into one utterance, which would give modern TTS engines a
 *    better sentence to work with — but the simple per-line queue works well
 *    enough and has no surprising pauses or dropped output.
 *  - LF (0x0A): explicitly listed as null data in the manual; ignored.
 *  - ESC (0x1B): unit-select prefix for daisy-chained TNT units.  ESC plus
 *    the following byte are consumed silently (not passed to speechSynthesis).
 *  - All other bytes: null data per the manual; ignored.
 */
 
// From the TNT Operator's Manual: "The input buffer can hold more than 750
// characters".
export const MAX_BUFFER = 750;
 
export class SpeechOutput {
    constructor() {
        this._buffer = "";
        this._escapeNext = false;
        this._enabled = false;
    }
 
    get enabled() {
        return this._enabled;
    }
 
    set enabled(value) {
        this._enabled = !!value;
        if (!this._enabled) {
            this._buffer = "";
            Eif (typeof speechSynthesis !== "undefined") speechSynthesis.cancel();
        }
    }
 
    /** RS-423 handler interface: called for each byte the BBC transmits. */
    onTransmit(byte) {
        if (!this._enabled) return;
 
        if (this._escapeNext) {
            this._escapeNext = false;
            return;
        }
 
        switch (byte) {
            case 0x1b: // ESC — next byte is a unit-select code, not text.
                this._escapeNext = true;
                return;
 
            case 0x0d: // CR — TALK-CLR: speak current buffer and clear it.
                this._flush();
                return;
 
            default:
                if (byte >= 0x20 && byte <= 0x7e) {
                    this._buffer += String.fromCharCode(byte);
                    if (this._buffer.length >= MAX_BUFFER) this._flush();
                }
            // Everything else is null data — silently ignored.
        }
    }
 
    /** RS-423 handler interface: nothing to send back to the BBC. */
    tryReceive() {
        return -1;
    }
 
    _flush() {
        const text = this._buffer.trim();
        this._buffer = "";
        if (!text || typeof speechSynthesis === "undefined") return;
        speechSynthesis.speak(new SpeechSynthesisUtterance(text));
    }
}