From a50c2db7ae958462161334a1e91bcbdc73bb88b8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 7 Dec 2020 21:13:07 +0530 Subject: [PATCH] Start work on windows tts config --- src/calibre/gui2/tts/windows.py | 57 +++++++- src/calibre/gui2/tts/windows_config.py | 188 +++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 src/calibre/gui2/tts/windows_config.py diff --git a/src/calibre/gui2/tts/windows.py b/src/calibre/gui2/tts/windows.py index 4b6a884a83..cec9c8e75f 100644 --- a/src/calibre/gui2/tts/windows.py +++ b/src/calibre/gui2/tts/windows.py @@ -19,15 +19,20 @@ class Client: def escape_marked_text(cls, text): return prepare_string_for_xml(text) - def __init__(self, settings, dispatch_on_main_thread): + def __init__(self, settings=None, dispatch_on_main_thread=lambda f: f()): from calibre.utils.windows.winsapi import ISpVoice - self.sp_voice = ISpVoice() self.events_thread = Thread(name='SAPIEvents', target=self.wait_for_events, daemon=True) - self.events_thread.start() + self.sp_voice = ISpVoice() + self.default_system_rate = self.sp_voice.get_current_rate() + self.default_system_voice = self.sp_voice.get_current_voice() + self.default_system_sound_output = self.sp_voice.get_current_sound_output() self.current_stream_number = None self.current_callback = None self.dispatch_on_main_thread = dispatch_on_main_thread + self.current_marked_text = self.last_mark = None self.status = {'synthesizing': False, 'paused': False} + self.events_thread.start() + self.apply_settings(settings) def __del__(self): if self.sp_voice is not None: @@ -36,6 +41,12 @@ class Client: self.sp_voice = None shutdown = __del__ + def apply_settings(self, new_settings=None): + settings = new_settings or {} + self.sp_voice.set_current_rate(settings.get('rate', self.default_system_rate)) + self.sp_voice.set_current_voice(settings.get('voice') or self.default_system_voice) + self.sp_voice.set_current_sound_output(settings.get('sound_output') or self.default_system_sound_output) + def wait_for_events(self): while True: if self.sp_voice.wait_for_event() is False: @@ -49,6 +60,7 @@ class Client: c = self.current_callback for (stream_number, event_type, event_data) in self.sp_voice.get_events(): if event_type == SPEI_TTS_BOOKMARK: + self.last_mark = event_data event = Event(EventType.mark, event_data) elif event_type == SPEI_START_INPUT_STREAM: event = Event(EventType.begin) @@ -70,15 +82,21 @@ class Client: SPF_ASYNC, SPF_IS_NOT_XML, SPF_PURGEBEFORESPEAK ) self.current_callback = None + self.current_marked_text = self.last_mark = None self.current_stream_number = self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_NOT_XML, True) - def speak_marked_text(self, text, callback): + def speak_xml(self, text): from calibre_extensions.winsapi import ( SPF_ASYNC, SPF_IS_XML, SPF_PURGEBEFORESPEAK ) - self.current_callback = callback self.current_stream_number = self.sp_voice.speak(text, SPF_ASYNC | SPF_PURGEBEFORESPEAK | SPF_IS_XML, True) + def speak_marked_text(self, text, callback): + self.current_marked_text = text + self.last_mark = None + self.current_callback = callback + self.speak_xml(text) + def stop(self): from calibre_extensions.winsapi import SPF_PURGEBEFORESPEAK if self.status['paused']: @@ -102,3 +120,32 @@ class Client: self.status = {'synthesizing': True, 'paused': False} if self.current_callback is not None: self.current_callback(Event(EventType.resume)) + + def resume_after_configure(self): + if self.status['paused']: + mark = self.mark_template.format(self.last_mark) + idx = self.current_marked_text.find(mark) + if idx == -1: + text = self.current_marked_text + else: + text = self.current_marked_text[idx:] + self.speak_xml(text) + self.status = {'synthesizing': True, 'paused': False} + if self.current_callback is not None: + self.current_callback(Event(EventType.resume)) + + def get_voice_data(self): + ans = getattr(self, 'voice_data', None) + if ans is None: + ans = self.voice_data = self.sp_voice.get_all_voices() + return ans + + def get_sound_outputs(self): + ans = getattr(self, 'sound_outputs', None) + if ans is None: + ans = self.sound_outputs = self.sp_voice.get_all_sound_outputs() + return ans + + def config_widget(self, backend_settings, parent): + from calibre.gui2.tts.windows_config import Widget + return Widget(self, backend_settings, parent) diff --git a/src/calibre/gui2/tts/windows_config.py b/src/calibre/gui2/tts/windows_config.py new file mode 100644 index 0000000000..11c325d1fe --- /dev/null +++ b/src/calibre/gui2/tts/windows_config.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2020, Kovid Goyal + +from contextlib import suppress +from PyQt5.Qt import ( + QAbstractItemView, QAbstractTableModel, QComboBox, QFontMetrics, QFormLayout, + QItemSelectionModel, QSlider, QSortFilterProxyModel, 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 + + def language(x): + return x.get('language_display_name') or x['language'] or '' + + self.current_voices = tuple((x['name'], language(x), x['age'], x['gender'], x['id']) for x in voice_data) + 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 '' + return ans + if role == Qt.ItemDataRole.UserRole: + row = index.row() + with suppress(IndexError): + if row == 0: + return self.system_default_voice + return self.current_voices[row - 1][4] + + def index_for_voice(self, v): + r = 0 + if v != self.system_default_voice: + for i, x in enumerate(self.current_voices): + if x['id'] == v: + r = i + i + break + else: + return + 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.all_sound_outputs = self.tts_client.get_sound_outputs() + + self.speed = s = QSlider(Qt.Orientation.Horizontal, self) + s.setMinimumWidth(200) + l.addRow(_('&Speed of speech (words per minute):'), s) + s.setRange(-10, 10) + s.setSingleStep(1) + + 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.sound_outputs = so = QComboBox(self) + so.addItem(_('System default'), '') + for x in self.all_sound_outputs: + so.addItem(x.get('description') or x['id'], x['id']) + l.addRow(_('Sound output:'), so) + + 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 sound_output(self): + return self.sound_output.currentData() + + @sound_output.setter + def sound_output(self, val): + val = val or '' + idx = 0 + if val: + q = self.sound_output.findData(val) + if q > -1: + idx = q + self.sound_output.setCurrentIndex(idx) + + @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 + so = self.sound_output + if so: + ans['sound_output'] = so + 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 + self.sound_output = val.get('sound_output') or '' + + +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()