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)
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:

View File

@ -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,6 +163,7 @@ class SpeechdTTSBackend(QObject):
def _apply_settings(self, settings: EngineSpecificSettings) -> bool:
if not self._ensure_state():
return False
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:
@ -178,6 +174,9 @@ class SpeechdTTSBackend(QObject):
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 = {}

View File

@ -2,6 +2,7 @@
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
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