echoact 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
"""Everything the app remembers between runs (F-24), and nothing it must not.
|
|
2
|
+
|
|
3
|
+
F-24 restores the model, language, gender, voice, speaking style, tempo, CPU
|
|
4
|
+
and memory settings after a normal exit, and is equally explicit that unsaved
|
|
5
|
+
input and the previous playback session are *not* restored. That second half
|
|
6
|
+
is a requirement about what this module may hold, so it is enforced by shape:
|
|
7
|
+
there is no field here for draft text, a playback position, or a last-opened
|
|
8
|
+
job, and ``test_settings.py`` asserts that no such key reaches a saved file.
|
|
9
|
+
A "restore my last session" convenience would be a defect, not a feature.
|
|
10
|
+
|
|
11
|
+
The rest of Section 4.1's changeable defaults live here too, so that one file
|
|
12
|
+
answers "what is configurable, and what does it start as". Safety limits are
|
|
13
|
+
not configurable and stay in ``echoact.policy``; a value read back from disk is
|
|
14
|
+
clamped against them rather than trusted, because a hand-edited or truncated
|
|
15
|
+
settings file must never widen a limit.
|
|
16
|
+
|
|
17
|
+
Writing is atomic (a temp file made unique per *call*, fsync, ``os.replace``).
|
|
18
|
+
N-14 requires previously sound data to survive a save that fails midway, and
|
|
19
|
+
settings are the state the app rewrites most often, so a partial write here is
|
|
20
|
+
the likeliest way to lose it. Two saves running at once must not share a temp
|
|
21
|
+
file either: they would interleave into one payload and each rename would
|
|
22
|
+
report the other's outcome, which loses an update while calling it a success.
|
|
23
|
+
Reading is the mirror image: an unreadable or damaged file yields defaults plus
|
|
24
|
+
a reported ``Problem``, never an exception that stops the app launching -- down
|
|
25
|
+
to the values JSON can carry but Python's ``int()`` and ``float()`` refuse,
|
|
26
|
+
``NaN``, ``Infinity``, and an integer literal too large for a float.
|
|
27
|
+
|
|
28
|
+
A file stamped with a ``version`` newer than this build writes is not read and
|
|
29
|
+
not rewritten (N-15). Interpreting the keys it happens to share with this
|
|
30
|
+
version would apply a v1 meaning to a v2 value, and saving afterwards would
|
|
31
|
+
stamp the v1 meaning back over the file -- destroying the newer settings that
|
|
32
|
+
N-15 exists to protect. Such a file yields defaults, a reported ``Problem``,
|
|
33
|
+
and a refusal to save until the newer build is used again.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import errno
|
|
39
|
+
import json
|
|
40
|
+
import locale
|
|
41
|
+
import math
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
import threading
|
|
46
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
47
|
+
from contextlib import suppress
|
|
48
|
+
from dataclasses import dataclass, field, replace
|
|
49
|
+
from enum import StrEnum
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any, Final
|
|
52
|
+
|
|
53
|
+
from ..domain import Budget, Gender, Language, SpeakingStyle, VoiceSettings
|
|
54
|
+
from ..errors import Code, EchoActError, Problem
|
|
55
|
+
from ..paths import redact, settings_path
|
|
56
|
+
from ..policy import (
|
|
57
|
+
AUTOPLAY_DEFAULT,
|
|
58
|
+
AUTOSAVE_DOCUMENTS_DEFAULT,
|
|
59
|
+
CPU_PERCENT_DEFAULT,
|
|
60
|
+
CPU_PERCENT_MAX,
|
|
61
|
+
CPU_PERCENT_MIN,
|
|
62
|
+
CREDENTIAL_DAYS_DEFAULT,
|
|
63
|
+
CREDENTIAL_DAYS_MAX,
|
|
64
|
+
CREDENTIAL_DAYS_MIN,
|
|
65
|
+
FOLLOW_DEFAULT,
|
|
66
|
+
MCP_ENABLED_DEFAULT,
|
|
67
|
+
MEMORY_CEILING_BYTES,
|
|
68
|
+
MEMORY_FLOOR_BYTES,
|
|
69
|
+
OS_NOTIFICATIONS_DEFAULT,
|
|
70
|
+
PLAYBACK_VOLUME_DEFAULT,
|
|
71
|
+
REST_ENABLED_DEFAULT,
|
|
72
|
+
REST_PORT_DEFAULT,
|
|
73
|
+
RETAIN_HISTORY_DEFAULT,
|
|
74
|
+
RETENTION_DEFAULT_BYTES,
|
|
75
|
+
RETENTION_MAX_BYTES,
|
|
76
|
+
RETENTION_MIN_BYTES,
|
|
77
|
+
SCHEDULED_BACKUP_DEFAULT,
|
|
78
|
+
TEMPO_DEFAULT,
|
|
79
|
+
TEMPO_MAX,
|
|
80
|
+
TEMPO_MIN,
|
|
81
|
+
VOICE_PRESET_MAX,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
#: The model this release ships (A.3). ``echoact.models`` owns the manifest
|
|
85
|
+
#: that describes it; settings need only a stable identifier to remember, and
|
|
86
|
+
#: importing the manifest here would make reading settings depend on the model
|
|
87
|
+
#: cache being present. Kept in step with the manifest's single entry.
|
|
88
|
+
DEFAULT_MODEL_ID: Final = "supertonic-3"
|
|
89
|
+
#: F-06: five female and five male voices, ``F1``-``F5`` and ``M1``-``M5``.
|
|
90
|
+
DEFAULT_VOICE_ID: Final = "F1"
|
|
91
|
+
DEFAULT_GENDER: Final = Gender.FEMALE
|
|
92
|
+
|
|
93
|
+
#: Bumped when the on-disk shape changes in a way a reader must notice. N-15
|
|
94
|
+
#: forbids an older build from destructively rewriting a newer format, so the
|
|
95
|
+
#: number is recorded even though this release only ever writes version 1.
|
|
96
|
+
SETTINGS_SCHEMA_VERSION: Final = 1
|
|
97
|
+
|
|
98
|
+
# The operating system's own division between privileged and user ports, not a
|
|
99
|
+
# product policy: the REST port is only ever bound on loopback (N-17), and a
|
|
100
|
+
# port below this cannot be bound by a standard user account (8.1).
|
|
101
|
+
_MIN_USER_PORT: Final = 1024
|
|
102
|
+
_MAX_PORT: Final = 65535
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DisplayLanguage(StrEnum):
|
|
106
|
+
"""F-86's UI language.
|
|
107
|
+
|
|
108
|
+
Deliberately *not* ``domain.Language``: that enum carries ``AUTO`` and
|
|
109
|
+
describes what the narration sounds like, and F-86 states the display
|
|
110
|
+
language is independent of it. Sharing one type would make "auto" a legal
|
|
111
|
+
answer to "which language is the menu in", which it is not.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
KO = "ko"
|
|
115
|
+
EN = "en"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def os_display_language() -> DisplayLanguage:
|
|
119
|
+
"""F-86's default: the operating system language, falling back to English.
|
|
120
|
+
|
|
121
|
+
The POSIX environment variables come first because they are what a user
|
|
122
|
+
overrides deliberately, then the Windows UI language, then the process
|
|
123
|
+
locale. ``locale.getlocale()`` is last because Python does not call
|
|
124
|
+
``setlocale`` for messages at start-up, so on a fresh interpreter it often
|
|
125
|
+
reports nothing at all and would mask a perfectly good answer above it.
|
|
126
|
+
"""
|
|
127
|
+
for var in ("ECHOACT_DISPLAY_LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
|
|
128
|
+
tag = os.environ.get(var)
|
|
129
|
+
if tag:
|
|
130
|
+
return _language_of_tag(tag)
|
|
131
|
+
if sys.platform == "win32": # pragma: no cover - platform-specific branch
|
|
132
|
+
tag = _windows_ui_language()
|
|
133
|
+
if tag:
|
|
134
|
+
return _language_of_tag(tag)
|
|
135
|
+
with suppress(ValueError, TypeError):
|
|
136
|
+
current = locale.getlocale()[0]
|
|
137
|
+
if current:
|
|
138
|
+
return _language_of_tag(current)
|
|
139
|
+
return DisplayLanguage.EN
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _language_of_tag(tag: str) -> DisplayLanguage:
|
|
143
|
+
normalised = tag.replace("_", "-").strip().lower()
|
|
144
|
+
if normalised.startswith(("ko", "korean")):
|
|
145
|
+
return DisplayLanguage.KO
|
|
146
|
+
return DisplayLanguage.EN
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _windows_ui_language() -> str: # pragma: no cover - platform-specific branch
|
|
150
|
+
try:
|
|
151
|
+
import ctypes
|
|
152
|
+
|
|
153
|
+
lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
|
|
154
|
+
except (ImportError, AttributeError, OSError):
|
|
155
|
+
return ""
|
|
156
|
+
return locale.windows_locale.get(lcid, "")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class VoicePreset:
|
|
161
|
+
"""F-66. A named combination of model, language, gender, voice, tempo and
|
|
162
|
+
speaking style -- and nothing else, because F-66 forbids a preset from
|
|
163
|
+
moving a resource ceiling or an integration permission."""
|
|
164
|
+
|
|
165
|
+
name: str
|
|
166
|
+
voice: VoiceSettings
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict[str, Any]:
|
|
169
|
+
return {"name": self.name, "voice": self.voice.to_dict()}
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(
|
|
173
|
+
cls, d: Mapping[str, Any], problems: list[Problem] | None = None
|
|
174
|
+
) -> VoicePreset:
|
|
175
|
+
"""Read one preset, clamped exactly like ``Settings.voice``.
|
|
176
|
+
|
|
177
|
+
``VoiceSettings.from_dict`` is deliberately *not* used: it trusts the
|
|
178
|
+
mapping and never calls ``validate()``, so a hand-edited file could
|
|
179
|
+
park a 99x tempo in a preset and ``apply_preset`` would copy it into
|
|
180
|
+
``Settings.voice``, past the clamp that guards the voice read from the
|
|
181
|
+
same file. F-07's range must hold for every route out of the file,
|
|
182
|
+
and a preset is one of them. Repairs are appended to ``problems``
|
|
183
|
+
when the caller is collecting them for F-25.
|
|
184
|
+
"""
|
|
185
|
+
voice = d["voice"]
|
|
186
|
+
if not isinstance(voice, Mapping):
|
|
187
|
+
raise TypeError("a preset's voice must be a table")
|
|
188
|
+
return cls(
|
|
189
|
+
name=str(d["name"]),
|
|
190
|
+
voice=_read_voice(voice, _default_voice(), [] if problems is None else problems),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class ResourcePolicyView:
|
|
196
|
+
"""F-78's on-screen distinction between what is set and what is in force.
|
|
197
|
+
|
|
198
|
+
A change made while a job runs applies to the *next* job, so the two values
|
|
199
|
+
legitimately disagree for the length of a job. Showing only one of them is
|
|
200
|
+
what F-78 forbids, and ``applied is None`` says plainly that no job holds a
|
|
201
|
+
budget rather than implying the configured value is currently in force.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
configured_cpu_percent: int
|
|
205
|
+
#: ``None`` means "derive it from this machine's RAM", per F-21.
|
|
206
|
+
configured_memory_bytes: int | None
|
|
207
|
+
applied: Budget | None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def differs(self) -> bool:
|
|
211
|
+
if self.applied is None:
|
|
212
|
+
return False
|
|
213
|
+
if self.applied.cpu_percent != self.configured_cpu_percent:
|
|
214
|
+
return True
|
|
215
|
+
return (
|
|
216
|
+
self.configured_memory_bytes is not None
|
|
217
|
+
and self.applied.memory_bytes != self.configured_memory_bytes
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def output_device_key(host_api: str, name: str) -> str:
|
|
222
|
+
"""The one spelling of a remembered output device (F-67).
|
|
223
|
+
|
|
224
|
+
``echoact.audio.devices`` owns this format -- ``OutputDevice.key`` builds
|
|
225
|
+
it and ``devices.resolve`` compares against it exactly -- and settings
|
|
226
|
+
only stores it. It is a function here rather than a sentence in a
|
|
227
|
+
docstring so that a caller holding a device cannot invent a second
|
|
228
|
+
spelling: a bare device name resolves to no device, which ``resolve``
|
|
229
|
+
defines as "the remembered device is gone", so F-67 would pause playback
|
|
230
|
+
on a speaker that is present and working, every launch.
|
|
231
|
+
``tests/test_settings.py`` pins this against the real ``OutputDevice``.
|
|
232
|
+
"""
|
|
233
|
+
return f"{host_api}::{name}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _default_display_language() -> DisplayLanguage:
|
|
237
|
+
return os_display_language()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _default_voice() -> VoiceSettings:
|
|
241
|
+
return VoiceSettings(
|
|
242
|
+
model_id=DEFAULT_MODEL_ID,
|
|
243
|
+
language=Language.AUTO,
|
|
244
|
+
gender=DEFAULT_GENDER,
|
|
245
|
+
voice_id=DEFAULT_VOICE_ID,
|
|
246
|
+
style=SpeakingStyle.NATURAL,
|
|
247
|
+
tempo=TEMPO_DEFAULT,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True, slots=True)
|
|
252
|
+
class Settings:
|
|
253
|
+
"""Every value the owner can change and the app remembers.
|
|
254
|
+
|
|
255
|
+
Frozen because one settings object is handed to the job engine, the
|
|
256
|
+
service, and the GUI at once; F-10 fixes a job's settings at creation, and
|
|
257
|
+
a shared mutable object would make that promise depend on nobody holding a
|
|
258
|
+
reference for too long. ``with_`` returns a new instance instead.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
voice: VoiceSettings = field(default_factory=_default_voice)
|
|
262
|
+
|
|
263
|
+
# -- resources (F-20, F-21) -------------------------------------------
|
|
264
|
+
cpu_percent: int = CPU_PERCENT_DEFAULT
|
|
265
|
+
#: ``None`` means the F-21 default -- roughly 25% of total RAM within 2-6
|
|
266
|
+
#: GiB -- which cannot be a constant because it depends on the machine.
|
|
267
|
+
#: Storing the derived number instead would freeze one machine's answer
|
|
268
|
+
#: into a settings file that may be restored onto another.
|
|
269
|
+
memory_bytes: int | None = None
|
|
270
|
+
|
|
271
|
+
# -- playback and reading surface (F-83, F-30, F-67) -------------------
|
|
272
|
+
autoplay: bool = AUTOPLAY_DEFAULT
|
|
273
|
+
follow: bool = FOLLOW_DEFAULT
|
|
274
|
+
volume: float = PLAYBACK_VOLUME_DEFAULT
|
|
275
|
+
muted: bool = False
|
|
276
|
+
#: The remembered output device's key: exactly the string
|
|
277
|
+
#: ``echoact.audio.devices.OutputDevice.key`` produces, built by
|
|
278
|
+
#: :func:`output_device_key`. Not a PortAudio index -- indices are
|
|
279
|
+
#: reassigned whenever a device appears or disappears, so remembering one
|
|
280
|
+
#: would make the app open a *different* speaker after a reboot, exactly
|
|
281
|
+
#: the unconfirmed switch F-67 forbids -- and not a bare device name
|
|
282
|
+
#: either, because ``devices.resolve`` matches this string against
|
|
283
|
+
#: ``OutputDevice.key`` and nothing else: a name alone would match no
|
|
284
|
+
#: device, and a present speaker would be reported as gone and playback
|
|
285
|
+
#: paused for good. ``None`` is the system default.
|
|
286
|
+
output_device: str | None = None
|
|
287
|
+
|
|
288
|
+
# -- presentation (F-86, 4.1) -----------------------------------------
|
|
289
|
+
display_language: DisplayLanguage = field(default_factory=_default_display_language)
|
|
290
|
+
os_notifications: bool = OS_NOTIFICATIONS_DEFAULT
|
|
291
|
+
|
|
292
|
+
# -- integrations (4.1, F-46) -----------------------------------------
|
|
293
|
+
rest_enabled: bool = REST_ENABLED_DEFAULT
|
|
294
|
+
rest_port: int = REST_PORT_DEFAULT
|
|
295
|
+
mcp_enabled: bool = MCP_ENABLED_DEFAULT
|
|
296
|
+
credential_days: int = CREDENTIAL_DAYS_DEFAULT
|
|
297
|
+
|
|
298
|
+
# -- storage (4.1, F-42, F-44) ----------------------------------------
|
|
299
|
+
autosave_documents: bool = AUTOSAVE_DOCUMENTS_DEFAULT
|
|
300
|
+
retain_history: bool = RETAIN_HISTORY_DEFAULT
|
|
301
|
+
retention_bytes: int = RETENTION_DEFAULT_BYTES
|
|
302
|
+
scheduled_backup: bool = SCHEDULED_BACKUP_DEFAULT
|
|
303
|
+
scheduled_backup_location: str | None = None
|
|
304
|
+
|
|
305
|
+
# -- model preparation (F-09 with 5.3, F-80 with N-11) -----------------
|
|
306
|
+
#: Models the owner has authorised for download. 5.3 refuses a download
|
|
307
|
+
#: triggered by REST or MCP for anything not listed, so the default is
|
|
308
|
+
#: empty: an integration can never provoke a large download on its own.
|
|
309
|
+
authorised_downloads: tuple[str, ...] = ()
|
|
310
|
+
#: model id -> the licence identifier the user accepted. N-11 makes an
|
|
311
|
+
#: unaccepted licence a bar to first preparation, and recording *which*
|
|
312
|
+
#: licence was accepted means changed terms ask again instead of
|
|
313
|
+
#: inheriting consent that was given to different terms.
|
|
314
|
+
accepted_licences: Mapping[str, str] = field(default_factory=dict)
|
|
315
|
+
|
|
316
|
+
# -- F-66 -------------------------------------------------------------
|
|
317
|
+
presets: tuple[VoicePreset, ...] = ()
|
|
318
|
+
|
|
319
|
+
#: Keys a future version wrote that this one does not understand. Kept so
|
|
320
|
+
#: that running an older build once does not silently discard the newer
|
|
321
|
+
#: build's settings (N-15).
|
|
322
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
323
|
+
|
|
324
|
+
#: The schema version of the document these values came from. Equal to
|
|
325
|
+
#: ``SETTINGS_SCHEMA_VERSION`` for anything this build read or built
|
|
326
|
+
#: itself; larger only for a file a newer build wrote, which
|
|
327
|
+
#: ``save_settings`` then refuses to overwrite (N-15). Carried on the
|
|
328
|
+
#: value rather than checked at the file, because the refusal has to
|
|
329
|
+
#: follow the settings through the ``with_`` copies the GUI makes.
|
|
330
|
+
schema_version: int = SETTINGS_SCHEMA_VERSION
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def from_a_newer_version(self) -> bool:
|
|
334
|
+
"""N-15: this document is one this build must not interpret or write."""
|
|
335
|
+
return self.schema_version > SETTINGS_SCHEMA_VERSION
|
|
336
|
+
|
|
337
|
+
# -- convenience -------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def with_(self, **kw: Any) -> Settings:
|
|
340
|
+
return replace(self, **kw)
|
|
341
|
+
|
|
342
|
+
def policy_view(self, applied: Budget | None) -> ResourcePolicyView:
|
|
343
|
+
"""F-78. Settings hold the configured value only; the ``Budget`` on a
|
|
344
|
+
running ``Job`` is the applied one, and this pairs them for display."""
|
|
345
|
+
return ResourcePolicyView(
|
|
346
|
+
configured_cpu_percent=self.cpu_percent,
|
|
347
|
+
configured_memory_bytes=self.memory_bytes,
|
|
348
|
+
applied=applied,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def may_download(self, model_id: str) -> bool:
|
|
352
|
+
"""5.3: only a model the owner authorised in advance may be downloaded
|
|
353
|
+
for a REST or MCP request."""
|
|
354
|
+
return model_id in self.authorised_downloads
|
|
355
|
+
|
|
356
|
+
def licence_accepted(self, model_id: str, licence_id: str) -> bool:
|
|
357
|
+
"""N-11 / F-80: the terms must be accepted before first preparation,
|
|
358
|
+
and acceptance is tied to the exact licence the manifest records."""
|
|
359
|
+
return self.accepted_licences.get(model_id) == licence_id
|
|
360
|
+
|
|
361
|
+
def accept_licence(self, model_id: str, licence_id: str) -> Settings:
|
|
362
|
+
accepted = dict(self.accepted_licences)
|
|
363
|
+
accepted[model_id] = licence_id
|
|
364
|
+
return replace(self, accepted_licences=accepted)
|
|
365
|
+
|
|
366
|
+
def authorise_download(self, model_id: str, allowed: bool = True) -> Settings:
|
|
367
|
+
remaining = [m for m in self.authorised_downloads if m != model_id]
|
|
368
|
+
if allowed:
|
|
369
|
+
remaining.append(model_id)
|
|
370
|
+
return replace(self, authorised_downloads=tuple(remaining))
|
|
371
|
+
|
|
372
|
+
# -- presets (F-66) ----------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def preset(self, name: str) -> VoicePreset | None:
|
|
375
|
+
for p in self.presets:
|
|
376
|
+
if p.name == name:
|
|
377
|
+
return p
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def save_preset(self, name: str, voice: VoiceSettings) -> Settings:
|
|
381
|
+
"""Add or overwrite a preset, keeping list order stable.
|
|
382
|
+
|
|
383
|
+
4.1 says a duplicate name is *confirmed* and then overwritten, so the
|
|
384
|
+
confirmation belongs to the caller and arriving here means it was
|
|
385
|
+
given. Refusing the overwrite instead would leave the GUI no way to
|
|
386
|
+
honour a confirmation the user has already made.
|
|
387
|
+
"""
|
|
388
|
+
clean = name.strip()
|
|
389
|
+
if not clean:
|
|
390
|
+
raise EchoActError(Code.INPUT_EMPTY, "A preset needs a name.")
|
|
391
|
+
voice.validate()
|
|
392
|
+
existing = self.preset(clean)
|
|
393
|
+
if existing is None and len(self.presets) >= VOICE_PRESET_MAX:
|
|
394
|
+
raise EchoActError(
|
|
395
|
+
Code.RETENTION_LIMIT_REACHED,
|
|
396
|
+
f"The limit of {VOICE_PRESET_MAX} voice presets has been reached.",
|
|
397
|
+
detail={"limit": VOICE_PRESET_MAX},
|
|
398
|
+
)
|
|
399
|
+
replacement = VoicePreset(name=clean, voice=voice)
|
|
400
|
+
if existing is None:
|
|
401
|
+
return replace(self, presets=(*self.presets, replacement))
|
|
402
|
+
return replace(
|
|
403
|
+
self,
|
|
404
|
+
presets=tuple(replacement if p.name == clean else p for p in self.presets),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def rename_preset(self, old: str, new: str) -> Settings:
|
|
408
|
+
clean = new.strip()
|
|
409
|
+
if not clean:
|
|
410
|
+
raise EchoActError(Code.INPUT_EMPTY, "A preset needs a name.")
|
|
411
|
+
target = self.preset(old)
|
|
412
|
+
if target is None:
|
|
413
|
+
raise EchoActError(Code.NOT_FOUND, "No preset by that name.")
|
|
414
|
+
# Renaming onto an occupied name is the same overwrite F-66 allows for
|
|
415
|
+
# a save: the renamed entry keeps its own position, the collision goes.
|
|
416
|
+
position = [p.name for p in self.presets].index(old)
|
|
417
|
+
kept = [p for p in self.presets if p.name not in (old, clean)]
|
|
418
|
+
kept.insert(min(position, len(kept)), VoicePreset(name=clean, voice=target.voice))
|
|
419
|
+
return replace(self, presets=tuple(kept))
|
|
420
|
+
|
|
421
|
+
def delete_preset(self, name: str) -> Settings:
|
|
422
|
+
if self.preset(name) is None:
|
|
423
|
+
raise EchoActError(Code.NOT_FOUND, "No preset by that name.")
|
|
424
|
+
return replace(self, presets=tuple(p for p in self.presets if p.name != name))
|
|
425
|
+
|
|
426
|
+
def apply_preset(self, name: str) -> Settings:
|
|
427
|
+
"""F-66: applying a preset moves the voice settings and nothing else."""
|
|
428
|
+
target = self.preset(name)
|
|
429
|
+
if target is None:
|
|
430
|
+
raise EchoActError(Code.NOT_FOUND, "No preset by that name.")
|
|
431
|
+
return replace(self, voice=target.voice)
|
|
432
|
+
|
|
433
|
+
# -- serialisation -----------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def to_dict(self) -> dict[str, Any]:
|
|
436
|
+
known: dict[str, Any] = {
|
|
437
|
+
# The document's own version, not this build's: a newer marker is
|
|
438
|
+
# preserved rather than stamped over. ``save_settings`` refuses
|
|
439
|
+
# to write such a document at all (N-15), so the only number that
|
|
440
|
+
# ever reaches a file from here is one this build can read back.
|
|
441
|
+
"version": self.schema_version,
|
|
442
|
+
"voice": self.voice.to_dict(),
|
|
443
|
+
"cpu_percent": self.cpu_percent,
|
|
444
|
+
"memory_bytes": self.memory_bytes,
|
|
445
|
+
"autoplay": self.autoplay,
|
|
446
|
+
"follow": self.follow,
|
|
447
|
+
"volume": round(self.volume, 4),
|
|
448
|
+
"muted": self.muted,
|
|
449
|
+
"output_device": self.output_device,
|
|
450
|
+
"display_language": self.display_language.value,
|
|
451
|
+
"os_notifications": self.os_notifications,
|
|
452
|
+
"rest_enabled": self.rest_enabled,
|
|
453
|
+
"rest_port": self.rest_port,
|
|
454
|
+
"mcp_enabled": self.mcp_enabled,
|
|
455
|
+
"credential_days": self.credential_days,
|
|
456
|
+
"autosave_documents": self.autosave_documents,
|
|
457
|
+
"retain_history": self.retain_history,
|
|
458
|
+
"retention_bytes": self.retention_bytes,
|
|
459
|
+
"scheduled_backup": self.scheduled_backup,
|
|
460
|
+
"scheduled_backup_location": self.scheduled_backup_location,
|
|
461
|
+
"authorised_downloads": list(self.authorised_downloads),
|
|
462
|
+
"accepted_licences": dict(self.accepted_licences),
|
|
463
|
+
"presets": [p.to_dict() for p in self.presets],
|
|
464
|
+
}
|
|
465
|
+
# Unknown keys first, so a key from a future build can never shadow one
|
|
466
|
+
# this build understands and knows how to clamp.
|
|
467
|
+
merged = dict(self.extra)
|
|
468
|
+
merged.update(known)
|
|
469
|
+
return merged
|
|
470
|
+
|
|
471
|
+
@classmethod
|
|
472
|
+
def from_dict(cls, d: Mapping[str, Any]) -> tuple[Settings, tuple[Problem, ...]]:
|
|
473
|
+
"""Read a settings mapping, repairing rather than rejecting.
|
|
474
|
+
|
|
475
|
+
A settings file the app cannot fully understand must not stop it from
|
|
476
|
+
launching, so each unreadable field falls back to its own default and
|
|
477
|
+
reports a ``Problem`` for F-25 to show. Nothing here raises.
|
|
478
|
+
"""
|
|
479
|
+
problems: list[Problem] = []
|
|
480
|
+
defaults = cls()
|
|
481
|
+
|
|
482
|
+
version = _read_version(d.get("version"), problems)
|
|
483
|
+
if version > SETTINGS_SCHEMA_VERSION:
|
|
484
|
+
# N-15. Every key is left unread: this build cannot know which of
|
|
485
|
+
# the names it recognises still mean what they meant in version 1,
|
|
486
|
+
# and a value read under the wrong meaning would be applied to a
|
|
487
|
+
# job and then written back over the newer file. The unknown keys
|
|
488
|
+
# are still carried so nothing is lost if the value is ever saved
|
|
489
|
+
# by a build that does understand them.
|
|
490
|
+
problems.append(
|
|
491
|
+
Problem(
|
|
492
|
+
code=Code.FILE_UNSUPPORTED,
|
|
493
|
+
message=(
|
|
494
|
+
"Your settings were saved by a newer version of EchoAct; "
|
|
495
|
+
"this version is using its defaults and will not change them."
|
|
496
|
+
),
|
|
497
|
+
remedies=(
|
|
498
|
+
"Use the newer version to change these settings.",
|
|
499
|
+
"Settings changed here cannot be saved until then.",
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
return (
|
|
504
|
+
cls(
|
|
505
|
+
schema_version=version,
|
|
506
|
+
extra={k: v for k, v in d.items() if k not in _KNOWN_KEYS},
|
|
507
|
+
),
|
|
508
|
+
tuple(problems),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
memory = _read_memory(d.get("memory_bytes"), problems)
|
|
512
|
+
|
|
513
|
+
return (
|
|
514
|
+
cls(
|
|
515
|
+
voice=_read_voice(d.get("voice"), defaults.voice, problems),
|
|
516
|
+
cpu_percent=_read_int(
|
|
517
|
+
d.get("cpu_percent", defaults.cpu_percent),
|
|
518
|
+
default=defaults.cpu_percent,
|
|
519
|
+
low=CPU_PERCENT_MIN,
|
|
520
|
+
high=CPU_PERCENT_MAX,
|
|
521
|
+
name="cpu_percent",
|
|
522
|
+
problems=problems,
|
|
523
|
+
),
|
|
524
|
+
memory_bytes=memory,
|
|
525
|
+
autoplay=_read_bool(d.get("autoplay"), defaults.autoplay, "autoplay", problems),
|
|
526
|
+
follow=_read_bool(d.get("follow"), defaults.follow, "follow", problems),
|
|
527
|
+
volume=_read_float(
|
|
528
|
+
d.get("volume", defaults.volume),
|
|
529
|
+
default=defaults.volume,
|
|
530
|
+
low=0.0,
|
|
531
|
+
high=1.0,
|
|
532
|
+
name="volume",
|
|
533
|
+
problems=problems,
|
|
534
|
+
),
|
|
535
|
+
muted=_read_bool(d.get("muted"), defaults.muted, "muted", problems),
|
|
536
|
+
output_device=_read_optional_str(d.get("output_device"), "output_device", problems),
|
|
537
|
+
display_language=_read_display_language(
|
|
538
|
+
d.get("display_language"), defaults.display_language, problems
|
|
539
|
+
),
|
|
540
|
+
os_notifications=_read_bool(
|
|
541
|
+
d.get("os_notifications"),
|
|
542
|
+
defaults.os_notifications,
|
|
543
|
+
"os_notifications",
|
|
544
|
+
problems,
|
|
545
|
+
),
|
|
546
|
+
rest_enabled=_read_bool(
|
|
547
|
+
d.get("rest_enabled"), defaults.rest_enabled, "rest_enabled", problems
|
|
548
|
+
),
|
|
549
|
+
rest_port=_read_int(
|
|
550
|
+
d.get("rest_port", defaults.rest_port),
|
|
551
|
+
default=defaults.rest_port,
|
|
552
|
+
low=_MIN_USER_PORT,
|
|
553
|
+
high=_MAX_PORT,
|
|
554
|
+
name="rest_port",
|
|
555
|
+
problems=problems,
|
|
556
|
+
),
|
|
557
|
+
mcp_enabled=_read_bool(
|
|
558
|
+
d.get("mcp_enabled"), defaults.mcp_enabled, "mcp_enabled", problems
|
|
559
|
+
),
|
|
560
|
+
credential_days=_read_int(
|
|
561
|
+
d.get("credential_days", defaults.credential_days),
|
|
562
|
+
default=defaults.credential_days,
|
|
563
|
+
low=CREDENTIAL_DAYS_MIN,
|
|
564
|
+
high=CREDENTIAL_DAYS_MAX,
|
|
565
|
+
name="credential_days",
|
|
566
|
+
problems=problems,
|
|
567
|
+
),
|
|
568
|
+
autosave_documents=_read_bool(
|
|
569
|
+
d.get("autosave_documents"),
|
|
570
|
+
defaults.autosave_documents,
|
|
571
|
+
"autosave_documents",
|
|
572
|
+
problems,
|
|
573
|
+
),
|
|
574
|
+
retain_history=_read_bool(
|
|
575
|
+
d.get("retain_history"), defaults.retain_history, "retain_history", problems
|
|
576
|
+
),
|
|
577
|
+
retention_bytes=_read_int(
|
|
578
|
+
d.get("retention_bytes", defaults.retention_bytes),
|
|
579
|
+
default=defaults.retention_bytes,
|
|
580
|
+
low=RETENTION_MIN_BYTES,
|
|
581
|
+
high=RETENTION_MAX_BYTES,
|
|
582
|
+
name="retention_bytes",
|
|
583
|
+
problems=problems,
|
|
584
|
+
),
|
|
585
|
+
scheduled_backup=_read_bool(
|
|
586
|
+
d.get("scheduled_backup"),
|
|
587
|
+
defaults.scheduled_backup,
|
|
588
|
+
"scheduled_backup",
|
|
589
|
+
problems,
|
|
590
|
+
),
|
|
591
|
+
scheduled_backup_location=_read_optional_str(
|
|
592
|
+
d.get("scheduled_backup_location"), "scheduled_backup_location", problems
|
|
593
|
+
),
|
|
594
|
+
authorised_downloads=_read_str_tuple(
|
|
595
|
+
d.get("authorised_downloads"), "authorised_downloads", problems
|
|
596
|
+
),
|
|
597
|
+
accepted_licences=_read_str_map(
|
|
598
|
+
d.get("accepted_licences"), "accepted_licences", problems
|
|
599
|
+
),
|
|
600
|
+
presets=_read_presets(d.get("presets"), problems),
|
|
601
|
+
extra={k: v for k, v in d.items() if k not in _KNOWN_KEYS},
|
|
602
|
+
),
|
|
603
|
+
tuple(problems),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
_KNOWN_KEYS: Final[frozenset[str]] = frozenset(
|
|
608
|
+
{
|
|
609
|
+
"version",
|
|
610
|
+
"voice",
|
|
611
|
+
"cpu_percent",
|
|
612
|
+
"memory_bytes",
|
|
613
|
+
"autoplay",
|
|
614
|
+
"follow",
|
|
615
|
+
"volume",
|
|
616
|
+
"muted",
|
|
617
|
+
"output_device",
|
|
618
|
+
"display_language",
|
|
619
|
+
"os_notifications",
|
|
620
|
+
"rest_enabled",
|
|
621
|
+
"rest_port",
|
|
622
|
+
"mcp_enabled",
|
|
623
|
+
"credential_days",
|
|
624
|
+
"autosave_documents",
|
|
625
|
+
"retain_history",
|
|
626
|
+
"retention_bytes",
|
|
627
|
+
"scheduled_backup",
|
|
628
|
+
"scheduled_backup_location",
|
|
629
|
+
"authorised_downloads",
|
|
630
|
+
"accepted_licences",
|
|
631
|
+
"presets",
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ======================================================================
|
|
637
|
+
# Field readers. Each repairs one field and records why.
|
|
638
|
+
# ======================================================================
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _problem(code: Code, message: str) -> Problem:
|
|
642
|
+
return Problem(
|
|
643
|
+
code=code,
|
|
644
|
+
message=message,
|
|
645
|
+
remedies=("Check that setting; everything else was kept as saved.",),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _read_bool(raw: Any, default: bool, name: str, problems: list[Problem]) -> bool:
|
|
650
|
+
if raw is None:
|
|
651
|
+
return default
|
|
652
|
+
if isinstance(raw, bool):
|
|
653
|
+
return raw
|
|
654
|
+
problems.append(
|
|
655
|
+
_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not true or false; using {default}.")
|
|
656
|
+
)
|
|
657
|
+
return default
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _as_number(raw: int | float) -> float | None:
|
|
661
|
+
"""A JSON number as a float that can be compared, or ``None`` if it cannot.
|
|
662
|
+
|
|
663
|
+
JSON as Python parses it is wider than the numbers ``int()`` and
|
|
664
|
+
``float()`` accept: ``json.loads`` reads the JavaScript spellings ``NaN``,
|
|
665
|
+
``Infinity`` and ``-Infinity`` by default, and an integer literal has no
|
|
666
|
+
size limit. ``int(nan)`` raises ``ValueError``; ``int(inf)`` and
|
|
667
|
+
``float(10**400)`` raise ``OverflowError``. Every field reader goes
|
|
668
|
+
through here first, because load must never raise: F-24's guarantee is
|
|
669
|
+
that a corrupt settings file costs the user their settings, not their
|
|
670
|
+
launch, and rule 3 forbids a ``ValueError`` crossing this boundary anyway.
|
|
671
|
+
|
|
672
|
+
An infinity keeps its sign, so a range check clamps it to the near end of
|
|
673
|
+
the range like any other out-of-range number. ``NaN`` is the one value
|
|
674
|
+
with no order at all, and gets ``None``: there is no end of the range it
|
|
675
|
+
is nearer to, so its reader falls back to the field's default instead.
|
|
676
|
+
|
|
677
|
+
Going through ``float`` costs nothing here: every range in this module is
|
|
678
|
+
far below 2**53, so any integer that could be *kept* converts exactly,
|
|
679
|
+
and one large enough to lose precision is one about to be clamped.
|
|
680
|
+
"""
|
|
681
|
+
try:
|
|
682
|
+
value = float(raw)
|
|
683
|
+
except OverflowError: # an integer literal with no float to represent it
|
|
684
|
+
return math.inf if raw > 0 else -math.inf
|
|
685
|
+
return None if math.isnan(value) else value
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _read_version(raw: Any, problems: list[Problem]) -> int:
|
|
689
|
+
"""The document's schema version (N-15).
|
|
690
|
+
|
|
691
|
+
An absent or unreadable marker reads as this build's own version: a file
|
|
692
|
+
with no version at all is one this build wrote before the marker existed,
|
|
693
|
+
and treating a damaged marker as "from the future" would lock the user
|
|
694
|
+
out of their own settings over a single bad byte. A marker that is
|
|
695
|
+
legibly larger is the case N-15 is about, and is honoured.
|
|
696
|
+
"""
|
|
697
|
+
if raw is None:
|
|
698
|
+
return SETTINGS_SCHEMA_VERSION
|
|
699
|
+
if isinstance(raw, bool) or not isinstance(raw, int | float):
|
|
700
|
+
problems.append(
|
|
701
|
+
_problem(Code.FILE_CORRUPT, "The settings file's version marker was unreadable.")
|
|
702
|
+
)
|
|
703
|
+
return SETTINGS_SCHEMA_VERSION
|
|
704
|
+
value = _as_number(raw)
|
|
705
|
+
if value is None or math.isinf(value):
|
|
706
|
+
problems.append(
|
|
707
|
+
_problem(Code.FILE_CORRUPT, "The settings file's version marker was unreadable.")
|
|
708
|
+
)
|
|
709
|
+
return SETTINGS_SCHEMA_VERSION
|
|
710
|
+
return int(value)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _read_memory(raw: Any, problems: list[Problem]) -> int | None:
|
|
714
|
+
"""``None`` is not a missing value here but F-21's automatic default, so an
|
|
715
|
+
unreadable one returns to automatic rather than to the 2 GiB floor -- the
|
|
716
|
+
floor is what F-23 refuses below, not what a user meant to ask for."""
|
|
717
|
+
if raw is None:
|
|
718
|
+
return None
|
|
719
|
+
# ``NaN`` is literally not a number, so it takes this branch rather than
|
|
720
|
+
# the clamp below: there is no size it is closest to.
|
|
721
|
+
if isinstance(raw, bool) or not isinstance(raw, int | float) or _as_number(raw) is None:
|
|
722
|
+
problems.append(
|
|
723
|
+
_problem(
|
|
724
|
+
Code.FILE_CORRUPT,
|
|
725
|
+
"Setting 'memory_bytes' was not a number; sizing it from this computer instead.",
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
return None
|
|
729
|
+
return _read_int(
|
|
730
|
+
raw,
|
|
731
|
+
default=MEMORY_FLOOR_BYTES,
|
|
732
|
+
low=MEMORY_FLOOR_BYTES,
|
|
733
|
+
high=MEMORY_CEILING_BYTES,
|
|
734
|
+
name="memory_bytes",
|
|
735
|
+
problems=problems,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _read_int(
|
|
740
|
+
raw: Any, *, default: int, low: int, high: int, name: str, problems: list[Problem]
|
|
741
|
+
) -> int:
|
|
742
|
+
if raw is None: # an explicit null reads the same as an absent key
|
|
743
|
+
return default
|
|
744
|
+
if isinstance(raw, bool) or not isinstance(raw, int | float):
|
|
745
|
+
problems.append(
|
|
746
|
+
_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not a number; using {default}.")
|
|
747
|
+
)
|
|
748
|
+
return default
|
|
749
|
+
value = _as_number(raw)
|
|
750
|
+
if value is None: # NaN: nothing to clamp towards
|
|
751
|
+
problems.append(
|
|
752
|
+
_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not a number; using {default}.")
|
|
753
|
+
)
|
|
754
|
+
return default
|
|
755
|
+
if value < low or value > high:
|
|
756
|
+
# Clamped from the bound, not from ``value``: an infinity has no
|
|
757
|
+
# ``int()``, and the bound is the answer either way.
|
|
758
|
+
clamped = low if value < low else high
|
|
759
|
+
problems.append(
|
|
760
|
+
_problem(
|
|
761
|
+
Code.FILE_CORRUPT,
|
|
762
|
+
f"Setting {name!r} was outside its allowed range; using {clamped}.",
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
return clamped
|
|
766
|
+
return int(value)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _read_float(
|
|
770
|
+
raw: Any, *, default: float, low: float, high: float, name: str, problems: list[Problem]
|
|
771
|
+
) -> float:
|
|
772
|
+
if raw is None:
|
|
773
|
+
return default
|
|
774
|
+
if isinstance(raw, bool) or not isinstance(raw, int | float):
|
|
775
|
+
problems.append(
|
|
776
|
+
_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not a number; using {default}.")
|
|
777
|
+
)
|
|
778
|
+
return default
|
|
779
|
+
value = _as_number(raw)
|
|
780
|
+
if value is None or value < low or value > high: # NaN has no order to clamp
|
|
781
|
+
clamped = default if value is None else (low if value < low else high)
|
|
782
|
+
problems.append(
|
|
783
|
+
_problem(
|
|
784
|
+
Code.FILE_CORRUPT,
|
|
785
|
+
f"Setting {name!r} was outside its allowed range; using {clamped}.",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
return clamped
|
|
789
|
+
return value
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _read_optional_str(raw: Any, name: str, problems: list[Problem]) -> str | None:
|
|
793
|
+
if raw is None:
|
|
794
|
+
return None
|
|
795
|
+
if isinstance(raw, str):
|
|
796
|
+
return raw or None
|
|
797
|
+
problems.append(_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not text; ignoring it."))
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _read_str_tuple(raw: Any, name: str, problems: list[Problem]) -> tuple[str, ...]:
|
|
802
|
+
if raw is None:
|
|
803
|
+
return ()
|
|
804
|
+
if isinstance(raw, str | bytes | Mapping) or not isinstance(raw, Iterable):
|
|
805
|
+
problems.append(_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not a list; ignoring."))
|
|
806
|
+
return ()
|
|
807
|
+
items = list(raw)
|
|
808
|
+
kept = [item for item in items if isinstance(item, str) and item]
|
|
809
|
+
if len(kept) != len(items):
|
|
810
|
+
problems.append(
|
|
811
|
+
_problem(Code.FILE_CORRUPT, f"Some entries of {name!r} were not text and were dropped.")
|
|
812
|
+
)
|
|
813
|
+
return tuple(dict.fromkeys(kept))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _read_str_map(raw: Any, name: str, problems: list[Problem]) -> dict[str, str]:
|
|
817
|
+
if raw is None:
|
|
818
|
+
return {}
|
|
819
|
+
if not isinstance(raw, Mapping):
|
|
820
|
+
problems.append(_problem(Code.FILE_CORRUPT, f"Setting {name!r} was not a table; ignoring."))
|
|
821
|
+
return {}
|
|
822
|
+
kept = {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)}
|
|
823
|
+
if len(kept) != len(raw):
|
|
824
|
+
problems.append(
|
|
825
|
+
_problem(Code.FILE_CORRUPT, f"Some entries of {name!r} were unreadable and dropped.")
|
|
826
|
+
)
|
|
827
|
+
return kept
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _read_display_language(
|
|
831
|
+
raw: Any, default: DisplayLanguage, problems: list[Problem]
|
|
832
|
+
) -> DisplayLanguage:
|
|
833
|
+
if raw is None:
|
|
834
|
+
return default
|
|
835
|
+
try:
|
|
836
|
+
return DisplayLanguage(str(raw))
|
|
837
|
+
except ValueError:
|
|
838
|
+
problems.append(
|
|
839
|
+
_problem(
|
|
840
|
+
Code.LANGUAGE_UNKNOWN,
|
|
841
|
+
f"Display language {raw!r} is not one this version has; using {default.value}.",
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
return default
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _read_voice(raw: Any, default: VoiceSettings, problems: list[Problem]) -> VoiceSettings:
|
|
848
|
+
"""F-24's core: model, language, gender, voice, style, and tempo.
|
|
849
|
+
|
|
850
|
+
Each field falls back on its own rather than the block falling back as a
|
|
851
|
+
whole, so a file naming a voice this build no longer ships still restores
|
|
852
|
+
the user's tempo, language, and style.
|
|
853
|
+
"""
|
|
854
|
+
if raw is None:
|
|
855
|
+
return default
|
|
856
|
+
if not isinstance(raw, Mapping):
|
|
857
|
+
problems.append(
|
|
858
|
+
_problem(Code.FILE_CORRUPT, "The saved voice settings were unreadable; using defaults.")
|
|
859
|
+
)
|
|
860
|
+
return default
|
|
861
|
+
|
|
862
|
+
model_id = raw.get("model_id")
|
|
863
|
+
if not isinstance(model_id, str) or not model_id:
|
|
864
|
+
model_id = default.model_id
|
|
865
|
+
problems.append(_problem(Code.MODEL_UNKNOWN, f"No saved model; using {default.model_id}."))
|
|
866
|
+
|
|
867
|
+
voice_id = raw.get("voice_id")
|
|
868
|
+
if not isinstance(voice_id, str) or not voice_id:
|
|
869
|
+
voice_id = default.voice_id
|
|
870
|
+
problems.append(_problem(Code.VOICE_UNKNOWN, f"No saved voice; using {default.voice_id}."))
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
language = Language(str(raw.get("language", default.language.value)))
|
|
874
|
+
except ValueError:
|
|
875
|
+
language = default.language
|
|
876
|
+
problems.append(
|
|
877
|
+
_problem(
|
|
878
|
+
Code.LANGUAGE_UNKNOWN,
|
|
879
|
+
"The saved narration language is not one this version has; using automatic.",
|
|
880
|
+
)
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
gender = Gender(str(raw.get("gender", default.gender.value)))
|
|
885
|
+
except ValueError:
|
|
886
|
+
gender = default.gender
|
|
887
|
+
problems.append(
|
|
888
|
+
_problem(
|
|
889
|
+
Code.FILE_CORRUPT,
|
|
890
|
+
f"The saved voice gender was unreadable; using {default.gender.value}.",
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
try:
|
|
895
|
+
style = SpeakingStyle(str(raw.get("style", default.style.value)))
|
|
896
|
+
except ValueError:
|
|
897
|
+
style = default.style
|
|
898
|
+
problems.append(
|
|
899
|
+
_problem(
|
|
900
|
+
Code.STYLE_UNKNOWN,
|
|
901
|
+
"The saved speaking style is not one this version has; using natural.",
|
|
902
|
+
)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
return VoiceSettings(
|
|
906
|
+
model_id=model_id,
|
|
907
|
+
language=language,
|
|
908
|
+
gender=gender,
|
|
909
|
+
voice_id=voice_id,
|
|
910
|
+
style=style,
|
|
911
|
+
tempo=_read_tempo(raw.get("tempo", default.tempo), default.tempo, problems),
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _read_tempo(raw: Any, default: float, problems: list[Problem]) -> float:
|
|
916
|
+
if isinstance(raw, bool) or not isinstance(raw, int | float):
|
|
917
|
+
problems.append(_problem(Code.FILE_CORRUPT, "The saved tempo was not a number; using 1.00x."))
|
|
918
|
+
return default
|
|
919
|
+
tempo = _as_number(raw)
|
|
920
|
+
if tempo is None: # NaN
|
|
921
|
+
problems.append(_problem(Code.TEMPO_OUT_OF_RANGE, "The saved tempo was not a number."))
|
|
922
|
+
return TEMPO_DEFAULT
|
|
923
|
+
if not TEMPO_MIN <= tempo <= TEMPO_MAX:
|
|
924
|
+
clamped = TEMPO_MIN if tempo < TEMPO_MIN else TEMPO_MAX
|
|
925
|
+
problems.append(
|
|
926
|
+
_problem(
|
|
927
|
+
Code.TEMPO_OUT_OF_RANGE,
|
|
928
|
+
f"The saved tempo was outside {TEMPO_MIN:.2f}x-{TEMPO_MAX:.2f}x; "
|
|
929
|
+
f"using {clamped:.2f}x.",
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
return clamped
|
|
933
|
+
return tempo
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _read_presets(raw: Any, problems: list[Problem]) -> tuple[VoicePreset, ...]:
|
|
937
|
+
if raw is None:
|
|
938
|
+
return ()
|
|
939
|
+
if isinstance(raw, str | bytes | Mapping) or not isinstance(raw, Iterable):
|
|
940
|
+
problems.append(_problem(Code.FILE_CORRUPT, "The saved presets were unreadable; ignoring."))
|
|
941
|
+
return ()
|
|
942
|
+
kept: list[VoicePreset] = []
|
|
943
|
+
seen: set[str] = set()
|
|
944
|
+
dropped = 0
|
|
945
|
+
for item in raw:
|
|
946
|
+
if not isinstance(item, Mapping):
|
|
947
|
+
dropped += 1
|
|
948
|
+
continue
|
|
949
|
+
# The name is settled before the voice is read, so that a duplicate
|
|
950
|
+
# this loop is about to discard does not report repairs for itself.
|
|
951
|
+
name = item.get("name")
|
|
952
|
+
if not isinstance(name, str) or not name or name in seen:
|
|
953
|
+
dropped += 1
|
|
954
|
+
continue
|
|
955
|
+
try:
|
|
956
|
+
preset = VoicePreset.from_dict(item, problems)
|
|
957
|
+
except (KeyError, TypeError, ValueError):
|
|
958
|
+
dropped += 1
|
|
959
|
+
continue
|
|
960
|
+
seen.add(preset.name)
|
|
961
|
+
kept.append(preset)
|
|
962
|
+
if len(kept) > VOICE_PRESET_MAX:
|
|
963
|
+
dropped += len(kept) - VOICE_PRESET_MAX
|
|
964
|
+
kept = kept[:VOICE_PRESET_MAX]
|
|
965
|
+
if dropped:
|
|
966
|
+
problems.append(
|
|
967
|
+
_problem(Code.FILE_CORRUPT, f"{dropped} saved voice preset(s) could not be read.")
|
|
968
|
+
)
|
|
969
|
+
return tuple(kept)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# ======================================================================
|
|
973
|
+
# Reading and writing the file
|
|
974
|
+
# ======================================================================
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def load_settings(path: Path | None = None) -> tuple[Settings, tuple[Problem, ...]]:
|
|
978
|
+
"""Read the settings file, or return defaults and say what went wrong.
|
|
979
|
+
|
|
980
|
+
Deliberately free of side effects: it does not create the file, and it does
|
|
981
|
+
not move a damaged one aside. A first launch has to be indistinguishable
|
|
982
|
+
from a launch that found nothing to read, and quarantining the file here
|
|
983
|
+
would destroy the only evidence of what went wrong before anyone saw it.
|
|
984
|
+
"""
|
|
985
|
+
target = path or settings_path()
|
|
986
|
+
try:
|
|
987
|
+
text = target.read_text(encoding="utf-8")
|
|
988
|
+
except FileNotFoundError:
|
|
989
|
+
return Settings(), ()
|
|
990
|
+
except PermissionError:
|
|
991
|
+
return Settings(), (
|
|
992
|
+
Problem(
|
|
993
|
+
code=Code.FILE_PERMISSION,
|
|
994
|
+
message="The settings file could not be read; defaults are in use.",
|
|
995
|
+
remedies=(f"Check the permissions on {redact(target)}.",),
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
except (OSError, UnicodeDecodeError):
|
|
999
|
+
return Settings(), (_unreadable(target, "The settings file could not be read"),)
|
|
1000
|
+
|
|
1001
|
+
try:
|
|
1002
|
+
data = json.loads(text)
|
|
1003
|
+
except ValueError:
|
|
1004
|
+
return Settings(), (_unreadable(target, "The settings file is damaged"),)
|
|
1005
|
+
if not isinstance(data, Mapping):
|
|
1006
|
+
return Settings(), (_unreadable(target, "The settings file does not contain settings"),)
|
|
1007
|
+
return Settings.from_dict(data)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _unreadable(target: Path, what: str) -> Problem:
|
|
1011
|
+
return Problem(
|
|
1012
|
+
code=Code.FILE_CORRUPT,
|
|
1013
|
+
message=f"{what}; defaults are in use.",
|
|
1014
|
+
remedies=(
|
|
1015
|
+
f"{redact(target)} is rewritten the next time a setting changes.",
|
|
1016
|
+
"Move it aside first if you want to keep a copy.",
|
|
1017
|
+
),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def save_settings(settings: Settings, path: Path | None = None) -> None:
|
|
1022
|
+
"""Write the settings file so that a crash cannot corrupt it (N-14).
|
|
1023
|
+
|
|
1024
|
+
A temp file of its own in the same directory, flush, ``fsync``, then
|
|
1025
|
+
``os.replace``. The rename is atomic on both supported platforms, so a
|
|
1026
|
+
reader sees either the whole old file or the whole new one. Writing in
|
|
1027
|
+
place would leave a truncated file if the process died between the
|
|
1028
|
+
truncate and the write, and N-14 requires previously sound data to survive
|
|
1029
|
+
a save that fails midway.
|
|
1030
|
+
|
|
1031
|
+
The temp name comes from ``mkstemp`` rather than from the process id: two
|
|
1032
|
+
saves at once in one process would otherwise share a single temp file,
|
|
1033
|
+
interleave their payloads in it, and race their renames -- landing one
|
|
1034
|
+
save's content while telling the *other* caller it succeeded. Lost data
|
|
1035
|
+
reported as a success is precisely what N-14 forbids. ``SettingsStore``
|
|
1036
|
+
serialises its own saves on top of this; a unique temp file is what makes
|
|
1037
|
+
two unserialised ones merely ordered rather than corrupting.
|
|
1038
|
+
"""
|
|
1039
|
+
if settings.from_a_newer_version:
|
|
1040
|
+
raise EchoActError(
|
|
1041
|
+
Code.FILE_UNSUPPORTED,
|
|
1042
|
+
"These settings were saved by a newer version of EchoAct and were not changed.",
|
|
1043
|
+
detail={
|
|
1044
|
+
"file_version": settings.schema_version,
|
|
1045
|
+
"this_version": SETTINGS_SCHEMA_VERSION,
|
|
1046
|
+
},
|
|
1047
|
+
)
|
|
1048
|
+
target = path or settings_path()
|
|
1049
|
+
try:
|
|
1050
|
+
# ``allow_nan`` off: ``json.dumps`` would happily write the JavaScript
|
|
1051
|
+
# spellings ``NaN`` and ``Infinity``, which are legal for the parser
|
|
1052
|
+
# to read back but are not a CPU percentage or a byte count. Refusing
|
|
1053
|
+
# here keeps the last sound file in place instead of writing one this
|
|
1054
|
+
# app could only ever repair, and turns a caller's bad value into the
|
|
1055
|
+
# one exception type rule 3 allows out of a module.
|
|
1056
|
+
payload = (
|
|
1057
|
+
json.dumps(
|
|
1058
|
+
settings.to_dict(), ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False
|
|
1059
|
+
)
|
|
1060
|
+
+ "\n"
|
|
1061
|
+
)
|
|
1062
|
+
except (ValueError, TypeError) as exc:
|
|
1063
|
+
raise EchoActError(
|
|
1064
|
+
Code.INTERNAL,
|
|
1065
|
+
"The settings could not be saved; your previous settings are unchanged.",
|
|
1066
|
+
cause=exc,
|
|
1067
|
+
) from exc
|
|
1068
|
+
|
|
1069
|
+
tmp: Path | None = None
|
|
1070
|
+
try:
|
|
1071
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1072
|
+
handle, name = tempfile.mkstemp(dir=target.parent, prefix=f"{target.name}.", suffix=".tmp")
|
|
1073
|
+
tmp = Path(name)
|
|
1074
|
+
with open(handle, "w", encoding="utf-8", newline="\n") as fh:
|
|
1075
|
+
fh.write(payload)
|
|
1076
|
+
fh.flush()
|
|
1077
|
+
os.fsync(fh.fileno())
|
|
1078
|
+
os.replace(tmp, target)
|
|
1079
|
+
_fsync_dir(target.parent)
|
|
1080
|
+
except OSError as exc:
|
|
1081
|
+
if tmp is not None:
|
|
1082
|
+
with suppress(OSError):
|
|
1083
|
+
tmp.unlink()
|
|
1084
|
+
raise EchoActError(
|
|
1085
|
+
_save_failure_code(exc),
|
|
1086
|
+
"The settings could not be saved; your previous settings are unchanged.",
|
|
1087
|
+
cause=exc,
|
|
1088
|
+
) from exc
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _save_failure_code(exc: OSError) -> Code:
|
|
1092
|
+
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
|
1093
|
+
return Code.STORAGE_FULL
|
|
1094
|
+
if exc.errno in {errno.EACCES, errno.EPERM, errno.EROFS}:
|
|
1095
|
+
return Code.FILE_PERMISSION
|
|
1096
|
+
return Code.INTERNAL
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _fsync_dir(directory: Path) -> None:
|
|
1100
|
+
"""Make the rename itself durable where the OS allows it.
|
|
1101
|
+
|
|
1102
|
+
Windows has no directory handle to sync -- ``os.open`` on a directory fails
|
|
1103
|
+
there -- so this is a no-op on Windows. The atomicity N-14 needs comes
|
|
1104
|
+
from ``os.replace`` either way; only the ordering of the rename against a
|
|
1105
|
+
power loss is left unguaranteed.
|
|
1106
|
+
"""
|
|
1107
|
+
if sys.platform == "win32":
|
|
1108
|
+
return
|
|
1109
|
+
with suppress(OSError): # pragma: no cover - platform-specific branch
|
|
1110
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
1111
|
+
try:
|
|
1112
|
+
os.fsync(fd)
|
|
1113
|
+
finally:
|
|
1114
|
+
os.close(fd)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
class SettingsStore:
|
|
1118
|
+
"""The app's one handle on the settings file.
|
|
1119
|
+
|
|
1120
|
+
Holds the last value successfully read or written, so that a failed save
|
|
1121
|
+
leaves the in-memory state matching what is actually on disk: N-14's
|
|
1122
|
+
"previously sound data is preserved" is only true if the app agrees with
|
|
1123
|
+
the file about which version survived.
|
|
1124
|
+
|
|
1125
|
+
One lock covers reading, writing, and the read-modify-write in
|
|
1126
|
+
``update``. The GUI, the REST service and the MCP server all change
|
|
1127
|
+
settings, on three different threads; without it two ``update`` calls both
|
|
1128
|
+
read the same starting value and the second silently drops the first
|
|
1129
|
+
caller's change, while the store's in-memory value ends up describing
|
|
1130
|
+
whichever save happened to rename last rather than what the file holds.
|
|
1131
|
+
It is an ``RLock`` because ``current`` may load inside a held lock.
|
|
1132
|
+
"""
|
|
1133
|
+
|
|
1134
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
1135
|
+
self._path = path or settings_path()
|
|
1136
|
+
self._settings = Settings()
|
|
1137
|
+
self._problems: tuple[Problem, ...] = ()
|
|
1138
|
+
self._loaded = False
|
|
1139
|
+
self._lock = threading.RLock()
|
|
1140
|
+
|
|
1141
|
+
@property
|
|
1142
|
+
def path(self) -> Path:
|
|
1143
|
+
return self._path
|
|
1144
|
+
|
|
1145
|
+
@property
|
|
1146
|
+
def problems(self) -> tuple[Problem, ...]:
|
|
1147
|
+
"""Whatever the last load had to repair. F-25 reports these once."""
|
|
1148
|
+
with self._lock:
|
|
1149
|
+
return self._problems
|
|
1150
|
+
|
|
1151
|
+
@property
|
|
1152
|
+
def current(self) -> Settings:
|
|
1153
|
+
with self._lock:
|
|
1154
|
+
if not self._loaded:
|
|
1155
|
+
self.load()
|
|
1156
|
+
return self._settings
|
|
1157
|
+
|
|
1158
|
+
def load(self) -> Settings:
|
|
1159
|
+
with self._lock:
|
|
1160
|
+
self._settings, self._problems = load_settings(self._path)
|
|
1161
|
+
self._loaded = True
|
|
1162
|
+
return self._settings
|
|
1163
|
+
|
|
1164
|
+
def save(self, settings: Settings) -> None:
|
|
1165
|
+
with self._lock:
|
|
1166
|
+
save_settings(settings, self._path)
|
|
1167
|
+
self._settings = settings
|
|
1168
|
+
self._loaded = True
|
|
1169
|
+
|
|
1170
|
+
def update(self, **kw: Any) -> Settings:
|
|
1171
|
+
with self._lock:
|
|
1172
|
+
updated = self.current.with_(**kw)
|
|
1173
|
+
self.save(updated)
|
|
1174
|
+
return updated
|
|
1175
|
+
|
|
1176
|
+
def mutate(self, change: Callable[[Settings], Settings]) -> Settings:
|
|
1177
|
+
"""Save a change computed from the current value, atomically.
|
|
1178
|
+
|
|
1179
|
+
``update`` covers "set these fields"; this covers the changes that
|
|
1180
|
+
have to see the old value to work out the new one -- adding a preset,
|
|
1181
|
+
recording a licence acceptance -- which is exactly where a read and a
|
|
1182
|
+
separate write let a concurrent save in between and lose one of them.
|
|
1183
|
+
"""
|
|
1184
|
+
with self._lock:
|
|
1185
|
+
updated = change(self.current)
|
|
1186
|
+
self.save(updated)
|
|
1187
|
+
return updated
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
class SettingsModelPreferences:
|
|
1191
|
+
"""``echoact.models.registry.ModelPreferences``, backed by the settings file.
|
|
1192
|
+
|
|
1193
|
+
N-11 puts licence acceptance in settings and 5.3 puts the download
|
|
1194
|
+
pre-authorisation there too, so both belong in the one file this module
|
|
1195
|
+
owns -- ``registry.JsonModelPreferences`` says as much and exists only
|
|
1196
|
+
until there is something here to hand it. This is that thing: wire it
|
|
1197
|
+
into ``ModelRegistry(preferences=SettingsModelPreferences(store))`` and
|
|
1198
|
+
the owner's two decisions have a single home, one that N-14's atomic save
|
|
1199
|
+
and N-15's version check already protect. Two homes would be worse than
|
|
1200
|
+
either: the F-80 policy screen reads settings, the registry reads its own
|
|
1201
|
+
file, and each would report a licence the other had never seen.
|
|
1202
|
+
|
|
1203
|
+
The protocol's spellings are kept exactly -- ``license_accepted`` and its
|
|
1204
|
+
``fingerprint``, both American, both the registry's -- because a protocol
|
|
1205
|
+
is only satisfied by the caller's names. The translation to this
|
|
1206
|
+
module's ``licence`` and to a new frozen ``Settings`` happens here, where
|
|
1207
|
+
it is one adapter rather than a rule every caller has to remember.
|
|
1208
|
+
"""
|
|
1209
|
+
|
|
1210
|
+
def __init__(self, store: SettingsStore | None = None) -> None:
|
|
1211
|
+
self._store = store if store is not None else SettingsStore()
|
|
1212
|
+
|
|
1213
|
+
@property
|
|
1214
|
+
def store(self) -> SettingsStore:
|
|
1215
|
+
return self._store
|
|
1216
|
+
|
|
1217
|
+
def license_accepted(self, model_id: str, fingerprint: str) -> bool:
|
|
1218
|
+
return self._store.current.licence_accepted(model_id, fingerprint)
|
|
1219
|
+
|
|
1220
|
+
def record_license_acceptance(self, model_id: str, fingerprint: str) -> None:
|
|
1221
|
+
self._store.mutate(lambda s: s.accept_licence(model_id, fingerprint))
|
|
1222
|
+
|
|
1223
|
+
def download_authorised(self, model_id: str) -> bool:
|
|
1224
|
+
return self._store.current.may_download(model_id)
|
|
1225
|
+
|
|
1226
|
+
def set_download_authorised(self, model_id: str, allowed: bool) -> None:
|
|
1227
|
+
self._store.mutate(lambda s: s.authorise_download(model_id, allowed))
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
__all__ = [
|
|
1231
|
+
"DEFAULT_MODEL_ID",
|
|
1232
|
+
"DEFAULT_VOICE_ID",
|
|
1233
|
+
"SETTINGS_SCHEMA_VERSION",
|
|
1234
|
+
"DisplayLanguage",
|
|
1235
|
+
"ResourcePolicyView",
|
|
1236
|
+
"Settings",
|
|
1237
|
+
"SettingsModelPreferences",
|
|
1238
|
+
"SettingsStore",
|
|
1239
|
+
"VoicePreset",
|
|
1240
|
+
"load_settings",
|
|
1241
|
+
"os_display_language",
|
|
1242
|
+
"output_device_key",
|
|
1243
|
+
"save_settings",
|
|
1244
|
+
]
|