Work on TTS config for macOS

This commit is contained in:
Kovid Goyal 2020-12-07 14:10:32 +05:30
parent 1f7775dda4
commit a488ec951d
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 224 additions and 1 deletions

View File

@ -14,12 +14,20 @@ class Client:
def escape_marked_text(cls, text): def escape_marked_text(cls, text):
return text.replace('[[', ' [ [ ').replace(']]', ' ] ] ') return text.replace('[[', ' [ [ ').replace(']]', ' ] ] ')
def __init__(self, settings, dispatch_on_main_thread): def __init__(self, settings=None, dispatch_on_main_thread=lambda f: f()):
from calibre_extensions.cocoa import NSSpeechSynthesizer from calibre_extensions.cocoa import NSSpeechSynthesizer
self.nsss = NSSpeechSynthesizer(self.handle_message) self.nsss = NSSpeechSynthesizer(self.handle_message)
self.default_system_rate = self.nsss.get_current_rate()
self.default_system_voice = self.nsss.get_current_voice()
self.current_callback = None self.current_callback = None
self.dispatch_on_main_thread = dispatch_on_main_thread self.dispatch_on_main_thread = dispatch_on_main_thread
self.status = {'synthesizing': False, 'paused': False} self.status = {'synthesizing': False, 'paused': False}
self.apply_settings(settings)
def apply_settings(self, new_settings=None):
settings = new_settings or {}
self.nsss.set_current_rate(settings.get('rate', self.default_system_rate))
self.nsss.set_current_voice(settings.get('voice') or self.default_system_voice)
def __del__(self): def __del__(self):
self.nsss = None self.nsss = None
@ -68,3 +76,22 @@ class Client:
def stop(self): def stop(self):
self.nsss.stop() self.nsss.stop()
@property
def rate(self):
return self.nss.get_current_rate()
@rate.setter
def rate(self, val):
val = val or self.default_system_rate
self.nss.set_current_rate(float(val))
def get_voice_data(self):
ans = getattr(self, 'voice_data', None)
if ans is None:
ans = self.voice_data = self.nsss.get_all_voices()
return ans
def config_widget(self, backend_settings, parent):
from calibre.gui2.tts.macos_config import Widget
return Widget(self, backend_settings, parent)

View File

@ -0,0 +1,170 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from contextlib import suppress
from calibre_extensions.cocoa import locale_names
from PyQt5.Qt import (
QAbstractItemView, QAbstractTableModel, QFontMetrics, QFormLayout,
QItemSelectionModel, QSortFilterProxyModel, QSlider, Qt, QTableView, QWidget
)
from calibre.gui2.preferences.look_feel import BusyCursor
class VoicesModel(QAbstractTableModel):
system_default_voice = ''
def __init__(self, voice_data, parent=None):
super().__init__(parent)
self.voice_data = voice_data
gmap = {'VoiceGenderNeuter': _('neutral'), 'VoiceGenderFemale': _('female'), 'VoiceGenderMale': _('male')}
def gender(x):
return gmap.get(x, x)
self.current_voices = tuple((x['name'], x['locale_id'], x['age'], gender(x['gender'])) for x in voice_data.values())
self.voice_ids = tuple(voice_data)
all_locales = tuple(filter(None, (x[1] for x in self.current_voices)))
self.locale_map = dict(zip(all_locales, locale_names(*all_locales)))
self.column_headers = _('Name'), _('Language'), _('Age'), _('Gender')
def rowCount(self, parent=None):
return len(self.current_voices) + 1
def columnCount(self, parent=None):
return len(self.column_headers)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.column_headers[section]
return super().headerData(section, orientation, role)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
row = index.row()
with suppress(IndexError):
if row == 0:
return (_('System default'), '', '', '')[index.column()]
data = self.current_voices[row - 1]
col = index.column()
ans = data[col] or ''
if col == 1:
ans = self.locale_map.get(ans, ans)
return ans
if role == Qt.ItemDataRole.UserRole:
row = index.row()
with suppress(IndexError):
if row == 0:
return self.system_default_voice
return self.voice_ids[row - 1]
def index_for_voice(self, v):
r = 0
if v != self.system_default_voice:
try:
idx = self.voice_ids.index(v)
except Exception:
return
r = idx + 1
return self.index(r, 0)
class Widget(QWidget):
def __init__(self, tts_client, initial_backend_settings=None, parent=None):
QWidget.__init__(self, parent)
self.l = l = QFormLayout(self)
self.tts_client = tts_client
with BusyCursor():
self.voice_data = self.tts_client.get_voice_data()
self.default_system_rate = self.tts_client.default_system_rate
self.speed = s = QSlider(Qt.Orientation.Horizontal, self)
s.setMinimumWidth(200)
l.addRow(_('&Speed of speech (words per minute):'), s)
delta = self.default_system_rate - 50
s.setRange(self.default_system_rate - delta, self.default_system_rate + delta)
s.setSingleStep(10)
self.voices = v = QTableView(self)
self.voices_model = VoicesModel(self.voice_data, parent=v)
self.proxy_model = p = QSortFilterProxyModel(self)
p.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
p.setSourceModel(self.voices_model)
v.setModel(p)
v.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
v.setSortingEnabled(True)
v.horizontalHeader().resizeSection(0, QFontMetrics(self.font()).averageCharWidth() * 30)
v.verticalHeader().close()
v.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
v.sortByColumn(0, Qt.SortOrder.AscendingOrder)
l.addRow(v)
self.backend_settings = initial_backend_settings or {}
def restore_to_defaults(self):
self.backend_settings = {}
def sizeHint(self):
ans = super().sizeHint()
ans.setHeight(max(ans.height(), 600))
ans.setWidth(max(ans.width(), 500))
return ans
@property
def selected_voice(self):
for x in self.voices.selectedIndexes():
return x.data(Qt.ItemDataRole.UserRole)
@selected_voice.setter
def selected_voice(self, val):
val = val or VoicesModel.system_default_voice
idx = self.voices_model.index_for_voice(val)
if idx is not None:
idx = self.proxy_model.mapFromSource(idx)
self.voices.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect)
self.voices.scrollTo(idx)
@property
def rate(self):
return self.speed.value()
@rate.setter
def rate(self, val):
val = int(val or self.default_system_rate)
self.speed.setValue(val)
@property
def backend_settings(self):
ans = {}
voice = self.selected_voice
if voice and voice != VoicesModel.system_default_voice:
ans['voice'] = voice
rate = self.rate
if rate and rate != self.default_system_rate:
ans['rate'] = rate
return ans
@backend_settings.setter
def backend_settings(self, val):
voice = val.get('voice') or VoicesModel.system_default_voice
self.selected_voice = voice
self.rate = val.get('rate') or self.default_system_rate
def develop():
from calibre.gui2 import Application
from calibre.gui2.tts.implementation import Client
app = Application([])
c = Client()
w = Widget(c, {})
w.show()
app.exec_()
print(w.backend_settings)
if __name__ == '__main__':
develop()

