diff --git a/src/calibre/gui2/tts/macos.py b/src/calibre/gui2/tts/macos.py index c57ec9cd5e..832c882ea7 100644 --- a/src/calibre/gui2/tts/macos.py +++ b/src/calibre/gui2/tts/macos.py @@ -14,12 +14,20 @@ class Client: def escape_marked_text(cls, text): 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 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.dispatch_on_main_thread = dispatch_on_main_thread 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): self.nsss = None @@ -68,3 +76,22 @@ class Client: def stop(self): 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) diff --git a/src/calibre/gui2/tts/macos_config.py b/src/calibre/gui2/tts/macos_config.py new file mode 100644 index 0000000000..44b05c00ce --- /dev/null +++ b/src/calibre/gui2/tts/macos_config.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2020, Kovid Goyal + +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() diff --git a/src/calibre/utils/cocoa.m b/src/calibre/utils/cocoa.m index efd264a165..0dbdfe174c 100644 --- a/src/calibre/utils/cocoa.m +++ b/src/calibre/utils/cocoa.m @@ -173,6 +173,31 @@ transient_scroller(PyObject *self) { 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[] = { {"transient_scroller", (PyCFunction)transient_scroller, 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, ""}, {"disable_cocoa_ui_elements", (PyCFunction)disable_cocoa_ui_elements, METH_VARARGS, ""}, {"send2trash", (PyCFunction)send2trash, METH_VARARGS, ""}, + {"locale_names", (PyCFunction)locale_names, METH_VARARGS, ""}, {NULL, NULL, 0, NULL} /* Sentinel */ };