mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Work on TTS config for macOS
This commit is contained in:
parent
1f7775dda4
commit
a488ec951d
@ -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)
|
||||||
|
170
src/calibre/gui2/tts/macos_config.py
Normal file
170
src/calibre/gui2/tts/macos_config.py
Normal 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()
|
@ -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 */
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user