Refactor tts client design so all interaction with underlying API happens only on the main thread

This commit is contained in:
Kovid Goyal 2020-11-19 20:29:41 +05:30
parent 3309f3728f
commit ab377cfda5
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 109 additions and 54 deletions

View File

@ -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 = '<bookmark mark="{}"/>'
else:
bm = '<mark name="{}"/>'
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'<mark name="{num}"/>')
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__':

View File

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

View File

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

View File

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

View File

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