diff --git a/src/calibre/gui2/tts/linux.py b/src/calibre/gui2/tts/linux.py index 353203c214..a21cad213f 100644 --- a/src/calibre/gui2/tts/linux.py +++ b/src/calibre/gui2/tts/linux.py @@ -13,13 +13,13 @@ from .errors import TTSSystemUnavailable class Client: mark_template = '' + 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 - self.ssip_client.stop() + 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 diff --git a/src/calibre/gui2/tts/linux_config.py b/src/calibre/gui2/tts/linux_config.py new file mode 100644 index 0000000000..2b2170afb0 --- /dev/null +++ b/src/calibre/gui2/tts/linux_config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2020, Kovid Goyal + +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 diff --git a/src/calibre/gui2/tts/macos.py b/src/calibre/gui2/tts/macos.py index f253762283..143ffdc59b 100644 --- a/src/calibre/gui2/tts/macos.py +++ b/src/calibre/gui2/tts/macos.py @@ -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): diff --git a/src/calibre/gui2/tts/windows.py b/src/calibre/gui2/tts/windows.py index a264b38dae..61b9fe6d2e 100644 --- a/src/calibre/gui2/tts/windows.py +++ b/src/calibre/gui2/tts/windows.py @@ -13,6 +13,7 @@ from .common import Event, EventType class Client: mark_template = '' + name = 'sapi' @classmethod def escape_marked_text(cls, text): diff --git a/src/calibre/gui2/viewer/config.py b/src/calibre/gui2/viewer/config.py index 8299cfbb0f..1d9c289996 100644 --- a/src/calibre/gui2/viewer/config.py +++ b/src/calibre/gui2/viewer/config.py @@ -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 {} diff --git a/src/calibre/gui2/viewer/tts.py b/src/calibre/gui2/viewer/tts.py index afd935a6c4..5c712e9a3a 100644 --- a/src/calibre/gui2/viewer/tts.py +++ b/src/calibre/gui2/viewer/tts.py @@ -2,9 +2,30 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2020, Kovid Goyal -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) diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 54c649b043..61f0302626 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -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() diff --git a/src/pyj/read_book/read_aloud.pyj b/src/pyj/read_book/read_aloud.pyj index f994c2af0f..4ad215fac0 100644 --- a/src/pyj/read_book/read_aloud.pyj +++ b/src/pyj/read_book/read_aloud.pyj @@ -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) diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index 2000c020b9..7e5e17b4e7 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -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, }