From bf47feab548d7acbe0d0a625d8c2cf8062721b27 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 27 Aug 2024 20:57:31 +0530 Subject: [PATCH] Implement persistence for engine settings --- src/calibre/gui2/tts2/qt.py | 60 +++++++++++++++++---------- src/calibre/gui2/tts2/speechd.py | 35 ++++++++-------- src/calibre/gui2/tts2/types.py | 69 ++++++++++++++++++++++++++++++-- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/src/calibre/gui2/tts2/qt.py b/src/calibre/gui2/tts2/qt.py index fe50e317e6..dc4220449c 100644 --- a/src/calibre/gui2/tts2/qt.py +++ b/src/calibre/gui2/tts2/qt.py @@ -53,11 +53,11 @@ class QtTTSBackend(QObject): saying = pyqtSignal(int, int) 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) self.tracker = Tracker() self._voices = None - self.apply_settings(engine_name, settings) + self._create_engine(engine_name) @property 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())) 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: current = self.tts.rate() new_rate = max(-1, min(current + 0.2 * steps, 1)) if current == new_rate: return False self.tts.setRate(new_rate) + self._current_settings = self._current_settings._replace(rate=new_rate) + self._current_settings.save_to_config() return True def shutdown(self) -> None: @@ -117,6 +99,40 @@ class QtTTSBackend(QObject): def error_message(self) -> str: 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: x = self.tracker.mark_word(start, length) if x is not None: diff --git a/src/calibre/gui2/tts2/speechd.py b/src/calibre/gui2/tts2/speechd.py index 422651cbb8..69d96c0ee8 100644 --- a/src/calibre/gui2/tts2/speechd.py +++ b/src/calibre/gui2/tts2/speechd.py @@ -43,7 +43,7 @@ class SpeechdTTSBackend(QObject): _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) self._last_error = '' self._state = QTextToSpeech.State.Ready @@ -55,7 +55,7 @@ class SpeechdTTSBackend(QObject): self._ssip_client: SSIPClient | None = None self._event_signal.connect(self._update_status, type=Qt.ConnectionType.QueuedConnection) self._current_marked_text = self._last_mark = None - self.apply_settings(engine_name, settings) + self._apply_settings(EngineSpecificSettings.create_from_config(engine_name)) @property def available_voices(self) -> dict[str, tuple[Voice, ...]]: @@ -66,12 +66,6 @@ class SpeechdTTSBackend(QObject): self._set_error(str(e)) 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: current = self._current_settings.rate new_rate = max(-1, min(current + 0.2 * steps, 1)) @@ -83,6 +77,7 @@ class SpeechdTTSBackend(QObject): self._set_error(str(e)) return False self._current_settings = self._current_settings._replace(rate=new_rate) + self._current_settings.save_to_config() return True def stop(self) -> None: @@ -168,16 +163,20 @@ class SpeechdTTSBackend(QObject): def _apply_settings(self, settings: EngineSpecificSettings) -> bool: if not self._ensure_state(): return False - self._ssip_client.set_pitch_range(int(max(-1, min(settings.pitch, 1)) * 100)) - self._ssip_client.set_rate(int(max(-1, min(settings.rate, 1)) * 100)) - if settings.volume is not None: - self._ssip_client.set_volume(-100 + int(max(0, min(settings.volume, 1)) * 200)) - om = settings.output_module or self._system_default_output_module - self._ssip_client.set_output_module(om) - if settings.voice_name: - self._ssip_client.set_synthesis_voice(settings.voice_name) - self._current_settings = settings - return True + try: + self._ssip_client.set_pitch_range(int(max(-1, min(settings.pitch, 1)) * 100)) + self._ssip_client.set_rate(int(max(-1, min(settings.rate, 1)) * 100)) + if settings.volume is not None: + self._ssip_client.set_volume(-100 + int(max(0, min(settings.volume, 1)) * 200)) + om = settings.output_module or self._system_default_output_module + self._ssip_client.set_output_module(om) + if settings.voice_name: + self._ssip_client.set_synthesis_voice(settings.voice_name) + 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]: ans = {} diff --git a/src/calibre/gui2/tts2/types.py b/src/calibre/gui2/tts2/types.py index 807b31cfed..f120602df0 100644 --- a/src/calibre/gui2/tts2/types.py +++ b/src/calibre/gui2/tts2/types.py @@ -2,6 +2,7 @@ # License: GPLv3 Copyright: 2024, Kovid Goyal import os +from contextlib import suppress from enum import Enum, auto from functools import lru_cache from typing import Literal, NamedTuple @@ -9,9 +10,16 @@ from typing import Literal, NamedTuple from qt.core import QLocale, QObject, QTextToSpeech, QVoice from calibre.constants import islinux, iswindows +from calibre.utils.config import JSONConfig from calibre.utils.config_base import tweaks from calibre.utils.localization import canonicalize_lang +CONFIG_NAME = 'tts' + +@lru_cache(2) +def load_config(): + return JSONConfig(CONFIG_NAME) + class TrackingCapability(Enum): NoTracking: int = auto() @@ -65,7 +73,62 @@ class EngineSpecificSettings(NamedTuple): pitch: float = 0 # -1 to 1 0 is normal speech volume: float | None = None # 0 to 1, None is platform default volume 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) @@ -102,7 +165,7 @@ def available_engines() -> dict[str, EngineMetadata]: 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 iswindows and tweaks.get('prefer_winsapi'): engine_name = 'sapi' @@ -113,10 +176,10 @@ def create_tts_backend(engine_name: str = '', settings: EngineSpecificSettings = if engine_name == 'speechd': from calibre.gui2.tts2.speechd import SpeechdTTSBackend - ans = SpeechdTTSBackend(engine_name, settings, parent) + ans = SpeechdTTSBackend(engine_name, parent) else: from calibre.gui2.tts2.qt import QtTTSBackend - ans = QtTTSBackend(engine_name, settings, parent) + ans = QtTTSBackend(engine_name, parent) return ans