Start work on configuring TTS backends

This commit is contained in:
Kovid Goyal 2020-12-06 11:36:15 +05:30
parent 289a5977af
commit 380f49b8fc
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
9 changed files with 167 additions and 13 deletions

View File

@ -13,13 +13,13 @@ from .errors import TTSSystemUnavailable
class Client:
mark_template = '<mark name="{}"/>'
name = 'speechd'
@classmethod
def escape_marked_text(cls, text):
return prepare_string_for_xml(text)
def __init__(self, dispatch_on_main_thread):
self.create_ssip_client()
self.status = {'synthesizing': False, 'paused': False}
self.dispatch_on_main_thread = dispatch_on_main_thread
self.current_marked_text = None
@ -27,6 +27,9 @@ class Client:
self.next_cancel_is_for_pause = False
self.next_begin_is_for_resume = False
self.current_callback = None
self.settings_applied = False
self.ssip_client = None
self.system_default_output_module = None
def create_ssip_client(self):
from speechd.client import Priority, SpawnError, SSIPClient
@ -37,12 +40,29 @@ class Client:
self.ssip_client.set_priority(Priority.TEXT)
def __del__(self):
if hasattr(self, 'ssip_client'):
if self.ssip_client is not None:
self.ssip_client.cancel()
self.ssip_client.close()
del self.ssip_client
self.ssip_client = None
shutdown = __del__
def ensure_state(self, use_ssml=False):
if self.ssip_client is None:
self.create_ssip_client()
if self.system_default_output_module is None:
self.system_default_output_module = self.ssip_client.get_output_module()
if not self.settings_applied:
self.apply_settings()
self.set_use_ssml(use_ssml)
def apply_settings(self, new_settings=None):
if self.settings_applied:
self.shutdown()
self.settings_applied = False
self.ensure_state()
self.settings_applied = True
# TODO: Implement this
def set_use_ssml(self, on):
from speechd.client import DataMode, SSIPCommunicationError
mode = DataMode.SSML if on else DataMode.TEXT
@ -50,12 +70,12 @@ class Client:
self.ssip_client.set_data_mode(mode)
except SSIPCommunicationError:
self.ssip_client.close()
self.create_ssip_client()
self.ssip_client.set_data_mode(mode)
self.ssip_client = None
self.ensure_state(on)
def speak_simple_text(self, text):
self.stop()
self.set_use_ssml(False)
self.ensure_state(use_ssml=False)
self.current_marked_text = self.last_mark = None
def callback(callback_type, index_mark=None):
@ -104,7 +124,7 @@ class Client:
self.dispatch_on_main_thread(partial(callback_wrapper, callback_type, index_mark))
self.current_callback = cw
self.set_use_ssml(True)
self.ensure_state(use_ssml=True)
self.ssip_client.speak(text, callback=self.current_callback)
def pause(self):
@ -131,4 +151,21 @@ class Client:
self.current_callback = self.current_marked_text = self.last_mark = None
self.next_cancel_is_for_pause = False
self.next_begin_is_for_resume = False
if self.ssip_client is not None:
self.ssip_client.stop()
def config_widget(self, backend_settings, parent):
from calibre.gui2.tts.linux_config import Widget
return Widget(self, backend_settings, parent)
def get_voice_data(self):
ans = getattr(self, 'voice_data', None)
if ans is None:
self.ensure_state()
ans = self.voice_data = {}
output_module = self.ssip_client.get_output_module()
for om in self.ssip_client.list_output_modules():
self.ssip_client.set_output_module(om)
ans[om] = tuple(self.ssip_client.list_synthesis_voices())
self.ssip_client.set_output_module(output_module)
return ans

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from PyQt5.Qt import (
QAbstractTableModel, QComboBox, QFontMetrics, QFormLayout, Qt, QTableView,
QWidget
)
from calibre.gui2.preferences.look_feel import BusyCursor
class VoicesModel(QAbstractTableModel):
def __init__(self, voice_data, default_output_module, parent=None):
super().__init__(parent)
self.voice_data = voice_data
self.current_voices = voice_data[default_output_module]
self.column_headers = (_('Name'), _('Language'), _('Variant'))
def rowCount(self, parent=None):
return len(self.current_voices)
def columnCount(self, parent=None):
return 3
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()
try:
data = self.current_voices[row]
return data[index.column()]
except IndexError:
return
def change_output_module(self, om):
self.beginResetModel()
try:
self.current_voices = self.voice_data[om]
finally:
self.endResetModel()
class Widget(QWidget):
def __init__(self, tts_client, initial_backend_settings, parent=None):
QWidget.__init__(self, parent)
self.l = l = QFormLayout(self)
self.tts_client = tts_client
self.output_modules = om = QComboBox(self)
with BusyCursor():
self.voice_data = self.tts_client.get_voice_data()
self.system_default_output_module = self.tts_client.system_default_output_module
om.addItem(_('System default'), self.system_default_output_module)
l.addRow(_('Speech synthesizer:'), om)
self.voices = v = QTableView(self)
self.voices_model = VoicesModel(self.voice_data, self.system_default_output_module, parent=v)
v.setModel(self.voices_model)
v.horizontalHeader().resizeSection(0, QFontMetrics(self.font()).averageCharWidth() * 30)
l.addRow(v)
def sizeHint(self):
ans = super().sizeHint()
ans.setHeight(max(ans.height(), 600))
return ans

