More work on speech backends

This commit is contained in:
Kovid Goyal 2020-11-20 22:39:11 +05:30
parent 10971de4b7
commit 05fe8d8ef5
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 204 additions and 19 deletions

View File

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

View File

@ -2,15 +2,24 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from calibre import prepare_string_for_xml
from .common import Event, EventType
from .errors import TTSSystemUnavailable
class Client:
mark_template = '<mark name="{}"/>'
@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):

View File

@ -2,15 +2,57 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
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

View File

@ -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 <NSSpeechSynthesizerDelegate> {
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;
}

View File

@ -4,11 +4,20 @@
from threading import Thread
from calibre import prepare_string_for_xml
from .common import Event, EventType
class Client:
mark_template = '<bookmark mark="{}"/>'
@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)