Initial attempt at porting over fenrir's hardware synth support. Probably buggy.
This commit is contained in:
@@ -1787,6 +1787,35 @@
|
||||
<property name="top_attach">5</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="hardwareDeviceLabel">
|
||||
<property name="visible">False</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="xalign">1</property>
|
||||
<property name="label" translatable="yes">Serial _device:</property>
|
||||
<property name="use_underline">True</property>
|
||||
<property name="justify">right</property>
|
||||
<property name="mnemonic_widget">hardwareDeviceCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="hardwareDeviceCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="left_attach">0</property>
|
||||
<property name="top_attach">9</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkComboBox" id="hardwareDeviceCombo">
|
||||
<property name="visible">False</property>
|
||||
<property name="can_focus">False</property>
|
||||
<signal name="changed" handler="hardwareDeviceChanged" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="left_attach">1</property>
|
||||
<property name="top_attach">9</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
||||
@@ -168,6 +168,9 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
self.speechFamiliesChoice = None
|
||||
self.speechFamiliesChoices = None
|
||||
self.speechFamiliesModel = None
|
||||
self.hardwareDeviceChoice = None
|
||||
self.hardwareDeviceChoices = None
|
||||
self.hardwareDeviceModel = None
|
||||
self.speechLanguagesChoice = None
|
||||
self.speechLanguagesChoices = None
|
||||
self.speechLanguagesModel = None
|
||||
@@ -405,6 +408,11 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
self._initComboBox(self.get_widget("speechLanguages"))
|
||||
self.speechFamiliesModel = \
|
||||
self._initComboBox(self.get_widget("speechFamilies"))
|
||||
try:
|
||||
self.hardwareDeviceModel = \
|
||||
self._initComboBox(self.get_widget("hardwareDeviceCombo"))
|
||||
except AttributeError:
|
||||
self.hardwareDeviceModel = None
|
||||
self.echoSpeechServersModel = \
|
||||
self._initComboBox(self.get_widget("echoSpeechServers"))
|
||||
self.echoSpeechFamiliesModel = \
|
||||
@@ -1703,6 +1711,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
#
|
||||
self.initializingSpeech = True
|
||||
self._setupSpeechSystems(factories)
|
||||
self._setupHardwareDevice()
|
||||
self._updateHardwareDeviceVisibility()
|
||||
self.initializingSpeech = False
|
||||
|
||||
def _getSpeechDispatcherFactory(self):
|
||||
@@ -3847,6 +3857,118 @@ print(json.dumps(result))
|
||||
self.prefsDict["onlySpeakDisplayedText"] = enable
|
||||
self.get_widget("contextOptionsGrid").set_sensitive(not enable)
|
||||
|
||||
|
||||
def _scanSerialDevices(self):
|
||||
"""Scan for available serial devices and return a list of paths."""
|
||||
import glob
|
||||
devices = []
|
||||
patterns = [
|
||||
"/dev/ttyUSB*",
|
||||
"/dev/ttyACM*",
|
||||
"/dev/ttyS*",
|
||||
"/dev/ttyAMA*",
|
||||
"/dev/rfcomm*",
|
||||
"/dev/serial/by-id/*",
|
||||
]
|
||||
for pattern in patterns:
|
||||
devices.extend(glob.glob(pattern))
|
||||
devices = sorted(set(devices))
|
||||
return devices
|
||||
|
||||
def _setupHardwareDevice(self):
|
||||
"""Sets up the hardware device combo box with available serial ports.
|
||||
|
||||
Populates the combo with scanned serial devices and restores the
|
||||
previously saved selection if still available.
|
||||
"""
|
||||
if self.hardwareDeviceModel is None:
|
||||
return
|
||||
combobox = self.get_widget("hardwareDeviceCombo")
|
||||
combobox.set_model(None)
|
||||
self.hardwareDeviceModel.clear()
|
||||
self.hardwareDeviceChoices = []
|
||||
|
||||
devices = self._scanSerialDevices()
|
||||
saved_device = self.prefsDict.get("hardwareSpeechDevice",
|
||||
settings.hardwareSpeechDevice)
|
||||
|
||||
# Always include a "(none)" option so the user can clear the device
|
||||
self.hardwareDeviceChoices.append("")
|
||||
self.hardwareDeviceModel.append((0, "(none)"))
|
||||
i = 1
|
||||
for device in devices:
|
||||
self.hardwareDeviceChoices.append(device)
|
||||
self.hardwareDeviceModel.append((i, device))
|
||||
i += 1
|
||||
|
||||
# If the saved device is not in the scanned list but is non-empty,
|
||||
# append it so the user still sees their configured device.
|
||||
if saved_device and saved_device not in devices:
|
||||
self.hardwareDeviceChoices.append(saved_device)
|
||||
self.hardwareDeviceModel.append((i, saved_device))
|
||||
i += 1
|
||||
|
||||
combobox.set_model(self.hardwareDeviceModel)
|
||||
self._setHardwareDeviceChoice(saved_device)
|
||||
|
||||
def _setHardwareDeviceChoice(self, device_name):
|
||||
"""Set the active item in the hardware device combo box.
|
||||
|
||||
Arguments:
|
||||
- device_name: the device path to select.
|
||||
"""
|
||||
if not self.hardwareDeviceChoices:
|
||||
self.hardwareDeviceChoice = None
|
||||
return
|
||||
|
||||
for i, choice in enumerate(self.hardwareDeviceChoices):
|
||||
if choice == device_name:
|
||||
self.get_widget("hardwareDeviceCombo").set_active(i)
|
||||
self.hardwareDeviceChoice = choice
|
||||
return
|
||||
|
||||
self.get_widget("hardwareDeviceCombo").set_active(0)
|
||||
self.hardwareDeviceChoice = self.hardwareDeviceChoices[0]
|
||||
|
||||
def _updateHardwareDeviceVisibility(self):
|
||||
"""Show or hide the hardware device combo based on speech system.
|
||||
|
||||
The hardware device selector is only visible when the hardware
|
||||
speech synthesizer factory is active.
|
||||
"""
|
||||
if self.hardwareDeviceModel is None:
|
||||
return
|
||||
is_hardware = False
|
||||
if self.speechSystemsChoice:
|
||||
try:
|
||||
is_hardware = (
|
||||
self.speechSystemsChoice.__name__ == "hardwarefactory"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.get_widget("hardwareDeviceLabel").set_visible(is_hardware)
|
||||
self.get_widget("hardwareDeviceCombo").set_visible(is_hardware)
|
||||
|
||||
def hardwareDeviceChanged(self, widget):
|
||||
"""Signal handler for the hardware device combo box changed signal.
|
||||
|
||||
Arguments:
|
||||
- widget: the component that generated the signal.
|
||||
"""
|
||||
if self.initializingSpeech:
|
||||
return
|
||||
|
||||
selected_index = widget.get_active()
|
||||
if selected_index >= 0 and selected_index < len(self.hardwareDeviceChoices):
|
||||
self.hardwareDeviceChoice = self.hardwareDeviceChoices[selected_index]
|
||||
else:
|
||||
self.hardwareDeviceChoice = None
|
||||
|
||||
# Update runtime settings so the factory sees the new device
|
||||
if self.hardwareDeviceChoice is not None:
|
||||
settings.hardwareSpeechDevice = self.hardwareDeviceChoice
|
||||
|
||||
def speechSystemsChanged(self, widget):
|
||||
"""Signal handler for the "changed" signal for the speechSystems
|
||||
GtkComboBox widget. The user has selected a different speech
|
||||
@@ -3866,6 +3988,7 @@ print(json.dumps(result))
|
||||
self._setupSpeechServers()
|
||||
self._setupEchoSpeechServers()
|
||||
self._setEchoVoiceItems()
|
||||
self._updateHardwareDeviceVisibility()
|
||||
|
||||
def speechServersChanged(self, widget):
|
||||
"""Signal handler for the "changed" signal for the speechServers
|
||||
@@ -4927,6 +5050,16 @@ print(json.dumps(result))
|
||||
self.prefsDict["speechServerFactory"] = \
|
||||
self.speechSystemsChoice.__name__
|
||||
|
||||
# Save hardware speech device setting when hardware factory is active
|
||||
if self.speechSystemsChoice and \
|
||||
self.speechSystemsChoice.__name__ == "hardwarefactory":
|
||||
if self.hardwareDeviceChoice is not None:
|
||||
self.prefsDict["hardwareSpeechDevice"] = self.hardwareDeviceChoice
|
||||
else:
|
||||
self.prefsDict["hardwareSpeechDevice"] = ""
|
||||
else:
|
||||
self.prefsDict["hardwareSpeechDevice"] = settings.hardwareSpeechDevice
|
||||
|
||||
speechServerChoice = self._getSpeechServerChoiceForSave()
|
||||
if speechServerChoice:
|
||||
self.prefsDict["speechServerInfo"] = \
|
||||
|
||||
@@ -870,6 +870,19 @@ SPEECH_DISPATCHER = _("Speech Dispatcher")
|
||||
# Translators: This label refers to the Piper neural text-to-speech system.
|
||||
# (https://github.com/rhasspy/piper)
|
||||
PIPER_TTS = _("Piper Neural TTS")
|
||||
# Translators: This label refers to external hardware serial speech synthesizers.
|
||||
HARDWARE_SPEECH = _("Hardware Speech Synthesizer")
|
||||
# Translators: This label refers to the LiteTalk hardware speech synthesizer.
|
||||
HARDWARE_LITETALK = _("LiteTalk")
|
||||
# Translators: This label refers to the DoubleTalk LT hardware speech synthesizer.
|
||||
HARDWARE_DOUBLETALK = _("DoubleTalk LT")
|
||||
# Translators: This label refers to the TripleTalk hardware speech synthesizer.
|
||||
HARDWARE_TRIPLETALK = _("TripleTalk")
|
||||
# Translators: This label refers to the Dectalk hardware synthesizer.
|
||||
HARDWARE_DECTALK = _("Dectalk")
|
||||
# Translators: This is the label for the combo box that lets the user choose
|
||||
# the serial device used by a hardware speech synthesizer.
|
||||
HARDWARE_SERIAL_DEVICE = _("Serial _device:")
|
||||
|
||||
# Translators: This is a label for a group of options related to Cthulhu's behavior
|
||||
# when presenting an application's spell check dialog.
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024 Stormux
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this library; if not, write to the
|
||||
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
||||
# Boston MA 02110-1301 USA.
|
||||
#
|
||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||
|
||||
"""Provides a Cthulhu speech server for hardware serial synthesizers.
|
||||
|
||||
Ports Fenrir's hardware serial drivers (LiteTalk/DoubleTalk/TripleTalk,
|
||||
Dectalk) to Cthulhu's SpeechServer interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import termios
|
||||
import threading
|
||||
import tty
|
||||
from queue import Empty, Queue
|
||||
|
||||
from . import debug
|
||||
from . import guilabels
|
||||
from . import messages
|
||||
from . import settings
|
||||
from . import speechserver
|
||||
from .acss import ACSS
|
||||
|
||||
|
||||
class _SpeakQueue(Queue):
|
||||
"""Queue with a clear() method."""
|
||||
|
||||
def clear(self):
|
||||
try:
|
||||
while True:
|
||||
self.get_nowait()
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
|
||||
class _HardwareSerialDriver:
|
||||
"""Base class for hardware serial speech synthesizers.
|
||||
|
||||
Ported from Fenrir's hardwareSerialDriver.py.
|
||||
"""
|
||||
|
||||
cancel_command = b""
|
||||
default_baud_rate = 9600
|
||||
|
||||
def __init__(self, device: str, baud_rate: int):
|
||||
self.device = device
|
||||
self.baud_rate = baud_rate
|
||||
self.serial_port: int | None = None
|
||||
self.text_queue = _SpeakQueue()
|
||||
self.lock = threading.Lock()
|
||||
self.worker_thread: threading.Thread | None = None
|
||||
self._stop_worker = False
|
||||
self._is_initialized = False
|
||||
|
||||
def initialize(self) -> bool:
|
||||
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()
|
||||
return self._is_initialized
|
||||
|
||||
def shutdown(self) -> None:
|
||||
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: str, interrupt: bool = True) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
if interrupt:
|
||||
self.stop()
|
||||
if not isinstance(text, str) or text == "":
|
||||
return
|
||||
self.text_queue.put(text)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self.clear_buffer()
|
||||
if self.cancel_command:
|
||||
self._write_bytes(self.cancel_command, "cancel")
|
||||
|
||||
def clear_buffer(self) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self.text_queue.clear()
|
||||
|
||||
def set_rate(self, rate: float) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self._write_bytes(self._rate_command(rate), "rate")
|
||||
|
||||
def set_pitch(self, pitch: float) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self._write_bytes(self._pitch_command(pitch), "pitch")
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
if not self._is_initialized:
|
||||
return
|
||||
self._write_bytes(self._volume_command(volume), "volume")
|
||||
|
||||
def _worker(self) -> None:
|
||||
while not self._stop_worker:
|
||||
text = self.text_queue.get()
|
||||
if text is None:
|
||||
return
|
||||
try:
|
||||
data = self._speak_bytes(text)
|
||||
self._write_bytes(data, "speech")
|
||||
except Exception as error:
|
||||
msg = f"HARDWARE SPEECH: worker failed: {error}"
|
||||
debug.printMessage(debug.LEVEL_ERROR, msg, True)
|
||||
|
||||
def _open_serial_port(self) -> None:
|
||||
if not self.device or self.device == "auto":
|
||||
msg = "HARDWARE SPEECH: requires an explicit serial device"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return
|
||||
|
||||
port = self._open_configured_serial_port(self.device)
|
||||
if port is not None:
|
||||
self._activate_serial_port(self.device, port)
|
||||
|
||||
def _open_configured_serial_port(self, device: str) -> int | None:
|
||||
port = None
|
||||
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)
|
||||
return port
|
||||
except (OSError, termios.error) as error:
|
||||
self._close_port(port)
|
||||
msg = f"HARDWARE SPEECH: device open failed: {device}: {error}"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return None
|
||||
|
||||
def _activate_serial_port(self, device: str, port: int) -> None:
|
||||
self.serial_port = port
|
||||
self.device = device
|
||||
msg = f"HARDWARE SPEECH: device opened: {device}, baud_rate={self.baud_rate}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
|
||||
def _close_serial_port(self) -> None:
|
||||
with self.lock:
|
||||
if self.serial_port is None:
|
||||
return
|
||||
self._close_port(self.serial_port)
|
||||
self.serial_port = None
|
||||
|
||||
def _close_port(self, port: int | None) -> None:
|
||||
if port is None:
|
||||
return
|
||||
try:
|
||||
os.close(port)
|
||||
except OSError as error:
|
||||
msg = f"HARDWARE SPEECH: device close failed: {error}"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
|
||||
def _write_bytes(self, data: bytes, description: str = "data") -> None:
|
||||
if not data:
|
||||
return
|
||||
with self.lock:
|
||||
if self.serial_port is None:
|
||||
return
|
||||
try:
|
||||
total_written = 0
|
||||
while total_written < len(data):
|
||||
bytes_written = os.write(self.serial_port, data[total_written:])
|
||||
if bytes_written == 0:
|
||||
raise OSError("serial write returned 0 bytes")
|
||||
total_written += bytes_written
|
||||
preview = self._format_bytes_preview(data)
|
||||
msg = f"HARDWARE SPEECH: wrote {total_written} {description} bytes: {preview}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
except OSError as error:
|
||||
msg = f"HARDWARE SPEECH: write failed: {error}"
|
||||
debug.printMessage(debug.LEVEL_ERROR, msg, True)
|
||||
|
||||
def _termios_baud_rate(self, baud_rate: int) -> int:
|
||||
baud_name = f"B{baud_rate}"
|
||||
if hasattr(termios, baud_name):
|
||||
return getattr(termios, baud_name)
|
||||
msg = f"HARDWARE SPEECH: unsupported baud rate {baud_rate}; using 9600"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return termios.B9600
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(text: str) -> str:
|
||||
text = text.replace("\r", " ").replace("\n", " ")
|
||||
return "".join(char if 0x20 <= ord(char) <= 0x7E else " " for char in text)
|
||||
|
||||
@staticmethod
|
||||
def _scale(value: float, minimum: int, maximum: int) -> int:
|
||||
value = max(0.0, min(1.0, value))
|
||||
return int(round(minimum + value * (maximum - minimum)))
|
||||
|
||||
@staticmethod
|
||||
def _format_bytes_preview(data: bytes, limit: int = 32) -> str:
|
||||
preview = data[:limit]
|
||||
hex_preview = " ".join(f"{byte:02x}" for byte in preview)
|
||||
ascii_preview = "".join(
|
||||
chr(byte) if 0x20 <= byte <= 0x7E else "." for byte in preview
|
||||
)
|
||||
suffix = "" if len(data) <= limit else " ..."
|
||||
return f"hex=[{hex_preview}{suffix}] ascii=[{ascii_preview}{suffix}]"
|
||||
|
||||
def _speak_bytes(self, text: str) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
def _rate_command(self, rate: float) -> bytes:
|
||||
return b""
|
||||
|
||||
def _pitch_command(self, pitch: float) -> bytes:
|
||||
return b""
|
||||
|
||||
def _volume_command(self, volume: float) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class _LiteTalkDriver(_HardwareSerialDriver):
|
||||
"""LiteTalk-compatible serial driver."""
|
||||
|
||||
cancel_command = b"\x18"
|
||||
|
||||
def _speak_bytes(self, text: str) -> bytes:
|
||||
return self._clean_text(text).encode("ascii", errors="replace") + b"\r"
|
||||
|
||||
def _rate_command(self, rate: float) -> bytes:
|
||||
return self._setting_command(self._scale(rate, 0, 9), b"S")
|
||||
|
||||
def _pitch_command(self, pitch: float) -> bytes:
|
||||
return self._setting_command(self._scale(pitch, 0, 99), b"P")
|
||||
|
||||
def _volume_command(self, volume: float) -> bytes:
|
||||
return self._setting_command(self._scale(volume, 0, 9), b"V")
|
||||
|
||||
@staticmethod
|
||||
def _setting_command(value: int, command: bytes) -> bytes:
|
||||
return b"\x01" + str(value).encode("ascii") + command
|
||||
|
||||
|
||||
class _DectalkDriver(_HardwareSerialDriver):
|
||||
"""Dectalk serial driver."""
|
||||
|
||||
cancel_command = b"\x18"
|
||||
|
||||
def _speak_bytes(self, text: str) -> bytes:
|
||||
return self._clean_text(text).encode("ascii", errors="replace") + b"\x01"
|
||||
|
||||
def _rate_command(self, rate: float) -> bytes:
|
||||
return self._setting_command("ra", self._scale(rate, 75, 650))
|
||||
|
||||
def _pitch_command(self, pitch: float) -> bytes:
|
||||
return self._setting_command("dv ap", self._scale(pitch, 50, 180))
|
||||
|
||||
def _volume_command(self, volume: float) -> bytes:
|
||||
return self._setting_command("vo", self._scale(volume, 0, 100))
|
||||
|
||||
@staticmethod
|
||||
def _setting_command(command: str, value: int) -> bytes:
|
||||
return f"[:{command} {value}]".encode("ascii")
|
||||
|
||||
|
||||
_DRIVER_MAP: dict[str, type[_HardwareSerialDriver]] = {
|
||||
"litetalk": _LiteTalkDriver,
|
||||
"doubletalk": _LiteTalkDriver,
|
||||
"tripletalk": _LiteTalkDriver,
|
||||
"dectalk": _DectalkDriver,
|
||||
}
|
||||
|
||||
_SYNTH_DISPLAY_NAMES = {
|
||||
"litetalk": guilabels.HARDWARE_LITETALK,
|
||||
"doubletalk": guilabels.HARDWARE_DOUBLETALK,
|
||||
"tripletalk": guilabels.HARDWARE_TRIPLETALK,
|
||||
"dectalk": guilabels.HARDWARE_DECTALK,
|
||||
}
|
||||
|
||||
|
||||
class SpeechServer(speechserver.SpeechServer):
|
||||
"""Hardware serial speech server implementation for Cthulhu."""
|
||||
|
||||
_active_servers: dict[str, SpeechServer] = {}
|
||||
|
||||
@staticmethod
|
||||
def getFactoryName() -> str:
|
||||
"""Returns a localized name describing this factory."""
|
||||
return guilabels.HARDWARE_SPEECH
|
||||
|
||||
@staticmethod
|
||||
def getSpeechServers() -> list[SpeechServer]:
|
||||
"""Gets available speech servers as a list."""
|
||||
return [
|
||||
SpeechServer(server_id, initialize=False, register=False)
|
||||
for server_id in _DRIVER_MAP
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _getSpeechServer(cls, server_id: str) -> SpeechServer | None:
|
||||
"""Return an active server for the given id."""
|
||||
active_server = cls._active_servers.get(server_id)
|
||||
if active_server is not None:
|
||||
if active_server._matches_current_settings():
|
||||
return active_server
|
||||
active_server.shutdown()
|
||||
|
||||
cls(server_id)
|
||||
return cls._active_servers.get(server_id)
|
||||
|
||||
@staticmethod
|
||||
def getSpeechServer(info: list[str] | None = None) -> SpeechServer | None:
|
||||
"""Gets a given SpeechServer based upon the info."""
|
||||
if info and len(info) >= 2:
|
||||
server_id = info[1]
|
||||
else:
|
||||
server_id = "litetalk"
|
||||
return SpeechServer._getSpeechServer(server_id)
|
||||
|
||||
@staticmethod
|
||||
def shutdownActiveServers() -> None:
|
||||
"""Cleans up and shuts down this factory."""
|
||||
servers = list(SpeechServer._active_servers.values())
|
||||
for server in servers:
|
||||
server.shutdown()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_id: str,
|
||||
initialize: bool = True,
|
||||
register: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self._id = server_id
|
||||
self._driver: _HardwareSerialDriver | None = None
|
||||
self._info: list[str] = []
|
||||
self._device = ""
|
||||
self._baud_rate = settings.hardwareSpeechBaudRate
|
||||
|
||||
driver_class = _DRIVER_MAP.get(server_id)
|
||||
if driver_class is None:
|
||||
msg = f"HARDWARE SPEECH: unknown synth type: {server_id}"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return
|
||||
|
||||
display_name = _SYNTH_DISPLAY_NAMES.get(server_id, server_id)
|
||||
self._info = [display_name, server_id]
|
||||
|
||||
if not initialize:
|
||||
return
|
||||
|
||||
self._device = settings.hardwareSpeechDevice
|
||||
self._baud_rate = settings.hardwareSpeechBaudRate
|
||||
self._driver = driver_class(self._device, self._baud_rate)
|
||||
if self._driver.initialize():
|
||||
if register:
|
||||
SpeechServer._active_servers[server_id] = self
|
||||
msg = f"HARDWARE SPEECH: server initialized: {server_id} on {self._device}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
else:
|
||||
msg = f"HARDWARE SPEECH: server initialization failed: {server_id}"
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
self._driver = None
|
||||
|
||||
def _matches_current_settings(self) -> bool:
|
||||
return (
|
||||
self._driver is not None
|
||||
and self._device == settings.hardwareSpeechDevice
|
||||
and self._baud_rate == settings.hardwareSpeechBaudRate
|
||||
)
|
||||
|
||||
def getInfo(self) -> list[str]:
|
||||
"""Returns [name, id]."""
|
||||
return self._info
|
||||
|
||||
def getVoiceFamilies(self) -> list[dict[str, str]]:
|
||||
"""Returns a list of VoiceFamily instances."""
|
||||
return []
|
||||
|
||||
def speakCharacter(self, character: str, acss: dict | None = None) -> None:
|
||||
"""Speaks a single character immediately."""
|
||||
if self._driver:
|
||||
self._apply_acss(acss)
|
||||
self._driver.speak(character, interrupt=True)
|
||||
|
||||
def speakKeyEvent(self, event, acss: dict | None = None) -> None:
|
||||
"""Speaks a key event immediately."""
|
||||
event_string = event.getKeyName()
|
||||
locking_state_string = event.getLockingStateString()
|
||||
text = f"{event_string} {locking_state_string}".strip()
|
||||
self.speak(text, acss=acss)
|
||||
|
||||
def speak(
|
||||
self,
|
||||
text: str | None = None,
|
||||
acss: dict | None = None,
|
||||
interrupt: bool = True,
|
||||
) -> None:
|
||||
"""Speaks all queued text immediately."""
|
||||
if not self._driver or text is None:
|
||||
return
|
||||
self._apply_acss(acss)
|
||||
self._driver.speak(text, interrupt=interrupt)
|
||||
|
||||
def sayAll(self, utteranceIterator, progressCallback) -> None:
|
||||
"""Iterates through the given utteranceIterator, speaking each utterance."""
|
||||
for context, acss in utteranceIterator:
|
||||
self.speak(context.utterance, acss=acss, interrupt=False)
|
||||
|
||||
def increaseSpeechRate(self, step: int = 5) -> None:
|
||||
self._change_default_speech_rate(step)
|
||||
|
||||
def decreaseSpeechRate(self, step: int = 5) -> None:
|
||||
self._change_default_speech_rate(step, decrease=True)
|
||||
|
||||
def increaseSpeechPitch(self, step: float = 0.5) -> None:
|
||||
self._change_default_speech_pitch(step)
|
||||
|
||||
def decreaseSpeechPitch(self, step: float = 0.5) -> None:
|
||||
self._change_default_speech_pitch(step, decrease=True)
|
||||
|
||||
def increaseSpeechVolume(self, step: float = 0.5) -> None:
|
||||
self._change_default_speech_volume(step)
|
||||
|
||||
def decreaseSpeechVolume(self, step: float = 0.5) -> None:
|
||||
self._change_default_speech_volume(step, decrease=True)
|
||||
|
||||
def updateCapitalizationStyle(self) -> None:
|
||||
pass
|
||||
|
||||
def updatePunctuationLevel(self) -> None:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._driver:
|
||||
self._driver.stop()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._driver:
|
||||
self._driver.shutdown()
|
||||
self._driver = None
|
||||
if self._id in SpeechServer._active_servers:
|
||||
del SpeechServer._active_servers[self._id]
|
||||
|
||||
def reset(self, text: str | None = None, acss: dict | None = None) -> None:
|
||||
if self._driver:
|
||||
self._driver.shutdown()
|
||||
self._driver = None
|
||||
|
||||
driver_class = _DRIVER_MAP.get(self._id)
|
||||
if driver_class is None:
|
||||
return
|
||||
|
||||
self._device = settings.hardwareSpeechDevice
|
||||
self._baud_rate = settings.hardwareSpeechBaudRate
|
||||
self._driver = driver_class(self._device, self._baud_rate)
|
||||
if not self._driver.initialize():
|
||||
self._driver = None
|
||||
|
||||
def _apply_acss(self, acss: dict | None) -> None:
|
||||
if not self._driver or not acss:
|
||||
return
|
||||
try:
|
||||
rate = acss.get(ACSS.RATE)
|
||||
if rate is not None:
|
||||
normalized = max(0.0, min(99.0, float(rate))) / 99.0
|
||||
self._driver.set_rate(normalized)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pitch = acss.get(ACSS.AVERAGE_PITCH)
|
||||
if pitch is not None:
|
||||
normalized = max(0.0, min(9.0, float(pitch))) / 9.0
|
||||
self._driver.set_pitch(normalized)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
volume = acss.get(ACSS.GAIN)
|
||||
if volume is not None:
|
||||
normalized = max(0.0, min(9.0, float(volume))) / 9.0
|
||||
self._driver.set_volume(normalized)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _change_default_speech_rate(self, step: float, decrease: bool = False) -> None:
|
||||
acss = settings.voices[settings.DEFAULT_VOICE]
|
||||
delta = step * (-1 if decrease else 1)
|
||||
try:
|
||||
rate = acss[ACSS.RATE]
|
||||
except KeyError:
|
||||
rate = 50.0
|
||||
acss[ACSS.RATE] = max(0, min(99, rate + delta))
|
||||
msg = f"HARDWARE SPEECH: rate set to {acss[ACSS.RATE]}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
if self._driver:
|
||||
normalized = acss[ACSS.RATE] / 99.0
|
||||
self._driver.set_rate(normalized)
|
||||
self.speak(
|
||||
messages.SPEECH_SLOWER if decrease else messages.SPEECH_FASTER,
|
||||
acss=acss
|
||||
)
|
||||
|
||||
def _change_default_speech_pitch(self, step: float, decrease: bool = False) -> None:
|
||||
acss = settings.voices[settings.DEFAULT_VOICE]
|
||||
delta = step * (-1 if decrease else 1)
|
||||
try:
|
||||
pitch = acss[ACSS.AVERAGE_PITCH]
|
||||
except KeyError:
|
||||
pitch = 5.0
|
||||
acss[ACSS.AVERAGE_PITCH] = max(0, min(9, pitch + delta))
|
||||
msg = f"HARDWARE SPEECH: pitch set to {acss[ACSS.AVERAGE_PITCH]}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
if self._driver:
|
||||
normalized = acss[ACSS.AVERAGE_PITCH] / 9.0
|
||||
self._driver.set_pitch(normalized)
|
||||
self.speak(
|
||||
messages.SPEECH_LOWER if decrease else messages.SPEECH_HIGHER,
|
||||
acss=acss
|
||||
)
|
||||
|
||||
def _change_default_speech_volume(self, step: float, decrease: bool = False) -> None:
|
||||
acss = settings.voices[settings.DEFAULT_VOICE]
|
||||
delta = step * (-1 if decrease else 1)
|
||||
try:
|
||||
volume = acss[ACSS.GAIN]
|
||||
except KeyError:
|
||||
volume = 10.0
|
||||
acss[ACSS.GAIN] = max(0, min(9, volume + delta))
|
||||
msg = f"HARDWARE SPEECH: volume set to {acss[ACSS.GAIN]}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
if self._driver:
|
||||
normalized = acss[ACSS.GAIN] / 9.0
|
||||
self._driver.set_volume(normalized)
|
||||
self.speak(
|
||||
messages.SPEECH_SOFTER if decrease else messages.SPEECH_LOUDER,
|
||||
acss=acss
|
||||
)
|
||||
@@ -101,6 +101,7 @@ cthulhu_python_sources = files([
|
||||
'speech.py',
|
||||
'spellcheck.py',
|
||||
'speechdispatcherfactory.py',
|
||||
'hardwarefactory.py',
|
||||
'speech_generator.py',
|
||||
'speechserver.py',
|
||||
'piperfactory.py',
|
||||
|
||||
@@ -300,19 +300,21 @@ class SpeechServer(speechserver.SpeechServer):
|
||||
return voiceInfo.sampleRate if voiceInfo else None
|
||||
|
||||
def _mapRate(self, acssRate):
|
||||
"""Map ACSS rate (0-99) to Piper length_scale.
|
||||
"""Map ACSS rate (0-100) to Piper length_scale.
|
||||
|
||||
ACSS rate 50 (default) = length_scale 1.0
|
||||
Higher ACSS rate = lower length_scale (faster)
|
||||
Lower ACSS rate = higher length_scale (slower)
|
||||
|
||||
Arguments:
|
||||
- acssRate: Rate value from 0-99
|
||||
- acssRate: Rate value from 0-100
|
||||
"""
|
||||
rate = acssRate if acssRate is not None else 50
|
||||
rate = max(0, min(99, rate))
|
||||
lengthScale = 2.0 - (rate / 99.0) * 1.5
|
||||
return max(0.5, min(2.0, lengthScale))
|
||||
rate = max(0.0, min(100.0, float(rate)))
|
||||
if rate <= 50.0:
|
||||
return 2.0 - (rate / 50.0)
|
||||
|
||||
return 1.0 - ((rate - 50.0) / 50.0) * 0.75
|
||||
|
||||
def _mapPitch(self, acssPitch):
|
||||
"""Map ACSS pitch (0-9) to pitch adjustment factor.
|
||||
@@ -614,7 +616,7 @@ class SpeechServer(speechserver.SpeechServer):
|
||||
rate = acss[ACSS.RATE]
|
||||
except KeyError:
|
||||
rate = 50
|
||||
acss[ACSS.RATE] = max(0, min(99, rate + delta))
|
||||
acss[ACSS.RATE] = max(0, min(100, rate + delta))
|
||||
msg = f"PIPER: Rate set to {acss[ACSS.RATE]}"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
self.speak(
|
||||
|
||||
@@ -42,6 +42,8 @@ userCustomizableSettings = [
|
||||
"onlySpeakDisplayedText",
|
||||
"speechServerFactory",
|
||||
"speechServerInfo",
|
||||
"hardwareSpeechDevice",
|
||||
"hardwareSpeechBaudRate",
|
||||
"voices",
|
||||
"speechVerbosityLevel",
|
||||
"readFullRowInGUITable",
|
||||
@@ -265,9 +267,11 @@ activeProfile = ['Default', 'default']
|
||||
profile = ['Default', 'default']
|
||||
|
||||
# Speech
|
||||
speechFactoryModules = ["speechdispatcherfactory", "piperfactory"]
|
||||
speechFactoryModules = ["speechdispatcherfactory", "piperfactory", "hardwarefactory"]
|
||||
speechServerFactory = "speechdispatcherfactory"
|
||||
speechServerInfo = None # None means let the factory decide.
|
||||
hardwareSpeechDevice = ""
|
||||
hardwareSpeechBaudRate = 9600
|
||||
enableSpeech = True
|
||||
silenceSpeech = False
|
||||
enableTutorialMessages = False
|
||||
|
||||
Reference in New Issue
Block a user