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,1202 @@
|
|
|
1
|
+
"""The main screen.
|
|
2
|
+
|
|
3
|
+
N-09 asks that input, voice settings, resource settings, generation, and
|
|
4
|
+
playback all be reachable from here, so this window is wide rather than
|
|
5
|
+
deep: the reading surface takes the space, everything else sits around it,
|
|
6
|
+
and the other screens are one button away.
|
|
7
|
+
|
|
8
|
+
The window owns every decision. The panels below it emit intent and show
|
|
9
|
+
state; they never call the engine. That is what makes "unavailable
|
|
10
|
+
controls are disabled" a single method rather than a rule each widget has
|
|
11
|
+
to remember.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import Qt, QTimer, Signal
|
|
20
|
+
from PySide6.QtGui import QAction, QCloseEvent, QKeySequence
|
|
21
|
+
from PySide6.QtWidgets import (
|
|
22
|
+
QDialog,
|
|
23
|
+
QFileDialog,
|
|
24
|
+
QFrame,
|
|
25
|
+
QHBoxLayout,
|
|
26
|
+
QLabel,
|
|
27
|
+
QMainWindow,
|
|
28
|
+
QMessageBox,
|
|
29
|
+
QProgressBar,
|
|
30
|
+
QPushButton,
|
|
31
|
+
QVBoxLayout,
|
|
32
|
+
QWidget,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from ..app import Application
|
|
36
|
+
from ..audio import wav
|
|
37
|
+
from ..audio.player import PlayerState, Timeline
|
|
38
|
+
from ..domain import JobState, RequestPath, RetentionMode, VoiceSettings
|
|
39
|
+
from ..errors import Code, EchoActError
|
|
40
|
+
from ..jobs.engine import Event
|
|
41
|
+
from ..jobs.request import JobRequest, estimate
|
|
42
|
+
from ..models.catalog import MANIFEST
|
|
43
|
+
from ..policy import MAX_INPUT_CODEPOINTS
|
|
44
|
+
from ..text.loader import load_file
|
|
45
|
+
from ..util import ids
|
|
46
|
+
from ..util.logging import get_logger
|
|
47
|
+
from . import icons
|
|
48
|
+
from .bridge import EngineBridge
|
|
49
|
+
from .controls import TransportBar, VoicePanel, label, tool_button
|
|
50
|
+
from .i18n import add_korean, approximate_duration, count, tr
|
|
51
|
+
from .licence import LicenceDialog
|
|
52
|
+
from .notifications import (
|
|
53
|
+
Level,
|
|
54
|
+
Notice,
|
|
55
|
+
NotificationCentre,
|
|
56
|
+
completion_notice,
|
|
57
|
+
failure_notice,
|
|
58
|
+
)
|
|
59
|
+
from .reading import ReadingSurface
|
|
60
|
+
from .theme import METRICS, Mode, Palette
|
|
61
|
+
from .theme import apply as apply_theme
|
|
62
|
+
|
|
63
|
+
log = get_logger("ui.window")
|
|
64
|
+
|
|
65
|
+
#: How often the reading position is repainted. This is a repaint cadence,
|
|
66
|
+
#: not a clock: the position itself comes from the audio callback's frame
|
|
67
|
+
#: counter (N-12, A.2), and 40 ms leaves the 300 ms budget almost entirely
|
|
68
|
+
#: to the device.
|
|
69
|
+
TICK_MS = 40
|
|
70
|
+
|
|
71
|
+
add_korean(
|
|
72
|
+
{
|
|
73
|
+
"{n} / {max} characters": "{n} / {max}자",
|
|
74
|
+
"Nothing to read yet": "읽을 내용이 없습니다",
|
|
75
|
+
"{n} segments, {duration}": "{n}개 문장, {duration}",
|
|
76
|
+
"Generating {done} of {total}": "{total}개 중 {done}개 생성 중",
|
|
77
|
+
"Ready to read": "읽을 준비가 되었습니다",
|
|
78
|
+
"Reading": "읽는 중",
|
|
79
|
+
"Finished": "끝났습니다",
|
|
80
|
+
"Generation canceled": "생성이 취소되었습니다",
|
|
81
|
+
"Highlighting unavailable while the text differs from the audio":
|
|
82
|
+
"본문이 오디오와 달라 강조 표시를 사용할 수 없습니다",
|
|
83
|
+
"Following paused": "따라가기 일시 중지됨",
|
|
84
|
+
"Open a text file": "텍스트 파일 열기",
|
|
85
|
+
"Activity": "활동",
|
|
86
|
+
"Connect an app": "앱 연결",
|
|
87
|
+
"Storage": "저장 공간",
|
|
88
|
+
"{n} items cleaned up": "{n}개 항목을 정리했습니다",
|
|
89
|
+
"Some items could not be deleted": "일부 항목을 삭제하지 못했습니다",
|
|
90
|
+
"EchoAct cannot check for a newer version in this build.":
|
|
91
|
+
"이 빌드에서는 새 버전을 확인할 수 없습니다.",
|
|
92
|
+
"Text files (*.txt *.md);;All files (*)": "텍스트 파일 (*.txt *.md);;모든 파일 (*)",
|
|
93
|
+
"Save audio": "오디오 저장",
|
|
94
|
+
"WAV audio (*.wav)": "WAV 오디오 (*.wav)",
|
|
95
|
+
"Replace the text that is already here?": "이미 입력된 본문을 바꿀까요?",
|
|
96
|
+
"The text you have now has not been saved.": "지금 입력한 본문은 저장되지 않았습니다.",
|
|
97
|
+
"Replace": "바꾸기",
|
|
98
|
+
"Keep": "유지",
|
|
99
|
+
"Too much text to paste": "붙여넣기에 본문이 너무 많습니다",
|
|
100
|
+
"Pasting {n} characters would pass the {max} character limit; there is room for {room}.":
|
|
101
|
+
"{n}자를 붙여넣으면 {max}자 제한을 넘습니다. {room}자만 넣을 수 있습니다.",
|
|
102
|
+
"A job is still running.": "작업이 아직 실행 중입니다.",
|
|
103
|
+
"Leave anyway": "그래도 종료",
|
|
104
|
+
"Stay": "머무르기",
|
|
105
|
+
"partial": "일부",
|
|
106
|
+
"This file covers {percent}% of the text.": "이 파일은 본문의 {percent}%를 담고 있습니다.",
|
|
107
|
+
"Line endings were normalised.": "줄바꿈 문자를 정규화했습니다.",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MainWindow(QMainWindow):
|
|
113
|
+
"""One window, one job at a time, one reading position."""
|
|
114
|
+
|
|
115
|
+
theme_changed = Signal(object)
|
|
116
|
+
|
|
117
|
+
def __init__(self, app: Application, mode: Mode = Mode.SYSTEM) -> None:
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.app = app
|
|
120
|
+
self.palette_tokens: Palette = apply_theme(
|
|
121
|
+
__import__("PySide6.QtWidgets", fromlist=["QApplication"]).QApplication.instance(),
|
|
122
|
+
mode,
|
|
123
|
+
)
|
|
124
|
+
self.setWindowTitle("EchoAct")
|
|
125
|
+
self.resize(1180, 780)
|
|
126
|
+
self.setMinimumSize(900, 560)
|
|
127
|
+
|
|
128
|
+
self._job_id: str | None = None
|
|
129
|
+
self._timeline: Timeline | None = None
|
|
130
|
+
self._snapshot: str = ""
|
|
131
|
+
self._started_playback = False
|
|
132
|
+
self._entry = MANIFEST.get(app.settings.voice.model_id)
|
|
133
|
+
|
|
134
|
+
self._build()
|
|
135
|
+
self._wire()
|
|
136
|
+
self._apply_settings()
|
|
137
|
+
self._refresh_estimate()
|
|
138
|
+
self._refresh_resources()
|
|
139
|
+
self._update_enabled()
|
|
140
|
+
self._set_status(tr("Ready to read"))
|
|
141
|
+
|
|
142
|
+
self._tick = QTimer(self)
|
|
143
|
+
self._tick.setInterval(TICK_MS)
|
|
144
|
+
self._tick.timeout.connect(self._on_tick)
|
|
145
|
+
self._tick.start()
|
|
146
|
+
|
|
147
|
+
self._report_startup()
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Construction
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _build(self) -> None:
|
|
154
|
+
m = METRICS
|
|
155
|
+
p = self.palette_tokens
|
|
156
|
+
root = QWidget()
|
|
157
|
+
root.setObjectName("Root")
|
|
158
|
+
outer = QVBoxLayout(root)
|
|
159
|
+
outer.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad)
|
|
160
|
+
outer.setSpacing(m.gap_wide)
|
|
161
|
+
|
|
162
|
+
# -- header ----------------------------------------------------
|
|
163
|
+
head = QHBoxLayout()
|
|
164
|
+
head.setSpacing(m.gap)
|
|
165
|
+
head.addWidget(label("EchoAct", "title"))
|
|
166
|
+
self.model_chip = label("", "muted")
|
|
167
|
+
head.addWidget(self.model_chip)
|
|
168
|
+
head.addStretch(1)
|
|
169
|
+
self.open_button = QPushButton(" " + tr("Open file"))
|
|
170
|
+
self.open_button.setProperty("variant", "quiet")
|
|
171
|
+
self.open_button.setIcon(icons.icon("folder", p.text_secondary, p.text_muted))
|
|
172
|
+
self.open_button.setIconSize(icons.icon_size(16))
|
|
173
|
+
self.save_button = QPushButton(" " + tr("Save audio"))
|
|
174
|
+
self.save_button.setProperty("variant", "quiet")
|
|
175
|
+
self.save_button.setIcon(icons.icon("export", p.text_secondary, p.text_muted))
|
|
176
|
+
self.save_button.setIconSize(icons.icon_size(16))
|
|
177
|
+
head.addWidget(self.open_button)
|
|
178
|
+
head.addWidget(self.save_button)
|
|
179
|
+
|
|
180
|
+
head.addSpacing(METRICS.gap)
|
|
181
|
+
self.nav: dict[str, QPushButton] = {}
|
|
182
|
+
for key, name, glyph in (
|
|
183
|
+
("library", tr("Library"), "library"),
|
|
184
|
+
("models", tr("Models"), "cube"),
|
|
185
|
+
("connect", tr("Connect an app"), "plug"),
|
|
186
|
+
("status", tr("Activity"), "pulse"),
|
|
187
|
+
("settings", tr("Settings"), "settings"),
|
|
188
|
+
):
|
|
189
|
+
b = QPushButton(" " + name)
|
|
190
|
+
b.setProperty("variant", "quiet")
|
|
191
|
+
b.setIcon(icons.icon(glyph, p.text_secondary, p.text_muted))
|
|
192
|
+
b.setIconSize(icons.icon_size(16))
|
|
193
|
+
b.setAccessibleName(name)
|
|
194
|
+
head.addWidget(b)
|
|
195
|
+
self.nav[key] = b
|
|
196
|
+
outer.addLayout(head)
|
|
197
|
+
|
|
198
|
+
# -- notice banner (F-25, F-70) --------------------------------
|
|
199
|
+
self.notice = QFrame()
|
|
200
|
+
self.notice.setObjectName("Panel")
|
|
201
|
+
notice_row = QHBoxLayout(self.notice)
|
|
202
|
+
notice_row.setContentsMargins(m.pad, m.gap, m.gap, m.gap)
|
|
203
|
+
notice_row.setSpacing(m.gap)
|
|
204
|
+
self.notice_icon = QLabel()
|
|
205
|
+
self.notice_text = label("", "secondary")
|
|
206
|
+
self.notice_text.setWordWrap(True)
|
|
207
|
+
notice_row.addWidget(self.notice_icon)
|
|
208
|
+
notice_row.addWidget(self.notice_text, 1)
|
|
209
|
+
self.notice_close = tool_button("close", p, size=14)
|
|
210
|
+
notice_row.addWidget(self.notice_close)
|
|
211
|
+
self.notice.hide()
|
|
212
|
+
outer.addWidget(self.notice)
|
|
213
|
+
|
|
214
|
+
# -- body ------------------------------------------------------
|
|
215
|
+
body = QHBoxLayout()
|
|
216
|
+
body.setSpacing(m.gap_wide)
|
|
217
|
+
|
|
218
|
+
column = QVBoxLayout()
|
|
219
|
+
column.setSpacing(m.gap)
|
|
220
|
+
self.reading = ReadingSurface(p)
|
|
221
|
+
column.addWidget(self.reading, 1)
|
|
222
|
+
|
|
223
|
+
meta = QHBoxLayout()
|
|
224
|
+
self.counter = label("", "muted")
|
|
225
|
+
self.estimate_label = label("", "muted")
|
|
226
|
+
self.estimate_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
227
|
+
meta.addWidget(self.counter)
|
|
228
|
+
meta.addStretch(1)
|
|
229
|
+
self.follow_button = QPushButton(" " + tr("Return to the reading position"))
|
|
230
|
+
self.follow_button.setProperty("variant", "quiet")
|
|
231
|
+
self.follow_button.setIcon(icons.icon("locate", p.accent))
|
|
232
|
+
self.follow_button.setIconSize(icons.icon_size(14))
|
|
233
|
+
self.follow_button.hide()
|
|
234
|
+
meta.addWidget(self.follow_button)
|
|
235
|
+
meta.addWidget(self.estimate_label)
|
|
236
|
+
column.addLayout(meta)
|
|
237
|
+
|
|
238
|
+
self.transport = TransportBar(p)
|
|
239
|
+
column.addWidget(self.transport)
|
|
240
|
+
|
|
241
|
+
self.progress = QProgressBar()
|
|
242
|
+
self.progress.setRange(0, 100)
|
|
243
|
+
self.progress.setValue(0)
|
|
244
|
+
self.progress.setTextVisible(False)
|
|
245
|
+
column.addWidget(self.progress)
|
|
246
|
+
|
|
247
|
+
self.status = label("", "muted")
|
|
248
|
+
column.addWidget(self.status)
|
|
249
|
+
body.addLayout(column, 1)
|
|
250
|
+
|
|
251
|
+
self.voice_panel = VoicePanel(p, self._entry)
|
|
252
|
+
body.addWidget(self.voice_panel, 0, Qt.AlignmentFlag.AlignTop)
|
|
253
|
+
outer.addLayout(body, 1)
|
|
254
|
+
|
|
255
|
+
self.setCentralWidget(root)
|
|
256
|
+
self._build_status_bar()
|
|
257
|
+
self._build_menu()
|
|
258
|
+
|
|
259
|
+
def _build_status_bar(self) -> None:
|
|
260
|
+
bar = self.statusBar()
|
|
261
|
+
self.service_label = label("", "muted")
|
|
262
|
+
self.usage_label = label("", "muted")
|
|
263
|
+
bar.addWidget(self.service_label)
|
|
264
|
+
bar.addPermanentWidget(self.usage_label)
|
|
265
|
+
self._refresh_service_label()
|
|
266
|
+
|
|
267
|
+
def _build_menu(self) -> None:
|
|
268
|
+
"""Menu entries exist so every action has a keyboard route (N-30),
|
|
269
|
+
not because the window needs a menu."""
|
|
270
|
+
actions = (
|
|
271
|
+
(tr("Open file"), QKeySequence.StandardKey.Open, self._open_file),
|
|
272
|
+
(tr("Save audio"), QKeySequence.StandardKey.Save, self._save_audio),
|
|
273
|
+
(tr("Read aloud"), QKeySequence("Ctrl+Return"), self._primary_action),
|
|
274
|
+
(tr("Play"), QKeySequence("Space"), None),
|
|
275
|
+
(tr("Stop"), QKeySequence("Ctrl+."), self._stop),
|
|
276
|
+
(tr("Return to the reading position"), QKeySequence("Ctrl+J"), self._return_to_position),
|
|
277
|
+
)
|
|
278
|
+
for name, shortcut, slot in actions:
|
|
279
|
+
if slot is None:
|
|
280
|
+
continue
|
|
281
|
+
action = QAction(name, self)
|
|
282
|
+
action.setShortcut(shortcut)
|
|
283
|
+
action.triggered.connect(slot)
|
|
284
|
+
self.addAction(action)
|
|
285
|
+
|
|
286
|
+
def _wire(self) -> None:
|
|
287
|
+
self.notifications = NotificationCentre(
|
|
288
|
+
os_notifications=self.app.settings.os_notifications
|
|
289
|
+
)
|
|
290
|
+
self.bridge = EngineBridge(self.app.engine, self)
|
|
291
|
+
self.bridge.accepted.connect(self._on_accepted)
|
|
292
|
+
self.bridge.state_changed.connect(self._on_state)
|
|
293
|
+
self.bridge.segment_ready.connect(self._on_segment)
|
|
294
|
+
self.bridge.finished.connect(self._on_finished)
|
|
295
|
+
|
|
296
|
+
self.reading.length_changed.connect(self._on_length)
|
|
297
|
+
self.reading.availability_changed.connect(self._on_availability)
|
|
298
|
+
self.reading.following_changed.connect(self._on_following)
|
|
299
|
+
self.reading.paste_refused.connect(self._on_paste_refused)
|
|
300
|
+
|
|
301
|
+
self.transport.read_requested.connect(self._primary_action)
|
|
302
|
+
self.transport.pause_requested.connect(self._toggle_play)
|
|
303
|
+
self.transport.stop_requested.connect(self._stop)
|
|
304
|
+
self.transport.seek_requested.connect(self._seek)
|
|
305
|
+
|
|
306
|
+
self.voice_panel.changed.connect(self._on_voice_changed)
|
|
307
|
+
self.voice_panel.autoplay_toggled.connect(
|
|
308
|
+
lambda on: self.app.update_settings(autoplay=on)
|
|
309
|
+
)
|
|
310
|
+
self.voice_panel.follow_toggled.connect(self._on_follow_toggled)
|
|
311
|
+
|
|
312
|
+
self.nav["library"].clicked.connect(self._show_library)
|
|
313
|
+
self.nav["models"].clicked.connect(self._show_models)
|
|
314
|
+
self.nav["settings"].clicked.connect(self._show_settings)
|
|
315
|
+
self.nav["status"].clicked.connect(self._show_status)
|
|
316
|
+
self.nav["connect"].clicked.connect(self._show_connect)
|
|
317
|
+
|
|
318
|
+
self.open_button.clicked.connect(self._open_file)
|
|
319
|
+
self.save_button.clicked.connect(self._save_audio)
|
|
320
|
+
self.follow_button.clicked.connect(self._return_to_position)
|
|
321
|
+
self.notice_close.clicked.connect(self.notice.hide)
|
|
322
|
+
|
|
323
|
+
def _apply_settings(self) -> None:
|
|
324
|
+
s = self.app.settings
|
|
325
|
+
self.voice_panel.apply(s.voice, autoplay=s.autoplay, follow=s.follow)
|
|
326
|
+
self.reading.set_follow(s.follow)
|
|
327
|
+
self.app.player.set_volume(s.volume)
|
|
328
|
+
self.app.player.set_muted(s.muted)
|
|
329
|
+
state = self.app.registry.status(s.voice.model_id)
|
|
330
|
+
ready = getattr(state, "state", None)
|
|
331
|
+
self.model_chip.setText(
|
|
332
|
+
f"{self._entry.display_name} · {self._entry.sample_rate // 1000}.{(self._entry.sample_rate // 100) % 10} kHz"
|
|
333
|
+
+ ("" if str(ready) == "ready" else " · " + tr("not prepared"))
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Starting and stopping a job
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _primary_action(self) -> None:
|
|
341
|
+
"""One button: start, or cancel what is running.
|
|
342
|
+
|
|
343
|
+
A.3 makes cancelling the owner's way to reclaim the single slot,
|
|
344
|
+
and F-69 asks for it to be reachable in one place.
|
|
345
|
+
"""
|
|
346
|
+
if self.app.engine.busy:
|
|
347
|
+
self._cancel()
|
|
348
|
+
else:
|
|
349
|
+
self._start()
|
|
350
|
+
|
|
351
|
+
def _start(self) -> None:
|
|
352
|
+
if not self._licence_accepted():
|
|
353
|
+
return
|
|
354
|
+
text = self.reading.source_text()
|
|
355
|
+
settings = self.voice_panel.settings()
|
|
356
|
+
request = JobRequest(
|
|
357
|
+
text=text,
|
|
358
|
+
settings=settings,
|
|
359
|
+
request_path=RequestPath.GUI,
|
|
360
|
+
owner_client_id="owner",
|
|
361
|
+
# A fresh key per press. F-49 exists to stop a retry becoming a
|
|
362
|
+
# second job, and a double-click is a retry.
|
|
363
|
+
idempotency_key=ids.request_id(),
|
|
364
|
+
retention=(
|
|
365
|
+
RetentionMode.RETAINED if self.app.settings.retain_history else RetentionMode.ONE_OFF
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
try:
|
|
369
|
+
job, created = self.app.engine.submit(request)
|
|
370
|
+
except EchoActError as exc:
|
|
371
|
+
self._show_problem(exc)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
self._job_id = job.job_id
|
|
375
|
+
self._snapshot = text
|
|
376
|
+
self._started_playback = False
|
|
377
|
+
self._timeline = Timeline(sample_rate=self._entry.sample_rate)
|
|
378
|
+
self.app.player.load(self._timeline)
|
|
379
|
+
self.reading.attach_job(text, self.app.store.list_segments(job.job_id))
|
|
380
|
+
self.progress.setValue(0)
|
|
381
|
+
self._update_enabled()
|
|
382
|
+
if not created:
|
|
383
|
+
self._set_status(tr("Ready to read"))
|
|
384
|
+
|
|
385
|
+
def _licence_accepted(self) -> bool:
|
|
386
|
+
"""N-11 and F-80: the terms are presented before first preparation.
|
|
387
|
+
|
|
388
|
+
Asked here rather than left to the engine's refusal, because a code
|
|
389
|
+
in a notice bar is not "presented as terms the user accepts".
|
|
390
|
+
Declining is not an error: the user said not now, and the window
|
|
391
|
+
simply does not start a job.
|
|
392
|
+
"""
|
|
393
|
+
pending = self.app.licence_pending()
|
|
394
|
+
if pending is None:
|
|
395
|
+
return True
|
|
396
|
+
dialog = LicenceDialog(MANIFEST.get(pending), self.palette_tokens, self)
|
|
397
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
398
|
+
return False
|
|
399
|
+
self.app.accept_licence(pending)
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
def _cancel(self) -> None:
|
|
403
|
+
if not self._job_id:
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
self.app.engine.cancel(self._job_id)
|
|
407
|
+
except EchoActError as exc:
|
|
408
|
+
self._show_problem(exc)
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Engine events, already on the GUI thread
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _on_accepted(self, event: Event) -> None:
|
|
415
|
+
self._set_status(tr("Preparing the model"))
|
|
416
|
+
self._update_enabled()
|
|
417
|
+
|
|
418
|
+
def _on_state(self, event: Event) -> None:
|
|
419
|
+
if event.job_id != self._job_id:
|
|
420
|
+
return
|
|
421
|
+
text = {
|
|
422
|
+
JobState.PREPARING_MODEL: tr("Preparing the model"),
|
|
423
|
+
JobState.GENERATING: tr("Generating"),
|
|
424
|
+
JobState.COMPLETE: tr("Finished"),
|
|
425
|
+
JobState.CANCELING: tr("Cancel generation"),
|
|
426
|
+
JobState.CANCELED: tr("Generation canceled"),
|
|
427
|
+
JobState.FAILED: tr("Failed"),
|
|
428
|
+
}.get(event.state or JobState.ACCEPTED, "")
|
|
429
|
+
self._set_status(text)
|
|
430
|
+
self._update_enabled()
|
|
431
|
+
|
|
432
|
+
def _on_segment(self, event: Event) -> None:
|
|
433
|
+
"""A segment is ready: extend the timeline and start if asked to.
|
|
434
|
+
|
|
435
|
+
F-12 starts playback at the first ready segment; F-83 lets the user
|
|
436
|
+
turn that off, and F-51 forbids it for a job an integration
|
|
437
|
+
created -- which is why this checks the job is ours as well as the
|
|
438
|
+
setting.
|
|
439
|
+
"""
|
|
440
|
+
if event.job_id != self._job_id or self._timeline is None:
|
|
441
|
+
return
|
|
442
|
+
detail = event.detail
|
|
443
|
+
start_ms, end_ms = int(detail.get("start_ms", 0)), int(detail.get("end_ms", 0))
|
|
444
|
+
frames = int(detail.get("frame_count", 0))
|
|
445
|
+
sr = self._timeline.sample_rate
|
|
446
|
+
span_frames = wav.frames_for_ms(end_ms, sr) - wav.frames_for_ms(start_ms, sr)
|
|
447
|
+
self._timeline.append(
|
|
448
|
+
event.segment_index or 0,
|
|
449
|
+
str(detail.get("audio_path", "")),
|
|
450
|
+
frames,
|
|
451
|
+
max(0, span_frames - frames),
|
|
452
|
+
)
|
|
453
|
+
self.app.player.timeline_grew()
|
|
454
|
+
self.reading.update_segments(self.app.store.list_segments(event.job_id))
|
|
455
|
+
self.transport.set_playable(self._timeline.duration_ms)
|
|
456
|
+
|
|
457
|
+
if event.total:
|
|
458
|
+
self.progress.setValue(int(100 * event.generated / event.total))
|
|
459
|
+
self._set_status(
|
|
460
|
+
tr("Generating {done} of {total}").format(done=event.generated, total=event.total)
|
|
461
|
+
)
|
|
462
|
+
if not self._started_playback and self.app.settings.autoplay:
|
|
463
|
+
self._started_playback = True
|
|
464
|
+
self._play()
|
|
465
|
+
|
|
466
|
+
def _on_finished(self, event: Event) -> None:
|
|
467
|
+
if event.job_id != self._job_id:
|
|
468
|
+
return
|
|
469
|
+
if self._timeline is not None:
|
|
470
|
+
self._timeline.complete = True
|
|
471
|
+
self.progress.setValue(100 if event.state is JobState.COMPLETE else self.progress.value())
|
|
472
|
+
self._update_enabled()
|
|
473
|
+
if event.state is JobState.FAILED and event.error_code:
|
|
474
|
+
self._post(failure_notice(tr("Generation failed"), event.error_code, job_id=event.job_id))
|
|
475
|
+
elif event.state is JobState.COMPLETE:
|
|
476
|
+
# F-70. A job the user started needs no explanation; one an
|
|
477
|
+
# integration created needs two things said, because the user
|
|
478
|
+
# did not ask for it and a one-off result goes within the hour.
|
|
479
|
+
job = self.app.store.get_job(event.job_id, include_segments=False)
|
|
480
|
+
self._post(
|
|
481
|
+
completion_notice(
|
|
482
|
+
job_id=event.job_id,
|
|
483
|
+
client_label=event.client_label,
|
|
484
|
+
expires_at=job.result.expires_at if job.result else None,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Playback
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def _play(self) -> None:
|
|
493
|
+
try:
|
|
494
|
+
self.app.player.play()
|
|
495
|
+
except EchoActError as exc:
|
|
496
|
+
self._show_problem(exc)
|
|
497
|
+
return
|
|
498
|
+
self.reading.set_playing()
|
|
499
|
+
self.transport.set_playback_state("", playing=True)
|
|
500
|
+
|
|
501
|
+
def _toggle_play(self) -> None:
|
|
502
|
+
player = self.app.player
|
|
503
|
+
if player.state is PlayerState.PLAYING:
|
|
504
|
+
player.pause()
|
|
505
|
+
self.reading.set_paused()
|
|
506
|
+
self.transport.set_playback_state("", playing=False)
|
|
507
|
+
else:
|
|
508
|
+
self._play()
|
|
509
|
+
|
|
510
|
+
def _stop(self) -> None:
|
|
511
|
+
"""F-13: stopping playback does not stop generation."""
|
|
512
|
+
self.app.player.stop()
|
|
513
|
+
self.reading.clear_highlight()
|
|
514
|
+
self.transport.set_playback_state("", playing=False)
|
|
515
|
+
self.transport.set_position(0)
|
|
516
|
+
|
|
517
|
+
def _seek(self, ms: int) -> None:
|
|
518
|
+
if not self.app.player.seek_ms(ms):
|
|
519
|
+
# F-14: ungenerated audio cannot be sought to. Snap back rather
|
|
520
|
+
# than pretend the seek happened.
|
|
521
|
+
self.transport.set_position(self.app.player.position_ms())
|
|
522
|
+
|
|
523
|
+
def _on_tick(self) -> None:
|
|
524
|
+
player = self.app.player
|
|
525
|
+
if self._timeline is None:
|
|
526
|
+
return
|
|
527
|
+
position = player.position_ms()
|
|
528
|
+
waiting = player.state is PlayerState.WAITING
|
|
529
|
+
if not self.transport.scrubbing:
|
|
530
|
+
self.transport.set_position(position)
|
|
531
|
+
if player.state in (PlayerState.PLAYING, PlayerState.WAITING):
|
|
532
|
+
self.reading.set_playback_ms(position, waiting=waiting)
|
|
533
|
+
if player.state is PlayerState.ENDED:
|
|
534
|
+
self.reading.clear_highlight()
|
|
535
|
+
self.transport.set_playback_state("", playing=False)
|
|
536
|
+
self._refresh_usage()
|
|
537
|
+
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
# Input
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def _on_length(self, n: int) -> None:
|
|
543
|
+
self.counter.setText(
|
|
544
|
+
tr("{n} / {max} characters").format(n=count(n), max=count(MAX_INPUT_CODEPOINTS))
|
|
545
|
+
)
|
|
546
|
+
self._refresh_estimate()
|
|
547
|
+
self._update_enabled()
|
|
548
|
+
|
|
549
|
+
def _refresh_estimate(self) -> None:
|
|
550
|
+
"""F-88's estimate, shown to the person as well as to a caller.
|
|
551
|
+
|
|
552
|
+
Cheap enough to run on every keystroke -- it is segmentation and
|
|
553
|
+
arithmetic, and it explicitly does not touch the engine.
|
|
554
|
+
"""
|
|
555
|
+
text = self.reading.source_text()
|
|
556
|
+
if not text.strip():
|
|
557
|
+
self.estimate_label.setText(tr("Nothing to read yet"))
|
|
558
|
+
return
|
|
559
|
+
est = estimate(
|
|
560
|
+
text,
|
|
561
|
+
self.voice_panel.settings(),
|
|
562
|
+
MANIFEST,
|
|
563
|
+
slot_free=not self.app.engine.busy,
|
|
564
|
+
model_ready=True,
|
|
565
|
+
)
|
|
566
|
+
if not est.valid:
|
|
567
|
+
self.estimate_label.setText(est.problems[0]["message"] if est.problems else "")
|
|
568
|
+
return
|
|
569
|
+
self.estimate_label.setText(
|
|
570
|
+
tr("{n} segments, {duration}").format(
|
|
571
|
+
n=est.segment_count, duration=approximate_duration(est.audio_ms)
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def _on_voice_changed(self, settings: VoiceSettings) -> None:
|
|
576
|
+
self.app.update_settings(voice=settings)
|
|
577
|
+
self._refresh_estimate()
|
|
578
|
+
self._refresh_resources()
|
|
579
|
+
|
|
580
|
+
def _on_follow_toggled(self, on: bool) -> None:
|
|
581
|
+
self.app.update_settings(follow=on)
|
|
582
|
+
self.reading.set_follow(on)
|
|
583
|
+
|
|
584
|
+
def _on_availability(self, available: bool) -> None:
|
|
585
|
+
if available:
|
|
586
|
+
self.notice.hide()
|
|
587
|
+
elif self._job_id:
|
|
588
|
+
self._show_notice(
|
|
589
|
+
tr("Highlighting unavailable"),
|
|
590
|
+
tr("Highlighting unavailable while the text differs from the audio"),
|
|
591
|
+
kind="info",
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def _on_following(self, following: bool) -> None:
|
|
595
|
+
self.follow_button.setVisible(self.reading.following_suspended)
|
|
596
|
+
|
|
597
|
+
def _return_to_position(self) -> None:
|
|
598
|
+
self.reading.return_to_position()
|
|
599
|
+
self.follow_button.hide()
|
|
600
|
+
|
|
601
|
+
def _on_paste_refused(self, attempted: int, room: int) -> None:
|
|
602
|
+
QMessageBox.information(
|
|
603
|
+
self,
|
|
604
|
+
tr("Too much text to paste"),
|
|
605
|
+
tr(
|
|
606
|
+
"Pasting {n} characters would pass the {max} character limit; "
|
|
607
|
+
"there is room for {room}."
|
|
608
|
+
).format(n=count(attempted), max=count(MAX_INPUT_CODEPOINTS), room=count(room)),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
# Files
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
def _open_file(self) -> None:
|
|
616
|
+
# F-36: replacing unsaved input needs confirmation, and a refusal
|
|
617
|
+
# must leave what is there untouched.
|
|
618
|
+
if self.reading.source_text().strip() and not self._confirm_replace():
|
|
619
|
+
return
|
|
620
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
621
|
+
self, tr("Open a text file"), "", tr("Text files (*.txt *.md);;All files (*)")
|
|
622
|
+
)
|
|
623
|
+
if not path:
|
|
624
|
+
return
|
|
625
|
+
try:
|
|
626
|
+
loaded = load_file(path)
|
|
627
|
+
except EchoActError as exc:
|
|
628
|
+
# F-32: on any failure the existing input is left as it was.
|
|
629
|
+
self._show_problem(exc)
|
|
630
|
+
return
|
|
631
|
+
self.reading.set_text(loaded.text)
|
|
632
|
+
if loaded.line_endings_normalised:
|
|
633
|
+
# The one edit the loader makes, said out loud: a user counting
|
|
634
|
+
# characters would otherwise be counting something else.
|
|
635
|
+
self._show_notice(loaded.source_name, tr("Line endings were normalised."), kind="info")
|
|
636
|
+
|
|
637
|
+
def _save_audio(self) -> None:
|
|
638
|
+
"""F-16, including the partial case.
|
|
639
|
+
|
|
640
|
+
A job that has not finished can still be saved; the file is named
|
|
641
|
+
and labelled partial and the coverage is reported, so it is never
|
|
642
|
+
mistaken for the whole document.
|
|
643
|
+
"""
|
|
644
|
+
if not self._job_id:
|
|
645
|
+
return
|
|
646
|
+
segments = self.app.store.list_segments(self._job_id)
|
|
647
|
+
ready = [s for s in segments if s.ready]
|
|
648
|
+
if not ready:
|
|
649
|
+
return
|
|
650
|
+
complete = len(ready) == len(segments)
|
|
651
|
+
suggested = "reading.wav" if complete else "reading-partial.wav"
|
|
652
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
653
|
+
self, tr("Save audio"), suggested, tr("WAV audio (*.wav)")
|
|
654
|
+
)
|
|
655
|
+
if not path:
|
|
656
|
+
return
|
|
657
|
+
try:
|
|
658
|
+
report = wav.export_partial(
|
|
659
|
+
segments,
|
|
660
|
+
Path(path),
|
|
661
|
+
sample_rate=self._entry.sample_rate,
|
|
662
|
+
total_codepoints=len(self._snapshot),
|
|
663
|
+
)
|
|
664
|
+
except EchoActError as exc:
|
|
665
|
+
self._show_problem(exc)
|
|
666
|
+
return
|
|
667
|
+
if not report.complete:
|
|
668
|
+
self._show_notice(
|
|
669
|
+
tr("partial"),
|
|
670
|
+
tr("This file covers {percent}% of the text.").format(
|
|
671
|
+
percent=int(report.coverage * 100)
|
|
672
|
+
),
|
|
673
|
+
kind="info",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# ------------------------------------------------------------------
|
|
677
|
+
# The other screens
|
|
678
|
+
# ------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
def _open_screen(self, title: str, widget: QWidget, *, width: int, height: int) -> QDialog:
|
|
681
|
+
"""Show a screen in its own window.
|
|
682
|
+
|
|
683
|
+
A separate window rather than a stacked page, so the reading
|
|
684
|
+
surface and the job it is following are never replaced by
|
|
685
|
+
something else: F-29 compares the live input against the job
|
|
686
|
+
snapshot continuously, and a screen that swapped the input out
|
|
687
|
+
would drop the highlight every time the user opened the library.
|
|
688
|
+
"""
|
|
689
|
+
dialog = QDialog(self)
|
|
690
|
+
dialog.setWindowTitle(title)
|
|
691
|
+
dialog.setModal(False)
|
|
692
|
+
layout = QVBoxLayout(dialog)
|
|
693
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
694
|
+
layout.addWidget(widget)
|
|
695
|
+
dialog.resize(width, height)
|
|
696
|
+
dialog.show()
|
|
697
|
+
return dialog
|
|
698
|
+
|
|
699
|
+
def _show_library(self) -> None:
|
|
700
|
+
from .library import LibraryScreen
|
|
701
|
+
|
|
702
|
+
screen = LibraryScreen(self.app.store, self.palette_tokens, manifest=MANIFEST)
|
|
703
|
+
if hasattr(screen, "open_document"):
|
|
704
|
+
screen.open_document.connect(self._open_document)
|
|
705
|
+
if hasattr(screen, "problem"):
|
|
706
|
+
screen.problem.connect(self._show_problem)
|
|
707
|
+
self._library = self._open_screen(tr("Library"), screen, width=1000, height=680)
|
|
708
|
+
|
|
709
|
+
def _show_models(self) -> None:
|
|
710
|
+
from ..config.budget import resolve_budget_from_system
|
|
711
|
+
from .models_view import ModelsView
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
budget = resolve_budget_from_system(self.app.settings)
|
|
715
|
+
except EchoActError as exc:
|
|
716
|
+
self._show_problem(exc)
|
|
717
|
+
return
|
|
718
|
+
screen = ModelsView(self.palette_tokens, self.app.registry, budget)
|
|
719
|
+
if hasattr(screen, "problem"):
|
|
720
|
+
screen.problem.connect(self._show_problem)
|
|
721
|
+
self._models = self._open_screen(tr("Models"), screen, width=780, height=680)
|
|
722
|
+
|
|
723
|
+
def _show_settings(self) -> None:
|
|
724
|
+
from .settings_view import SettingsView
|
|
725
|
+
|
|
726
|
+
screen = SettingsView(self.palette_tokens, self.app.settings)
|
|
727
|
+
self._connect_settings(screen)
|
|
728
|
+
self._refresh_settings_screen(screen)
|
|
729
|
+
# Wide enough that the section text and the controls beside it do
|
|
730
|
+
# not need the horizontal scrollbar. N-30 allows scrolling as the
|
|
731
|
+
# fallback; needing it at the default size is still a bad default.
|
|
732
|
+
self._settings_screen = self._open_screen(
|
|
733
|
+
tr("Settings"), screen, width=900, height=780
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def _refresh_settings_screen(self, screen: QWidget) -> None:
|
|
737
|
+
"""Fill in what the view cannot measure for itself.
|
|
738
|
+
|
|
739
|
+
F-78's applied budget comes from the *running job*, not from a
|
|
740
|
+
fresh resolution: resolving again would show the same number
|
|
741
|
+
twice and quietly turn the distinction the requirement draws
|
|
742
|
+
into two views of one value.
|
|
743
|
+
"""
|
|
744
|
+
job = self.app.engine.current()
|
|
745
|
+
screen.set_resource_state(
|
|
746
|
+
job.budget if job is not None else None,
|
|
747
|
+
job_running=job is not None,
|
|
748
|
+
)
|
|
749
|
+
screen.set_credentials(self.app.credentials.list())
|
|
750
|
+
self._refresh_storage(screen)
|
|
751
|
+
|
|
752
|
+
def _refresh_storage(self, screen: QWidget) -> None:
|
|
753
|
+
"""F-73's five figures.
|
|
754
|
+
|
|
755
|
+
Off the main thread: it walks the model cache, which is 385 MB of
|
|
756
|
+
files on an ordinary install and can be far more.
|
|
757
|
+
"""
|
|
758
|
+
from .settings_view import storage_snapshot
|
|
759
|
+
|
|
760
|
+
def measure() -> None:
|
|
761
|
+
try:
|
|
762
|
+
sizes = storage_snapshot(self.app.store, self.app.registry)
|
|
763
|
+
except (EchoActError, OSError):
|
|
764
|
+
return
|
|
765
|
+
QTimer.singleShot(0, lambda: screen.set_storage(sizes))
|
|
766
|
+
|
|
767
|
+
threading.Thread(target=measure, name="echoact-storage", daemon=True).start()
|
|
768
|
+
|
|
769
|
+
def _connect_settings_actions(self, screen: QWidget) -> None:
|
|
770
|
+
"""The buttons that do work rather than change a value.
|
|
771
|
+
|
|
772
|
+
Each one is the window's to perform: F-76 requires the deletion
|
|
773
|
+
scopes to be offered separately and to report what failed, and
|
|
774
|
+
F-71 requires a revocation to reach that client's running job as
|
|
775
|
+
well as its credential (5.3).
|
|
776
|
+
"""
|
|
777
|
+
handlers = {
|
|
778
|
+
"apply_now_requested": lambda: self._apply_budget_now(screen),
|
|
779
|
+
"devices_refresh_requested": lambda: self._refresh_devices(screen),
|
|
780
|
+
"storage_refresh_requested": lambda: self._refresh_storage(screen),
|
|
781
|
+
"cleanup_requested": lambda: self._clean_up(screen),
|
|
782
|
+
"credential_issue_requested": lambda *a: self._issue_credential(screen, *a),
|
|
783
|
+
"credential_reissue_requested": lambda ref: self._reissue_credential(screen, ref),
|
|
784
|
+
"credential_revoke_requested": lambda ref: self._revoke_credential(screen, ref),
|
|
785
|
+
"credential_capabilities_changed": (
|
|
786
|
+
lambda ref, caps: self._set_capabilities(screen, ref, caps)
|
|
787
|
+
),
|
|
788
|
+
"reset_requested": lambda scope: self._reset(screen, scope),
|
|
789
|
+
"version_check_requested": lambda: self._check_version(screen),
|
|
790
|
+
}
|
|
791
|
+
for name, handler in handlers.items():
|
|
792
|
+
signal = getattr(screen, name, None)
|
|
793
|
+
if signal is not None:
|
|
794
|
+
signal.connect(handler)
|
|
795
|
+
|
|
796
|
+
def _apply_budget_now(self, screen: QWidget) -> None:
|
|
797
|
+
"""F-78: applying immediately means cancelling the current job,
|
|
798
|
+
and the view has already confirmed that with the user."""
|
|
799
|
+
job = self.app.engine.current()
|
|
800
|
+
if job is not None:
|
|
801
|
+
try:
|
|
802
|
+
self.app.engine.cancel(job.job_id)
|
|
803
|
+
except EchoActError as exc:
|
|
804
|
+
self._show_problem(exc)
|
|
805
|
+
return
|
|
806
|
+
self._refresh_settings_screen(screen)
|
|
807
|
+
self._refresh_resources()
|
|
808
|
+
|
|
809
|
+
def _refresh_devices(self, screen: QWidget) -> None:
|
|
810
|
+
"""F-68: after a resume or a device change, PortAudio still holds
|
|
811
|
+
the list it built at start-up until it is reinitialised."""
|
|
812
|
+
from ..audio import devices
|
|
813
|
+
|
|
814
|
+
devices.refresh()
|
|
815
|
+
if hasattr(screen, "reload_devices"):
|
|
816
|
+
screen.reload_devices()
|
|
817
|
+
|
|
818
|
+
def _clean_up(self, screen: QWidget) -> None:
|
|
819
|
+
"""F-73's cleanup: expired data only, and never anything in use."""
|
|
820
|
+
from ..jobs.engine import clear_temp_tree, expire_one_off_results
|
|
821
|
+
from ..paths import temp_dir
|
|
822
|
+
from ..util.logging import prune_old_logs
|
|
823
|
+
|
|
824
|
+
try:
|
|
825
|
+
removed = expire_one_off_results(self.app.store, temp_dir())
|
|
826
|
+
except EchoActError as exc:
|
|
827
|
+
self._show_problem(exc)
|
|
828
|
+
return
|
|
829
|
+
if not self.app.engine.busy:
|
|
830
|
+
# N-28 and F-73: data in use by a running job is excluded, and
|
|
831
|
+
# the scratch tree is exactly what a running job is using.
|
|
832
|
+
removed += clear_temp_tree(temp_dir())
|
|
833
|
+
removed += prune_old_logs(ids.now())
|
|
834
|
+
self._show_notice(tr("Storage"), tr("{n} items cleaned up").format(n=removed))
|
|
835
|
+
self._refresh_storage(screen)
|
|
836
|
+
|
|
837
|
+
def _issue_credential(self, screen: QWidget, name: str, capabilities, days: int) -> None:
|
|
838
|
+
"""F-71: shown once, and only a verifier is kept."""
|
|
839
|
+
try:
|
|
840
|
+
issued = self.app.credentials.issue(
|
|
841
|
+
name=name, capabilities=set(capabilities), days=int(days)
|
|
842
|
+
)
|
|
843
|
+
except EchoActError as exc:
|
|
844
|
+
self._show_problem(exc)
|
|
845
|
+
return
|
|
846
|
+
self._show_credential_once(issued)
|
|
847
|
+
screen.set_credentials(self.app.credentials.list())
|
|
848
|
+
|
|
849
|
+
def _reissue_credential(self, screen: QWidget, ref: str) -> None:
|
|
850
|
+
try:
|
|
851
|
+
issued = self.app.credentials.reissue(ref)
|
|
852
|
+
except EchoActError as exc:
|
|
853
|
+
self._show_problem(exc)
|
|
854
|
+
return
|
|
855
|
+
self._show_credential_once(issued)
|
|
856
|
+
screen.set_credentials(self.app.credentials.list())
|
|
857
|
+
|
|
858
|
+
def _revoke_credential(self, screen: QWidget, ref: str) -> None:
|
|
859
|
+
"""5.3: revocation cancels that client's in-progress jobs too."""
|
|
860
|
+
try:
|
|
861
|
+
credential = self.app.credentials.get(ref)
|
|
862
|
+
self.app.credentials.revoke(ref)
|
|
863
|
+
except EchoActError as exc:
|
|
864
|
+
self._show_problem(exc)
|
|
865
|
+
return
|
|
866
|
+
self._cancel_jobs_of(credential.client_id)
|
|
867
|
+
screen.set_credentials(self.app.credentials.list())
|
|
868
|
+
|
|
869
|
+
def _set_capabilities(self, screen: QWidget, ref: str, capabilities) -> None:
|
|
870
|
+
try:
|
|
871
|
+
change = self.app.credentials.set_capabilities(ref, set(capabilities))
|
|
872
|
+
except EchoActError as exc:
|
|
873
|
+
self._show_problem(exc)
|
|
874
|
+
return
|
|
875
|
+
if change.cancels_jobs:
|
|
876
|
+
# 5.3: narrowing a client's permissions cancels its running job.
|
|
877
|
+
self._cancel_jobs_of(change.client_id)
|
|
878
|
+
screen.set_credentials(self.app.credentials.list())
|
|
879
|
+
|
|
880
|
+
def _cancel_jobs_of(self, client_id: str) -> None:
|
|
881
|
+
job = self.app.engine.current()
|
|
882
|
+
if job is not None and job.owner_client_id == client_id:
|
|
883
|
+
try:
|
|
884
|
+
self.app.engine.cancel(job.job_id)
|
|
885
|
+
except EchoActError as exc:
|
|
886
|
+
self._show_problem(exc)
|
|
887
|
+
|
|
888
|
+
def _show_credential_once(self, issued) -> None:
|
|
889
|
+
from .credential_dialog import show_credential
|
|
890
|
+
|
|
891
|
+
show_credential(self, issued, self.palette_tokens)
|
|
892
|
+
|
|
893
|
+
def _reset(self, screen: QWidget, scope: str) -> None:
|
|
894
|
+
"""F-76's four scopes, each on its own, each reporting failures."""
|
|
895
|
+
from .settings_view import ResetScope
|
|
896
|
+
|
|
897
|
+
failures: list[str] = []
|
|
898
|
+
try:
|
|
899
|
+
if scope == ResetScope.VOICE_AND_DISPLAY:
|
|
900
|
+
self.app.update_settings(
|
|
901
|
+
voice=self.app.settings_store.current.voice.with_(tempo=1.0),
|
|
902
|
+
volume=1.0,
|
|
903
|
+
muted=False,
|
|
904
|
+
)
|
|
905
|
+
elif scope == ResetScope.INTEGRATIONS:
|
|
906
|
+
for credential in list(self.app.credentials.list()):
|
|
907
|
+
if not credential.is_owner:
|
|
908
|
+
self.app.credentials.revoke(credential.ref)
|
|
909
|
+
self._cancel_jobs_of(credential.client_id)
|
|
910
|
+
elif scope == ResetScope.RETAINED_DATA:
|
|
911
|
+
deletion = self.app.store.delete_all_history()
|
|
912
|
+
# N-16 and F-43: a job that is running is not deleted, and
|
|
913
|
+
# the ones that were skipped are reported rather than
|
|
914
|
+
# counted as done.
|
|
915
|
+
failures = list(deletion.blocked_job_ids)
|
|
916
|
+
elif scope == ResetScope.MODEL_CACHE:
|
|
917
|
+
self.app.registry.delete(self.app.settings.voice.model_id)
|
|
918
|
+
except EchoActError as exc:
|
|
919
|
+
self._show_problem(exc)
|
|
920
|
+
return
|
|
921
|
+
if failures:
|
|
922
|
+
self._show_notice(
|
|
923
|
+
tr("Some items could not be deleted"), ", ".join(failures[:5]), kind="error"
|
|
924
|
+
)
|
|
925
|
+
self._refresh_settings_screen(screen)
|
|
926
|
+
|
|
927
|
+
def _check_version(self, screen: QWidget) -> None:
|
|
928
|
+
"""F-75: queried only at the user's request, and nothing is
|
|
929
|
+
downloaded or installed. There is no updater in this build, so
|
|
930
|
+
the honest answer is that the check is unavailable rather than a
|
|
931
|
+
silent nothing."""
|
|
932
|
+
screen.set_released_version(
|
|
933
|
+
None, error=tr("EchoAct cannot check for a newer version in this build.")
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
def _show_connect(self) -> None:
|
|
937
|
+
"""F-58: the app hands the user what their MCP client needs.
|
|
938
|
+
|
|
939
|
+
Reachable from the header rather than buried in Settings,
|
|
940
|
+
because it is the first thing someone wanting an integration
|
|
941
|
+
looks for and the values are not guessable: the ``--mcp`` flag,
|
|
942
|
+
the two environment variables, and a different key name in every
|
|
943
|
+
client's configuration file.
|
|
944
|
+
"""
|
|
945
|
+
from .mcp_setup import McpSetupView
|
|
946
|
+
|
|
947
|
+
settings = self.app.settings
|
|
948
|
+
screen = McpSetupView(
|
|
949
|
+
self.palette_tokens,
|
|
950
|
+
port=settings.rest_port,
|
|
951
|
+
mcp_enabled=settings.mcp_enabled,
|
|
952
|
+
rest_enabled=settings.rest_enabled,
|
|
953
|
+
credentials=[c for c in self.app.credentials.list() if not c.is_owner],
|
|
954
|
+
)
|
|
955
|
+
screen.credential_requested.connect(
|
|
956
|
+
lambda name: self._issue_for_client(screen, name)
|
|
957
|
+
)
|
|
958
|
+
screen.open_settings_requested.connect(self._show_settings)
|
|
959
|
+
self._connect = self._open_screen(
|
|
960
|
+
tr("Connect an app"), screen, width=880, height=760
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
def _issue_for_client(self, screen: QWidget, name: str) -> None:
|
|
964
|
+
"""Mint a credential and put it straight into the snippet.
|
|
965
|
+
|
|
966
|
+
F-71 shows a credential once, so this is the only moment the
|
|
967
|
+
value can be written into a configuration block. It is given
|
|
968
|
+
generate and read-results and nothing else: F-61 authorises
|
|
969
|
+
history separately, and a setup screen is the wrong place to
|
|
970
|
+
hand out more than the client asked for.
|
|
971
|
+
"""
|
|
972
|
+
from ..domain import Capability
|
|
973
|
+
|
|
974
|
+
try:
|
|
975
|
+
issued = self.app.credentials.issue(
|
|
976
|
+
name=name,
|
|
977
|
+
capabilities={Capability.GENERATE, Capability.READ_RESULTS},
|
|
978
|
+
days=self.app.settings.credential_days,
|
|
979
|
+
)
|
|
980
|
+
except EchoActError as exc:
|
|
981
|
+
self._show_problem(exc)
|
|
982
|
+
return
|
|
983
|
+
screen.set_token(issued.token)
|
|
984
|
+
|
|
985
|
+
def _show_status(self) -> None:
|
|
986
|
+
from .status_view import StatusView
|
|
987
|
+
|
|
988
|
+
screen = StatusView(self.app, self.palette_tokens)
|
|
989
|
+
self._status = self._open_screen(tr("Activity"), screen, width=740, height=560)
|
|
990
|
+
|
|
991
|
+
def _connect_settings(self, screen: QWidget) -> None:
|
|
992
|
+
"""One place where a settings change becomes an application change.
|
|
993
|
+
|
|
994
|
+
Each signal carries the new value and nothing else, so the screen
|
|
995
|
+
never holds the Application, and F-78's rule -- a change during a
|
|
996
|
+
job applies to the next job -- is enforced once inside
|
|
997
|
+
Application.update_settings rather than per control.
|
|
998
|
+
"""
|
|
999
|
+
pairs = (
|
|
1000
|
+
("cpu_changed", "cpu_percent"),
|
|
1001
|
+
("memory_changed", "memory_bytes"),
|
|
1002
|
+
("volume_changed", "volume"),
|
|
1003
|
+
("muted_changed", "muted"),
|
|
1004
|
+
("autoplay_changed", "autoplay"),
|
|
1005
|
+
("follow_changed", "follow"),
|
|
1006
|
+
("output_device_changed", "output_device"),
|
|
1007
|
+
("rest_enabled_changed", "rest_enabled"),
|
|
1008
|
+
("rest_port_changed", "rest_port"),
|
|
1009
|
+
("mcp_enabled_changed", "mcp_enabled"),
|
|
1010
|
+
("credential_days_changed", "credential_days"),
|
|
1011
|
+
("retention_changed", "retention_bytes"),
|
|
1012
|
+
("os_notifications_changed", "os_notifications"),
|
|
1013
|
+
("autosave_documents_changed", "autosave_documents"),
|
|
1014
|
+
("retain_history_changed", "retain_history"),
|
|
1015
|
+
)
|
|
1016
|
+
for signal_name, field in pairs:
|
|
1017
|
+
signal = getattr(screen, signal_name, None)
|
|
1018
|
+
if signal is not None:
|
|
1019
|
+
signal.connect(lambda value, f=field: self._apply_setting(f, value))
|
|
1020
|
+
|
|
1021
|
+
language = getattr(screen, "display_language_changed", None)
|
|
1022
|
+
if language is not None:
|
|
1023
|
+
language.connect(self._apply_display_language)
|
|
1024
|
+
|
|
1025
|
+
self._connect_settings_actions(screen)
|
|
1026
|
+
|
|
1027
|
+
def _apply_setting(self, field: str, value: object) -> None:
|
|
1028
|
+
try:
|
|
1029
|
+
self.app.update_settings(**{field: value})
|
|
1030
|
+
except EchoActError as exc:
|
|
1031
|
+
self._show_problem(exc)
|
|
1032
|
+
return
|
|
1033
|
+
if field in {"volume", "muted"}:
|
|
1034
|
+
self.app.player.set_volume(self.app.settings.volume)
|
|
1035
|
+
self.app.player.set_muted(self.app.settings.muted)
|
|
1036
|
+
elif field == "follow":
|
|
1037
|
+
self.reading.set_follow(bool(value))
|
|
1038
|
+
elif field in {"cpu_percent", "memory_bytes"}:
|
|
1039
|
+
self._refresh_resources()
|
|
1040
|
+
elif field in {"rest_enabled", "rest_port", "mcp_enabled"}:
|
|
1041
|
+
self._restart_service()
|
|
1042
|
+
elif field == "os_notifications":
|
|
1043
|
+
self.notifications.set_os_notifications(bool(value))
|
|
1044
|
+
|
|
1045
|
+
def _apply_display_language(self, value: str) -> None:
|
|
1046
|
+
"""F-86. Only what is shown changes: A-22 requires documents, job
|
|
1047
|
+
snapshots and API responses to be untouched."""
|
|
1048
|
+
from .i18n import Lang, set_language
|
|
1049
|
+
|
|
1050
|
+
self.app.update_settings(display_language=value)
|
|
1051
|
+
set_language(Lang(value) if value in {"ko", "en"} else Lang.SYSTEM)
|
|
1052
|
+
|
|
1053
|
+
def _restart_service(self) -> None:
|
|
1054
|
+
"""F-79: a port change restarts the service, not the app, and a
|
|
1055
|
+
failure is an actionable notice rather than a stoppage."""
|
|
1056
|
+
self.app.stop_service()
|
|
1057
|
+
problem = self.app.start_service()
|
|
1058
|
+
self._refresh_service_label()
|
|
1059
|
+
if problem is not None:
|
|
1060
|
+
self._show_notice(problem.code.value, problem.message, kind="error")
|
|
1061
|
+
|
|
1062
|
+
def _open_document(self, document_id: str) -> None:
|
|
1063
|
+
"""F-36 still applies here: opening replaces the input, so it asks."""
|
|
1064
|
+
try:
|
|
1065
|
+
document = self.app.store.get_document(document_id)
|
|
1066
|
+
except EchoActError as exc:
|
|
1067
|
+
self._show_problem(exc)
|
|
1068
|
+
return
|
|
1069
|
+
if self.reading.source_text().strip() and not self._confirm_replace():
|
|
1070
|
+
return
|
|
1071
|
+
self.reading.set_text(document.body)
|
|
1072
|
+
|
|
1073
|
+
def _confirm_replace(self) -> bool:
|
|
1074
|
+
answer = QMessageBox.question(
|
|
1075
|
+
self,
|
|
1076
|
+
tr("Replace the text that is already here?"),
|
|
1077
|
+
tr("The text you have now has not been saved."),
|
|
1078
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
|
1079
|
+
)
|
|
1080
|
+
return answer is QMessageBox.StandardButton.Yes
|
|
1081
|
+
|
|
1082
|
+
# ------------------------------------------------------------------
|
|
1083
|
+
# Chrome
|
|
1084
|
+
# ------------------------------------------------------------------
|
|
1085
|
+
|
|
1086
|
+
def _update_enabled(self) -> None:
|
|
1087
|
+
busy = self.app.engine.busy
|
|
1088
|
+
has_text = bool(self.reading.source_text().strip())
|
|
1089
|
+
over = len(self.reading.source_text()) > MAX_INPUT_CODEPOINTS
|
|
1090
|
+
self.transport.read.setEnabled(busy or (has_text and not over))
|
|
1091
|
+
self.transport.set_generating(busy)
|
|
1092
|
+
self.voice_panel.set_locked(busy)
|
|
1093
|
+
playable = self._timeline is not None and self._timeline.total_frames > 0
|
|
1094
|
+
self.transport.pause.setEnabled(playable)
|
|
1095
|
+
self.transport.stop.setEnabled(playable)
|
|
1096
|
+
self.save_button.setEnabled(playable)
|
|
1097
|
+
self.open_button.setEnabled(not busy)
|
|
1098
|
+
|
|
1099
|
+
def _set_status(self, text: str) -> None:
|
|
1100
|
+
self.status.setText(text)
|
|
1101
|
+
|
|
1102
|
+
def _refresh_resources(self) -> None:
|
|
1103
|
+
"""F-78 shows the configured value; the job shows what it ran under.
|
|
1104
|
+
|
|
1105
|
+
This is the configured one, because no job is running when the user
|
|
1106
|
+
is reading it -- and when one is, the status bar carries the live
|
|
1107
|
+
figures instead.
|
|
1108
|
+
"""
|
|
1109
|
+
from ..config.budget import resolve_budget_from_system
|
|
1110
|
+
from .i18n import memory_size
|
|
1111
|
+
|
|
1112
|
+
try:
|
|
1113
|
+
budget = resolve_budget_from_system(self.app.settings)
|
|
1114
|
+
except EchoActError as exc:
|
|
1115
|
+
self.voice_panel.set_resource_summary(exc.message)
|
|
1116
|
+
return
|
|
1117
|
+
self.voice_panel.set_resource_summary(
|
|
1118
|
+
f"{tr('CPU')} {budget.cpu_percent}% · {memory_size(budget.memory_bytes)}"
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
def _refresh_service_label(self) -> None:
|
|
1122
|
+
s = self.app.settings
|
|
1123
|
+
if self.app.service_running:
|
|
1124
|
+
state = f"{tr('Local service')} {tr('on')} · 127.0.0.1:{s.rest_port}"
|
|
1125
|
+
elif s.rest_enabled:
|
|
1126
|
+
state = tr("Integrations unavailable")
|
|
1127
|
+
else:
|
|
1128
|
+
state = f"{tr('Local service')} {tr('off')}"
|
|
1129
|
+
mcp = f" · MCP {tr('on') if s.mcp_enabled else tr('off')}"
|
|
1130
|
+
self.service_label.setText(state + mcp)
|
|
1131
|
+
|
|
1132
|
+
def _refresh_usage(self) -> None:
|
|
1133
|
+
"""F-22: usage while generating, and while idle with a model held."""
|
|
1134
|
+
usage = None
|
|
1135
|
+
try:
|
|
1136
|
+
usage = self.app.supervisor.usage()
|
|
1137
|
+
except Exception: # noqa: BLE001 - a usage read must never break a tick
|
|
1138
|
+
usage = None
|
|
1139
|
+
if usage is None:
|
|
1140
|
+
self.usage_label.setText("")
|
|
1141
|
+
return
|
|
1142
|
+
from .i18n import memory_size
|
|
1143
|
+
|
|
1144
|
+
self.usage_label.setText(
|
|
1145
|
+
f"{tr('CPU')} {usage.cpu_percent:.0f}% · "
|
|
1146
|
+
f"{tr('Memory')} {memory_size(usage.rss_bytes)}"
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def _post(self, notice: Notice) -> None:
|
|
1150
|
+
"""Record a notice and show it. Only notices go to the OS, and
|
|
1151
|
+
only when the user turned that on -- F-70 and 4.1."""
|
|
1152
|
+
self.notifications.post(notice)
|
|
1153
|
+
kind = {Level.OK: "ok", Level.ERROR: "error"}.get(notice.level, "info")
|
|
1154
|
+
self._show_notice(notice.title, notice.detail, kind=kind)
|
|
1155
|
+
|
|
1156
|
+
def _show_notice(self, title: str, detail: str, *, kind: str = "info") -> None:
|
|
1157
|
+
p = self.palette_tokens
|
|
1158
|
+
colour = {"info": p.accent, "ok": p.ok, "error": p.danger}.get(kind, p.accent)
|
|
1159
|
+
name = {"info": "info", "ok": "check-circle", "error": "error"}.get(kind, "info")
|
|
1160
|
+
self.notice_icon.setPixmap(icons.pixmap(name, colour, 18, self.devicePixelRatioF()))
|
|
1161
|
+
self.notice_text.setText(f"{title} — {detail}" if detail else title)
|
|
1162
|
+
self.notice.show()
|
|
1163
|
+
|
|
1164
|
+
def _show_problem(self, exc: EchoActError) -> None:
|
|
1165
|
+
"""F-25: a failure is reported with a reason, not a shrug."""
|
|
1166
|
+
if exc.code is Code.BUSY:
|
|
1167
|
+
self._show_notice(tr("A job is still running."), exc.message, kind="info")
|
|
1168
|
+
return
|
|
1169
|
+
self._show_notice(exc.code.value, exc.message, kind="error")
|
|
1170
|
+
log.info("reported to user: %s", exc.code.value)
|
|
1171
|
+
|
|
1172
|
+
def _report_startup(self) -> None:
|
|
1173
|
+
st = self.app.startup
|
|
1174
|
+
if st.interrupted_jobs:
|
|
1175
|
+
self._show_notice(
|
|
1176
|
+
tr("Interrupted"),
|
|
1177
|
+
f"{len(st.interrupted_jobs)}",
|
|
1178
|
+
kind="info",
|
|
1179
|
+
)
|
|
1180
|
+
elif st.problems:
|
|
1181
|
+
first = st.problems[0]
|
|
1182
|
+
self._show_notice(first.code.value, first.message, kind="error")
|
|
1183
|
+
|
|
1184
|
+
# ------------------------------------------------------------------
|
|
1185
|
+
# Exit (F-77, F-52)
|
|
1186
|
+
# ------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
|
1189
|
+
if self.app.engine.busy:
|
|
1190
|
+
answer = QMessageBox.question(
|
|
1191
|
+
self,
|
|
1192
|
+
tr("A job is still running."),
|
|
1193
|
+
tr("The text you have now has not been saved."),
|
|
1194
|
+
QMessageBox.StandardButton.Close | QMessageBox.StandardButton.Cancel,
|
|
1195
|
+
)
|
|
1196
|
+
if answer is not QMessageBox.StandardButton.Close:
|
|
1197
|
+
event.ignore()
|
|
1198
|
+
return
|
|
1199
|
+
self._tick.stop()
|
|
1200
|
+
self.bridge.detach()
|
|
1201
|
+
self.app.shutdown()
|
|
1202
|
+
event.accept()
|