From ab377cfda53dbec73d2aaa3d76b3c1e059a5df99 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 19 Nov 2020 20:29:41 +0530 Subject: [PATCH] Refactor tts client design so all interaction with underlying API happens only on the main thread --- src/calibre/gui2/tts/develop.py | 34 +++++++----- src/calibre/gui2/tts/linux.py | 15 ++++-- src/calibre/gui2/tts/windows.py | 35 +++++++++++-- src/calibre/gui2/viewer/web_view.py | 4 +- src/calibre/utils/windows/winsapi.cpp | 75 +++++++++++++++------------ 5 files changed, 109 insertions(+), 54 deletions(-) diff --git a/src/calibre/gui2/tts/develop.py b/src/calibre/gui2/tts/develop.py index 45649a26c1..aa408f1b49 100644 --- a/src/calibre/gui2/tts/develop.py +++ b/src/calibre/gui2/tts/develop.py @@ -10,6 +10,7 @@ from PyQt5.Qt import ( ) from calibre import prepare_string_for_xml +from calibre.constants import iswindows from calibre.gui2 import Application from .common import EventType @@ -22,6 +23,10 @@ def add_markup(text): counter = count() pos_map = {} last = None + if iswindows: + bm = '' + else: + bm = '' for m in re.finditer(r'\w+', text): start, end = m.start(), m.end() if first: @@ -29,7 +34,7 @@ def add_markup(text): if start: buf.append(prepare_string_for_xml(text[:start])) num = next(counter) - buf.append(f'') + buf.append(bm.format(num)) pos_map[num] = start, end buf.append(prepare_string_for_xml(m.group())) last = end @@ -40,8 +45,9 @@ def add_markup(text): return ''.join(buf), pos_map -class TTS(QWidget): +class TTSWidget(QWidget): + events_available = pyqtSignal() mark_changed = pyqtSignal(object) def __init__(self, parent=None): @@ -84,6 +90,7 @@ example, which of. l.addWidget(bb) b.clicked.connect(self.play_clicked) self.render_text() + self.events_available.connect(self.handle_events, type=Qt.QueuedConnection) def render_text(self): text = self.text @@ -100,15 +107,16 @@ example, which of. self.la.setText('\n'.join(lines)) def play_clicked(self): - self.tts.speak_marked_text(self.ssml, self.handle_event) + self.tts.speak_marked_text(self.ssml, self.events_available.emit) - 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 handle_events(self): + for event in self.tts.get_events(): + 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): self.current_mark = mark @@ -118,11 +126,13 @@ example, which of. def main(): app = Application([]) w = QMainWindow() - tts = TTS(w) + tts = TTSWidget(w) w.setCentralWidget(tts) w.show() app.exec_() - del tts.tts + tts.events_available.disconnect() + tts.mark_changed.disconnect() + tts.tts.shutdown() if __name__ == '__main__': diff --git a/src/calibre/gui2/tts/linux.py b/src/calibre/gui2/tts/linux.py index 833c58f4b8..41cb71516e 100644 --- a/src/calibre/gui2/tts/linux.py +++ b/src/calibre/gui2/tts/linux.py @@ -10,6 +10,7 @@ class Client: def __init__(self): self.create_ssip_client() + self.pending_events = [] def create_ssip_client(self): from speechd.client import SpawnError, SSIPClient @@ -19,8 +20,10 @@ class Client: raise TTSSystemUnavailable(_('Could not find speech-dispatcher on your system. Please install it.'), str(err)) def __del__(self): - self.ssip_client.close() - del self.ssip_client + if hasattr(self, 'ssip_client'): + self.ssip_client.close() + del self.ssip_client + shutdown = __del__ def set_use_ssml(self, on): from speechd.client import DataMode, SSIPCommunicationError @@ -54,7 +57,13 @@ class Client: event = Event(EventType.resume) else: return - callback(event) + self.pending_events.append(event) + callback() self.set_use_ssml(True) self.ssip_client.speak(text, callback=callback_wrapper) + + def get_events(self): + events = self.pending_events + self.pending_events = [] + return events diff --git a/src/calibre/gui2/tts/windows.py b/src/calibre/gui2/tts/windows.py index e80a779c96..befe545f9e 100644 --- a/src/calibre/gui2/tts/windows.py +++ b/src/calibre/gui2/tts/windows.py @@ -4,6 +4,7 @@ from threading import Thread +from .common import Event, EventType class Client: @@ -13,18 +14,44 @@ class Client: self.sp_voice = ISpVoice() self.events_thread = Thread(name='SAPIEvents', target=self.wait_for_events, daemon=True) self.events_thread.start() + self.current_stream_number = None + self.current_callback = None def __del__(self): self.sp_voice.shutdown_event_loop() self.events_thread.join(5) self.sp_voice = None + shutdown = __del__ def wait_for_events(self): - self.sp_voice.run_event_loop(self.process_event) + while True: + if self.sp_voice.wait_for_event() is False: + break + if self.current_callback is not None: + self.current_callback() - def process_event(self, stream_number, event_type, event_data=None): - pass + def get_events(self): + from calibre_extensions.winsapi import SPEI_TTS_BOOKMARK, SPEI_START_INPUT_STREAM, SPEI_END_INPUT_STREAM + ans = [] + 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: + event = Event(EventType.mark, event_data) + elif event_type == SPEI_START_INPUT_STREAM: + event = Event(EventType.begin) + elif event_type == SPEI_END_INPUT_STREAM: + event = Event(EventType.end) + else: + continue + ans.append(event) + return ans def speak_simple_text(self, text): from calibre_extensions.winsapi import SPF_ASYNC, SPF_PURGEBEFORESPEAK, SPF_IS_NOT_XML - self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_NOT_XML) + self.current_callback = None + self.current_stream_number = self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_NOT_XML) + + def speak_marked_text(self, text, callback): + from calibre_extensions.winsapi import SPF_ASYNC, SPF_PURGEBEFORESPEAK, SPF_IS_XML + self.current_callback = callback + self.current_stream_number = self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_XML, True) diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index c7189a3e8b..c41f67143b 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -541,7 +541,9 @@ class WebView(RestartingWebEngineView): self.tts_client.speak_simple_text(text) def shutdown(self): - self._tts_client = None + if self._tts_client is not None: + self._tts_client.shutdown() + self._tts_client = None def set_shortcut_map(self, smap): self.shortcut_map = smap diff --git a/src/calibre/utils/windows/winsapi.cpp b/src/calibre/utils/windows/winsapi.cpp index 372ae271f2..b7efd088ff 100644 --- a/src/calibre/utils/windows/winsapi.cpp +++ b/src/calibre/utils/windows/winsapi.cpp @@ -30,6 +30,8 @@ static PyTypeObject VoiceType = { PyVarObject_HEAD_INIT(NULL, 0) }; +static const ULONGLONG speak_events = SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM) | SPFEI(SPEI_TTS_BOOKMARK); + static PyObject * Voice_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { HRESULT hr = CoInitialize(NULL); @@ -55,19 +57,12 @@ Voice_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { PyErr_SetString(PyExc_OSError, "Failed to get events handle for ISpVoice"); return NULL; } - self->shutdown_events_thread = CreateEvent(NULL, true, false, NULL); + self->shutdown_events_thread = CreateEventW(NULL, true, false, NULL); if (self->shutdown_events_thread == INVALID_HANDLE_VALUE) { Py_CLEAR(self); PyErr_SetFromWindowsErr(0); return NULL; } - ULONGLONG events = SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM) | SPFEI(SPEI_TTS_BOOKMARK); - if (FAILED(hr = self->voice->SetInterest(events, events))) { - CloseHandle(self->shutdown_events_thread); - Py_CLEAR(self); - return error_from_hresult(hr, "Failed to register event interest"); - } - } return (PyObject*)self; } @@ -266,13 +261,18 @@ static PyObject* Voice_speak(Voice *self, PyObject *args) { wchar_raii text_or_path; unsigned long flags = SPF_DEFAULT; - if (!PyArg_ParseTuple(args, "O&|k", py_to_wchar, &text_or_path, &flags)) return NULL; - ULONG stream_number; + int want_events = 0; HRESULT hr = S_OK; + if (!PyArg_ParseTuple(args, "O&|kp", py_to_wchar, &text_or_path, &flags, &want_events)) return NULL; + ULONGLONG events = want_events ? speak_events : 0; + if (FAILED(hr = self->voice->SetInterest(events, events))) { + return error_from_hresult(hr, "Failed to ask for events"); + } + ULONG stream_number; Py_BEGIN_ALLOW_THREADS; hr = self->voice->Speak(text_or_path.ptr(), flags, &stream_number); Py_END_ALLOW_THREADS; - if (FAILED(hr)) return error_from_hresult(hr, "Failed to speak", PyTuple_GET_ITEM(args, 0)); + if (FAILED(hr)) return error_from_hresult(hr, "Failed to speak"); return PyLong_FromUnsignedLong(stream_number); } @@ -339,8 +339,8 @@ Voice_shutdown_event_loop(Voice *self, PyObject *args) { Py_RETURN_NONE; } -static inline void -dispatch_events(Voice *self, PyObject *callback) { +static PyObject* +get_events(Voice *self, PyObject *args) { HRESULT hr; const ULONG asz = 32; ULONG num_events; @@ -348,6 +348,8 @@ dispatch_events(Voice *self, PyObject *callback) { PyObject *ret; long long val; int etype; + PyObject *ans = PyList_New(0); + if (!ans) return NULL; while (true) { Py_BEGIN_ALLOW_THREADS; hr = self->voice->GetEvents(asz, events, &num_events); @@ -356,38 +358,42 @@ dispatch_events(Voice *self, PyObject *callback) { if (num_events == 0) break; for (ULONG i = 0; i < num_events; i++) { etype = events[i].eEventId; -#define CALL(fmt, ...) { ret = PyObject_CallFunction(callback, fmt, __VA_ARGS__); if (ret) Py_DECREF(ret); else PyErr_Print(); } break; + bool ok = false; switch(etype) { case SPEI_TTS_BOOKMARK: val = events[i].wParam; - CALL("kiL", events[i].ulStreamNum, etype, val); + ok = true; + break; case SPEI_START_INPUT_STREAM: case SPEI_END_INPUT_STREAM: - CALL("ki", events[i].ulStreamNum, etype); + val = 0; + ok = true; + break; + } + if (ok) { + ret = Py_BuildValue("kiL", events[i].ulStreamNum, etype, val); + if (!ret) { Py_CLEAR(ans); return NULL; } + int x = PyList_Append(ans, ret); + Py_DECREF(ret); + if (x != 0) { Py_CLEAR(ans); return NULL; } } -#undef CALL } } + return ans; } static PyObject* -Voice_run_event_loop(Voice *self, PyObject *callback) { +Voice_wait_for_event(Voice *self, PyObject *callback) { if (!PyCallable_Check(callback)) { PyErr_SetString(PyExc_TypeError, "callback object is not callable"); return NULL; } - HANDLE handles[2] = {self->shutdown_events_thread, self->events_available}; - bool keep_going = true; - DWORD ev; - while(keep_going) { - Py_BEGIN_ALLOW_THREADS; - ev = WaitForMultipleObjects(2, handles, true, INFINITE); - Py_END_ALLOW_THREADS; - switch (ev) { - case WAIT_OBJECT_0: - keep_going = false; - break; - case WAIT_OBJECT_0 + 1: - dispatch_events(self, callback); - break; - } + const HANDLE handles[2] = {self->shutdown_events_thread, self->events_available}; + Py_BEGIN_ALLOW_THREADS; + ev = WaitForMultipleObjects(2, handles, true, INFINITE); + Py_END_ALLOW_THREADS; + switch (ev) { + case WAIT_OBJECT_0: + Py_RETURN_FALSE; + case WAIT_OBJECT_0 + 1: + Py_RETURN_TRUE; } Py_RETURN_NONE; } @@ -414,7 +420,8 @@ static PyMethodDef Voice_methods[] = { M(set_current_sound_output, METH_VARARGS), M(shutdown_event_loop, METH_NOARGS), - M(run_event_loop, METH_O), + M(wait_for_event, METH_O), + M(get_events, METH_NOARGS), {NULL, NULL, 0, NULL} }; #undef M