From 5945c0d8b1451967443db77739c2ca3e9bb7a9c4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 31 Aug 2024 10:35:13 +0530 Subject: [PATCH] Abstract the TTSBackend type --- src/calibre/gui2/tts2/manager.py | 9 ++-- src/calibre/gui2/tts2/qt.py | 21 ++------- src/calibre/gui2/tts2/types.py | 76 ++++++++++++-------------------- 3 files changed, 37 insertions(+), 69 deletions(-) diff --git a/src/calibre/gui2/tts2/manager.py b/src/calibre/gui2/tts2/manager.py index 9caeeac1dc..ab8d5892a5 100644 --- a/src/calibre/gui2/tts2/manager.py +++ b/src/calibre/gui2/tts2/manager.py @@ -4,13 +4,16 @@ from collections import deque from contextlib import contextmanager -from typing import NamedTuple +from typing import NamedTuple, TYPE_CHECKING from qt.core import QApplication, QDialog, QObject, QTextToSpeech, QWidget, pyqtSignal from calibre.gui2 import error_dialog from calibre.gui2.widgets import BusyCursor +if TYPE_CHECKING: + from calibre.gui2.tts2.types import TTSBackend + class Utterance(NamedTuple): text: str @@ -108,12 +111,12 @@ class TTSManager(QObject): def __init__(self, parent=None): super().__init__(parent) - self._tts = None + self._tts: 'TTSBackend' | None = None self.state = QTextToSpeech.State.Ready self.tracker = Tracker() @property - def tts(self): + def tts(self) -> 'TTSBackend': if self._tts is None: with BusyCursor(): from calibre.gui2.tts2.types import create_tts_backend diff --git a/src/calibre/gui2/tts2/qt.py b/src/calibre/gui2/tts2/qt.py index d9dcbee4be..a7321f6c4d 100644 --- a/src/calibre/gui2/tts2/qt.py +++ b/src/calibre/gui2/tts2/qt.py @@ -2,15 +2,12 @@ # License: GPLv3 Copyright: 2024, Kovid Goyal -from qt.core import QMediaDevices, QObject, QTextToSpeech, pyqtSignal +from qt.core import QMediaDevices, QObject, QTextToSpeech -from calibre.gui2.tts2.types import EngineSpecificSettings, Voice, qvoice_to_voice +from calibre.gui2.tts2.types import EngineSpecificSettings, TTSBackend, Voice, qvoice_to_voice -class QtTTSBackend(QObject): - - saying = pyqtSignal(int, int) - state_changed = pyqtSignal(QTextToSpeech.State) +class QtTTSBackend(TTSBackend): def __init__(self, engine_name: str = '', parent: QObject|None = None): super().__init__(parent) @@ -31,18 +28,6 @@ class QtTTSBackend(QObject): def default_output_module(self) -> str: return '' - def change_rate(self, steps: int = 1) -> bool: - current = self.tts.rate() - new_rate = max(-1, min(current + 0.2 * steps, 1)) - if current == new_rate: - return False - self.tts.pause() - self.tts.setRate(new_rate) - self._current_settings = self._current_settings._replace(rate=new_rate) - self._current_settings.save_to_config() - self.tts.resume() - self.tts.stop(QTextToSpeech.BoundaryHint.Immediate) - def pause(self) -> None: self.tts.pause() diff --git a/src/calibre/gui2/tts2/types.py b/src/calibre/gui2/tts2/types.py index f61ea846c5..a444a34bbf 100644 --- a/src/calibre/gui2/tts2/types.py +++ b/src/calibre/gui2/tts2/types.py @@ -7,7 +7,7 @@ from enum import Enum, auto from functools import lru_cache from typing import Literal, NamedTuple -from qt.core import QLocale, QObject, QTextToSpeech, QVoice +from qt.core import QLocale, QObject, QTextToSpeech, QVoice, pyqtSignal from calibre.constants import islinux, ismacos, iswindows from calibre.utils.config import JSONConfig @@ -183,7 +183,33 @@ def default_engine_name() -> str: return 'speechd' -def create_tts_backend(parent: QObject|None = None, force_engine: str | None = None): +class TTSBackend(QObject): + saying = pyqtSignal(int, int) # offset, length + state_changed = pyqtSignal(QTextToSpeech.State) + available_voices: dict[str, tuple[Voice, ...]] = {} + engine_name: str = '' + default_output_module: str = '' + + def __init__(self, engine_name: str = '', parent: QObject|None = None): + super().__init__(parent) + + def pause(self) -> None: + raise NotImplementedError() + + def resume(self) -> None: + raise NotImplementedError() + + def stop(self) -> None: + raise NotImplementedError() + + def say(self, text: str) -> None: + raise NotImplementedError() + + def error_message(self) -> str: + raise NotImplementedError() + + +def create_tts_backend(parent: QObject|None = None, force_engine: str | None = None) -> TTSBackend: prefs = load_config() engine_name = prefs.get('engine', '') if force_engine is None else force_engine engine_name = engine_name or default_engine_name() @@ -201,51 +227,5 @@ def create_tts_backend(parent: QObject|None = None, force_engine: str | None = N return ans -def develop(engine_name=''): - # {{{ - marked_text = [2, 'Demonstration', ' ', 16, 'of', ' ', 19, 'DOCX', ' ', 24, 'support', ' ', 32, 'in', ' ', 35, 'calibre', '\n\t', 44, 'This', ' ', 49, 'document', ' ', 58, 'demonstrates', ' ', 71, 'the', ' ', 75, 'ability', ' ', 83, 'of', ' ', 86, 'the', ' ', 90, 'calibre', ' ', 98, 'DOCX', ' ', 103, 'Input', ' ', 109, 'plugin', ' ', 116, 'to', ' ', 119, 'convert', ' ', 127, 'the', ' ', 131, 'various', ' ', 139, 'typographic', ' ', 151, 'features', ' ', 160, 'in', ' ', 163, 'a', ' ', 165, 'Microsoft', ' ', 175, 'Word', ' ', 180, '(2007', ' ', 186, 'and', ' ', 190, 'newer)', ' ', 197, 'document.', ' ', 207, 'Convert', ' ', 215, 'this', ' ', 220, 'document', ' ', 229, 'to', ' ', 232, 'a', ' ', 234, 'modern', ' ', 241, 'ebook', ' ', 247, 'format,', ' ', 255, 'such', ' ', 260, 'as', ' ', 263, 'AZW3', ' ', 268, 'for', ' ', 272, 'Kindles', ' ', 280, 'or', ' ', 283, 'EPUB', ' ', 288, 'for', ' ', 292, 'other', ' ', 298, 'ebook', ' ', 304, 'readers,', ' ', 313, 'to', ' ', 316, 'see', ' ', 320, 'it', ' ', 323, 'in', ' ', 326, 'action.', '\n\t', 335, 'There', ' ', 341, 'is', ' ', 344, 'support', ' ', 352, 'for', ' ', 356, 'images,', ' ', 364, 'tables,', ' ', 372, 'lists,', ' ', 379, 'footnotes,', ' ', 390, 'endnotes,', ' ', 400, 'links,', ' ', 407, 'dropcaps', ' ', 416, 'and', ' ', 420, 'various', ' ', 428, 'types', ' ', 434, 'of', ' ', 437, 'text', ' ', 442, 'and', ' ', 446, 'paragraph', ' ', 456, 'level', ' ', 462, 'formatting.', '\n\t', 475, 'To', ' ', 478, 'see', ' ', 482, 'the', ' ', 486, 'DOCX', ' ', 491, 'conversion', ' ', 502, 'in', ' ', 505, 'action,', ' ', 513, 'simply', ' ', 520, 'add', ' ', 524, 'this', ' ', 529, 'file', ' ', 534, 'to', ' ', 537, 'calibre', ' ', 545, 'using', ' ', 551, 'the', ' ', 555, '“Add', ' ', 560, 'Books”', ' ', 567, 'button', ' ', 574, 'and', ' ', 578, 'then', ' ', 583, 'click', ' ', 589, '“Convert”.', ' ', 601, 'Set', ' ', 605, 'the', ' ', 609, 'output', ' ', 616, 'format', ' ', 623, 'in', ' ', 626, 'the', ' ', 630, 'top', ' ', 634, 'right', ' ', 640, 'corner', ' ', 647, 'of', ' ', 650, 'the', ' ', 654, 'conversion', ' ', 665, 'dialog', ' ', 672, 'to', ' ', 675, 'EPUB', ' ', 680, 'or', ' ', 683, 'AZW3', ' ', 688, 'and', ' ', 692, 'click', ' ', 698, '“OK”.', '\n\t\xa0\n\t'] # noqa }}} - - from calibre.gui2 import Application - app = Application([]) - app.shutdown_signal_received.connect(lambda: app.exit(1)) - tts = create_tts_backend(force_engine=engine_name) - speech_started = False - - def print_saying(s, e): - bits = [] - in_region = False - for x in marked_text: - if isinstance(x, int): - if in_region: - if x >= e: - break - else: - if x == s: - in_region = True - elif x > e: - break - elif in_region: - bits.append(x) - print('Saying:', repr(''.join(bits))) - - import sys - - def state_changed(state): - nonlocal speech_started - print('State changed:', state) - if state == QTextToSpeech.State.Speaking: - speech_started = True - elif state == QTextToSpeech.State.Error: - print(tts.error_message(), file=sys.stderr) - app.exit(1) - elif state == QTextToSpeech.State.Ready: - if speech_started: - app.quit() - tts.saying.connect(print_saying) - tts.state_changed.connect(state_changed) - tts.speak_marked_text(marked_text) - app.exec() - - if __name__ == '__main__': develop()