Implement persistence for engine settings

This commit is contained in:
Kovid Goyal 2024-08-27 20:57:31 +05:30
parent 16f7ddb416
commit bf47feab54
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 121 additions and 43 deletions

View File

@ -53,11 +53,11 @@ class QtTTSBackend(QObject):
saying = pyqtSignal(int, int) saying = pyqtSignal(int, int)
state_changed = pyqtSignal(QTextToSpeech.State) state_changed = pyqtSignal(QTextToSpeech.State)
def __init__(self, engine_name: str = '', settings: EngineSpecificSettings = EngineSpecificSettings(), parent: QObject|None = None): def __init__(self, engine_name: str = '', parent: QObject|None = None):
super().__init__(parent) super().__init__(parent)
self.tracker = Tracker() self.tracker = Tracker()
self._voices = None self._voices = None
self.apply_settings(engine_name, settings) self._create_engine(engine_name)
@property @property
def available_voices(self) -> dict[str, tuple[Voice, ...]]: def available_voices(self) -> dict[str, tuple[Voice, ...]]:
@ -65,32 +65,14 @@ class QtTTSBackend(QObject):
self._voices = tuple(map(qvoice_to_voice, self.tts.availableVoices())) self._voices = tuple(map(qvoice_to_voice, self.tts.availableVoices()))
return {'': self._voices} return {'': self._voices}
def apply_settings(self, engine_name: str, settings: EngineSpecificSettings) -> None:
s = {}
if settings.audio_device_id:
for x in QMediaDevices.audioOutputs():
if bytes(x.id) == settings.audio_device_id.id:
s['audioDevice'] = x
break
self.tts = QTextToSpeech(engine_name, s, self)
self.tts.setRate(max(-1, min(float(settings.rate), 1)))
self.tts.setPitch(max(-1, min(float(settings.pitch), 1)))
if settings.volume is not None:
self.tts.setVolume(max(0, min(float(settings.volume), 1)))
if settings.voice_name:
for v in self.availableVoices():
if v.name() == settings.voice_name:
self.setVoice(v)
break
self.tts.sayingWord.connect(self._saying_word)
self.tts.stateChanged.connect(self.state_changed.emit)
def change_rate(self, steps: int = 1) -> bool: def change_rate(self, steps: int = 1) -> bool:
current = self.tts.rate() current = self.tts.rate()
new_rate = max(-1, min(current + 0.2 * steps, 1)) new_rate = max(-1, min(current + 0.2 * steps, 1))
if current == new_rate: if current == new_rate:
return False return False
self.tts.setRate(new_rate) self.tts.setRate(new_rate)
self._current_settings = self._current_settings._replace(rate=new_rate)
self._current_settings.save_to_config()
return True return True
def shutdown(self) -> None: def shutdown(self) -> None:
@ -117,6 +99,40 @@ class QtTTSBackend(QObject):
def error_message(self) -> str: def error_message(self) -> str:
return self.tts.errorString() return self.tts.errorString()
def _create_engine(self, engine_name: str) -> None:
s = {}
if engine_name:
settings = EngineSpecificSettings.create_from_config(engine_name)
if settings.audio_device_id:
for x in QMediaDevices.audioOutputs():
if bytes(x.id) == settings.audio_device_id.id:
s['audioDevice'] = x
break
self.tts = QTextToSpeech(engine_name, s, self)
else:
self.tts = QTextToSpeech(self)
engine_name = self.tts.engine()
settings = EngineSpecificSettings.create_from_config(engine_name)
if settings.audio_device_id:
for x in QMediaDevices.audioOutputs():
if bytes(x.id) == settings.audio_device_id.id:
s['audioDevice'] = x
self.tts = QTextToSpeech(engine_name, s, self)
break
self.tts.setRate(max(-1, min(float(settings.rate), 1)))
self.tts.setPitch(max(-1, min(float(settings.pitch), 1)))
if settings.volume is not None:
self.tts.setVolume(max(0, min(float(settings.volume), 1)))
if settings.voice_name:
for v in self.availableVoices():
if v.name() == settings.voice_name:
self.setVoice(v)
break
self.tts.sayingWord.connect(self._saying_word)
self.tts.stateChanged.connect(self.state_changed.emit)
self._current_settings = settings
def _saying_word(self, word: str, utterance_id: int, start: int, length: int) -> None: def _saying_word(self, word: str, utterance_id: int, start: int, length: int) -> None:
x = self.tracker.mark_word(start, length) x = self.tracker.mark_word(start, length)
if x is not None: if x is not None:

