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

View File

@ -2,7 +2,7 @@
# 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.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([])

View File

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

View File

@ -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
if new_backend:
self.tts = QTextToSpeech(engine_name, s, self)
else:
self.tts.setEngine(engine_name, s)
else:
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)

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, 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':
if engine_name not in engine_instances:
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:
if engine_name not in available_engines():
engine_name = '' # let Qt pick the engine
if 'qt' not in engine_instances:
# Bad things happen with more than one QTextToSpeech instance
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