diff --git a/src/calibre/gui2/tts2/config.py b/src/calibre/gui2/tts2/config.py index a27fd342d9..afb49bd7bb 100644 --- a/src/calibre/gui2/tts2/config.py +++ b/src/calibre/gui2/tts2/config.py @@ -125,7 +125,7 @@ class Voices(QTreeWidget): self.system_default_voice = Voice() def sizeHint(self) -> QSize: - return QSize(400, 600) + return QSize(400, 500) def set_voices(self, all_voices: tuple[Voice, ...], current_voice: str, engine_metadata: EngineMetadata) -> None: self.clear() @@ -176,7 +176,7 @@ class EngineSpecificConfig(QWidget): l.addRow(_('&Output module:'), om) self.engine_name = '' om.currentIndexChanged.connect(self.rebuild_voices) - self.engine_instances = {} + self.default_output_modules = {} self.voice_data = {} self.engine_specific_settings = {} self.rate = r = FloatSlider(parent=self) @@ -199,12 +199,11 @@ class EngineSpecificConfig(QWidget): self.engine_specific_settings[self.engine_name] = self.as_settings() self.engine_name = engine_name metadata = available_engines()[engine_name] - if engine_name not in self.engine_instances: - self.engine_instances[engine_name] = tts = create_tts_backend(force_engine=engine_name) + tts = create_tts_backend(force_engine=engine_name) + if engine_name not in self.voice_data: self.voice_data[engine_name] = tts.available_voices self.engine_specific_settings[engine_name] = EngineSpecificSettings.create_from_config(engine_name) - else: - tts = self.engine_instances[engine_name] + self.default_output_modules[engine_name] = tts.default_output_module self.output_module.blockSignals(True) self.output_module.clear() if metadata.has_multiple_output_modules: @@ -256,7 +255,7 @@ class EngineSpecificConfig(QWidget): metadata = available_engines()[self.engine_name] output_module = self.output_module.currentData() or '' if metadata.has_multiple_output_modules: - output_module = output_module or self.engine_instances[self.engine_name].default_output_module + output_module = output_module or self.default_output_modules[self.engine_name].default_output_module all_voices = self.voice_data[self.engine_name][output_module] self.voices.set_voices(all_voices, s.voice_name, metadata) @@ -289,7 +288,12 @@ class ConfigDialog(Dialog): l.addWidget(ec) l.addWidget(esc) l.addWidget(self.bb) - esc.set_engine(ec.value) + self.initial_engine_choice = ec.value + esc.set_engine(self.initial_engine_choice) + + @property + def engine_changed(self) -> bool: + return self.engine_choice.value != self.initial_engine_choice def accept(self): s = self.engine_specific_config.as_settings() diff --git a/src/calibre/gui2/tts2/develop.py b/src/calibre/gui2/tts2/develop.py index 14545b3114..3bdd3f9704 100644 --- a/src/calibre/gui2/tts2/develop.py +++ b/src/calibre/gui2/tts2/develop.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2024, Kovid Goyal -from qt.core import QAction, QKeySequence, QPlainTextEdit, Qt, QTextCursor, QTextToSpeech, QToolBar +from qt.core import QAction, QKeySequence, QPlainTextEdit, QSize, Qt, QTextCursor, QTextToSpeech, QToolBar from calibre.gui2 import Application from calibre.gui2.main_window import MainWindow @@ -78,7 +78,7 @@ class MainWindow(MainWindow): self.display.setTextCursor(c) else: self.play_action.setChecked(True) - self.stop_action.setEnabled(state in (QTextToSpeech.State.Speaking, QTextToSpeech.State.Synthesizing)) + self.stop_action.setEnabled(state in (QTextToSpeech.State.Speaking, QTextToSpeech.State.Synthesizing, QTextToSpeech.State.Paused)) def toggled(self): if self.play_action.isChecked(): @@ -100,6 +100,9 @@ class MainWindow(MainWindow): c.movePosition(QTextCursor.MoveOperation.WordRight, QTextCursor.MoveMode.KeepAnchor) self.display.setTextCursor(c) + def sizeHint(self): + return QSize(500, 400) + def main(): app = Application([]) diff --git a/src/calibre/gui2/tts2/manager.py b/src/calibre/gui2/tts2/manager.py index ab8d5892a5..e25d07b145 100644 --- a/src/calibre/gui2/tts2/manager.py +++ b/src/calibre/gui2/tts2/manager.py @@ -4,7 +4,7 @@ from collections import deque from contextlib import contextmanager -from typing import NamedTuple, TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from qt.core import QApplication, QDialog, QObject, QTextToSpeech, QWidget, pyqtSignal @@ -104,6 +104,11 @@ class Tracker: return None +class ResumeData: + is_speaking: bool = True + needs_full_resume: bool = False + + class TTSManager(QObject): state_changed = pyqtSignal(QTextToSpeech.State) @@ -120,7 +125,7 @@ class TTSManager(QObject): if self._tts is None: with BusyCursor(): from calibre.gui2.tts2.types import create_tts_backend - self._tts = create_tts_backend(parent=self) + self._tts = create_tts_backend() self._tts.state_changed.connect(self._state_changed) self._tts.saying.connect(self._saying) return self._tts @@ -144,12 +149,13 @@ class TTSManager(QObject): @contextmanager def resume_after(self): - is_speaking = self._tts is not None and self.state in (QTextToSpeech.State.Speaking, QTextToSpeech.State.Synthesizing, QTextToSpeech.State.Paused) + rd = ResumeData() + rd.is_speaking = self._tts is not None and self.state in (QTextToSpeech.State.Speaking, QTextToSpeech.State.Synthesizing, QTextToSpeech.State.Paused) if self.state is not QTextToSpeech.State.Paused: self.tts.pause() - yield is_speaking - if is_speaking: - if self._tts is None: + yield rd + if rd.is_speaking: + if rd.needs_full_resume: self.tts.say(self.tracker.resume()) else: self.tts.resume() @@ -162,11 +168,10 @@ class TTSManager(QObject): if new_rate != s.rate: s = s._replace(rate=new_rate) s.save_to_config() - with self.resume_after() as is_speaking: + with self.resume_after() as rd: if self._tts is not None: - if is_speaking: - self.tts.stop() - self._tts = None + rd.needs_full_resume = True + self.tts.reload_after_configure() return True return False @@ -183,13 +188,16 @@ class TTSManager(QObject): p = self while p is not None and not isinstance(p, QWidget): p = p.parent() - with self.resume_after() as is_speaking: + with self.resume_after() as rd: d = ConfigDialog(parent=p) - if d.exec() == QDialog.DialogCode.Accepted: - if self._tts is not None: - if is_speaking: + if d.exec() == QDialog.DialogCode.Accepted and self._tts is not None: + rd.needs_full_resume = True + if d.engine_changed: + if rd.is_speaking: self.tts.stop() self._tts = None + else: + self.tts.reload_after_configure() def _state_changed(self, state: QTextToSpeech.State) -> None: self.state = state diff --git a/src/calibre/gui2/tts2/qt.py b/src/calibre/gui2/tts2/qt.py index a7321f6c4d..f96aba9956 100644 --- a/src/calibre/gui2/tts2/qt.py +++ b/src/calibre/gui2/tts2/qt.py @@ -11,8 +11,7 @@ class QtTTSBackend(TTSBackend): def __init__(self, engine_name: str = '', parent: QObject|None = None): super().__init__(parent) - self._voices = None - self._create_engine(engine_name) + self._qt_reload_after_configure(engine_name) @property def available_voices(self) -> dict[str, tuple[Voice, ...]]: @@ -43,8 +42,14 @@ class QtTTSBackend(TTSBackend): def error_message(self) -> str: return self.tts.errorString() - def _create_engine(self, engine_name: str) -> None: + def reload_after_configure(self) -> None: + self._qt_reload_after_configure(self.engine_name) + + def _qt_reload_after_configure(self, engine_name: str) -> None: + # Bad things happen with more than one QTextToSpeech instance s = {} + self._voices = None + new_backend = not hasattr(self, 'tts') if engine_name: settings = EngineSpecificSettings.create_from_config(engine_name) if settings.audio_device_id: @@ -52,9 +57,15 @@ class QtTTSBackend(TTSBackend): if bytes(x.id) == settings.audio_device_id.id: s['audioDevice'] = x break - self.tts = QTextToSpeech(engine_name, s, self) + if new_backend: + self.tts = QTextToSpeech(engine_name, s, self) + else: + self.tts.setEngine(engine_name, s) else: - self.tts = QTextToSpeech(self) + if new_backend: + self.tts = QTextToSpeech(self) + else: + self.tts.setEngine('') engine_name = self.tts.engine() settings = EngineSpecificSettings.create_from_config(engine_name) if settings.audio_device_id: @@ -63,6 +74,9 @@ class QtTTSBackend(TTSBackend): s['audioDevice'] = x self.tts = QTextToSpeech(engine_name, s, self) break + if new_backend: + self.tts.sayingWord.connect(self._saying_word) + self.tts.stateChanged.connect(self._state_changed) self.tts.setRate(max(-1, min(float(settings.rate), 1))) self.tts.setPitch(max(-1, min(float(settings.pitch), 1))) @@ -73,9 +87,10 @@ class QtTTSBackend(TTSBackend): if v.name() == settings.voice_name: self.tts.setVoice(v) break - self.tts.sayingWord.connect(self._saying_word) - self.tts.stateChanged.connect(self.state_changed.emit) self._current_settings = settings def _saying_word(self, word: str, utterance_id: int, start: int, length: int) -> None: self.saying.emit(start, length) + + def _state_changed(self, state: QTextToSpeech.State) -> None: + self.state_changed.emit(state) diff --git a/src/calibre/gui2/tts2/types.py b/src/calibre/gui2/tts2/types.py index ef564d31be..da2fd54130 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, pyqtSignal +from qt.core import QApplication, QLocale, QObject, QTextToSpeech, QVoice, pyqtSignal from calibre.constants import islinux, ismacos, iswindows from calibre.utils.config import JSONConfig @@ -208,20 +208,30 @@ class TTSBackend(QObject): def error_message(self) -> str: raise NotImplementedError() + def reload_after_configure(self) -> str: + raise NotImplementedError() -def create_tts_backend(parent: QObject|None = None, force_engine: str | None = None) -> TTSBackend: + +engine_instances: dict[str, TTSBackend] = {} + + +def create_tts_backend(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() if engine_name not in available_engines(): engine_name = default_engine_name() - if engine_name == 'speechd': - from calibre.gui2.tts2.speechd import SpeechdTTSBackend - ans = SpeechdTTSBackend(engine_name, parent) + if engine_name not in engine_instances: + from calibre.gui2.tts2.speechd import SpeechdTTSBackend + engine_instances[engine_name] = SpeechdTTSBackend(engine_name, QApplication.instance()) + ans = engine_instances[engine_name] else: - if engine_name not in available_engines(): - engine_name = '' # let Qt pick the engine - from calibre.gui2.tts2.qt import QtTTSBackend - ans = QtTTSBackend(engine_name, parent) + if 'qt' not in engine_instances: + # Bad things happen with more than one QTextToSpeech instance + from calibre.gui2.tts2.qt import QtTTSBackend + engine_instances['qt'] = QtTTSBackend(engine_name if engine_name in available_engines() else '', QApplication.instance()) + ans = engine_instances['qt'] + if ans.engine_name != engine_name: + ans._qt_reload_after_configure(engine_name if engine_name in available_engines() else '') return ans