View File

@ -8,6 +8,7 @@ from .common import Event, EventType
class Client:
mark_template = '[[sync 0x{:x}]]'
name = 'nsss'
@classmethod
def escape_marked_text(cls, text):

View File

@ -13,6 +13,7 @@ from .common import Event, EventType
class Client:
mark_template = '<bookmark mark="{}"/>'
name = 'sapi'
@classmethod
def escape_marked_text(cls, text):

View File

@ -22,3 +22,8 @@ def get_session_pref(name, default=None, group='standalone_misc_settings'):
sd = vprefs['session_data']
g = sd.get(group, {}) if group else sd
return g.get(name, default)
def get_pref_group(name):
sd = vprefs['session_data']
return sd.get(name) or {}

View File

@ -2,9 +2,30 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from PyQt5.Qt import QObject, pyqtSignal
from PyQt5.Qt import QObject, QVBoxLayout, pyqtSignal
from calibre.gui2 import error_dialog
from calibre.gui2.viewer.config import get_pref_group, vprefs
from calibre.gui2.widgets2 import Dialog
class Config(Dialog):
def __init__(self, tts_client, ui_settings, backend_settings, parent):
self.tts_client = tts_client
self.ui_settings = ui_settings
self.backend_settings = backend_settings
Dialog.__init__(self, _('Configure Read aloud'), 'read-aloud-config', parent, prefs=vprefs)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.config_widget = self.tts_client.config_widget(self.backend_settings, self)
l.addWidget(self.config_widget)
l.addWidget(self.bb)
def accept(self):
self.backend_settings = self.config_widget.backend_settings
return super().accept()
def add_markup(text_parts):
@ -23,6 +44,7 @@ class TTS(QObject):
dispatch_on_main_thread_signal = pyqtSignal(object)
event_received = pyqtSignal(object, object)
settings_changed = pyqtSignal(object)
def __init__(self, parent=None):
QObject.__init__(self, parent)
@ -80,3 +102,11 @@ class TTS(QObject):
def stop(self, data):
self.tts_client.stop()
def configure(self, data):
ui_settings = get_pref_group('tts').copy()
key = 'tts_' + self.tts_client.name
d = Config(self.tts_client, ui_settings, vprefs.get(key) or {}, parent=self.parent())
if d.exec_() == d.DialogCode.Accepted:
vprefs[key] = d.backend_settings
self.settings_changed.emit(d.ui_settings)

View File

@ -351,8 +351,10 @@ class WebPage(QWebEnginePage):
QApplication.instance().clipboard().setMimeData(md)
def javaScriptConsoleMessage(self, level, msg, linenumber, source_id):
prefix = {QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: 'INFO', QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: 'WARNING'}.get(
level, 'ERROR')
prefix = {
QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: 'INFO',
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: 'WARNING'
}.get(level, 'ERROR')
prints('%s: %s:%s: %s' % (prefix, source_id, linenumber, msg), file=sys.stderr)
try:
sys.stderr.flush()

View File

@ -117,6 +117,7 @@ class ReadAloud:
bar.appendChild(cb(None, 'hourglass', _('Pause reading')))
else:
bar.appendChild(cb('play', 'play', _('Start reading') if self.state is STOPPED else _('Resume reading')))
bar.appendChild(cb('configure', 'cogs', _('Configure Read aloud')))
bar.appendChild(cb('hide', 'close', _('Close Read aloud')))
if self.state is not WAITING_FOR_PLAY_TO_START:
notes_container = bar_container.lastChild
@ -129,6 +130,10 @@ class ReadAloud:
else:
notes_container.textContent = _('Tap/click on a word to continue from there')
def configure(self):
self.pause()
ui_operations.tts('configure')
def play(self):
if self.state is PAUSED:
ui_operations.tts('resume')
@ -194,8 +199,7 @@ class ReadAloud:
self.state = PLAYING
elif which is 'end':
self.state = STOPPED
if not self.view.show_next_spine_item():
self.hide()
self.view.show_next_spine_item()
def send_message(self, type, **kw):
self.view.iframe_wrapper.send_message('tts', type=type, **kw)

View File

@ -69,6 +69,7 @@ defaults = {
'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']",
'selection_bar_quick_highlights': v"[]",
'skipped_dialogs': v'{}',
'tts': v'{}',
}
is_local_setting = {
@ -98,6 +99,7 @@ is_local_setting = {
'standalone_recently_opened': True,
'user_stylesheet': True,
'highlight_style': True,
'tts': True,
}