Refactor TTS clients to dispatch events on main thread

Gets rid of pending events and allows for state tracking even without
callbacks
This commit is contained in:
Kovid Goyal 2020-11-22 13:49:44 +05:30
parent 7ebfb0f248
commit a8cf85ca9b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 89 additions and 77 deletions

View File

@ -43,13 +43,14 @@ def add_markup(text):
class TTSWidget(QWidget): class TTSWidget(QWidget):
events_available = pyqtSignal() dispatch_on_main_thread_signal = pyqtSignal(object)
mark_changed = pyqtSignal(object) mark_changed = pyqtSignal(object)
def __init__(self, parent=None): def __init__(self, parent=None):
QWidget.__init__(self, parent) QWidget.__init__(self, parent)
self.mark_changed.connect(self.on_mark_change) self.mark_changed.connect(self.on_mark_change)
self.tts = Client() self.dispatch_on_main_thread_signal.connect(self.dispatch_on_main_thread, type=Qt.QueuedConnection)
self.tts = Client(self.dispatch_on_main_thread_signal.emit)
self.l = l = QVBoxLayout(self) self.l = l = QVBoxLayout(self)
self.la = la = QLabel(self) self.la = la = QLabel(self)
la.setTextFormat(Qt.RichText) la.setTextFormat(Qt.RichText)
@ -86,7 +87,6 @@ example, which of.
l.addWidget(bb) l.addWidget(bb)
b.clicked.connect(self.play_clicked) b.clicked.connect(self.play_clicked)
self.render_text() self.render_text()
self.events_available.connect(self.handle_events, type=Qt.QueuedConnection)
def render_text(self): def render_text(self):
text = self.text text = self.text
@ -103,16 +103,22 @@ example, which of.
self.la.setText('\n'.join(lines)) self.la.setText('\n'.join(lines))
def play_clicked(self): def play_clicked(self):
self.tts.speak_marked_text(self.ssml, self.events_available.emit) self.tts.speak_marked_text(self.ssml, self.handle_event)
def handle_events(self): def dispatch_on_main_thread(self, func):
for event in self.tts.get_events(): try:
if event.type is EventType.mark: func()
try: except Exception:
mark = int(event.data) import traceback
except Exception: traceback.print_exc()
return
self.mark_changed.emit(mark) def handle_event(self, event):
if event.type is EventType.mark:
try:
mark = int(event.data)
except Exception:
return
self.mark_changed.emit(mark)
def on_mark_change(self, mark): def on_mark_change(self, mark):
self.current_mark = mark self.current_mark = mark
@ -126,7 +132,7 @@ def main():
w.setCentralWidget(tts) w.setCentralWidget(tts)
w.show() w.show()
app.exec_() app.exec_()
tts.events_available.disconnect() tts.dispatch_on_main_thread_signal.disconnect()
tts.mark_changed.disconnect() tts.mark_changed.disconnect()
tts.tts.shutdown() tts.tts.shutdown()

View File