View File

@ -173,6 +173,31 @@ transient_scroller(PyObject *self) {
return PyBool_FromLong([NSScroller preferredScrollerStyle] == NSScrollerStyleOverlay); return PyBool_FromLong([NSScroller preferredScrollerStyle] == NSScrollerStyleOverlay);
} }
static PyObject*
locale_names(PyObject *self, PyObject *args) {
PyObject *ans = PyTuple_New(PyTuple_GET_SIZE(args));
if (!ans) return NULL;
NSLocale *locale = [NSLocale autoupdatingCurrentLocale];
for (Py_ssize_t i = 0; i < PyTuple_GET_SIZE(ans); i++) {
PyObject *x = PyTuple_GET_ITEM(args, i);
if (!PyUnicode_Check(x)) { PyErr_SetString(PyExc_TypeError, "language codes must be unicode"); Py_CLEAR(ans); return NULL; }
if (PyUnicode_READY(x) != 0) { Py_CLEAR(ans); return NULL; }
const char *code = PyUnicode_AsUTF8(x);
if (code == NULL) { Py_CLEAR(ans); return NULL; }
NSString *display_name = [locale displayNameForKey:NSLocaleIdentifier value:@(code)];
if (display_name) {
PyObject *p = PyUnicode_FromString([display_name UTF8String]);
if (!p) { Py_CLEAR(ans); return NULL; }
PyTuple_SET_ITEM(ans, i, p);
} else {
Py_INCREF(x);
PyTuple_SET_ITEM(ans, i, x);
}
}
return ans;
}
static PyMethodDef module_methods[] = { static PyMethodDef module_methods[] = {
{"transient_scroller", (PyCFunction)transient_scroller, METH_NOARGS, ""}, {"transient_scroller", (PyCFunction)transient_scroller, METH_NOARGS, ""},
{"cursor_blink_time", (PyCFunction)cursor_blink_time, METH_NOARGS, ""}, {"cursor_blink_time", (PyCFunction)cursor_blink_time, METH_NOARGS, ""},
@ -181,6 +206,7 @@ static PyMethodDef module_methods[] = {
{"send_notification", (PyCFunction)send_notification, METH_VARARGS, ""}, {"send_notification", (PyCFunction)send_notification, METH_VARARGS, ""},
{"disable_cocoa_ui_elements", (PyCFunction)disable_cocoa_ui_elements, METH_VARARGS, ""}, {"disable_cocoa_ui_elements", (PyCFunction)disable_cocoa_ui_elements, METH_VARARGS, ""},
{"send2trash", (PyCFunction)send2trash, METH_VARARGS, ""}, {"send2trash", (PyCFunction)send2trash, METH_VARARGS, ""},
{"locale_names", (PyCFunction)locale_names, METH_VARARGS, ""},
{NULL, NULL, 0, NULL} /* Sentinel */ {NULL, NULL, 0, NULL} /* Sentinel */
}; };