From fac3065c6ac3139a90cfa3321d62cd2d146277ab Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 26 Jul 2026 21:24:28 -0400 Subject: [PATCH] More tweaks to dectalk driver. Think we're nearly there. Hopefully. --- .../speechDriver/dectalkDriver.py | 6 +- .../speechDriver/dectalkUsbDriver.py | 81 ++++++++++++++++++- tests/unit/test_dectalk_usb_driver.py | 43 ++++++++++ tests/unit/test_hardware_speech_drivers.py | 12 +++ 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/fenrirscreenreader/speechDriver/dectalkDriver.py b/src/fenrirscreenreader/speechDriver/dectalkDriver.py index 20a97e87..c085d9be 100644 --- a/src/fenrirscreenreader/speechDriver/dectalkDriver.py +++ b/src/fenrirscreenreader/speechDriver/dectalkDriver.py @@ -14,7 +14,11 @@ class driver(hardware_serial_driver): use_xon_xoff = True def _speak_bytes(self, text): - return self._clean_text(text).encode("ascii", errors="replace") + b"\x0b" + text = self._neutralize_inline_commands(self._clean_text(text)) + return text.encode("ascii", errors="replace") + b"\x0b" + + def _neutralize_inline_commands(self, text): + return text.replace("[:", "[ :") def _rate_command(self, rate): value = self._scale_around_default(rate, 75, 180, 650) diff --git a/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py b/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py index db38d59b..a8812258 100644 --- a/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py +++ b/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py @@ -19,6 +19,11 @@ class driver(dectalkDriver.driver): packet_size = 64 write_timeout = 1000 + def __init__(self): + super().__init__() + self._speech_cancel_generation = 0 + self._speech_cancel_lock = threading.Lock() + def initialize(self, environment): self.env = environment self._is_initialized = False @@ -129,6 +134,7 @@ class driver(dectalkDriver.driver): def cancel(self): if not self._is_initialized: return + self._advance_speech_cancel_generation() self.clear_buffer() self._write_endpoint( self.immediate_endpoint, @@ -137,8 +143,56 @@ class driver(dectalkDriver.driver): write_lock=self.immediate_lock, ) - def _write_bytes(self, data, description="data"): - self._write_endpoint(self.speech_endpoint, data, description) + 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._debug( + "Hardware speech queued text: " + f"{len(text)} chars, queue_size={self.text_queue.qsize()}", + debug.DebugLevel.INFO, + on_any_level=True, + ) + self.text_queue.put( + (self._get_speech_cancel_generation(), text) + ) + + def _worker(self): + while not self._stop_worker: + queue_item = self.text_queue.get() + if queue_item is None: + return + cancel_generation, text = queue_item + if cancel_generation != self._get_speech_cancel_generation(): + continue + try: + data = self._speak_bytes(text) + self._debug( + "Hardware speech worker prepared speech bytes: " + f"{len(data)} bytes", + debug.DebugLevel.INFO, + on_any_level=True, + ) + self._write_bytes(data, "speech", cancel_generation) + except Exception as error: + self._debug( + f"Hardware speech worker failed: {error}", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + + def _write_bytes(self, data, description="data", cancel_generation=None): + if description == "speech" and cancel_generation is None: + cancel_generation = self._get_speech_cancel_generation() + self._write_endpoint( + self.speech_endpoint, + data, + description, + cancel_generation=cancel_generation, + ) def _write_endpoint( self, @@ -146,6 +200,7 @@ class driver(dectalkDriver.driver): data, description, write_lock=None, + cancel_generation=None, ): if not data: return @@ -157,6 +212,15 @@ class driver(dectalkDriver.driver): try: total_written = 0 while total_written < len(data): + if self._speech_cancel_requested(cancel_generation): + self._debug( + "DECtalk USB stopped speech write after " + f"{total_written} bytes because speech was " + "cancelled", + debug.DebugLevel.INFO, + on_any_level=True, + ) + return chunk = data[ total_written : total_written + self.packet_size ] @@ -186,6 +250,19 @@ class driver(dectalkDriver.driver): on_any_level=True, ) + def _advance_speech_cancel_generation(self): + with self._speech_cancel_lock: + self._speech_cancel_generation += 1 + + def _get_speech_cancel_generation(self): + with self._speech_cancel_lock: + return self._speech_cancel_generation + + def _speech_cancel_requested(self, cancel_generation): + if cancel_generation is None: + return False + return cancel_generation != self._get_speech_cancel_generation() + def _dispose_usb_device(self, usb_device): try: self.usb_util.dispose_resources(usb_device) diff --git a/tests/unit/test_dectalk_usb_driver.py b/tests/unit/test_dectalk_usb_driver.py index fb86f26a..9c6e87ec 100644 --- a/tests/unit/test_dectalk_usb_driver.py +++ b/tests/unit/test_dectalk_usb_driver.py @@ -85,6 +85,19 @@ class BlockingUsbDevice(FakeUsbDevice): return result +class CancelDuringSpeechUsbDevice(FakeUsbDevice): + def __init__(self): + super().__init__() + self.first_normal_write_started = threading.Event() + self.release_first_normal_write = threading.Event() + + def write(self, endpoint, data, timeout=None): + if endpoint == 0x02 and not self.first_normal_write_started.is_set(): + self.first_normal_write_started.set() + self.release_first_normal_write.wait(timeout=1.0) + return super().write(endpoint, data, timeout=timeout) + + def build_environment(): return { "runtime": { @@ -197,6 +210,36 @@ def test_dectalk_usb_cancel_bypasses_blocked_normal_write(monkeypatch): speech_driver.shutdown() +def test_dectalk_usb_cancel_stops_current_speech_upload(monkeypatch): + device = CancelDuringSpeechUsbDevice() + usb_core = FakeUsbCore(device) + usb_util = FakeUsbUtil() + speech_driver = dectalkUsbDriver.driver() + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + speech_driver.initialize(build_environment()) + normal_thread = threading.Thread( + target=speech_driver._write_bytes, + args=(b"x" * 192, "speech"), + ) + try: + normal_thread.start() + assert device.first_normal_write_started.wait(timeout=0.2) + speech_driver.cancel() + finally: + device.release_first_normal_write.set() + normal_thread.join(timeout=1.0) + speech_driver.shutdown() + + normal_writes = [ + payload for endpoint, payload, _ in device.writes if endpoint == 0x02 + ] + assert normal_writes == [b"x" * 64] + + def test_dectalk_usb_driver_reports_missing_pyusb(monkeypatch): speech_driver = dectalkUsbDriver.driver() diff --git a/tests/unit/test_hardware_speech_drivers.py b/tests/unit/test_hardware_speech_drivers.py index 92d248db..c0c2a987 100644 --- a/tests/unit/test_hardware_speech_drivers.py +++ b/tests/unit/test_hardware_speech_drivers.py @@ -70,6 +70,18 @@ def test_dectalk_driver_speaks_printable_text(serial_pair): speech_driver.shutdown() +def test_dectalk_driver_neutralizes_inline_commands_in_speech( + serial_pair, +): + speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) + try: + speech_driver.speak("status [:dv g5 10] done") + expected = b"status [ :dv g5 10] done\x0b" + assert read_available(master_fd, len(expected)) == expected + finally: + speech_driver.shutdown() + + def test_dectalk_driver_writes_settings_and_cancel(serial_pair): speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) try: