Abstract the TTSBackend type

This commit is contained in:
Kovid Goyal 2024-08-31 10:35:13 +05:30
parent 661474fd33
commit 5945c0d8b1
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 37 additions and 69 deletions

View File

@ -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

View File

@ -2,15 +2,12 @@
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
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()

View File

@ -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()