mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Start work on windows tts config
This commit is contained in:
parent
0e3bc56602
commit
a50c2db7ae
@ -19,15 +19,20 @@ class Client:
|
|||||||
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, settings, dispatch_on_main_thread):
|
def __init__(self, settings=None, dispatch_on_main_thread=lambda f: f()):
|
||||||
from calibre.utils.windows.winsapi import ISpVoice
|
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 = 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_stream_number = None
|
||||||
self.current_callback = None
|
self.current_callback = None
|
||||||
self.dispatch_on_main_thread = dispatch_on_main_thread
|
self.dispatch_on_main_thread = dispatch_on_main_thread
|
||||||
|
self.current_marked_text = self.last_mark = None
|
||||||
self.status = {'synthesizing': False, 'paused': False}
|
self.status = {'synthesizing': False, 'paused': False}
|
||||||
|
self.events_thread.start()
|
||||||
|
self.apply_settings(settings)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
if self.sp_voice is not None:
|
if self.sp_voice is not None:
|
||||||
@ -36,6 +41,12 @@ class Client:
|
|||||||
self.sp_voice = None
|
self.sp_voice = None
|
||||||
shutdown = __del__
|
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):
|
def wait_for_events(self):
|
||||||
while True:
|
while True:
|
||||||
if self.sp_voice.wait_for_event() is False:
|
if self.sp_voice.wait_for_event() is False:
|
||||||
@ -49,6 +60,7 @@ class Client:
|
|||||||
c = self.current_callback
|
c = self.current_callback
|
||||||
for (stream_number, event_type, event_data) in self.sp_voice.get_events():
|
for (stream_number, event_type, event_data) in self.sp_voice.get_events():
|
||||||
if event_type == SPEI_TTS_BOOKMARK:
|
if event_type == SPEI_TTS_BOOKMARK:
|
||||||
|
self.last_mark = event_data
|
||||||
event = Event(EventType.mark, event_data)
|
event = Event(EventType.mark, event_data)
|
||||||
elif event_type == SPEI_START_INPUT_STREAM:
|
elif event_type == SPEI_START_INPUT_STREAM:
|
||||||
event = Event(EventType.begin)
|
event = Event(EventType.begin)
|
||||||
@ -70,15 +82,21 @@ class Client:
|
|||||||
SPF_ASYNC, SPF_IS_NOT_XML, SPF_PURGEBEFORESPEAK
|
SPF_ASYNC, SPF_IS_NOT_XML, SPF_PURGEBEFORESPEAK
|
||||||
)
|
)
|
||||||
self.current_callback = None
|
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)
|
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 (
|
from calibre_extensions.winsapi import (
|
||||||
SPF_ASYNC, SPF_IS_XML, SPF_PURGEBEFORESPEAK
|
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)
|
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):
|
def stop(self):
|
||||||
from calibre_extensions.winsapi import SPF_PURGEBEFORESPEAK
|
from calibre_extensions.winsapi import SPF_PURGEBEFORESPEAK
|
||||||
if self.status['paused']:
|
if self.status['paused']:
|
||||||
@ -102,3 +120,32 @@ class Client:
|
|||||||
self.status = {'synthesizing': True, 'paused': False}
|
self.status = {'synthesizing': True, 'paused': False}
|
||||||
if self.current_callback is not None:
|
if self.current_callback is not None:
|
||||||
self.current_callback(Event(EventType.resume))
|
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)
|
||||||
|
188
src/calibre/gui2/tts/windows_config.py
Normal file
188
src/calibre/gui2/tts/windows_config.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
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()
|
Loading…
x
Reference in New Issue
Block a user