View File

@ -43,7 +43,7 @@ class SpeechdTTSBackend(QObject):
_event_signal = pyqtSignal(object, object) _event_signal = pyqtSignal(object, object)
def __init__(self, engine_name: str = '', settings: EngineSpecificSettings = EngineSpecificSettings(), parent: QObject|None = None): def __init__(self, engine_name: str = '', parent: QObject|None = None):
super().__init__(parent) super().__init__(parent)
self._last_error = '' self._last_error = ''
self._state = QTextToSpeech.State.Ready self._state = QTextToSpeech.State.Ready
@ -55,7 +55,7 @@ class SpeechdTTSBackend(QObject):
self._ssip_client: SSIPClient | None = None self._ssip_client: SSIPClient | None = None
self._event_signal.connect(self._update_status, type=Qt.ConnectionType.QueuedConnection) self._event_signal.connect(self._update_status, type=Qt.ConnectionType.QueuedConnection)
self._current_marked_text = self._last_mark = None self._current_marked_text = self._last_mark = None
self.apply_settings(engine_name, settings) self._apply_settings(EngineSpecificSettings.create_from_config(engine_name))
@property @property
def available_voices(self) -> dict[str, tuple[Voice, ...]]: def available_voices(self) -> dict[str, tuple[Voice, ...]]:
@ -66,12 +66,6 @@ class SpeechdTTSBackend(QObject):
self._set_error(str(e)) self._set_error(str(e))
return self._voices or {} return self._voices or {}
def apply_settings(self, engine_name: str, settings: EngineSpecificSettings) -> None:
try:
self._apply_settings(settings)
except Exception as err:
self._set_error(str(err))
def change_rate(self, steps: int = 1) -> bool: def change_rate(self, steps: int = 1) -> bool:
current = self._current_settings.rate current = self._current_settings.rate
new_rate = max(-1, min(current + 0.2 * steps, 1)) new_rate = max(-1, min(current + 0.2 * steps, 1))
@ -83,6 +77,7 @@ class SpeechdTTSBackend(QObject):
self._set_error(str(e)) self._set_error(str(e))
return False return False
self._current_settings = self._current_settings._replace(rate=new_rate) self._current_settings = self._current_settings._replace(rate=new_rate)
self._current_settings.save_to_config()
return True return True
def stop(self) -> None: def stop(self) -> None:
@ -168,16 +163,20 @@ class SpeechdTTSBackend(QObject):
def _apply_settings(self, settings: EngineSpecificSettings) -> bool: def _apply_settings(self, settings: EngineSpecificSettings) -> bool:
if not self._ensure_state(): if not self._ensure_state():
return False return False
self._ssip_client.set_pitch_range(int(max(-1, min(settings.pitch, 1)) * 100)) try:
self._ssip_client.set_rate(int(max(-1, min(settings.rate, 1)) * 100)) self._ssip_client.set_pitch_range(int(max(-1, min(settings.pitch, 1)) * 100))
if settings.volume is not None: self._ssip_client.set_rate(int(max(-1, min(settings.rate, 1)) * 100))
self._ssip_client.set_volume(-100 + int(max(0, min(settings.volume, 1)) * 200)) if settings.volume is not None:
om = settings.output_module or self._system_default_output_module self._ssip_client.set_volume(-100 + int(max(0, min(settings.volume, 1)) * 200))
self._ssip_client.set_output_module(om) om = settings.output_module or self._system_default_output_module
if settings.voice_name: self._ssip_client.set_output_module(om)
self._ssip_client.set_synthesis_voice(settings.voice_name) if settings.voice_name:
self._current_settings = settings self._ssip_client.set_synthesis_voice(settings.voice_name)
return True self._current_settings = settings
return True
except Exception as e:
self._set_error(str(e))
return False
def _get_all_voices_for_all_output_modules(self) -> dict[str, Voice]: def _get_all_voices_for_all_output_modules(self) -> dict[str, Voice]:
ans = {} ans = {}

View File

