Release candidate.
This commit is contained in:
@@ -4,6 +4,7 @@ import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
@@ -273,29 +274,13 @@ class command:
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "espeak-ng":
|
||||
voice = self.process_espeak_voice(line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
else:
|
||||
voices.append(line.strip())
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def process_espeak_voice(self, voiceLine):
|
||||
"""Process espeak voice format"""
|
||||
parts = [p for p in voiceLine.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lang_code = parts[-2].lower()
|
||||
variant = parts[-1].lower()
|
||||
return (
|
||||
f"{lang_code}+{variant}"
|
||||
if variant and variant != "none"
|
||||
else lang_code
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -5,6 +5,7 @@ import threading
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
@@ -179,12 +180,9 @@ class command:
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "espeak-ng":
|
||||
voice = self.process_espeak_voice(line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
else:
|
||||
voices.append(line.strip())
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
# Limit voice count to prevent memory issues
|
||||
if len(voices) > 1000:
|
||||
@@ -205,21 +203,5 @@ class command:
|
||||
)
|
||||
return []
|
||||
|
||||
def process_espeak_voice(self, voiceLine):
|
||||
"""Process espeak voice format"""
|
||||
try:
|
||||
parts = [p for p in voiceLine.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lang_code = parts[-2].lower()
|
||||
variant = parts[-1].lower()
|
||||
return (
|
||||
f"{lang_code}+{variant}"
|
||||
if variant and variant != "none"
|
||||
else lang_code
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -79,10 +79,11 @@ class command:
|
||||
# updates
|
||||
delta_text = self.env["screen"]["new_delta"]
|
||||
delta_length = len(delta_text)
|
||||
explicit_progress_lines = self.get_explicit_progress_lines(delta_text)
|
||||
if (
|
||||
delta_length > 200
|
||||
): # Allow longer progress lines such as terminal status output
|
||||
if not self.is_explicit_progress_delta(delta_text):
|
||||
if not explicit_progress_lines:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Progress filter: delta too long ({delta_length})",
|
||||
debug.DebugLevel.INFO,
|
||||
@@ -91,7 +92,11 @@ class command:
|
||||
|
||||
# If delta contains newlines and is substantial, let incoming handler
|
||||
# deal with it to avoid interfering with multi-line text output
|
||||
if '\n' in delta_text and delta_length > 50:
|
||||
if (
|
||||
"\n" in delta_text
|
||||
and delta_length > 50
|
||||
and not explicit_progress_lines
|
||||
):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Progress filter: multiline delta ({delta_length} chars)",
|
||||
debug.DebugLevel.INFO,
|
||||
@@ -119,13 +124,35 @@ class command:
|
||||
if not has_percentage:
|
||||
return self.has_progress_status_fields(text)
|
||||
|
||||
return bool(
|
||||
re.search(
|
||||
r"[|\[\]#=*>█▉▊▋▌▍▎▏▒▓░]"
|
||||
r"|\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b",
|
||||
text,
|
||||
)
|
||||
bracketed_bar = re.search(
|
||||
r"\[(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\])"
|
||||
r"(?=[^\]]*[#=*>█▉▊▋▌▍▎▏▒▓░])"
|
||||
r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\]",
|
||||
text,
|
||||
)
|
||||
pipe_bar = re.search(
|
||||
r"\|(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\|)"
|
||||
r"(?=[^|]*[#=*>█▉▊▋▌▍▎▏▒▓░])"
|
||||
r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\|",
|
||||
text,
|
||||
)
|
||||
transfer_rate = re.search(
|
||||
r"\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b",
|
||||
text,
|
||||
)
|
||||
return bool(
|
||||
bracketed_bar
|
||||
or pipe_bar
|
||||
or transfer_rate
|
||||
)
|
||||
|
||||
def get_explicit_progress_lines(self, text):
|
||||
"""Return structurally explicit progress rows from a screen delta."""
|
||||
return [
|
||||
line
|
||||
for line in text.splitlines()
|
||||
if self.is_explicit_progress_delta(line)
|
||||
]
|
||||
|
||||
def reset_progress_state(self):
|
||||
"""Reset progress state when a prompt is detected, allowing new progress operations to start fresh"""
|
||||
@@ -140,6 +167,15 @@ class command:
|
||||
import re
|
||||
import time
|
||||
|
||||
if "\n" in text:
|
||||
explicit_progress_lines = self.get_explicit_progress_lines(text)
|
||||
if not explicit_progress_lines:
|
||||
return
|
||||
# Parallel transfer tools commonly repaint several package rows,
|
||||
# followed by an aggregate row. One tone per update is enough, and
|
||||
# the final explicit row is normally that aggregate.
|
||||
text = explicit_progress_lines[-1]
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Debug: Print what we're checking
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class DynamicVoiceCommand:
|
||||
@@ -354,30 +355,10 @@ def get_module_voices(module):
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "espeak-ng":
|
||||
voice = process_espeak_voice(line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
else:
|
||||
voices.append(line.strip())
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def process_espeak_voice(voiceLine):
|
||||
"""Process espeak voice format"""
|
||||
try:
|
||||
parts = [p for p in voiceLine.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lang_code = parts[-2].lower()
|
||||
variant = parts[-1].lower()
|
||||
return (
|
||||
f"{lang_code}+{variant}"
|
||||
if variant and variant != "none"
|
||||
else lang_code
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -133,16 +133,16 @@ class OutputManager:
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
|
||||
# Handle dots at the beginning of words (like .local, .bashrc, .config)
|
||||
# Look for non-word character (or start of string), dot, then word characters
|
||||
text = re.sub(r'(?<!\w)\.(\w+)', r'dot \1', text)
|
||||
|
||||
# Replace dots that appear between word characters with spoken form
|
||||
# Use a loop to handle multiple consecutive dots like www.example.com or a.b.c.d
|
||||
while re.search(r'\b\w+\.\w+\b', text):
|
||||
text = re.sub(r'\b(\w+)\.(\w+)\b', r'\1 dot \2', text)
|
||||
|
||||
text = re.sub(r"(?<!\w)\.(\w+)", r"dot \1", text)
|
||||
|
||||
# Replace dots between word characters in one pass. The old loop
|
||||
# rescanned the full speech string once per remaining dotted word,
|
||||
# which made large directory listings block the event loop for seconds.
|
||||
text = re.sub(r"(?<=\w)\.(?=\w)", " dot ", text)
|
||||
|
||||
return text
|
||||
|
||||
def speak_text(
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.core.settingsData import settings_data
|
||||
import subprocess
|
||||
import time
|
||||
from fenrirscreenreader.utils.speechd_utils import (
|
||||
get_synthesis_voice_name,
|
||||
parse_synthesis_voice_line,
|
||||
)
|
||||
|
||||
|
||||
class SpeechHelperMixin:
|
||||
@@ -89,20 +94,15 @@ class SpeechHelperMixin:
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "espeak-ng":
|
||||
voice = self._process_espeak_voice(line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
elif module.lower() == "voxin":
|
||||
if module.lower() == "voxin":
|
||||
# For Voxin, store voice name with language
|
||||
voice_data = self._process_voxin_voice(line)
|
||||
if voice_data:
|
||||
voices.append(voice_data)
|
||||
else:
|
||||
# For other modules, extract first field (voice name)
|
||||
parts = line.strip().split()
|
||||
if parts:
|
||||
voices.append(parts[0])
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
self._voices_cache[module] = voices
|
||||
return voices
|
||||
@@ -115,23 +115,6 @@ class SpeechHelperMixin:
|
||||
|
||||
return []
|
||||
|
||||
def _process_espeak_voice(self, voice_line):
|
||||
"""Process espeak-ng voice format into usable voice name.
|
||||
|
||||
Args:
|
||||
voice_line (str): Raw line from spd-say -L output
|
||||
|
||||
Returns:
|
||||
str: Processed voice name (e.g., 'en-us' or 'en-us+f3')
|
||||
"""
|
||||
parts = [p for p in voice_line.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lang_code = parts[-2].lower()
|
||||
variant = parts[-1].lower()
|
||||
return (f"{lang_code}+{variant}"
|
||||
if variant and variant != "none" else lang_code)
|
||||
|
||||
def _process_voxin_voice(self, voice_line):
|
||||
"""Process Voxin voice format with language information.
|
||||
|
||||
@@ -142,11 +125,12 @@ class SpeechHelperMixin:
|
||||
Returns:
|
||||
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
|
||||
"""
|
||||
parts = [p for p in voice_line.split() if p]
|
||||
if len(parts) < 2:
|
||||
voice_data = parse_synthesis_voice_line(voice_line)
|
||||
if voice_data is None:
|
||||
return None
|
||||
voice_name, language, _variant = voice_data
|
||||
if not language:
|
||||
return None
|
||||
voice_name = parts[0]
|
||||
language = parts[1]
|
||||
# Encode language with voice for later extraction
|
||||
return f"{voice_name}|{language}"
|
||||
|
||||
|
||||
@@ -521,6 +521,7 @@ class SettingsManager:
|
||||
"speechdDriver",
|
||||
"genericDriver",
|
||||
"dectalkDriver",
|
||||
"dectalkUsbDriver",
|
||||
"doubletalkDriver",
|
||||
"litetalkDriver",
|
||||
"tripletalkDriver",
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2026.07.19"
|
||||
version = "2026.07.29"
|
||||
code_name = "master"
|
||||
|
||||
@@ -329,48 +329,54 @@ class driver(remoteDriver):
|
||||
def watch_dog(self, active, event_queue):
|
||||
# echo "command say this is a test" | socat -
|
||||
# UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
||||
for socket_file, optional in self._get_socket_candidates():
|
||||
fenrir_sock = self._bind_socket(socket_file, optional)
|
||||
if fenrir_sock is None:
|
||||
continue
|
||||
self.fenrirSocks.append(fenrir_sock)
|
||||
self.bound_sockets.append((fenrir_sock, socket_file))
|
||||
try:
|
||||
for socket_file, optional in self._get_socket_candidates():
|
||||
fenrir_sock = self._bind_socket(socket_file, optional)
|
||||
if fenrir_sock is None:
|
||||
continue
|
||||
self.fenrirSocks.append(fenrir_sock)
|
||||
self.bound_sockets.append((fenrir_sock, socket_file))
|
||||
|
||||
if not self.fenrirSocks:
|
||||
return
|
||||
if not self.fenrirSocks:
|
||||
return
|
||||
|
||||
self._try_register_instance()
|
||||
last_register = time.time()
|
||||
while active.value:
|
||||
if time.time() - last_register > 10.0:
|
||||
self._try_register_instance()
|
||||
last_register = time.time()
|
||||
self._try_register_instance()
|
||||
last_register = time.time()
|
||||
while active.value:
|
||||
if time.time() - last_register > 10.0:
|
||||
self._try_register_instance()
|
||||
last_register = time.time()
|
||||
|
||||
# Check if the client is still connected and if data is available:
|
||||
try:
|
||||
r, _, _ = select.select(self.fenrirSocks, [], [], 0.8)
|
||||
except select.error:
|
||||
break
|
||||
if r == []:
|
||||
continue
|
||||
for fenrir_sock in r:
|
||||
client_sock, client_addr = fenrir_sock.accept()
|
||||
socket_file = self._socket_file_for_socket(fenrir_sock)
|
||||
# Ensure client socket is always closed to prevent resource
|
||||
# leaks
|
||||
# Check if the client is still connected and data is available.
|
||||
try:
|
||||
self._handle_client(client_sock, event_queue, socket_file)
|
||||
finally:
|
||||
# Always close client socket, even if data processing fails
|
||||
r, _, _ = select.select(self.fenrirSocks, [], [], 0.8)
|
||||
except select.error:
|
||||
break
|
||||
if r == []:
|
||||
continue
|
||||
for fenrir_sock in r:
|
||||
client_sock, _client_addr = fenrir_sock.accept()
|
||||
socket_file = self._socket_file_for_socket(fenrir_sock)
|
||||
try:
|
||||
client_sock.close()
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"unixDriver watch_dog: Error closing client socket: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
self._handle_client(
|
||||
client_sock, event_queue, socket_file
|
||||
)
|
||||
self._cleanup()
|
||||
finally:
|
||||
try:
|
||||
client_sock.close()
|
||||
except Exception as e:
|
||||
self.env["runtime"][
|
||||
"DebugManager"
|
||||
].write_debug_out(
|
||||
"unixDriver watch_dog: Error closing client "
|
||||
"socket: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
finally:
|
||||
# ProcessManager restarts a failed watchdog. Always release the
|
||||
# old listeners first so each restart cannot leak another socket.
|
||||
self._cleanup()
|
||||
|
||||
def shutdown(self):
|
||||
self._cleanup()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# blink = 5 if attr & 1 else 0
|
||||
# bold = 1 if attr & 16 else 0
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import os
|
||||
@@ -53,6 +54,9 @@ class driver(screenDriver):
|
||||
fgColorValues: Foreground color value mappings
|
||||
hichar: High character mask for Unicode support
|
||||
"""
|
||||
read_retry_attempts = 10
|
||||
read_retry_delay = 0.05
|
||||
|
||||
def __init__(self):
|
||||
screenDriver.__init__(self)
|
||||
self.ListSessions = None
|
||||
@@ -251,23 +255,40 @@ class driver(screenDriver):
|
||||
bytes: File content as bytes
|
||||
"""
|
||||
d = b""
|
||||
file.seek(0)
|
||||
try:
|
||||
d = file.read()
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"vcsaDriver get_screen_text: Error reading file: " + str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
for attempt in range(self.read_retry_attempts + 1):
|
||||
file.seek(0)
|
||||
while True:
|
||||
# Read from file
|
||||
try:
|
||||
d += file.readline(1)
|
||||
if not d:
|
||||
break
|
||||
except Exception as e:
|
||||
try:
|
||||
return file.read()
|
||||
except OSError as e:
|
||||
if (
|
||||
e.errno == errno.EINVAL
|
||||
and attempt < self.read_retry_attempts
|
||||
):
|
||||
time.sleep(self.read_retry_delay)
|
||||
continue
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"vcsaDriver get_screen_text: Error reading file: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"vcsaDriver get_screen_text: Error reading file: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
break
|
||||
|
||||
file.seek(0)
|
||||
while True:
|
||||
try:
|
||||
chunk = file.readline(1)
|
||||
if not chunk:
|
||||
break
|
||||
d += chunk
|
||||
except Exception:
|
||||
break
|
||||
return d
|
||||
|
||||
def update_watchdog(self, active, event_queue):
|
||||
|
||||
@@ -10,19 +10,32 @@ from fenrirscreenreader.speechDriver.hardwareSerialDriver import (
|
||||
|
||||
|
||||
class driver(hardware_serial_driver):
|
||||
cancel_command = b"\x18"
|
||||
cancel_command = b"\x03"
|
||||
use_xon_xoff = True
|
||||
|
||||
def _speak_bytes(self, text):
|
||||
return self._clean_text(text).encode("ascii", errors="replace") + b"\x01"
|
||||
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):
|
||||
return self._setting_command("ra", self._scale(rate, 75, 650))
|
||||
value = self._scale_around_default(rate, 75, 180, 650)
|
||||
return self._setting_command("ra", value)
|
||||
|
||||
def _pitch_command(self, pitch):
|
||||
return self._setting_command("dv ap", self._scale(pitch, 50, 180))
|
||||
value = self._scale_around_default(pitch, 50, 122, 350)
|
||||
return self._setting_command("dv ap", value)
|
||||
|
||||
def _volume_command(self, volume):
|
||||
return self._setting_command("vo", self._scale(volume, 0, 100))
|
||||
return self._setting_command("dv g5", self._scale(volume, 60, 86))
|
||||
|
||||
def _scale_around_default(self, value, minimum, default, maximum):
|
||||
value = max(0.0, min(1.0, value))
|
||||
if value <= 0.5:
|
||||
return self._scale(value * 2, minimum, default)
|
||||
return self._scale((value - 0.5) * 2, default, maximum)
|
||||
|
||||
def _setting_command(self, command, value):
|
||||
return f"[:{command} {value}]".encode("ascii")
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import threading
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.speechDriver import dectalkDriver
|
||||
|
||||
|
||||
class driver(dectalkDriver.driver):
|
||||
vendor_id = 0x0DD0
|
||||
product_id = 0x2001
|
||||
interface = 0
|
||||
speech_endpoint = 0x02
|
||||
immediate_endpoint = 0x04
|
||||
packet_size = 64
|
||||
write_timeout = 1000
|
||||
buffer_timeout_command = b"[:timeout 1]"
|
||||
|
||||
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
|
||||
self._reset_setting_cache()
|
||||
self.usb_device = None
|
||||
self.usb_core = None
|
||||
self.usb_util = None
|
||||
self.immediate_lock = threading.Lock()
|
||||
self._debug(
|
||||
"DECtalk USB initialize: requested_device=0dd0:2001",
|
||||
debug.DebugLevel.INFO,
|
||||
on_any_level=True,
|
||||
)
|
||||
|
||||
try:
|
||||
self.usb_core, self.usb_util = self._load_usb_modules()
|
||||
except ImportError as error:
|
||||
self._debug(
|
||||
f"DECtalk USB requires PyUSB: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
raise RuntimeError("DECtalk USB requires PyUSB") from error
|
||||
|
||||
try:
|
||||
usb_device = self.usb_core.find(
|
||||
idVendor=self.vendor_id,
|
||||
idProduct=self.product_id,
|
||||
)
|
||||
except (
|
||||
self.usb_core.NoBackendError,
|
||||
self.usb_core.USBError,
|
||||
OSError,
|
||||
) as error:
|
||||
self._debug(
|
||||
f"DECtalk USB discovery failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"DECtalk USB discovery failed: {error}"
|
||||
) from error
|
||||
if usb_device is None:
|
||||
self._debug(
|
||||
"DECtalk USB device 0dd0:2001 was not found",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
raise RuntimeError("DECtalk USB device 0dd0:2001 was not found")
|
||||
|
||||
try:
|
||||
try:
|
||||
usb_device.get_active_configuration()
|
||||
except self.usb_core.USBError:
|
||||
usb_device.set_configuration()
|
||||
self.usb_util.claim_interface(usb_device, self.interface)
|
||||
except (self.usb_core.USBError, OSError) as error:
|
||||
self._dispose_usb_device(usb_device)
|
||||
self._debug(
|
||||
f"DECtalk USB open failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
raise RuntimeError(f"DECtalk USB open failed: {error}") from error
|
||||
|
||||
self.usb_device = usb_device
|
||||
self._write_endpoint(
|
||||
self.speech_endpoint,
|
||||
self.buffer_timeout_command,
|
||||
"buffer timeout",
|
||||
)
|
||||
self._is_initialized = True
|
||||
self._stop_worker = False
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._worker,
|
||||
daemon=True,
|
||||
)
|
||||
self.worker_thread.start()
|
||||
self._debug(
|
||||
"DECtalk USB device opened: 0dd0:2001",
|
||||
debug.DebugLevel.INFO,
|
||||
on_any_level=True,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
with self.lock, self.immediate_lock:
|
||||
usb_device = self.usb_device
|
||||
self.usb_device = None
|
||||
if usb_device is None:
|
||||
return
|
||||
try:
|
||||
self.usb_util.release_interface(
|
||||
usb_device,
|
||||
self.interface,
|
||||
)
|
||||
except (self.usb_core.USBError, OSError) as error:
|
||||
self._debug(
|
||||
f"DECtalk USB interface release failed: {error}",
|
||||
debug.DebugLevel.WARNING,
|
||||
)
|
||||
self._dispose_usb_device(usb_device)
|
||||
finally:
|
||||
self._is_initialized = False
|
||||
|
||||
def cancel(self):
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self._advance_speech_cancel_generation()
|
||||
self.clear_buffer()
|
||||
self._write_endpoint(
|
||||
self.immediate_endpoint,
|
||||
b"\x06",
|
||||
"cancel",
|
||||
write_lock=self.immediate_lock,
|
||||
)
|
||||
|
||||
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()
|
||||
return self._write_endpoint(
|
||||
self.speech_endpoint,
|
||||
data,
|
||||
description,
|
||||
cancel_generation=cancel_generation,
|
||||
)
|
||||
|
||||
def _write_endpoint(
|
||||
self,
|
||||
endpoint,
|
||||
data,
|
||||
description,
|
||||
write_lock=None,
|
||||
cancel_generation=None,
|
||||
):
|
||||
if not data:
|
||||
return False
|
||||
if write_lock is None:
|
||||
write_lock = self.lock
|
||||
with write_lock:
|
||||
if self.usb_device is None:
|
||||
return False
|
||||
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 False
|
||||
chunk = data[
|
||||
total_written : total_written + self.packet_size
|
||||
]
|
||||
bytes_written = self.usb_device.write(
|
||||
endpoint,
|
||||
chunk,
|
||||
timeout=self.write_timeout,
|
||||
)
|
||||
if bytes_written != len(chunk):
|
||||
raise OSError(
|
||||
"USB write returned "
|
||||
f"{bytes_written} of {len(chunk)} bytes"
|
||||
)
|
||||
total_written += bytes_written
|
||||
preview = self._format_bytes_preview(data)
|
||||
self._debug(
|
||||
"DECtalk USB wrote "
|
||||
f"{total_written} {description} bytes "
|
||||
f"to endpoint 0x{endpoint:02x}: {preview}",
|
||||
debug.DebugLevel.INFO,
|
||||
on_any_level=True,
|
||||
)
|
||||
return True
|
||||
except (self.usb_core.USBError, OSError) as error:
|
||||
self._debug(
|
||||
f"DECtalk USB write failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
return False
|
||||
|
||||
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)
|
||||
except Exception as error:
|
||||
self._debug(
|
||||
f"DECtalk USB resource disposal failed: {error}",
|
||||
debug.DebugLevel.WARNING,
|
||||
)
|
||||
|
||||
def _load_usb_modules(self):
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
return usb.core, usb.util
|
||||
@@ -27,6 +27,7 @@ class SpeakQueue(Queue):
|
||||
class hardware_serial_driver(speech_driver):
|
||||
cancel_command = b""
|
||||
default_baud_rate = 9600
|
||||
use_xon_xoff = False
|
||||
|
||||
def __init__(self):
|
||||
speech_driver.__init__(self)
|
||||
@@ -37,10 +38,12 @@ class hardware_serial_driver(speech_driver):
|
||||
self.lock = threading.Lock()
|
||||
self.worker_thread = None
|
||||
self._stop_worker = False
|
||||
self._setting_cache = {}
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self._is_initialized = False
|
||||
self._reset_setting_cache()
|
||||
settings_manager = self.env["runtime"]["SettingsManager"]
|
||||
self.device = self._clean_device_setting(
|
||||
settings_manager.get_setting("speech", "hardware_device")
|
||||
@@ -114,21 +117,30 @@ class hardware_serial_driver(speech_driver):
|
||||
return
|
||||
if not isinstance(rate, float):
|
||||
return
|
||||
self._write_bytes(self._rate_command(rate), "rate")
|
||||
self._write_setting_command("rate", 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), "pitch")
|
||||
self._write_setting_command("pitch", 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), "volume")
|
||||
self._write_setting_command("volume", self._volume_command(volume))
|
||||
|
||||
def _reset_setting_cache(self):
|
||||
self._setting_cache = {}
|
||||
|
||||
def _write_setting_command(self, setting, command):
|
||||
if self._setting_cache.get(setting) == command:
|
||||
return
|
||||
if self._write_bytes(command, setting):
|
||||
self._setting_cache[setting] = command
|
||||
|
||||
def _worker(self):
|
||||
while not self._stop_worker:
|
||||
@@ -176,7 +188,11 @@ class hardware_serial_driver(speech_driver):
|
||||
attrs[5] = baud_rate
|
||||
attrs[6][termios.VMIN] = 0
|
||||
attrs[6][termios.VTIME] = 0
|
||||
attrs[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY)
|
||||
if self.use_xon_xoff:
|
||||
attrs[0] |= termios.IXON
|
||||
attrs[0] &= ~(termios.IXOFF | termios.IXANY)
|
||||
else:
|
||||
attrs[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY)
|
||||
termios.tcsetattr(port, termios.TCSANOW, attrs)
|
||||
return port
|
||||
except (OSError, termios.error) as error:
|
||||
@@ -218,10 +234,10 @@ class hardware_serial_driver(speech_driver):
|
||||
|
||||
def _write_bytes(self, data, description="data"):
|
||||
if not data:
|
||||
return
|
||||
return False
|
||||
with self.lock:
|
||||
if self.serial_port is None:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
total_written = 0
|
||||
while total_written < len(data):
|
||||
@@ -238,12 +254,14 @@ class hardware_serial_driver(speech_driver):
|
||||
debug.DebugLevel.INFO,
|
||||
on_any_level=True,
|
||||
)
|
||||
return True
|
||||
except OSError as error:
|
||||
self._debug(
|
||||
f"Hardware speech write failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
on_any_level=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _termios_baud_rate(self, baud_rate):
|
||||
baud_name = f"B{baud_rate}"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
|
||||
def parse_synthesis_voice_line(voice_line):
|
||||
"""Parse one column-aligned spd-say synthesis voice row."""
|
||||
fields = voice_line.rsplit(maxsplit=2)
|
||||
if len(fields) != 3:
|
||||
return None
|
||||
|
||||
name, language, variant = (field.strip() for field in fields)
|
||||
if not name:
|
||||
return None
|
||||
return name, language, variant
|
||||
|
||||
|
||||
def get_synthesis_voice_name(module, voice_line):
|
||||
"""Return the value Speech Dispatcher expects for voice selection."""
|
||||
voice_data = parse_synthesis_voice_line(voice_line)
|
||||
if voice_data is None:
|
||||
return None
|
||||
|
||||
name, language, variant = voice_data
|
||||
if module.lower() != "espeak-ng":
|
||||
return name
|
||||
|
||||
language = language.lower()
|
||||
variant = variant.lower()
|
||||
if not language:
|
||||
return None
|
||||
if variant and variant != "none":
|
||||
return f"{language}+{variant}"
|
||||
return language
|
||||
Reference in New Issue
Block a user