@ -2,6 +2,8 @@
# vim:fileencoding=utf-8 # vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from functools import partial
from calibre import prepare_string_for_xml from calibre import prepare_string_for_xml
from .common import Event, EventType from .common import Event, EventType
@ -16,10 +18,10 @@ class Client:
def escape_marked_text(cls, text): def escape_marked_text(cls, text):
return prepare_string_for_xml(text) return prepare_string_for_xml(text)
def __init__(self): def __init__(self, dispatch_on_main_thread):
self.create_ssip_client() self.create_ssip_client()
self.pending_events = []
self.status = {'synthesizing': False, 'paused': False} self.status = {'synthesizing': False, 'paused': False}
self.dispatch_on_main_thread = dispatch_on_main_thread
def create_ssip_client(self): def create_ssip_client(self):
from speechd.client import SpawnError, SSIPClient from speechd.client import SpawnError, SSIPClient
@ -46,8 +48,11 @@ class Client:
def speak_simple_text(self, text): def speak_simple_text(self, text):
self.set_use_ssml(False) self.set_use_ssml(False)
self.pending_events = []
self.ssip_client.speak(text, self.update_status) def callback(callback_type, index_mark=None):
self.dispatch_on_main_thread(partial(self.update_status, callback_type, index_mark))
self.ssip_client.speak(text, callback)
def update_status(self, callback_type, index_mark=None): def update_status(self, callback_type, index_mark=None):
from speechd.client import CallbackType from speechd.client import CallbackType
@ -62,33 +67,31 @@ class Client:
elif callback_type is CallbackType.RESUME: elif callback_type is CallbackType.RESUME:
self.status = {'synthesizing': True, 'paused': False} self.status = {'synthesizing': True, 'paused': False}
def speak_marked_text(self, text, callback): def msg_as_event(self, callback_type, index_mark=None):
from speechd.client import CallbackType from speechd.client import CallbackType
if callback_type is CallbackType.INDEX_MARK:
return Event(EventType.mark, index_mark)
if callback_type is CallbackType.BEGIN:
return Event(EventType.begin)
if callback_type is CallbackType.END:
return Event(EventType.end)
if callback_type is CallbackType.CANCEL:
return Event(EventType.cancel)
if callback_type is CallbackType.PAUSE:
return Event(EventType.pause)
if callback_type is CallbackType.RESUME:
return Event(EventType.resume)
def speak_marked_text(self, text, callback):
def callback_wrapper(callback_type, index_mark=None): def callback_wrapper(callback_type, index_mark=None):
self.update_status(callback_type, index_mark) self.update_status(callback_type, index_mark)
if callback_type is CallbackType.INDEX_MARK: event = self.msg_as_event(callback_type, index_mark)
event = Event(EventType.mark, index_mark) if event is not None:
elif callback_type is CallbackType.BEGIN: callback(event)
event = Event(EventType.begin)
elif callback_type is CallbackType.END: def cw(callback_type, index_mark=None):
event = Event(EventType.end) self.dispatch_on_main_thread(partial(callback_wrapper, callback_type, index_mark))
elif callback_type is CallbackType.CANCEL:
event = Event(EventType.cancel)
elif callback_type is CallbackType.PAUSE:
event = Event(EventType.pause)
elif callback_type is CallbackType.RESUME:
event = Event(EventType.resume)
else:
return
self.pending_events.append(event)
callback()
self.set_use_ssml(True) self.set_use_ssml(True)
self.pending_events = [] self.ssip_client.speak(text, callback=cw)
self.ssip_client.speak(text, callback=callback_wrapper)
def get_events(self):
events = self.pending_events
self.pending_events = []
return events

View File

@ -13,11 +13,11 @@ class Client:
def escape_marked_text(cls, text): def escape_marked_text(cls, text):
return text.replace('[[', ' [ [ ').replace(']]', ' ] ] ') return text.replace('[[', ' [ [ ').replace(']]', ' ] ] ')
def __init__(self): def __init__(self, dispatch_on_main_thread):
from calibre_extensions.cocoa import NSSpeechSynthesizer from calibre_extensions.cocoa import NSSpeechSynthesizer
self.nsss = NSSpeechSynthesizer(self.handle_message) self.nsss = NSSpeechSynthesizer(self.handle_message)
self.current_callback = None self.current_callback = None
self.pending_events = [] self.dispatch_on_main_thread = dispatch_on_main_thread
def __del__(self): def __del__(self):
self.nsss = None self.nsss = None
@ -25,31 +25,23 @@ class Client:
def handle_message(self, message_type, data): def handle_message(self, message_type, data):
from calibre_extensions.cocoa import MARK, END from calibre_extensions.cocoa import MARK, END
if message_type == MARK:
event = Event(EventType.mark, data)
elif message_type == END:
event = Event(EventType.end if data else EventType.cancel)
else:
return
if self.current_callback is not None: if self.current_callback is not None:
if message_type == MARK: self.current_callback(event)
event = Event(EventType.mark, data)
elif message_type == END:
event = Event(EventType.end if data else EventType.cancel)
else:
return
self.pending_events.append(event)
self.current_callback()
def speak_simple_text(self, text): def speak_simple_text(self, text):
self.current_callback = None self.current_callback = None
self.pending_events = []
self.nsss.speak(self.escape_marked_text(text)) self.nsss.speak(self.escape_marked_text(text))
def speak_marked_text(self, text, callback): def speak_marked_text(self, text, callback):
self.current_callback = callback self.current_callback = callback
self.pending_events = []
self.nsss.speak(text) self.nsss.speak(text)
def get_events(self):
events = self.pending_events
self.pending_events = []
return events
@property @property
def status(self): def status(self):
ans = self.nsss.status() ans = self.nsss.status()

View File