@ -2,6 +2,7 @@
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net> # License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
import os import os
from contextlib import suppress
from enum import Enum, auto from enum import Enum, auto
from functools import lru_cache from functools import lru_cache
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
@ -9,9 +10,16 @@ from typing import Literal, NamedTuple
from qt.core import QLocale, QObject, QTextToSpeech, QVoice from qt.core import QLocale, QObject, QTextToSpeech, QVoice
from calibre.constants import islinux, iswindows from calibre.constants import islinux, iswindows
from calibre.utils.config import JSONConfig
from calibre.utils.config_base import tweaks from calibre.utils.config_base import tweaks
from calibre.utils.localization import canonicalize_lang from calibre.utils.localization import canonicalize_lang
CONFIG_NAME = 'tts'
@lru_cache(2)
def load_config():
return JSONConfig(CONFIG_NAME)
class TrackingCapability(Enum): class TrackingCapability(Enum):
NoTracking: int = auto() NoTracking: int = auto()
@ -65,7 +73,62 @@ class EngineSpecificSettings(NamedTuple):
pitch: float = 0 # -1 to 1 0 is normal speech pitch: float = 0 # -1 to 1 0 is normal speech
volume: float | None = None # 0 to 1, None is platform default volume volume: float | None = None # 0 to 1, None is platform default volume
output_module: str = '' output_module: str = ''
engine_name: str = ''
@classmethod
def create_from_prefs(cls, engine_name: str, prefs: dict[str, object]) -> 'EngineSpecificSettings':
adev = prefs.get('audio_device_id')
audio_device_id = None
if adev:
with suppress(Exception):
aid = bytes.fromhex(adev['id'])
description = adev['description']
audio_device_id = AudioDeviceId(aid, description)
rate = 0
with suppress(Exception):
rate = max(-1, min(float(prefs.get('rate')), 1))
pitch = 0
with suppress(Exception):
pitch = max(-1, min(float(prefs.get('pitch')), 1))
volume = None
with suppress(Exception):
volume = max(0, min(float(prefs.get('volume')), 1))
return EngineSpecificSettings(
voice_name=str(prefs.get('voice_name', '')),
output_module=str(prefs.get('output_module', '')),
audio_device_id=audio_device_id, rate=rate, pitch=pitch, volume=volume, engine_name=engine_name)
@classmethod
def create_from_config(cls, engine_name: str) -> 'EngineSpecificSettings':
prefs = load_config().get('engines', {}).get(engine_name, {})
return cls.create_from_prefs(engine_name, prefs)
@property
def as_dict(self) -> dict[str, object]:
ans = {}
if self.audio_device_id:
ans['audio_device_id'] = {'id': self.audio_device_id.id.hex(), 'description': self.audio_device_id.description}
if self.voice_name:
ans['voice_name'] = self.voice_name
if self.rate:
ans['rate'] = self.rate
if self.pitch:
ans['pitch'] = self.pitch
if self.volume is not None:
ans['volume'] = self.volume
if self.output_module:
ans['output_module'] = self.output_module
return ans
def save_to_config(self):
prefs = load_config()
val = self.as_dict
engines = prefs.get('engines', {})
if not val:
engines.pop(self.engine_name, None)
else:
engines[self.engine_name] = val
prefs['engines'] = engines
@lru_cache(2) @lru_cache(2)
@ -102,7 +165,7 @@ def available_engines() -> dict[str, EngineMetadata]:
return ans return ans
def create_tts_backend(engine_name: str = '', settings: EngineSpecificSettings = EngineSpecificSettings(), parent: QObject|None = None): def create_tts_backend(engine_name: str = '', parent: QObject|None = None):
if engine_name == '': if engine_name == '':
if iswindows and tweaks.get('prefer_winsapi'): if iswindows and tweaks.get('prefer_winsapi'):
engine_name = 'sapi' engine_name = 'sapi'
@ -113,10 +176,10 @@ def create_tts_backend(engine_name: str = '', settings: EngineSpecificSettings =
if engine_name == 'speechd': if engine_name == 'speechd':
from calibre.gui2.tts2.speechd import SpeechdTTSBackend from calibre.gui2.tts2.speechd import SpeechdTTSBackend
ans = SpeechdTTSBackend(engine_name, settings, parent) ans = SpeechdTTSBackend(engine_name, parent)
else: else:
from calibre.gui2.tts2.qt import QtTTSBackend from calibre.gui2.tts2.qt import QtTTSBackend
ans = QtTTSBackend(engine_name, settings, parent) ans = QtTTSBackend(engine_name, parent)
return ans return ans