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,
}