diff --git a/src/calibre/gui2/tts/develop.py b/src/calibre/gui2/tts/develop.py index aa408f1b49..529fffcddf 100644 --- a/src/calibre/gui2/tts/develop.py +++ b/src/calibre/gui2/tts/develop.py @@ -6,11 +6,10 @@ import re from itertools import count from PyQt5.Qt import ( - QDialogButtonBox, QLabel, QMainWindow, Qt, QVBoxLayout, QWidget, pyqtSignal + QDialogButtonBox, QLabel, QMainWindow, Qt, QTimer, QVBoxLayout, QWidget, + pyqtSignal ) -from calibre import prepare_string_for_xml -from calibre.constants import iswindows from calibre.gui2 import Application from .common import EventType @@ -23,25 +22,22 @@ def add_markup(text): counter = count() pos_map = {} last = None - if iswindows: - bm = '' - else: - bm = '' + bm = Client.mark_template for m in re.finditer(r'\w+', text): start, end = m.start(), m.end() if first: first = False if start: - buf.append(prepare_string_for_xml(text[:start])) + buf.append(Client.escape_marked_text(text[:start])) num = next(counter) buf.append(bm.format(num)) pos_map[num] = start, end - buf.append(prepare_string_for_xml(m.group())) + buf.append(Client.escape_marked_text(m.group())) last = end if last is None: - buf.append(prepare_string_for_xml(text)) + buf.append(Client.escape_marked_text(text)) else: - buf.append(prepare_string_for_xml(text[last:])) + buf.append(Client.escape_marked_text(text[last:])) return ''.join(buf), pos_map @@ -135,5 +131,25 @@ def main(): tts.tts.shutdown() +def headless(): + app = Application([]) + c = Client() + text = '[[sync 0x123456]]very [[sync 0x80]]good [[sync 0x81]]indeed' + + def callback(): + for ev in c.get_events(): + if ev.type is EventType.mark: + print('mark:', hex(ev.data)) + if ev.type in (EventType.end, EventType.cancel): + print(ev.type) + app.quit() + + def run(): + c.speak_marked_text(text, callback) + QTimer.singleShot(10, run) + QTimer.singleShot(5000, app.quit) + app.exec_() + + if __name__ == '__main__': main() diff --git a/src/calibre/gui2/tts/linux.py b/src/calibre/gui2/tts/linux.py index 41cb71516e..c861649a03 100644 --- a/src/calibre/gui2/tts/linux.py +++ b/src/calibre/gui2/tts/linux.py @@ -2,15 +2,24 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2020, Kovid Goyal +from calibre import prepare_string_for_xml + from .common import Event, EventType from .errors import TTSSystemUnavailable class Client: + mark_template = '' + + @classmethod + def escape_marked_text(cls, text): + return prepare_string_for_xml(text) + def __init__(self): self.create_ssip_client() self.pending_events = [] + self.status = {'synthesizing': False, 'paused': False} def create_ssip_client(self): from speechd.client import SpawnError, SSIPClient @@ -37,12 +46,27 @@ class Client: def speak_simple_text(self, text): self.set_use_ssml(False) - self.ssip_client.speak(text) + self.pending_events = [] + self.ssip_client.speak(text, self.update_status) + + def update_status(self, callback_type, index_mark=None): + from speechd.client import CallbackType + if callback_type is CallbackType.BEGIN: + self.status = {'synthesizing': True, 'paused': False} + elif callback_type is CallbackType.END: + self.status = {'synthesizing': False, 'paused': False} + elif callback_type is CallbackType.CANCEL: + self.status = {'synthesizing': False, 'paused': False} + elif callback_type is CallbackType.PAUSE: + self.status = {'synthesizing': True, 'paused': True} + elif callback_type is CallbackType.RESUME: + self.status = {'synthesizing': True, 'paused': False} def speak_marked_text(self, text, callback): from speechd.client import CallbackType def callback_wrapper(callback_type, index_mark=None): + self.update_status(callback_type, index_mark) if callback_type is CallbackType.INDEX_MARK: event = Event(EventType.mark, index_mark) elif callback_type is CallbackType.BEGIN: @@ -61,6 +85,7 @@ class Client: callback() self.set_use_ssml(True) + self.pending_events = [] self.ssip_client.speak(text, callback=callback_wrapper) def get_events(self): diff --git a/src/calibre/gui2/tts/macos.py b/src/calibre/gui2/tts/macos.py index 3fee7077fa..3e5c235522 100644 --- a/src/calibre/gui2/tts/macos.py +++ b/src/calibre/gui2/tts/macos.py @@ -2,15 +2,57 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2020, Kovid Goyal +from .common import Event, EventType + class Client: + mark_template = '[[sync 0x{:x}]]' + + @classmethod + def escape_marked_text(cls, text): + return text.replace('[[', ' [ [ ').replace(']]', ' ] ] ') + def __init__(self): from calibre_extensions.cocoa import NSSpeechSynthesizer - self.nsss = NSSpeechSynthesizer() + self.nsss = NSSpeechSynthesizer(self.handle_message) + self.current_callback = None + self.pending_events = [] def __del__(self): self.nsss = None + shutdown = __del__ + + def handle_message(self, message_type, data): + from calibre_extensions.cocoa import MARK, END + if self.current_callback is not None: + if message_type == MARK: + 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): + self.current_callback = None + self.pending_events = [] + self.nsss.speak(text.replace('[[', '[').replace(']]', ']')) + + def speak_marked_text(self, text, callback): + self.current_callback = callback + self.pending_events = [] self.nsss.speak(text) + + def get_events(self): + events = self.pending_events + self.pending_events = [] + return events + + @property + def status(self): + ans = self.nsss.status() + ans['synthesizing'] = ans.get('synthesizing', False) + ans['paused'] = ans.get('paused', False) + return ans diff --git a/src/calibre/gui2/tts/nsss.m b/src/calibre/gui2/tts/nsss.m index 5c9fe7e0e9..324001bd56 100644 --- a/src/calibre/gui2/tts/nsss.m +++ b/src/calibre/gui2/tts/nsss.m @@ -11,30 +11,77 @@ typedef struct { PyObject_HEAD NSSpeechSynthesizer *nsss; + PyObject *callback; } NSSS; +typedef enum { MARK, END } MessageType; static PyTypeObject NSSSType = { PyVarObject_HEAD_INIT(NULL, 0) }; + +static void +dispatch_message(NSSS *self, MessageType which, unsigned long val) { + PyGILState_STATE state = PyGILState_Ensure(); + PyObject *ret = PyObject_CallFunction(self->callback, "ik", which, val); + if (ret) Py_DECREF(ret); + else PyErr_Print(); + PyGILState_Release(state); +} + +@interface SynthesizerDelegate : NSObject { + NSSS *parent; +} +- (id)initWithNSSS:(NSSS *)x; +@end + +@implementation SynthesizerDelegate + +- (id)initWithNSSS:(NSSS *)x { + self = [super init]; + if (self) parent = x; + return self; +} + +- (void)speechSynthesizer:(NSSpeechSynthesizer *)sender didFinishSpeaking:(BOOL)success { + dispatch_message(parent, END, success); +} + +- (void)speechSynthesizer:(NSSpeechSynthesizer *)sender didEncounterSyncMessage:(NSString *)message { + NSError *err = nil; + NSNumber *syncProp = (NSNumber*) [sender objectForProperty: NSSpeechRecentSyncProperty error: &err]; + if (syncProp && !err) dispatch_message(parent, MARK, syncProp.unsignedLongValue); +} + +@end // }}} static PyObject * NSSS_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + PyObject *callback; + if (!PyArg_ParseTuple(args, "O", &callback)) return NULL; + if (!PyCallable_Check(callback)) { PyErr_SetString(PyExc_TypeError, "callback must be a callable"); return NULL; } NSSS *self = (NSSS *) type->tp_alloc(type, 0); if (self) { + self->callback = callback; + Py_INCREF(callback); self->nsss = [[NSSpeechSynthesizer alloc] initWithVoice:nil]; if (self->nsss) { - - } else PyErr_NoMemory(); + self->nsss.delegate = [[SynthesizerDelegate alloc] initWithNSSS:self]; + } else return PyErr_NoMemory(); } return (PyObject*)self; } static void NSSS_dealloc(NSSS *self) { - if (self->nsss) [self->nsss release]; + if (self->nsss) { + if (self->nsss.delegate) [self->nsss.delegate release]; + self->nsss.delegate = nil; + [self->nsss release]; + } self->nsss = nil; + Py_CLEAR(self->callback); } static PyObject* @@ -73,6 +120,20 @@ NSSS_get_all_voices(NSSS *self, PyObject *args) { return ans; } +static PyObject* +NSSS_set_command_delimiters(NSSS *self, PyObject *args) { + // this function doesn't actually work + // https://openradar.appspot.com/6524554 + const char *left, *right; + if (!PyArg_ParseTuple(args, "ss", &left, &right)) return NULL; + NSError *err = nil; + [self->nsss setObject:@{NSSpeechCommandPrefix:@(left), NSSpeechCommandSuffix:@(right)} forProperty:NSSpeechCommandDelimiterProperty error:&err]; + if (err) { + PyErr_SetString(PyExc_OSError, [[NSString stringWithFormat:@"Failed to set delimiters: %@", err] UTF8String]); + return NULL; + } + Py_RETURN_NONE; +} static PyObject* NSSS_get_current_voice(NSSS *self, PyObject *args) { @@ -144,10 +205,33 @@ NSSS_start_saving_to_path(NSSS *self, PyObject *args) { Py_RETURN_FALSE; } +static PyObject* +NSSS_status(NSSS *self, PyObject *args) { + NSError *err = nil; + NSDictionary *status = [self->nsss objectForProperty:NSSpeechStatusProperty error:&err]; + if (err) { + PyErr_SetString(PyExc_OSError, [[err localizedDescription] UTF8String]); + return NULL; + } + PyObject *ans = PyDict_New(); + if (ans) { + NSNumber *result = [status objectForKey:NSSpeechStatusOutputBusy]; + if (result) { + if (PyDict_SetItemString(ans, "synthesizing", [result boolValue] ? Py_True : Py_False) != 0) { Py_CLEAR(ans); return NULL; } + } + result = [status objectForKey:NSSpeechStatusOutputPaused]; + if (result) { + if (PyDict_SetItemString(ans, "paused", [result boolValue] ? Py_True : Py_False) != 0) { Py_CLEAR(ans); return NULL; } + } + } + return ans; +} + // Boilerplate {{{ #define M(name, args) { #name, (PyCFunction)NSSS_##name, args, ""} static PyMethodDef NSSS_methods[] = { M(get_all_voices, METH_NOARGS), + M(status, METH_NOARGS), M(speak, METH_VARARGS), M(start_saving_to_path, METH_VARARGS), M(speaking, METH_NOARGS), @@ -159,6 +243,7 @@ static PyMethodDef NSSS_methods[] = { M(set_current_volume, METH_VARARGS), M(get_current_rate, METH_NOARGS), M(set_current_rate, METH_VARARGS), + M(set_command_delimiters, METH_VARARGS), {NULL, NULL, 0, NULL} }; #undef M @@ -180,6 +265,8 @@ nsss_init_module(PyObject *module) { Py_DECREF(&NSSSType); return -1; } + PyModule_AddIntMacro(module, MARK); + PyModule_AddIntMacro(module, END); return 0; } diff --git a/src/calibre/gui2/tts/windows.py b/src/calibre/gui2/tts/windows.py index 8ed3076fb6..5b4486befc 100644 --- a/src/calibre/gui2/tts/windows.py +++ b/src/calibre/gui2/tts/windows.py @@ -4,11 +4,20 @@ from threading import Thread + +from calibre import prepare_string_for_xml + from .common import Event, EventType class Client: + mark_template = '' + + @classmethod + def escape_marked_text(cls, text): + return prepare_string_for_xml(text) + def __init__(self): from calibre.utils.windows.winsapi import ISpVoice self.sp_voice = ISpVoice() @@ -33,7 +42,9 @@ class Client: c() def get_events(self): - from calibre_extensions.winsapi import SPEI_TTS_BOOKMARK, SPEI_START_INPUT_STREAM, SPEI_END_INPUT_STREAM + from calibre_extensions.winsapi import ( + SPEI_END_INPUT_STREAM, SPEI_START_INPUT_STREAM, SPEI_TTS_BOOKMARK + ) ans = [] for (stream_number, event_type, event_data) in self.sp_voice.get_events(): if stream_number == self.current_stream_number: @@ -49,11 +60,15 @@ class Client: return ans def speak_simple_text(self, text): - from calibre_extensions.winsapi import SPF_ASYNC, SPF_PURGEBEFORESPEAK, SPF_IS_NOT_XML + from calibre_extensions.winsapi import ( + SPF_ASYNC, SPF_IS_NOT_XML, SPF_PURGEBEFORESPEAK + ) 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 + from calibre_extensions.winsapi import ( + SPF_ASYNC, SPF_IS_XML, SPF_PURGEBEFORESPEAK + ) self.current_callback = callback self.current_stream_number = self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_XML, True)