@ -18,13 +18,14 @@ class Client:
def escape_marked_text(cls, text): def escape_marked_text(cls, text):
return prepare_string_for_xml(text) return prepare_string_for_xml(text)
def __init__(self): def __init__(self, dispatch_on_main_thread):
from calibre.utils.windows.winsapi import ISpVoice from calibre.utils.windows.winsapi import ISpVoice
self.sp_voice = ISpVoice() self.sp_voice = ISpVoice()
self.events_thread = Thread(name='SAPIEvents', target=self.wait_for_events, daemon=True) self.events_thread = Thread(name='SAPIEvents', target=self.wait_for_events, daemon=True)
self.events_thread.start() self.events_thread.start()
self.current_stream_number = None self.current_stream_number = None
self.current_callback = None self.current_callback = None
self.dispatch_on_main_thread = dispatch_on_main_thread
def __del__(self): def __del__(self):
if self.sp_voice is not None: if self.sp_voice is not None:
@ -37,27 +38,24 @@ class Client:
while True: while True:
if self.sp_voice.wait_for_event() is False: if self.sp_voice.wait_for_event() is False:
break break
c = self.current_callback self.dispatch_on_main_thread(self.handle_events)
if c is not None:
c()
def get_events(self): def handle_events(self):
from calibre_extensions.winsapi import ( from calibre_extensions.winsapi import (
SPEI_END_INPUT_STREAM, SPEI_START_INPUT_STREAM, SPEI_TTS_BOOKMARK SPEI_END_INPUT_STREAM, SPEI_START_INPUT_STREAM, SPEI_TTS_BOOKMARK
) )
ans = [] c = self.current_callback
for (stream_number, event_type, event_data) in self.sp_voice.get_events(): for (stream_number, event_type, event_data) in self.sp_voice.get_events():
if stream_number == self.current_stream_number: if event_type == SPEI_TTS_BOOKMARK:
if event_type == SPEI_TTS_BOOKMARK: event = Event(EventType.mark, event_data)
event = Event(EventType.mark, event_data) elif event_type == SPEI_START_INPUT_STREAM:
elif event_type == SPEI_START_INPUT_STREAM: event = Event(EventType.begin)
event = Event(EventType.begin) elif event_type == SPEI_END_INPUT_STREAM:
elif event_type == SPEI_END_INPUT_STREAM: event = Event(EventType.end)
event = Event(EventType.end) else:
else: continue
continue if c is not None and stream_number == self.current_stream_number:
ans.append(event) c(event)
return ans
def speak_simple_text(self, text): def speak_simple_text(self, text):
from calibre_extensions.winsapi import ( from calibre_extensions.winsapi import (

View File

@ -464,6 +464,7 @@ class WebView(RestartingWebEngineView):
paged_mode_changed = pyqtSignal() paged_mode_changed = pyqtSignal()
standalone_misc_settings_changed = pyqtSignal(object) standalone_misc_settings_changed = pyqtSignal(object)
view_created = pyqtSignal(object) view_created = pyqtSignal(object)
dispatch_on_main_thread_signal = pyqtSignal(object)
def __init__(self, parent=None): def __init__(self, parent=None):
self._host_widget = None self._host_widget = None
@ -474,6 +475,7 @@ class WebView(RestartingWebEngineView):
RestartingWebEngineView.__init__(self, parent) RestartingWebEngineView.__init__(self, parent)
self.dead_renderer_error_shown = False self.dead_renderer_error_shown = False
self.render_process_failed.connect(self.render_process_died) self.render_process_failed.connect(self.render_process_died)
self.dispatch_on_main_thread_signal.connect(self.dispatch_on_main_thread)
w = QApplication.instance().desktop().availableGeometry(self).width() w = QApplication.instance().desktop().availableGeometry(self).width()
QApplication.instance().palette_changed.connect(self.palette_changed) QApplication.instance().palette_changed.connect(self.palette_changed)
self.show_home_page_on_ready = True self.show_home_page_on_ready = True
@ -534,11 +536,22 @@ class WebView(RestartingWebEngineView):
def tts_client(self): def tts_client(self):
if self._tts_client is None: if self._tts_client is None:
from calibre.gui2.tts.implementation import Client from calibre.gui2.tts.implementation import Client
self._tts_client = Client() self._tts_client = Client(self.dispatch_on_main_thread_signal.emit)
return self._tts_client return self._tts_client
def dispatch_on_main_thread(self, func):
try:
func()
except Exception:
import traceback
traceback.print_exc()
def speak_simple_text(self, text): def speak_simple_text(self, text):
self.tts_client.speak_simple_text(text) from calibre.gui2.tts.errors import TTSSystemUnavailable
try:
self.tts_client.speak_simple_text(text)
except TTSSystemUnavailable as err:
return error_dialog(self, _('Text-to-Speech unavailable'), str(err), show=True)
def shutdown(self): def shutdown(self):
if self._tts_client is not None: if self._tts_client is not None: