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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. 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
+ ]