mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
Start work on configuring TTS backends
This commit is contained in:
parent
289a5977af
commit
380f49b8fc
@ -13,13 +13,13 @@ from .errors import TTSSystemUnavailable
|
|||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
mark_template = '<mark name="{}"/>'
|
mark_template = '<mark name="{}"/>'
|
||||||
|
name = 'speechd'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def escape_marked_text(cls, text):
|
def escape_marked_text(cls, text):
|
||||||
return prepare_string_for_xml(text)
|
return prepare_string_for_xml(text)
|
||||||
|
|
||||||
def __init__(self, dispatch_on_main_thread):
|
def __init__(self, dispatch_on_main_thread):
|
||||||
self.create_ssip_client()
|
|
||||||
self.status = {'synthesizing': False, 'paused': False}
|
self.status = {'synthesizing': False, 'paused': False}
|
||||||
self.dispatch_on_main_thread = dispatch_on_main_thread
|
self.dispatch_on_main_thread = dispatch_on_main_thread
|
||||||
self.current_marked_text = None
|
self.current_marked_text = None
|
||||||
@ -27,6 +27,9 @@ class Client:
|
|||||||
self.next_cancel_is_for_pause = False
|
self.next_cancel_is_for_pause = False
|
||||||
self.next_begin_is_for_resume = False
|
self.next_begin_is_for_resume = False
|
||||||
self.current_callback = None
|
self.current_callback = None
|
||||||
|
self.settings_applied = False
|
||||||
|
self.ssip_client = None
|
||||||
|
self.system_default_output_module = None
|
||||||
|
|
||||||
def create_ssip_client(self):
|
def create_ssip_client(self):
|
||||||
from speechd.client import Priority, SpawnError, SSIPClient
|
from speechd.client import Priority, SpawnError, SSIPClient
|
||||||
@ -37,12 +40,29 @@ class Client:
|
|||||||
self.ssip_client.set_priority(Priority.TEXT)
|
self.ssip_client.set_priority(Priority.TEXT)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
if hasattr(self, 'ssip_client'):
|
if self.ssip_client is not None:
|
||||||
self.ssip_client.cancel()
|
self.ssip_client.cancel()
|
||||||
self.ssip_client.close()
|
self.ssip_client.close()
|
||||||
del self.ssip_client
|
self.ssip_client = None
|
||||||
shutdown = __del__
|
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):
|
def set_use_ssml(self, on):
|
||||||
from speechd.client import DataMode, SSIPCommunicationError
|
from speechd.client import DataMode, SSIPCommunicationError
|
||||||
mode = DataMode.SSML if on else DataMode.TEXT
|
mode = DataMode.SSML if on else DataMode.TEXT
|
||||||
@ -50,12 +70,12 @@ class Client:
|
|||||||
self.ssip_client.set_data_mode(mode)
|
self.ssip_client.set_data_mode(mode)
|
||||||
except SSIPCommunicationError:
|
except SSIPCommunicationError:
|
||||||
self.ssip_client.close()
|
self.ssip_client.close()
|
||||||
self.create_ssip_client()
|
self.ssip_client = None
|
||||||
self.ssip_client.set_data_mode(mode)
|
self.ensure_state(on)
|
||||||
|
|
||||||
def speak_simple_text(self, text):
|
def speak_simple_text(self, text):
|
||||||
self.stop()
|
self.stop()
|
||||||
self.set_use_ssml(False)
|
self.ensure_state(use_ssml=False)
|
||||||
self.current_marked_text = self.last_mark = None
|
self.current_marked_text = self.last_mark = None
|
||||||
|
|
||||||
def callback(callback_type, index_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.dispatch_on_main_thread(partial(callback_wrapper, callback_type, index_mark))
|
||||||
self.current_callback = cw
|
self.current_callback = cw
|
||||||
|
|
||||||
self.set_use_ssml(True)
|
self.ensure_state(use_ssml=True)
|
||||||
self.ssip_client.speak(text, callback=self.current_callback)
|
self.ssip_client.speak(text, callback=self.current_callback)
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
@ -131,4 +151,21 @@ class Client:
|
|||||||
self.current_callback = self.current_marked_text = self.last_mark = None
|
self.current_callback = self.current_marked_text = self.last_mark = None
|
||||||
self.next_cancel_is_for_pause = False
|
self.next_cancel_is_for_pause = False
|
||||||
self.next_begin_is_for_resume = 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
|
||||||
|
72
src/calibre/gui2/tts/linux_config.py
Normal file
72
src/calibre/gui2/tts/linux_config.py
Normal 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
|
@ -8,6 +8,7 @@ from .common import Event, EventType
|
|||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
mark_template = '[[sync 0x{:x}]]'
|
mark_template = '[[sync 0x{:x}]]'
|
||||||
|
name = 'nsss'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def escape_marked_text(cls, text):
|
def escape_marked_text(cls, text):
|
||||||
|
@ -13,6 +13,7 @@ from .common import Event, EventType
|
|||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
mark_template = '<bookmark mark="{}"/>'
|
mark_template = '<bookmark mark="{}"/>'
|
||||||
|
name = 'sapi'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def escape_marked_text(cls, text):
|
def escape_marked_text(cls, text):
|
||||||
|
@ -22,3 +22,8 @@ def get_session_pref(name, default=None, group='standalone_misc_settings'):
|
|||||||
sd = vprefs['session_data']
|
sd = vprefs['session_data']
|
||||||
g = sd.get(group, {}) if group else sd
|
g = sd.get(group, {}) if group else sd
|
||||||
return g.get(name, default)
|
return g.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pref_group(name):
|
||||||
|
sd = vprefs['session_data']
|
||||||
|
return sd.get(name) or {}
|
||||||
|
@ -2,9 +2,30 @@
|
|||||||
# vim:fileencoding=utf-8
|
# vim:fileencoding=utf-8
|
||||||
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
# 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 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):
|
def add_markup(text_parts):
|
||||||
@ -23,6 +44,7 @@ class TTS(QObject):
|
|||||||
|
|
||||||
dispatch_on_main_thread_signal = pyqtSignal(object)
|
dispatch_on_main_thread_signal = pyqtSignal(object)
|
||||||
event_received = pyqtSignal(object, object)
|
event_received = pyqtSignal(object, object)
|
||||||
|
settings_changed = pyqtSignal(object)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
QObject.__init__(self, parent)
|
QObject.__init__(self, parent)
|
||||||
@ -80,3 +102,11 @@ class TTS(QObject):
|
|||||||
|
|
||||||
def stop(self, data):
|
def stop(self, data):
|
||||||
self.tts_client.stop()
|
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)
|
||||||
|
@ -351,8 +351,10 @@ class WebPage(QWebEnginePage):
|
|||||||
QApplication.instance().clipboard().setMimeData(md)
|
QApplication.instance().clipboard().setMimeData(md)
|
||||||
|
|
||||||
def javaScriptConsoleMessage(self, level, msg, linenumber, source_id):
|
def javaScriptConsoleMessage(self, level, msg, linenumber, source_id):
|
||||||
prefix = {QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: 'INFO', QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: 'WARNING'}.get(
|
prefix = {
|
||||||
level, 'ERROR')
|
QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: 'INFO',
|
||||||
|
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: 'WARNING'
|
||||||
|
}.get(level, 'ERROR')
|
||||||
prints('%s: %s:%s: %s' % (prefix, source_id, linenumber, msg), file=sys.stderr)
|
prints('%s: %s:%s: %s' % (prefix, source_id, linenumber, msg), file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
@ -117,6 +117,7 @@ class ReadAloud:
|
|||||||
bar.appendChild(cb(None, 'hourglass', _('Pause reading')))
|
bar.appendChild(cb(None, 'hourglass', _('Pause reading')))
|
||||||
else:
|
else:
|
||||||
bar.appendChild(cb('play', 'play', _('Start reading') if self.state is STOPPED else _('Resume reading')))
|
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')))
|
bar.appendChild(cb('hide', 'close', _('Close Read aloud')))
|
||||||
if self.state is not WAITING_FOR_PLAY_TO_START:
|
if self.state is not WAITING_FOR_PLAY_TO_START:
|
||||||
notes_container = bar_container.lastChild
|
notes_container = bar_container.lastChild
|
||||||
@ -129,6 +130,10 @@ class ReadAloud:
|
|||||||
else:
|
else:
|
||||||
notes_container.textContent = _('Tap/click on a word to continue from there')
|
notes_container.textContent = _('Tap/click on a word to continue from there')
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
self.pause()
|
||||||
|
ui_operations.tts('configure')
|
||||||
|
|
||||||
def play(self):
|
def play(self):
|
||||||
if self.state is PAUSED:
|
if self.state is PAUSED:
|
||||||
ui_operations.tts('resume')
|
ui_operations.tts('resume')
|
||||||
@ -194,8 +199,7 @@ class ReadAloud:
|
|||||||
self.state = PLAYING
|
self.state = PLAYING
|
||||||
elif which is 'end':
|
elif which is 'end':
|
||||||
self.state = STOPPED
|
self.state = STOPPED
|
||||||
if not self.view.show_next_spine_item():
|
self.view.show_next_spine_item()
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def send_message(self, type, **kw):
|
def send_message(self, type, **kw):
|
||||||
self.view.iframe_wrapper.send_message('tts', type=type, **kw)
|
self.view.iframe_wrapper.send_message('tts', type=type, **kw)
|
||||||
|
@ -69,6 +69,7 @@ defaults = {
|
|||||||
'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']",
|
'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']",
|
||||||
'selection_bar_quick_highlights': v"[]",
|
'selection_bar_quick_highlights': v"[]",
|
||||||
'skipped_dialogs': v'{}',
|
'skipped_dialogs': v'{}',
|
||||||
|
'tts': v'{}',
|
||||||
}
|
}
|
||||||
|
|
||||||
is_local_setting = {
|
is_local_setting = {
|
||||||
@ -98,6 +99,7 @@ is_local_setting = {
|
|||||||
'standalone_recently_opened': True,
|
'standalone_recently_opened': True,
|
||||||
'user_stylesheet': True,
|
'user_stylesheet': True,
|
||||||
'highlight_style': True,
|
'highlight_style': True,
|
||||||
|
'tts': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user