aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 19:03:09 -0400
committerhistoria <historiavg@proton.me>2026-08-25 19:03:09 -0400
commit8c9a782dfe94525dc5f0893c98fd19543648264b (patch)
treefdff70d967ef06f0da560ca284842427cbe647a8
parent64e9940d43fad1bc5908b39673b03e4fdc1e8f2f (diff)
downloadtts-audiobook-generator-8c9a782dfe94525dc5f0893c98fd19543648264b.tar.gz
fix: tui errors wait for getch()
-rwxr-xr-xapp/backends/audiocpp.py55
-rwxr-xr-xapp/backends/faster.py16
-rw-r--r--app/backends/qwen.py14
-rw-r--r--app/tests/test_backends_audiocpp.py39
-rw-r--r--app/tests/test_hub.py34
-rw-r--r--app/tests/test_runview.py8
-rw-r--r--app/tests/test_taskview.py50
-rw-r--r--app/tests/test_tui.py21
-rw-r--r--app/ui/hub.py26
-rw-r--r--app/ui/runview.py3
-rw-r--r--app/ui/taskview.py6
-rw-r--r--app/ui/tui.py32
12 files changed, 202 insertions, 102 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index 31aa01d..f6121ea 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -1987,39 +1987,28 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str,
return rc
-def _print_launch_hint(audiocpp_dir: Path, output_path: Path,
- pending_build: bool = False) -> None:
- """Print the exact command to start the server (or build guidance).
-
- The command is prefixed with ``cd <checkout> &&`` because the server
- discovers model_specs/<family>.json relative to its working directory.
- PENDING_BUILD is True when the server is still building in a parallel
- lane; instead of "build it first" remediation the hint then names the
- command to run once that build finishes.
+def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None:
+ """Print remediation when audiocpp_server is missing (troubleshooting).
+
+ The hub starts and stops the server itself, so a working install gets
+ no manual launch instructions. When no binary was built, though, the
+ user needs to know how to build and run it by hand. The commands are
+ prefixed with ``cd <checkout> &&`` because the server discovers
+ model_specs/<family>.json relative to its working directory.
"""
- binary = find_audiocpp_server_bin(audiocpp_dir)
- print()
- if binary is not None:
- print("Start the server with:")
- print(f" cd {audiocpp_dir} && {binary} --config {output_path}")
- elif pending_build:
- print("The server is still building in the other panel — once it "
- "finishes, start it with:")
- print(f" cd {audiocpp_dir} && ./build/<platform>-<backend>-release"
- f"/bin/audiocpp_server --config {output_path}")
- else:
- print("[INFO] audiocpp_server binary not found. Build it first, e.g.:")
- script = find_build_script(audiocpp_dir)
- if script is not None:
- print(f" sh {script} --backend <cuda|vulkan|hip|cpu> "
- "--target audiocpp_server --deployment-build")
- print(f" then run: cd {audiocpp_dir} && ./build/<platform>-<backend>"
- f"-release/bin/audiocpp_server --config {output_path}")
+ if find_audiocpp_server_bin(audiocpp_dir) is not None:
+ return
+ print("\n[INFO] audiocpp_server binary not found. Build it first, e.g.:")
+ script = find_build_script(audiocpp_dir)
+ if script is not None:
+ print(f" sh {script} --backend <cuda|vulkan|hip|cpu> "
+ "--target audiocpp_server --deployment-build")
+ print(f" then run: cd {audiocpp_dir} && ./build/<platform>-<backend>"
+ f"-release/bin/audiocpp_server --config {output_path}")
def _execute_lanes(settings: dict,
- args: argparse.Namespace,
- parallel: bool = False) -> List[taskview.TaskLane]:
+ args: argparse.Namespace) -> List[taskview.TaskLane]:
"""Build the ordered setup steps for the in-TUI task view, per lane.
The same work ``_execute`` runs on the console, split into two lanes so
@@ -2031,8 +2020,7 @@ def _execute_lanes(settings: dict,
dict scoped to the models lane. Each step's ``work(emit, cancel)``
returns its exit code; subprocess steps stream through EMIT and abort on
CANCEL, while print()-based steps are captured by the view's stdout
- routing. PARALLEL marks the launch hint as concurrent-with-build so it
- does not claim the binary is missing while the build is still running.
+ routing.
"""
audiocpp_dir = settings["audiocpp_dir"]
state: dict = {}
@@ -2102,8 +2090,7 @@ def _execute_lanes(settings: dict,
def install(emit, cancel):
_install_models(audiocpp_dir, settings["install_guidance"],
settings["download"], emit=emit, cancel=cancel)
- _print_launch_hint(audiocpp_dir, settings["output_path"],
- pending_build=bool(build and parallel))
+ _print_launch_hint(audiocpp_dir, settings["output_path"])
return 0
install_title = "Download models" if settings.get("download") \
else "Print model install commands"
@@ -2163,7 +2150,7 @@ def setup_screen(stdscr) -> int:
if settings is None:
return 1
return taskview.run_lanes(stdscr, "Setting up audio.cpp",
- _execute_lanes(settings, args, parallel=True))
+ _execute_lanes(settings, args))
def build_screen(stdscr) -> int:
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 8c64183..7ac4dce 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -428,15 +428,15 @@ def _execute(settings: dict) -> int:
def _print_launch_hint(voices_path: Path, port: int) -> None:
- print()
+ """Remediation only (troubleshooting): what's missing when not cloned.
+
+ The hub starts and stops the server itself, so a working install gets
+ no manual launch instructions.
+ """
if _is_cloned():
- py = envs.env_python()
- print("Start the server with (or use the hub's 'Server' menu):")
- print(f" {py} {_checkout()}/examples/openai_server.py "
- f"--voices {voices_path} --port {port}")
- else:
- print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,")
- print(f" then run it with --voices {voices_path} --port {port}")
+ return
+ print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,")
+ print(f" then run it with --voices {voices_path} --port {port}")
def setup_screen(stdscr) -> int:
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index c6a11bf..cc84f8e 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -189,8 +189,6 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
else:
print("[WARNING] Could not update SPEAKER; edit "
"app/converter/config.py by hand")
-
- _print_launch_hint(settings["custom_port"], settings["clone_port"])
return 0
steps.append(taskview.TaskStep("Sync config & ports", sync))
@@ -202,18 +200,6 @@ def _execute(settings: dict) -> int:
return taskview.run_steps_inline(_execute_steps(settings))
-def _print_launch_hint(custom_port: int, clone_port: int) -> None:
- demo = envs.env_script("qwen-tts-demo")
- print()
- print("Start the servers (in separate terminals), or use the hub's")
- print("'Server' menu / let a conversion start one automatically:")
- print(f" {demo} {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 "
- f"--port {custom_port}")
- print(f" {demo} {QWEN_BASE_MODEL} --ip 127.0.0.1 "
- f"--port {clone_port}")
- print("Then run: python audiobook.py --backend qwen")
-
-
def setup_screen(stdscr) -> int:
"""Run the setup wizard on an existing curses screen (the hub's).
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 665dbf0..3063e99 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -1895,7 +1895,6 @@ class SetupScreenTests(unittest.TestCase):
self.assertEqual(rc, 0)
mk_lanes.assert_called_once()
self.assertIs(mk_lanes.call_args[0][0], settings)
- self.assertTrue(mk_lanes.call_args[1]["parallel"])
mk_run.assert_called_once()
self.assertEqual(mk_run.call_args[0][2], lanes)
@@ -1956,48 +1955,34 @@ class ExecuteLanesTests(unittest.TestCase):
"Write server.json & sync config",
"Download models"])
- def test_download_step_prints_the_parallel_launch_hint(self):
- args = make_server.build_parser().parse_args([])
- lanes = make_server._execute_lanes(self._settings(), args,
- parallel=True)
- install_step = lanes[1].steps[2]
- with patch.object(make_server, "_install_models") as mk_install, \
- patch.object(make_server, "_print_launch_hint") as mk_hint:
- install_step.work(lambda line: None, threading.Event())
- mk_hint.assert_called_once()
- self.assertTrue(mk_hint.call_args[1]["pending_build"])
-
- def test_download_step_console_hint_is_not_pending(self):
+ def test_download_step_prints_the_launch_hint(self):
args = make_server.build_parser().parse_args([])
lanes = make_server._execute_lanes(self._settings(), args)
install_step = lanes[1].steps[2]
- with patch.object(make_server, "_install_models") as mk_install, \
+ with patch.object(make_server, "_install_models"), \
patch.object(make_server, "_print_launch_hint") as mk_hint:
install_step.work(lambda line: None, threading.Event())
- mk_hint.assert_called_once()
- self.assertFalse(mk_hint.call_args[1]["pending_build"])
+ mk_hint.assert_called_once_with(Path("/x"), Path("/x/server.json"))
class LaunchHintTests(unittest.TestCase):
- """_print_launch_hint: exact command vs. the pending-build message."""
+ """_print_launch_hint: silent when built, remediation when not."""
- def _capture(self, audiocpp_dir, output_path, pending_build=False):
+ def _capture(self, audiocpp_dir, output_path, binary=None):
buf = io.StringIO()
with redirect_stdout(buf), \
patch.object(make_server, "find_audiocpp_server_bin",
- return_value=None):
- make_server._print_launch_hint(audiocpp_dir, output_path,
- pending_build=pending_build)
+ return_value=binary):
+ make_server._print_launch_hint(audiocpp_dir, output_path)
return buf.getvalue()
- def test_pending_build_names_the_post_build_command(self):
+ def test_built_server_prints_nothing(self):
+ # The hub starts/stops the server itself; no manual instructions.
out = self._capture(Path("/tmp/acpp"), Path("/tmp/acpp/server.json"),
- pending_build=True)
- self.assertIn("still building", out)
- self.assertNotIn("Build it first", out)
- self.assertIn("audiocpp_server --config /tmp/acpp/server.json", out)
+ binary=Path("/tmp/acpp/build/x/bin/audiocpp_server"))
+ self.assertEqual(out, "")
def test_missing_binary_gives_build_remediation(self):
out = self._capture(Path("/tmp/acpp"), Path("/tmp/acpp/server.json"))
self.assertIn("Build it first", out)
- self.assertNotIn("still building", out)
+ self.assertNotIn("Start the server with:", out)
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index ec079f5..3feaa05 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -1730,6 +1730,40 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
self.assertIs(result, tui.Wizard.BACK)
self.assertEqual(flashes, ["kaboom"])
+ def test_screen_install_runs_setup_inline_then_goes_back(self):
+ # No stack frame for the setup: BACK pops past the picker straight
+ # to the Configure menu instead of re-showing "Install Backend".
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(info, "setup_screen") as mk_setup:
+ result = hub._Hub(None).screen_install()
+ mk_setup.assert_called_once_with(None)
+ self.assertIs(result, tui.Wizard.BACK)
+
+ def test_screen_install_esc_on_picker_goes_back_without_setup(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=None), \
+ patch.object(info, "setup_screen") as mk_setup:
+ result = hub._Hub(None).screen_install()
+ mk_setup.assert_not_called()
+ self.assertIs(result, tui.Wizard.BACK)
+
+ def test_screen_install_flashes_on_crash_and_still_goes_back(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ flashes = []
+
+ def boom(scr):
+ raise RuntimeError("kaboom")
+
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(info, "setup_screen", boom), \
+ patch.object(hub.tui, "flash",
+ lambda scr, text, kind="warn":
+ flashes.append(text)):
+ result = hub._Hub(None).screen_install()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(flashes, ["kaboom"])
+
def test_screen_uninstall_runs_in_task_view_and_goes_back(self):
info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
with patch.object(hub._Hub, "_pick_backend", return_value=info), \
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py
index df697c8..7006427 100644
--- a/app/tests/test_runview.py
+++ b/app/tests/test_runview.py
@@ -191,6 +191,14 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
# (started_server is False).
self.assertEqual(view.phase, "done")
+ def test_run_leaves_the_screen_blocking_again(self):
+ # The timed redraw cadence must not leak into the hub: blocking
+ # input is restored so later dialogs (tui.flash) wait for keys.
+ view, screen = self.make_view(keys=[ord("x")])
+ view._queue.put({"kind": "done", "ok": 1, "total": 1})
+ view.run()
+ self.assertEqual(screen.timeouts[-1], -1)
+
def test_esc_cancels_and_confirms_stop_server(self):
confirm_answers = [True, True] # cancel? yes; stop server? yes
with patch.object(runview.tui, "confirm",
diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py
index 4b03b41..6c984ad 100644
--- a/app/tests/test_taskview.py
+++ b/app/tests/test_taskview.py
@@ -276,6 +276,42 @@ class NoWaitTests(_FakeTui, unittest.TestCase):
self.assertNotIn("press any key", text)
+class InputModeRestoreTests(_FakeTui, unittest.TestCase):
+ """run() must leave the screen blocking again when the view exits.
+
+ The views drive their redraw loop with a timed getch; if that cadence
+ leaked into the hub, single-getch dialogs like tui.flash would dismiss
+ themselves after one timeout instead of waiting for a key.
+ """
+
+ def test_task_view_run_restores_blocking_getch(self):
+ def fake_worker_main(view):
+ view._queue.put({"kind": "step_start", "index": 0,
+ "title": "one"})
+ view._queue.put({"kind": "step_done", "index": 0, "rc": 0})
+ view._queue.put({"kind": "finish", "phase": "done", "rc": 0})
+
+ screen = FakeScreen(width=80, height=24)
+ with patch.object(taskview.TaskView, "_worker_main",
+ fake_worker_main):
+ view = taskview.TaskView(screen, "Setup", [_step("one")],
+ clock=lambda: 1000.0,
+ wait_on_finish=False)
+ view._worker = _SyncWorker(view._worker_main)
+ view.run()
+ self.assertEqual(screen.timeouts[-1], -1)
+
+ def test_lanes_view_run_restores_blocking_getch(self):
+ screen = FakeScreen(keys=[27]) # any key leaves the finished view
+ lanes = [taskview.TaskLane("A", [_step("a")]),
+ taskview.TaskLane("B", [_step("b")])]
+ view = taskview.LanesView(screen, "Setup", lanes,
+ clock=lambda: 1000.0)
+ with patch.object(taskview.threading, "Thread", _SyncThread):
+ view.run()
+ self.assertEqual(screen.timeouts[-1], -1)
+
+
class _SyncWorker:
"""A stand-in for threading.Thread that runs the target synchronously."""
@@ -286,6 +322,20 @@ class _SyncWorker:
self._fn()
+class _SyncThread:
+ """A threading.Thread stand-in that runs its target on start()."""
+
+ def __init__(self, target=None, args=(), kwargs=None, daemon=None):
+ self._target = target
+ self._args = args
+
+ def start(self):
+ self._target(*self._args)
+
+ def join(self, timeout=None):
+ pass
+
+
class LabelTests(unittest.TestCase):
def test_fmt_bytes(self):
self.assertEqual(taskview._fmt_bytes(512), "512B")
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 543194e..1b7af86 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -88,6 +88,10 @@ class FakeScreen:
self.height = height
self.strings = [] # (y, x, text, attr) from addstr
self.chars = [] # (y, x, ch, attr) from addch
+ self.timeouts = [] # ms values passed to timeout()
+
+ def timeout(self, ms):
+ self.timeouts.append(ms)
def getmaxyx(self):
return self.height, self.width
@@ -979,6 +983,23 @@ class FlashTests(TuiTestCase):
self.assertEqual(attr, tui._THEME["warn"])
self.assert_inside_border(screen)
+ def test_timed_out_reads_are_ignored_until_a_real_key(self):
+ # A screen left in redraw-cadence mode feeds getch() -1s; the
+ # notice must still wait for an actual key press.
+ screen = FakeScreen(keys=[-1, -1, ord("x")])
+ tui.flash(screen, "a notice", kind="err")
+ self.assertEqual(screen.keys, [])
+
+ def test_ctrl_c_still_aborts(self):
+ screen = FakeScreen(keys=[-1, 3])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.flash(screen, "a notice")
+
+ def test_frame_flash_ignores_timed_out_reads_too(self):
+ frame = tui.Frame(FakeScreen(keys=[-1, 10]), "Title", "footer")
+ frame.flash("status line notice", "err")
+ self.assertIsNone(frame.status)
+
class WizardTests(unittest.TestCase):
"""The tui.Wizard screen-stack driver: Esc steps back one screen."""
diff --git a/app/ui/hub.py b/app/ui/hub.py
index d20b28e..c1f5dce 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -237,20 +237,32 @@ class _Hub:
back to the menu that launched it. A crash flashes and does the same.
"""
def screen():
- try:
- info.setup_screen(self.stdscr)
- except tui.WizardCancelled:
- pass
- except Exception as exc: # noqa: BLE001 - keep the hub alive
- tui.flash(self.stdscr, str(exc), "err")
+ self._run_setup(info)
return tui.Wizard.BACK
return screen
+ def _run_setup(self, info) -> None:
+ """Run one backend's setup wizard on this session (no stack frame)."""
+ try:
+ info.setup_screen(self.stdscr)
+ except tui.WizardCancelled:
+ pass
+ except Exception as exc: # noqa: BLE001 - keep the hub alive
+ tui.flash(self.stdscr, str(exc), "err")
+
def screen_install(self):
+ """Pick a backend to install and run its setup inline.
+
+ The setup is not pushed as a stack frame: when it finishes this
+ screen returns BACK, popping straight past the picker to the
+ Configure menu, whose status table re-detects the new install.
+ Esc on the picker still pops back one screen normally.
+ """
info = self._pick_backend(installed_only=False)
if info is None:
return tui.Wizard.BACK
- return self.screen_setup(info)
+ self._run_setup(info)
+ return tui.Wizard.BACK
def screen_uninstall(self):
"""Pick a backend, confirm, then uninstall it inside the task view.
diff --git a/app/ui/runview.py b/app/ui/runview.py
index f016922..d49d949 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -301,6 +301,9 @@ class RunView:
finally:
self._monitor_stop.set()
self._cancel.set()
+ # Leave the screen blocking again: the timed redraw getch must
+ # not make later hub dialogs (e.g. tui.flash) dismiss themselves.
+ self._blocking()
def _get_key(self) -> Optional[int]:
"""One key from the screen (None on the redraw timeout)."""
diff --git a/app/ui/taskview.py b/app/ui/taskview.py
index c4fd35e..6708304 100644
--- a/app/ui/taskview.py
+++ b/app/ui/taskview.py
@@ -349,6 +349,9 @@ class TaskView:
return self._result_rc()
finally:
self._cancel.set()
+ # Leave the screen blocking again: the timed redraw getch must
+ # not make later hub dialogs (e.g. tui.flash) dismiss themselves.
+ self._blocking()
def _result_rc(self) -> int:
"""The exit code for the whole run (cancelled counts as failure)."""
@@ -860,6 +863,9 @@ class LanesView:
return self._result_rc()
finally:
self._cancel.set()
+ # Leave the screen blocking again: the timed redraw getch
+ # must not make later hub dialogs dismiss themselves.
+ self._blocking()
finally:
sys.stdout, sys.stderr = saved_out, saved_err
diff --git a/app/ui/tui.py b/app/ui/tui.py
index d675946..b1b18f1 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -123,17 +123,22 @@ def flash(scr, text: str, kind: str = "warn") -> None:
Used by the hub for "not set up yet"-style messages. KIND is a theme
key (warn/err/ok/info). The notice itself is the dialog's only
content (no "Notice" heading); Esc dismisses it (it does not abort).
+ A timed-out getch (-1; the screen may still be in a redraw cadence)
+ is ignored so the notice really waits for a key.
"""
frame = Frame(scr, "", "Press any key to continue Esc = back")
frame.mark(text, frame.theme.get(kind, frame.theme["body"]))
frame.cursor = None
frame.draw()
- try:
- key = scr.getch()
- except KeyboardInterrupt:
- raise WizardCancelled() from None
- if key == 3: # Ctrl-C still aborts
- raise WizardCancelled()
+ while True:
+ try:
+ key = scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+ if key != -1:
+ return
# On screens without typed text, Esc and 'q' mean the same thing: go
@@ -581,12 +586,15 @@ class Frame:
"""Show TEXT on the status line until any key is pressed."""
self.status = (text, kind)
self.draw()
- try:
- key = self.scr.getch()
- if key == 3: # Ctrl-C still aborts
- raise WizardCancelled()
- except KeyboardInterrupt:
- raise WizardCancelled() from None
+ while True:
+ try:
+ key = self.scr.getch()
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key != -1:
+ break
self.status = None
def edit_status(self, prompt: str = "") -> Optional[str]: