From 8467bd74c38848865bbc8b1c72fb3d0decb8c3d9 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 20 May 2026 18:02:51 -0400 Subject: [PATCH] New hardware synth support added. Untested, so consider this experimental. --- README.md | 18 +- config/settings/settings.conf | 21 +- docs/fenrir.1 | 8 + docs/fenrir.adoc | 29 ++- docs/user.md | 15 ++ docs/user.txt | 24 +- .../vmenu-profiles/KEY/fenrir/config_base.py | 2 + .../KEY/fenrir/management/reset_defaults.py | 2 + src/fenrirscreenreader/core/settingsData.py | 2 + .../core/settingsManager.py | 4 + src/fenrirscreenreader/fenrirVersion.py | 2 +- .../speechDriver/dectalkDriver.py | 28 +++ .../speechDriver/doubletalkDriver.py | 7 + .../speechDriver/hardwareSerialDriver.py | 226 ++++++++++++++++++ .../speechDriver/litetalkDriver.py | 28 +++ .../speechDriver/tripletalkDriver.py | 7 + tests/unit/test_hardware_speech_drivers.py | 124 ++++++++++ tests/unit/test_settings_validation.py | 4 + 18 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 src/fenrirscreenreader/speechDriver/dectalkDriver.py create mode 100644 src/fenrirscreenreader/speechDriver/doubletalkDriver.py create mode 100644 src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py create mode 100644 src/fenrirscreenreader/speechDriver/litetalkDriver.py create mode 100644 src/fenrirscreenreader/speechDriver/tripletalkDriver.py create mode 100644 tests/unit/test_hardware_speech_drivers.py diff --git a/README.md b/README.md index c2b860a8..27ae50ea 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,15 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform - python-speechd 2. **genericDriver** - Generic subprocess speech driver - espeak or espeak-ng (or any TTS command) -3. **debugDriver** - Debug speech driver for testing +3. **dectalkDriver** - Serial DECtalk-compatible hardware speech driver + - RPITalk gadget mode or a DECtalk-compatible serial device +4. **litetalkDriver** - Serial LiteTalk-compatible hardware speech driver + - RPITalk gadget mode or a LiteTalk-compatible serial device +5. **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech driver + - DoubleTalk LT; does not support the internal DoubleTalk PC card +6. **tripletalkDriver** - Serial TripleTalk-compatible hardware speech driver + - External DB9 serial TripleTalk devices, or USB models that expose a tty serial device +7. **debugDriver** - Debug speech driver for testing - No dependencies @@ -447,7 +455,13 @@ setting [parameters] - `speech#volume=0.1-1.0` - Speech volume - `speech#voice=voice_name` - Voice selection (e.g., "en-us+f3") - `speech#module=module_name` - TTS module (e.g., "espeak-ng") -- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver) +- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver/dectalkDriver/litetalkDriver/doubletalkDriver/tripletalkDriver) +- `speech#hardware_device=auto` - Hardware synth serial device for dectalkDriver/litetalkDriver +- `speech#hardware_baud_rate=9600` - Hardware synth serial baud rate + +USB hardware synths are supported only when Linux exposes them as a serial tty +such as `/dev/ttyACM0` or `/dev/ttyUSB0`. A USB-only TripleTalk with no tty +device would require a separate USB protocol driver. - `speech#auto_read_incoming=True/False` - Auto-read new text *Sound Settings:* diff --git a/config/settings/settings.conf b/config/settings/settings.conf index df2d9f3e..5259b48b 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -35,9 +35,14 @@ progress_monitoring=True # Turn speech on or off: enabled=True -# Select speech driver, options are speechdDriver or genericDriver: +# Select speech driver, options are speechdDriver, genericDriver, +# dectalkDriver, litetalkDriver, doubletalkDriver, or tripletalkDriver: driver=speechdDriver #driver=genericDriver +#driver=dectalkDriver +#driver=litetalkDriver +#driver=doubletalkDriver +#driver=tripletalkDriver # The rate selects how fast Fenrir will speak. Options range from 0, slowest, to 1.0, fastest. rate=0.5 @@ -70,6 +75,20 @@ volume=1.0 # Select the language you want Fenrir to use. #language=en +# Hardware speech synthesizer serial device. +# Used by dectalkDriver, litetalkDriver, doubletalkDriver, and tripletalkDriver. +# USB serial devices are supported if Linux exposes them as /dev/ttyACM* +# or /dev/ttyUSB*. USB-only synths with no tty device need a separate driver. +# auto checks /dev/ttyACM* first, then /dev/ttyUSB*. +# Examples: +# hardware_device=/dev/ttyACM0 # RPITalk USB gadget mode +# hardware_device=/dev/ttyUSB0 # USB serial adapter +# hardware_device=/dev/ttyS0 # built-in serial port +hardware_device=auto + +# Serial baud rate for hardware speech synthesizers. +hardware_baud_rate=9600 + # Read new text as it happens? auto_read_incoming=True diff --git a/docs/fenrir.1 b/docs/fenrir.1 index bf15df00..b0d918ed 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -238,6 +238,14 @@ speechdDriver - Speech-dispatcher (recommended) .IP \[bu] 4 genericDriver - Command-line TTS (espeak, etc.) .IP \[bu] 4 +dectalkDriver - DECtalk-compatible serial hardware speech +.IP \[bu] 4 +litetalkDriver - LiteTalk-compatible serial hardware speech +.IP \[bu] 4 +doubletalkDriver - DoubleTalk LT-compatible serial hardware speech +.IP \[bu] 4 +tripletalkDriver - TripleTalk-compatible serial hardware speech +.IP \[bu] 4 debugDriver - Debug/testing .TP diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index 99d0de91..30ccde5f 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -1548,8 +1548,12 @@ enabled=True Values: on=`+True+`, off=`+False+` # Select speech driver, options are speechdDriver (default), -genericDriver or espeakDriver: driver=speechdDriver #driver=espeakDriver +genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: driver=speechdDriver #driver=genericDriver +#driver=dectalkDriver +#driver=litetalkDriver +#driver=doubletalkDriver +#driver=tripletalkDriver This Selects the driver used to generate speech output. @@ -1677,6 +1681,29 @@ the pico module: language=de-DE .... +Hardware speech drivers use a serial device. The default `+auto+` checks +`+/dev/ttyACM*+` first, then `+/dev/ttyUSB*+`. Set an explicit path for +stable systems. + +.... +hardware_device=auto +hardware_device=/dev/ttyACM0 +hardware_device=/dev/ttyUSB0 +hardware_device=/dev/ttyS0 +.... + +Hardware speech drivers use 9600 baud by default. + +.... +hardware_baud_rate=9600 +.... + +The `+doubletalkDriver+` targets DoubleTalk LT-style serial devices. It does +not support the internal DoubleTalk PC ISA card. +USB hardware speech synthesizers are supported only when Linux exposes them as +a serial tty such as `+/dev/ttyACM0+` or `+/dev/ttyUSB0+`. USB-only TripleTalk +models with no tty device need a separate driver. + Read new text as it occurs auto_read_incoming=True Values: on=`+True+`, off=`+False+` diff --git a/docs/user.md b/docs/user.md index f3144d74..1e4f683b 100644 --- a/docs/user.md +++ b/docs/user.md @@ -100,6 +100,8 @@ driver=speechdDriver rate=0.5 pitch=0.5 volume=1.0 +hardware_device=auto +hardware_baud_rate=9600 [sound] enabled=True @@ -330,6 +332,19 @@ Fenrir automatically detects and provides audio feedback for progress indicators ### Speech Drivers - **speechdDriver** - Speech-dispatcher (recommended) - **genericDriver** - Command-line TTS (espeak, etc.) +- **dectalkDriver** - Serial DECtalk-compatible hardware speech +- **litetalkDriver** - Serial LiteTalk-compatible hardware speech +- **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech +- **tripletalkDriver** - Serial TripleTalk-compatible hardware speech + +For hardware speech, set `speech#hardware_device` to `auto` or an explicit +serial path. RPITalk gadget mode usually appears as `/dev/ttyACM0`; USB serial +adapters usually appear as `/dev/ttyUSB0`; built-in serial ports may be +`/dev/ttyS0`. The default baud rate is `9600`. `doubletalkDriver` targets +DoubleTalk LT-style serial devices, not the internal DoubleTalk PC ISA card. +USB TripleTalk devices work only if Linux exposes them as a serial tty such as +`/dev/ttyACM0` or `/dev/ttyUSB0`; USB-only models with no tty device need a +separate driver. ### Sound Drivers - **genericDriver** - Sox-based (default) diff --git a/docs/user.txt b/docs/user.txt index 7cc0e764..34c15b37 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -878,10 +878,13 @@ Turn speech on or off: enabled=True Values: on=''True'', off=''False'' -# Select speech driver, options are speechdDriver (default), genericDriver or espeakDriver: +# Select speech driver, options are speechdDriver (default), genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: driver=speechdDriver -#driver=espeakDriver #driver=genericDriver +#driver=dectalkDriver +#driver=litetalkDriver +#driver=doubletalkDriver +#driver=tripletalkDriver Select the driver used to generate speech output. @@ -890,7 +893,10 @@ Select the driver used to generate speech output. Available Drivers: * ''genericDriver'' using the generic driver, for Fenrir <1.5 this is not available * ''speechdDriver'' using speech-dispatcher, for Fenrir <1.5 just use ''speechd'' - * ''espeakDriver'' using the espeak directly, for Fenrir <1.5 just use ''espeak'' + * ''dectalkDriver'' using DECtalk-compatible serial hardware or RPITalk + * ''litetalkDriver'' using LiteTalk-compatible serial hardware or RPITalk + * ''doubletalkDriver'' using DoubleTalk LT-compatible serial hardware + * ''tripletalkDriver'' using TripleTalk-compatible serial hardware The rate selects how fast Fenrir will speak. rate=0.65 @@ -921,6 +927,18 @@ Select the language you want Fenrir to use. language=english-us Values: Text, see your TTS synths documentation what is available. +Hardware speech drivers use a serial device. The default ''auto'' checks /dev/ttyACM* first, then /dev/ttyUSB*. Set an explicit path for stable systems. + hardware_device=auto + hardware_device=/dev/ttyACM0 + hardware_device=/dev/ttyUSB0 + hardware_device=/dev/ttyS0 + +Hardware speech drivers use 9600 baud by default. + hardware_baud_rate=9600 + +The doubletalkDriver targets DoubleTalk LT-style serial devices. It does not support the internal DoubleTalk PC ISA card. +USB hardware speech synthesizers are supported only when Linux exposes them as a serial tty such as /dev/ttyACM0 or /dev/ttyUSB0. USB-only TripleTalk models with no tty device need a separate driver. + Read new text as it occurs auto_read_incoming=True Values: on=''True'', off=''False'' diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py index 2e8bd550..80e56bf4 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py @@ -127,6 +127,8 @@ class config_command: self.config.set("speech", "rate", "0.75") self.config.set("speech", "pitch", "0.5") self.config.set("speech", "volume", "1.0") + self.config.set("speech", "hardware_device", "auto") + self.config.set("speech", "hardware_baud_rate", "9600") self.config.add_section("sound") self.config.set("sound", "driver", "genericDriver") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py index 72ab5dd2..0aa2356a 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py @@ -108,6 +108,8 @@ class command(config_command): "rate": "0.5", "pitch": "0.5", "volume": "1.0", + "hardware_device": "auto", + "hardware_baud_rate": "9600", "auto_read_incoming": "True", } diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index 07c2fede..79fcefa2 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -28,6 +28,8 @@ settings_data = { "module": "", "voice": "en-us", "language": "", + "hardware_device": "auto", + "hardware_baud_rate": 9600, "auto_read_incoming": True, "read_numbers_as_digits": False, "rapid_update_threshold": 5, diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index de855391..0cdb5d78 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -508,6 +508,10 @@ class SettingsManager: valid_drivers = [ "speechdDriver", "genericDriver", + "dectalkDriver", + "doubletalkDriver", + "litetalkDriver", + "tripletalkDriver", "dummyDriver", ] if value not in valid_drivers: diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 95722b55..caba316d 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.05.19" +version = "2026.05.20" code_name = "testing" diff --git a/src/fenrirscreenreader/speechDriver/dectalkDriver.py b/src/fenrirscreenreader/speechDriver/dectalkDriver.py new file mode 100644 index 00000000..c4346f8b --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/dectalkDriver.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.speechDriver.hardwareSerialDriver import ( + hardware_serial_driver, +) + + +class driver(hardware_serial_driver): + cancel_command = b"\x18" + + def _speak_bytes(self, text): + return self._clean_text(text).encode("ascii", errors="replace") + b"\x01" + + def _rate_command(self, rate): + return self._setting_command("ra", self._scale(rate, 75, 650)) + + def _pitch_command(self, pitch): + return self._setting_command("dv ap", self._scale(pitch, 50, 180)) + + def _volume_command(self, volume): + return self._setting_command("vo", self._scale(volume, 0, 100)) + + def _setting_command(self, command, value): + return f"[:{command} {value}]".encode("ascii") diff --git a/src/fenrirscreenreader/speechDriver/doubletalkDriver.py b/src/fenrirscreenreader/speechDriver/doubletalkDriver.py new file mode 100644 index 00000000..ca4fe804 --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/doubletalkDriver.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.speechDriver.litetalkDriver import driver diff --git a/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py b/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py new file mode 100644 index 00000000..e006b865 --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import glob +import os +import termios +import threading +import tty +from queue import Empty +from queue import Queue + +from fenrirscreenreader.core import debug +from fenrirscreenreader.core.speechDriver import speech_driver + + +class SpeakQueue(Queue): + def clear(self): + try: + while True: + self.get_nowait() + except Empty: + pass + + +class hardware_serial_driver(speech_driver): + cancel_command = b"" + default_baud_rate = 9600 + + def __init__(self): + speech_driver.__init__(self) + self.device = "" + self.baud_rate = self.default_baud_rate + self.serial_port = None + self.text_queue = SpeakQueue() + self.lock = threading.Lock() + self.worker_thread = None + self._stop_worker = False + + def initialize(self, environment): + self.env = environment + self._is_initialized = False + settings_manager = self.env["runtime"]["SettingsManager"] + self.device = settings_manager.get_setting( + "speech", "hardware_device" + ) + self.baud_rate = settings_manager.get_setting_as_int( + "speech", "hardware_baud_rate" + ) + self._open_serial_port() + self._is_initialized = self.serial_port is not None + if self._is_initialized: + self._stop_worker = False + self.worker_thread = threading.Thread( + target=self._worker, daemon=True + ) + self.worker_thread.start() + + def shutdown(self): + if not self._is_initialized: + return + self._stop_worker = True + self.clear_buffer() + self.text_queue.put(None) + if self.worker_thread: + self.worker_thread.join(timeout=0.5) + self._close_serial_port() + self._is_initialized = False + + def speak(self, text, queueable=True, ignore_punctuation=False): + if not self._is_initialized: + return + if not queueable: + self.cancel() + if not isinstance(text, str) or text == "": + return + self.text_queue.put(text) + + def cancel(self): + if not self._is_initialized: + return + self.clear_buffer() + if self.cancel_command: + self._write_bytes(self.cancel_command) + + def clear_buffer(self): + if not self._is_initialized: + return + self.text_queue.clear() + + def set_rate(self, rate): + if not self._is_initialized: + return + if not isinstance(rate, float): + return + self._write_bytes(self._rate_command(rate)) + + def set_pitch(self, pitch): + if not self._is_initialized: + return + if not isinstance(pitch, float): + return + self._write_bytes(self._pitch_command(pitch)) + + def set_volume(self, volume): + if not self._is_initialized: + return + if not isinstance(volume, float): + return + self._write_bytes(self._volume_command(volume)) + + def _worker(self): + while not self._stop_worker: + text = self.text_queue.get() + if text is None: + return + self._write_bytes(self._speak_bytes(text)) + + def _open_serial_port(self): + device = self._resolve_device(self.device) + if not device: + self._debug( + "Hardware speech device not found", + debug.DebugLevel.ERROR, + ) + return + try: + port = os.open(device, os.O_RDWR | os.O_NOCTTY) + tty.setraw(port) + attrs = termios.tcgetattr(port) + attrs[2] |= termios.CLOCAL | termios.CREAD + baud_rate = self._termios_baud_rate(self.baud_rate) + attrs[4] = baud_rate + attrs[5] = baud_rate + attrs[6][termios.VMIN] = 0 + attrs[6][termios.VTIME] = 0 + attrs[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY) + termios.tcsetattr(port, termios.TCSANOW, attrs) + self.serial_port = port + self.device = device + except OSError as error: + self._debug( + f"Hardware speech device open failed: {device}: {error}", + debug.DebugLevel.ERROR, + ) + self.serial_port = None + + def _close_serial_port(self): + with self.lock: + if self.serial_port is None: + return + try: + os.close(self.serial_port) + except OSError as error: + self._debug( + f"Hardware speech device close failed: {error}", + debug.DebugLevel.WARNING, + ) + finally: + self.serial_port = None + + def _write_bytes(self, data): + if not data: + return + with self.lock: + if self.serial_port is None: + return + try: + os.write(self.serial_port, data) + except OSError as error: + self._debug( + f"Hardware speech write failed: {error}", + debug.DebugLevel.ERROR, + ) + + def _resolve_device(self, device): + if device and device != "auto": + return device + for pattern in ("/dev/ttyACM*", "/dev/ttyUSB*"): + matches = sorted(glob.glob(pattern)) + if matches: + return matches[0] + return "" + + def _termios_baud_rate(self, baud_rate): + baud_name = f"B{baud_rate}" + if hasattr(termios, baud_name): + return getattr(termios, baud_name) + self._debug( + f"Unsupported hardware speech baud rate {baud_rate}; using 9600", + debug.DebugLevel.WARNING, + ) + return termios.B9600 + + def _clean_text(self, text): + text = text.replace("\r", " ").replace("\n", " ") + return "".join( + char if 0x20 <= ord(char) <= 0x7E else " " + for char in text + ) + + def _scale(self, value, minimum, maximum): + value = max(0.0, min(1.0, value)) + return int(round(minimum + value * (maximum - minimum))) + + def _debug(self, message, level): + try: + self.env["runtime"]["DebugManager"].write_debug_out( + message, level + ) + except Exception: + pass + + def _speak_bytes(self, text): + raise NotImplementedError + + def _rate_command(self, rate): + return b"" + + def _pitch_command(self, pitch): + return b"" + + def _volume_command(self, volume): + return b"" diff --git a/src/fenrirscreenreader/speechDriver/litetalkDriver.py b/src/fenrirscreenreader/speechDriver/litetalkDriver.py new file mode 100644 index 00000000..6dadeda2 --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/litetalkDriver.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.speechDriver.hardwareSerialDriver import ( + hardware_serial_driver, +) + + +class driver(hardware_serial_driver): + cancel_command = b"\x18" + + def _speak_bytes(self, text): + return self._clean_text(text).encode("ascii", errors="replace") + b"\r" + + def _rate_command(self, rate): + return self._setting_command(self._scale(rate, 0, 9), b"S") + + def _pitch_command(self, pitch): + return self._setting_command(self._scale(pitch, 0, 99), b"P") + + def _volume_command(self, volume): + return self._setting_command(self._scale(volume, 0, 9), b"V") + + def _setting_command(self, value, command): + return b"\x01" + str(value).encode("ascii") + command diff --git a/src/fenrirscreenreader/speechDriver/tripletalkDriver.py b/src/fenrirscreenreader/speechDriver/tripletalkDriver.py new file mode 100644 index 00000000..ca4fe804 --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/tripletalkDriver.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.speechDriver.litetalkDriver import driver diff --git a/tests/unit/test_hardware_speech_drivers.py b/tests/unit/test_hardware_speech_drivers.py new file mode 100644 index 00000000..8f5fce86 --- /dev/null +++ b/tests/unit/test_hardware_speech_drivers.py @@ -0,0 +1,124 @@ +import os +import select +import time +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.speechDriver import dectalkDriver +from fenrirscreenreader.speechDriver import doubletalkDriver +from fenrirscreenreader.speechDriver import litetalkDriver +from fenrirscreenreader.speechDriver import tripletalkDriver + + +def build_environment(device): + settings_manager = Mock() + settings_manager.get_setting.side_effect = ( + lambda section, setting: device + if (section, setting) == ("speech", "hardware_device") + else "" + ) + settings_manager.get_setting_as_int.side_effect = ( + lambda section, setting: 9600 + if (section, setting) == ("speech", "hardware_baud_rate") + else 0 + ) + return { + "runtime": { + "SettingsManager": settings_manager, + "DebugManager": Mock(), + } + } + + +def read_available(fd, expected_length, timeout=1.0): + deadline = time.monotonic() + timeout + data = b"" + while len(data) < expected_length and time.monotonic() < deadline: + readable, _, _ = select.select([fd], [], [], 0.05) + if readable: + data += os.read(fd, 1024) + return data + + +@pytest.fixture +def serial_pair(): + master_fd, slave_fd = os.openpty() + try: + yield master_fd, os.ttyname(slave_fd) + finally: + os.close(master_fd) + os.close(slave_fd) + + +def initialized_driver(driver_class, serial_pair): + master_fd, slave_name = serial_pair + speech_driver = driver_class.driver() + speech_driver.initialize(build_environment(slave_name)) + assert speech_driver._is_initialized + return speech_driver, master_fd + + +def test_dectalk_driver_speaks_printable_text(serial_pair): + speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) + try: + speech_driver.speak("Hello\nworld ☃") + assert read_available(master_fd, 13) == b"Hello world \x01" + finally: + speech_driver.shutdown() + + +def test_dectalk_driver_writes_settings_and_cancel(serial_pair): + speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) + try: + speech_driver.set_rate(1.0) + speech_driver.set_pitch(0.0) + speech_driver.set_volume(0.5) + speech_driver.cancel() + assert read_available(master_fd, 33) == ( + b"[:ra 650][:dv ap 50][:vo 50]\x18" + ) + finally: + speech_driver.shutdown() + + +def test_litetalk_driver_speaks_printable_text(serial_pair): + speech_driver, master_fd = initialized_driver(litetalkDriver, serial_pair) + try: + speech_driver.speak("Ready") + assert read_available(master_fd, 6) == b"Ready\r" + finally: + speech_driver.shutdown() + + +def test_litetalk_driver_writes_settings_and_cancel(serial_pair): + speech_driver, master_fd = initialized_driver(litetalkDriver, serial_pair) + try: + speech_driver.set_rate(1.0) + speech_driver.set_pitch(0.0) + speech_driver.set_volume(0.5) + speech_driver.cancel() + assert read_available(master_fd, 9) == b"\x019S\x010P\x014V\x18" + finally: + speech_driver.shutdown() + + +@pytest.mark.parametrize("driver_class", [doubletalkDriver, tripletalkDriver]) +def test_litetalk_compatible_alias_drivers(driver_class, serial_pair): + speech_driver, master_fd = initialized_driver(driver_class, serial_pair) + try: + speech_driver.speak("Alias") + speech_driver.set_rate(1.0) + assert read_available(master_fd, 10) == b"\x019SAlias\r" + finally: + speech_driver.shutdown() + + +def test_hardware_driver_ignores_empty_and_non_string_text(serial_pair): + speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) + try: + speech_driver.speak("") + speech_driver.speak(None) + assert read_available(master_fd, 1, timeout=0.2) == b"" + finally: + speech_driver.shutdown() diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index 5e65115c..ef185e13 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -71,6 +71,10 @@ class TestSpeechSettingsValidation: # Valid drivers self.manager._validate_setting_value("speech", "driver", "speechdDriver") self.manager._validate_setting_value("speech", "driver", "genericDriver") + self.manager._validate_setting_value("speech", "driver", "dectalkDriver") + self.manager._validate_setting_value("speech", "driver", "doubletalkDriver") + self.manager._validate_setting_value("speech", "driver", "litetalkDriver") + self.manager._validate_setting_value("speech", "driver", "tripletalkDriver") self.manager._validate_setting_value("speech", "driver", "dummyDriver") # Invalid driver