mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-07 10:14:46 -04:00
Refactor tts client design so all interaction with underlying API happens only on the main thread
This commit is contained in:
parent
3309f3728f
commit
ab377cfda5
@ -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__':
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user