Workaround for QTextToSpeech crashes if more than one instance is created/destroyed

This commit is contained in:
Kovid Goyal 2024-08-31 13:27:53 +05:30
parent 0fb5249ff0
commit 75d3714b41
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 80 additions and 40 deletions

View File

@ -125,7 +125,7 @@ class Voices(QTreeWidget):
self.system_default_voice = Voice() self.system_default_voice = Voice()
def sizeHint(self) -> QSize: 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: def set_voices(self, all_voices: tuple[Voice, ...], current_voice: str, engine_metadata: EngineMetadata) -> None:
self.clear() self.clear()
@ -176,7 +176,7 @@ class EngineSpecificConfig(QWidget):
l.addRow(_('&Output module:'), om) l.addRow(_('&Output module:'), om)
self.engine_name = '' self.engine_name = ''
om.currentIndexChanged.connect(self.rebuild_voices) om.currentIndexChanged.connect(self.rebuild_voices)
self.engine_instances = {} self.default_output_modules = {}
self.voice_data = {} self.voice_data = {}
self.engine_specific_settings = {} self.engine_specific_settings = {}
self.rate = r = FloatSlider(parent=self) 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_specific_settings[self.engine_name] = self.as_settings()
self.engine_name = engine_name self.engine_name = engine_name
metadata = available_engines()[engine_name] metadata = available_engines()[engine_name]
if engine_name not in self.engine_instances: tts = create_tts_backend(force_engine=engine_name)
self.engine_instances[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.voice_data[engine_name] = tts.available_voices
self.engine_specific_settings[engine_name] = EngineSpecificSettings.create_from_config(engine_name) self.engine_specific_settings[engine_name] = EngineSpecificSettings.create_from_config(engine_name)
else: self.default_output_modules[engine_name] = tts.default_output_module
tts = self.engine_instances[engine_name]
self.output_module.blockSignals(True) self.output_module.blockSignals(True)
self.output_module.clear() self.output_module.clear()
if metadata.has_multiple_output_modules: if metadata.has_multiple_output_modules:
@ -256,7 +255,7 @@ class EngineSpecificConfig(QWidget):
metadata = available_engines()[self.engine_name] metadata = available_engines()[self.engine_name]
output_module = self.output_module.currentData() or '' output_module = self.output_module.currentData() or ''
if metadata.has_multiple_output_modules: 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] all_voices = self.voice_data[self.engine_name][output_module]
self.voices.set_voices(all_voices, s.voice_name, metadata) self.voices.set_voices(all_voices, s.voice_name, metadata)
@ -289,7 +288,12 @@ class ConfigDialog(Dialog):
l.addWidget(ec) l.addWidget(ec)
l.addWidget(esc) l.addWidget(esc)
l.addWidget(self.bb) 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): def accept(self):
s = self.engine_specific_config.as_settings() s = self.engine_specific_config.as_settings()

View File

@ -2,7 +2,7 @@
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net> # License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
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 import Application
from calibre.gui2.main_window import MainWindow from calibre.gui2.main_window import MainWindow
@ -78,7 +78,7 @@ class MainWindow(MainWindow):
self.display.setTextCursor(c) self.display.setTextCursor(c)
else: else:
self.play_action.setChecked(True) 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): def toggled(self):
if self.play_action.isChecked(): if self.play_action.isChecked():
@ -100,6 +100,9 @@ class MainWindow(MainWindow):
c.movePosition(QTextCursor.MoveOperation.WordRight, QTextCursor.MoveMode.KeepAnchor) c.movePosition(QTextCursor.MoveOperation.WordRight, QTextCursor.MoveMode.KeepAnchor)
self.display.setTextCursor(c) self.display.setTextCursor(c)
def sizeHint(self):
return QSize(500, 400)
def main(): def main():
app = Application([]) app = Application([])

View File

@ -4,7 +4,7 @@
from collections import deque from collections import deque
from contextlib import contextmanager 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 from qt.core import QApplication, QDialog, QObject, QTextToSpeech, QWidget, pyqtSignal
@ -104,6 +104,11 @@ class Tracker:
return None return None
class ResumeData:
is_speaking: bool = True
needs_full_resume: bool = False
class TTSManager(QObject): class TTSManager(QObject):
state_changed = pyqtSignal(QTextToSpeech.State) state_changed = pyqtSignal(QTextToSpeech.State)
@ -120,7 +125,7 @@ class TTSManager(QObject):
if self._tts is None: if self._tts is None:
with BusyCursor(): with BusyCursor():
from calibre.gui2.tts2.types import create_tts_backend 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.state_changed.connect(self._state_changed)
self._tts.saying.connect(self._saying) self._tts.saying.connect(self._saying)
return self._tts return self._tts
@ -144,12 +149,13 @@ class TTSManager(QObject):
@contextmanager @contextmanager
def resume_after(self): 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: if self.state is not QTextToSpeech.State.Paused:
self.tts.pause() self.tts.pause()
yield is_speaking yield rd
if is_speaking: if rd.is_speaking:
if self._tts is None: if rd.needs_full_resume:
self.tts.say(self.tracker.resume()) self.tts.say(self.tracker.resume())
else: else:
self.tts.resume() self.tts.resume()
@ -162,11 +168,10 @@ class TTSManager(QObject):
if new_rate != s.rate: if new_rate != s.rate:
s = s._replace(rate=new_rate) s = s._replace(rate=new_rate)
s.save_to_config() s.save_to_config()
with self.resume_after() as is_speaking: with self.resume_after() as rd:
if self._tts is not None: if self._tts is not None:
if is_speaking: rd.needs_full_resume = True
self.tts.stop() self.tts.reload_after_configure()
self._tts = None
return True return True
return False return False
@ -183,13 +188,16 @@ class TTSManager(QObject):
p = self p = self
while p is not None and not isinstance(p, QWidget): while p is not None and not isinstance(p, QWidget):
p = p.parent() p = p.parent()
with self.resume_after() as is_speaking: with self.resume_after() as rd:
d = ConfigDialog(parent=p) d = ConfigDialog(parent=p)
if d.exec() == QDialog.DialogCode.Accepted: if d.exec() == QDialog.DialogCode.Accepted and self._tts is not None:
if self._tts is not None: rd.needs_full_resume = True
if is_speaking: if d.engine_changed:
if rd.is_speaking:
self.tts.stop() self.tts.stop()
self._tts = None self._tts = None
else:
self.tts.reload_after_configure()
def _state_changed(self, state: QTextToSpeech.State) -> None: def _state_changed(self, state: QTextToSpeech.State) -> None:
self.state = state self.state = state

