-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathlaunch.py
More file actions
317 lines (266 loc) · 10.8 KB
/
Copy pathlaunch.py
File metadata and controls
317 lines (266 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
#!/usr/bin/env python3
"""
Launcher script for tts-audiobook-tool.
Scans the project root directory for virtual environments, detects which
TTS model libraries each venv has installed, and lets the user pick one
to launch the app with.
Duplicates some app code and logic to avoid importing cascade of app dependencies.
Optional venvs base directory can be passed as first argument.
"""
import json
import os
import subprocess
import sys
import signal
# ---------------------------------------------------------------------------
# ANSI helpers lifted from tts_audiobook_tool/ansi.py (truecolor + xterm-256
# fallback) and color constants from tts_audiobook_tool/constants.py.
# ---------------------------------------------------------------------------
def rgb_to_xterm256(r: int, g: int, b: int) -> int:
"""Convert RGB to nearest xterm 256 color index."""
if abs(r - g) < 8 and abs(g - b) < 8:
gray = round((r + g + b) / 3)
if gray < 8:
return 16
if gray > 248:
return 231
gray_index = round(((gray - 8) / 240) * 23)
return 232 + gray_index
r_idx = round((r / 255) * 5)
g_idx = round((g / 255) * 5)
b_idx = round((b / 255) * 5)
return 16 + (r_idx * 36) + (g_idx * 6) + b_idx
class Ansi:
RESET = "\033[0m"
CLEAR_SCREEN_AND_SCROLLBACK = "\033[2J\033[3J\033[H"
@staticmethod
def hex(hex_color: str) -> str:
if hex_color.startswith("#"):
hex_color = hex_color[1:]
hex_color = hex_color.ljust(6, "0")[:6]
try:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
except ValueError:
r = g = b = 255
colorterm = os.environ.get("COLORTERM", "").lower()
if colorterm in ("truecolor", "24bit"):
return f"\033[38;2;{r};{g};{b}m"
else:
idx = rgb_to_xterm256(r, g, b)
return f"\033[38;5;{idx}m"
# Color constants matching tts_audiobook_tool/constants.py
COL_ACCENT = Ansi.hex("ffaa44")
COL_DIM = Ansi.hex("888888")
COL_DEFAULT = Ansi.RESET
COL_ERROR = Ansi.hex("ff0000")
COL_OK = Ansi.hex("00ff00")
COL_INPUT = Ansi.hex("aaaaaa")
# ---------------------------------------------------------------------------
# Import TtsModelType directly — its deps are all stdlib (enum, functools,
# typing), so this is safe without installing the app package.
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
from tts_audiobook_tool.tts_models.tts_model_type import TtsModelType
# Build the list of (module_test, proper_name) from the enum, skipping NONE
QUALIFIED_MODELS: list[tuple[str, str]] = []
for member in TtsModelType:
if member.name == "NONE":
continue
QUALIFIED_MODELS.append((member.value.local_module_test, member.value.ui["proper_name"]))
SGL_OMNI_MARKER_MODULE = "tts_audiobook_tool_sgl_omni_marker"
SGL_OMNI_DISPLAY_MODEL = "SGL-Omni server mode"
def find_venvs(base_dir: str) -> list[str]:
"""Return paths to subdirectories that contain a pyvenv.cfg file."""
venvs: list[str] = []
if not os.path.isdir(base_dir):
return venvs
for entry in os.listdir(base_dir):
child = os.path.join(base_dir, entry)
if os.path.isdir(child) and os.path.isfile(os.path.join(child, "pyvenv.cfg")):
venvs.append(child)
return sorted(venvs)
def get_venv_python(venv_path: str) -> str | None:
"""Return the path to the Python executable inside the venv."""
if sys.platform == "win32":
candidate = os.path.join(venv_path, "Scripts", "python.exe")
else:
candidate = os.path.join(venv_path, "bin", "python")
return candidate if os.path.isfile(candidate) else None
def probe_venv(venv_path: str) -> tuple[list[str], int]:
"""
Inside *venv_path*, run a subprocess that checks each module_test.
Returns (list_of_proper_names, match_count) where match_count is the
number of matched models. If match_count > 1 the venv is ambiguous
and should not be used.
"""
python_exe = get_venv_python(venv_path)
if python_exe is None:
return [], 0
tests_json = json.dumps(QUALIFIED_MODELS)
probe_code = (
"import importlib.metadata\n"
"import importlib.util\n"
"import json\n"
"tests = " + tests_json + "\n"
"def check(mod):\n"
" try:\n"
" if mod.startswith('dist:'):\n"
" dist_test = mod.removeprefix('dist:').strip()\n"
" if '==' in dist_test:\n"
" dist_name, expected_version = [part.strip() for part in dist_test.split('==', 1)]\n"
" return importlib.metadata.version(dist_name) == expected_version\n"
" importlib.metadata.version(dist_test)\n"
" return True\n"
" return importlib.util.find_spec(mod) is not None\n"
" except Exception:\n"
" return False\n"
"matched = [i for i, (mod, _) in enumerate(tests) if check(mod)]\n"
"infos = [tests[i] for i in matched]\n"
"names = [n for _, n in infos]\n"
"match_count = len(infos)\n"
"print(json.dumps({'names': names, 'match_count': match_count}))\n"
)
try:
result = subprocess.run(
[python_exe, "-c", probe_code],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
parsed = json.loads(result.stdout.strip())
names = parsed.get("names", [])
count = parsed.get("match_count", len(names))
return names, count
return [], 0
except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError):
return [], 0
def has_sgl_omni_marker(venv_path: str) -> bool:
"""Return True if *venv_path* has the dedicated SGL-Omni launcher marker package."""
python_exe = get_venv_python(venv_path)
if python_exe is None:
return False
probe_code = (
"import importlib.util\n"
f"raise SystemExit(0 if importlib.util.find_spec({SGL_OMNI_MARKER_MODULE!r}) is not None else 1)\n"
)
try:
result = subprocess.run(
[python_exe, "-c", probe_code],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
return False
def build_venv_list(base_dir: str) -> list[tuple[str, str, list[str]]]:
"""
Returns list of (venv_path, display_name, detected_models).
Only includes venvs that have exactly one detected model, mirroring tts.py's
init_model_type assertion that exactly 0 or 1 model should match.
Ambiguous venvs (>1 match) are printed as warnings and skipped.
"""
results: list[tuple[str, str, list[str]]] = []
for vp in find_venvs(base_dir):
name = os.path.basename(vp)
models, match_count = probe_venv(vp)
if match_count == 0:
if has_sgl_omni_marker(vp):
results.append((vp, name, [SGL_OMNI_DISPLAY_MODEL]))
continue
if match_count > 1:
print(
f" {COL_DIM}Warning: {name} matched {match_count} models"
f" ({' / '.join(models)}){COL_DEFAULT}"
f"\u2014skipping (ambiguous environment)",
file=sys.stderr,
)
continue
# match_count == 1
results.append((vp, name, models))
return results
def make_bracket(num: int, width: int) -> str:
"""Return e.g. '[ 9]' or '[10]' with proper alignment."""
return f"[{COL_ACCENT}{num:>{width}}{Ansi.RESET}]"
def show_menu(venvs: list[tuple[str, str, list[str]]], auto_choice: int | None = None) -> int | None:
"""Print a colored numbered menu and return the chosen index (0-based), or None on no match."""
if len(venvs) >= 10:
width = len(str(len(venvs)))
else:
width = 1
print()
heading = "tts-audiobook-tool - Virtual environment convenience launcher"
print(f"{COL_ACCENT}{heading}{Ansi.RESET}")
print()
for i, (_, name, models) in enumerate(venvs, start=1):
if len(models) == 1:
models_str = models[0]
else:
models_str = ", ".join(models)
print(f" {make_bracket(i, width)} {name} {COL_DIM}({models_str}){Ansi.RESET}")
print()
prompt = f"{COL_INPUT}Selection: {Ansi.RESET}"
if auto_choice is not None and 1 <= auto_choice <= len(venvs):
print(prompt, end="")
print(auto_choice)
return auto_choice - 1
choice = input(prompt).strip()
try:
idx = int(choice)
if 1 <= idx <= len(venvs):
return idx - 1
except ValueError:
pass
return None
def main() -> None:
auto_choice: int | None = None
if len(sys.argv) > 1:
try:
auto_choice = int(sys.argv[1])
base_dir = os.path.dirname(os.path.abspath(__file__))
except ValueError:
base_dir = sys.argv[1]
elif env_dir := os.environ.get("TTS_LAUNCH_BASE_DIR"):
base_dir = env_dir
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
venvs = build_venv_list(base_dir)
if not venvs:
print()
heading = " tts-audiobook-tool \u2014 No qualified virtual environments found"
print(f"{COL_DIM}{'-' * (len(heading) + 2)}{Ansi.RESET}")
print(f"{COL_DEFAULT}{heading}{Ansi.RESET}")
print(f"{COL_DIM}{'-' * (len(heading) + 2)}{Ansi.RESET}")
print()
print(f" {COL_DIM}Searched in: {base_dir}{Ansi.RESET}")
print()
print(f" {COL_DIM}A qualified venv is a subdirectory containing pyvenv.cfg and")
print(f" at least one supported TTS model library installed.{Ansi.RESET}")
print()
sys.exit(1)
choice_idx = show_menu(venvs, auto_choice=auto_choice)
if choice_idx is None:
return
venv_path = venvs[choice_idx][0]
python_exe = get_venv_python(venv_path)
if python_exe is None:
print(f"\n {COL_ERROR}Error:{Ansi.RESET} Could not find Python executable in {venv_path}")
sys.exit(1)
# Change to the project root so `python -m tts_audiobook_tool` resolves the
# package regardless of the caller's current working directory.
os.chdir(SCRIPT_DIR)
args = [python_exe, "-m", "tts_audiobook_tool"]
if sys.platform == "win32":
old_sigint = signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
proc = subprocess.Popen(args)
sys.exit(proc.wait())
finally:
signal.signal(signal.SIGINT, old_sigint)
os.execv(python_exe, args)
if __name__ == "__main__":
main()