New hardware synth support added. Untested, so consider this experimental.

This commit is contained in:
Storm Dragon
2026-05-20 18:02:51 -04:00
parent f09437ea60
commit 8467bd74c3
18 changed files with 543 additions and 8 deletions
+16 -2
View File
@@ -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 <action> [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:*
+20 -1
View File
@@ -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
+8
View File
@@ -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
+28 -1
View File
@@ -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+`
+15
View File
@@ -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)
+21 -3
View File
@@ -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''
@@ -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")
@@ -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",
}
@@ -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,
@@ -508,6 +508,10 @@ class SettingsManager:
valid_drivers = [
"speechdDriver",
"genericDriver",
"dectalkDriver",
"doubletalkDriver",
"litetalkDriver",
"tripletalkDriver",
"dummyDriver",
]
if value not in valid_drivers:
+1 -1
View File
@@ -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"
@@ -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")
@@ -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
@@ -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""
@@ -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
@@ -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
+124
View File
@@ -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()
+4
View File
@@ -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