View File

@ -11,8 +11,7 @@ class QtTTSBackend(TTSBackend):
def __init__(self, engine_name: str = '', parent: QObject|None = None): def __init__(self, engine_name: str = '', parent: QObject|None = None):
super().__init__(parent) super().__init__(parent)
self._voices = None self._qt_reload_after_configure(engine_name)
self._create_engine(engine_name)
@property @property
def available_voices(self) -> dict[str, tuple[Voice, ...]]: def available_voices(self) -> dict[str, tuple[Voice, ...]]:
@ -43,8 +42,14 @@ class QtTTSBackend(TTSBackend):
def error_message(self) -> str: def error_message(self) -> str:
return self.tts.errorString() 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 = {} s = {}
self._voices = None
new_backend = not hasattr(self, 'tts')
if engine_name: if engine_name:
settings = EngineSpecificSettings.create_from_config(engine_name) settings = EngineSpecificSettings.create_from_config(engine_name)
if settings.audio_device_id: if settings.audio_device_id:
@ -52,9 +57,15 @@ class QtTTSBackend(TTSBackend):
if bytes(x.id) == settings.audio_device_id.id: if bytes(x.id) == settings.audio_device_id.id:
s['audioDevice'] = x s['audioDevice'] = x
break break
if new_backend:
self.tts = QTextToSpeech(engine_name, s, self) self.tts = QTextToSpeech(engine_name, s, self)
else: else:
self.tts.setEngine(engine_name, s)
else:
if new_backend:
self.tts = QTextToSpeech(self) self.tts = QTextToSpeech(self)
else:
self.tts.setEngine('')
engine_name = self.tts.engine() engine_name = self.tts.engine()
settings = EngineSpecificSettings.create_from_config(engine_name) settings = EngineSpecificSettings.create_from_config(engine_name)
if settings.audio_device_id: if settings.audio_device_id:
@ -63,6 +74,9 @@ class QtTTSBackend(TTSBackend):
s['audioDevice'] = x s['audioDevice'] = x
self.tts = QTextToSpeech(engine_name, s, self) self.tts = QTextToSpeech(engine_name, s, self)
break 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.setRate(max(-1, min(float(settings.rate), 1)))
self.tts.setPitch(max(-1, min(float(settings.pitch), 1))) self.tts.setPitch(max(-1, min(float(settings.pitch), 1)))
@ -73,9 +87,10 @@ class QtTTSBackend(TTSBackend):
if v.name() == settings.voice_name: if v.name() == settings.voice_name:
self.tts.setVoice(v) self.tts.setVoice(v)
break break
self.tts.sayingWord.connect(self._saying_word)
self.tts.stateChanged.connect(self.state_changed.emit)
self._current_settings = settings self._current_settings = settings
def _saying_word(self, word: str, utterance_id: int, start: int, length: int) -> None: def _saying_word(self, word: str, utterance_id: int, start: int, length: int) -> None:
self.saying.emit(start, length) self.saying.emit(start, length)
def _state_changed(self, state: QTextToSpeech.State) -> None:
self.state_changed.emit(state)

View File

@ -7,7 +7,7 @@ from enum import Enum, auto
from functools import lru_cache from functools import lru_cache
from typing import Literal, NamedTuple 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.constants import islinux, ismacos, iswindows
from calibre.utils.config import JSONConfig from calibre.utils.config import JSONConfig
@ -208,20 +208,30 @@ class TTSBackend(QObject):
def error_message(self) -> str: def error_message(self) -> str:
raise NotImplementedError() 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() prefs = load_config()
engine_name = prefs.get('engine', '') if force_engine is None else force_engine engine_name = prefs.get('engine', '') if force_engine is None else force_engine
engine_name = engine_name or default_engine_name() engine_name = engine_name or default_engine_name()
if engine_name not in available_engines(): if engine_name not in available_engines():
engine_name = default_engine_name() engine_name = default_engine_name()
if engine_name == 'speechd': if engine_name == 'speechd':
if engine_name not in engine_instances:
from calibre.gui2.tts2.speechd import SpeechdTTSBackend from calibre.gui2.tts2.speechd import SpeechdTTSBackend
ans = SpeechdTTSBackend(engine_name, parent) engine_instances[engine_name] = SpeechdTTSBackend(engine_name, QApplication.instance())
ans = engine_instances[engine_name]
else: else:
if engine_name not in available_engines(): if 'qt' not in engine_instances:
engine_name = '' # let Qt pick the engine # Bad things happen with more than one QTextToSpeech instance
from calibre.gui2.tts2.qt import QtTTSBackend from calibre.gui2.tts2.qt import QtTTSBackend
ans = QtTTSBackend(engine_name, parent) 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 return ans