From 08e702700303f9bb5767f02a337b8b34be410df9 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 2 Sep 2026 19:56:51 -0400 Subject: feat: move clone .wav directory to settings --- app/converter/config.py | 6 ++ app/tests/test_hub.py | 181 ++++++++++++++++++++++-------------------------- app/ui/hub.py | 99 +++++++++++++------------- 3 files changed, 135 insertions(+), 151 deletions(-) (limited to 'app') diff --git a/app/converter/config.py b/app/converter/config.py index ad6c2d0..d771e46 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -15,6 +15,12 @@ CHUNK_SIZE = 250 INPUT_DIR = "./input" OUTPUT_DIR = "./output" +# Where voice-cloning reference .wavs live; the TUI Settings menu exposes +# this as "Clone .wav directory" and the qwen-tts Base / SGLang-Omni voice +# pickers list the .wav files found here. Relative paths resolve against +# the project root. +CLONE_WAV_DIR = "./voices" + # Output audiobook file at a different tempo. SPEED = 1.0 diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 94ea286..f49b8d1 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -375,11 +375,11 @@ class HubMenuTests(unittest.TestCase): # ("input") rows; the numbered steps start at the margin and # the indented rows carry a leading space of their own, so # they line up with the step text after the "N. " prefixes. - # They resolve live from the converter module, so a Settings - # change this session is reflected. + # They resolve at call time, so a Settings change this session + # is reflected. self.assertIn(([(" " + str(hub.converter_mod.BOOKS_FOLDER), "input")], 1), items) - self.assertIn(([(" " + str(hub.common.VOICES_DIR), "input")], 1), + self.assertIn(([(" " + str(hub._clone_wav_dir()), "input")], 1), items) self.assertIn(([(" " + str(hub.converter_mod.AUDIOBOOKS_FOLDER), "input")], 1), items) @@ -2314,7 +2314,7 @@ class ConvertFlowTests(unittest.TestCase): fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "qwen.mode", "qwen.speaker", - "qwen.clone_dir", "qwen.clone", + "qwen.clone", "qwen.qwen_instructions", "single_file"]) mode_field = self._field("mode") # Model names are padded to the widest ("CustomVoice"/"VoiceDesign" @@ -2331,25 +2331,18 @@ class ConvertFlowTests(unittest.TestCase): # collapses its column padding back to the gutter. self.assertTrue(mode_field["compact_label"]) speaker_field = self._field("speaker") - clone_dir_field = self._field("clone_dir") - # The .wav directory browser alerts on the .wavs it lists. - self.assertIs(clone_dir_field["info"], hub.common.wav_dir_info) - self.assertIs(clone_dir_field["preview"], hub.common.wav_dir_preview) clone_field = self._field("clone") design_field = self._field("qwen_instructions") - # Speaker shows in custom mode; the .wav directory browser and - # picker in clone mode and the instruction in design mode. + # Speaker shows in custom mode; the .wav picker in clone mode and + # the instruction in design mode. self.assertTrue(speaker_field["visible"](fields)) - self.assertFalse(clone_dir_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) self.assertFalse(design_field["visible"](fields)) mode_field["value"] = "clone" self.assertFalse(speaker_field["visible"](fields)) - self.assertTrue(clone_dir_field["visible"](fields)) self.assertTrue(clone_field["visible"](fields)) mode_field["value"] = "design" self.assertFalse(speaker_field["visible"](fields)) - self.assertFalse(clone_dir_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) self.assertTrue(design_field["visible"](fields)) @@ -2394,10 +2387,10 @@ class ConvertFlowTests(unittest.TestCase): self._convert(None, [self._ready("qwen", "qwen-tts")]) self.assertEqual(self._field("mode")["value"], "custom") - def test_qwen_clone_dir_defaults_to_the_project_voices(self): - # The Clone .wav directory is the shared directory widget, seeded - # with the project's ./voices; the picker starts on its first .wav - # (alphabetically), ignoring non-.wav files. + def test_qwen_clone_picker_lists_the_configured_directory(self): + # The Voice to clone picker lists the .wavs in the configured + # Clone .wav directory (the Settings entry), starting on the + # first one (alphabetically), ignoring non-.wav files. with tempfile.TemporaryDirectory() as td: root = Path(td) (root / "narrator.wav").write_bytes(b"") @@ -2407,7 +2400,7 @@ class ConvertFlowTests(unittest.TestCase): ("alice.wav", str(root / "alice.wav")), ("narrator.wav", str(root / "narrator.wav")), ] - with patch.object(hub.common, "VOICES_DIR", root), \ + with patch.object(hub.config, "CLONE_WAV_DIR", str(root)), \ patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="custom", @@ -2415,66 +2408,37 @@ class ConvertFlowTests(unittest.TestCase): cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) fields = self.tui.forms_seen[0][1] - clone_dir_field = self._field("clone_dir") clone_field = self._field("clone") self.assertIsNone(cmd[2]["clone"]) # custom mode: no clone - self.assertEqual(clone_dir_field["kind"], "dir") - self.assertEqual(clone_dir_field["value"], root) self.assertEqual(clone_field["kind"], "choice") self.assertEqual(clone_field["choices"](fields), expected_choices) self.assertEqual(clone_field["value"], expected_choices[0][1]) - def test_qwen_clone_picker_resets_when_the_directory_changes(self): - # Changing the directory browser re-points the picker at the new - # directory's first .wav; an empty directory clears the pick. - with tempfile.TemporaryDirectory() as td, \ - tempfile.TemporaryDirectory() as other, \ - tempfile.TemporaryDirectory() as nowhere: - root, other = Path(td), Path(other) - (root / "one.wav").write_bytes(b"") - (other / "beta.wav").write_bytes(b"") - (other / "alpha.wav").write_bytes(b"") - with patch.object(hub.common, "VOICES_DIR", root), \ - patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.common, "update_config_value"): - self._answer_form(backend="qwen", mode="clone", clone="") - self._convert(None, [self._ready("qwen", "qwen-tts")]) - fields = self.tui.forms_seen[0][1] - clone_dir_field = self._field("clone_dir") - clone_field = self._field("clone") - # The picker seeds itself with the default directory's first - # .wav, and follows the directory browser from there. - self.assertEqual(clone_field["value"], str(root / "one.wav")) - clone_dir_field["value"] = other - clone_dir_field["on_change"](fields) - self.assertEqual(clone_field["value"], str(other / "alpha.wav")) - clone_dir_field["value"] = Path(nowhere) / "no-such-dir" - clone_dir_field["on_change"](fields) - self.assertEqual(clone_field["value"], "") - def test_qwen_clone_picker_refuses_generate_without_wavs(self): - # No .wav files in the directory: the picker stays empty, opening - # it flashes the hint, and Generate! is refused with the same - # message naming the directory. + # No .wav files in the configured directory: the picker stays + # empty, opening it flashes the hint, and Generate! is refused + # with the same message naming the directory. with tempfile.TemporaryDirectory() as td: empty = Path(td) - with patch.object(hub.common, "VOICES_DIR", empty), \ + with patch.object(hub.config, "CLONE_WAV_DIR", str(empty)), \ patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="clone", clone="") cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) - self.assertIsNone(cmd[2]["clone"]) - fields = self.tui.forms_seen[0][1] - clone_field = self._field("clone") - self.assertEqual(clone_field["choices"](fields), []) - message = clone_field["on_empty_choices"](fields) - self.assertIn(str(empty), message) - self.assertIn("No .wav files", message) - self.assertEqual(clone_field["validate"](""), message) - self.assertIsNone(clone_field["validate"](str(empty / "x.wav"))) + self.assertIsNone(cmd[2]["clone"]) + fields = self.tui.forms_seen[0][1] + clone_field = self._field("clone") + self.assertEqual(clone_field["choices"](fields), []) + message = clone_field["on_empty_choices"](fields) + self.assertIn(str(empty), message) + self.assertIn("No .wav files", message) + self.assertIn("Check directory in Settings", message) + self.assertEqual(clone_field["validate"](""), message) + self.assertIsNone(clone_field["validate"]( + str(empty / "x.wav"))) # ------------------------------------------------------------------ # faster: remote server (no local voices.json) @@ -2598,7 +2562,7 @@ class ConvertFlowTests(unittest.TestCase): [f["key"] for f in fields], ["backend", "audiocpp.model_id", "audiocpp.audiocpp_voice", "audiocpp.instructions", "audiocpp.request_options", - "qwen.mode", "qwen.speaker", "qwen.clone_dir", + "qwen.mode", "qwen.speaker", "qwen.clone", "qwen.qwen_instructions", "single_file"]) # The form opens on the configured default (audio.cpp): its fields # show, the other backend's hide. Instructions shows too (optional @@ -2608,20 +2572,17 @@ class ConvertFlowTests(unittest.TestCase): for key in ("model_id", "audiocpp_voice", "instructions"): self.assertTrue(self._field(key)["visible"](fields)) self.assertFalse(self._field("request_options")["visible"](fields)) - for key in ("mode", "speaker", "clone_dir", "clone", - "qwen_instructions"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. fields[0]["value"] = "qwen" self.assertTrue(self._field("mode")["visible"](fields)) self.assertTrue(self._field("speaker")["visible"](fields)) - self.assertFalse(self._field("clone_dir")["visible"](fields)) self.assertFalse(self._field("clone")["visible"](fields)) - # qwen's clone mode hides the speaker and shows the .wav directory - # browser and the picker of the .wavs inside it. + # qwen's clone mode hides the speaker and shows the picker of the + # configured clone directory's .wavs. self._field("mode")["value"] = "clone" self.assertFalse(self._field("speaker")["visible"](fields)) - self.assertTrue(self._field("clone_dir")["visible"](fields)) self.assertTrue(self._field("clone")["visible"](fields)) for key in ("model_id", "audiocpp_voice", "instructions", "request_options"): @@ -2630,8 +2591,7 @@ class ConvertFlowTests(unittest.TestCase): fields[0]["value"] = "audiocpp" for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) - for key in ("mode", "speaker", "clone_dir", "clone", - "qwen_instructions"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) # ------------------------------------------------------------------ @@ -2654,8 +2614,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="Vivian", clone_dir="/tmp", - instructions="") + voice="Vivian", instructions="") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) self.assertEqual(cmd[1], hub.BACKEND_SGLOMNI) @@ -2671,21 +2630,19 @@ class ConvertFlowTests(unittest.TestCase): with tempfile.TemporaryDirectory() as td: ref = Path(td) / "ref.wav" ref.write_bytes(b"RIFF") - with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ + with patch.object(hub.config, "CLONE_WAV_DIR", str(td)), \ + patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ patch.object(hub.config, "LANGUAGE", "English"), \ patch.object(hub.config, "SPEED", 1.0), \ patch.object(hub.config, "DEBUG", False), \ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice=str(ref), clone_dir=td, - instructions="") + voice=str(ref), instructions="") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) fields = self.tui.forms_seen[-1][1] voice_field = self._field("voice") - next(f for f in fields - if f["key"] == "sglomni.clone_dir")["value"] = td wav_labels = [label for label, _ in voice_field["choices"](fields)] kwargs = cmd[2] @@ -2708,7 +2665,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="", clone_dir="/tmp", instructions="") + voice="", instructions="") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) kwargs = cmd[2] @@ -2725,7 +2682,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="", clone_dir="/tmp", + voice="", instructions="A warm narrator.") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) @@ -2750,8 +2707,7 @@ class ConvertFlowTests(unittest.TestCase): self._mock_preflight() self._answer_form(backend="sglomni-remote", model_id="higgs_audio_v3_tts", - voice="narrator", clone_dir="/tmp", - instructions="") + voice="narrator", instructions="") cmd = self._convert( None, [self._remote("sglomni", "SGLang-Omni", url="http://sgl.local:8100")]) @@ -2807,8 +2763,7 @@ class ConvertFlowTests(unittest.TestCase): # built-in entry, no clone .wavs — those belong to the # clone-capable models' combined menu). self._answer_form(backend="sglomni", model_id="voxtral_tts", - voice="casual_male", clone_dir="/tmp", - instructions="") + voice="casual_male", instructions="") cmd = self._convert(None, statuses) fields = self.tui.forms_seen[-1][1] # The captured fields carry the form's opening state (the @@ -2846,9 +2801,8 @@ class ConvertFlowTests(unittest.TestCase): speaker -> the preset Voice menu; clone -> the combined Voice menu (the built-in default voice on top when the model can - narrate without a reference, then the clone directory's .wavs) - beside the Clone .wav directory browser; design -> the - Instructions box. + narrate without a reference, then the configured clone + directory's .wavs); design -> the Instructions box. """ def fake_preset_voices(entry): @@ -2864,7 +2818,8 @@ class ConvertFlowTests(unittest.TestCase): with tempfile.TemporaryDirectory() as td: ref = Path(td) / "ref.wav" ref.write_bytes(b"RIFF") - with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ + with patch.object(hub.config, "CLONE_WAV_DIR", str(td)), \ + patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ patch.object(hub.config, "LANGUAGE", "English"), \ patch.object(hub.config, "SPEED", 1.0), \ patch.object(hub.config, "DEBUG", False), \ @@ -2874,7 +2829,7 @@ class ConvertFlowTests(unittest.TestCase): with self.subTest(entry=entry.key): overrides = {"backend": "sglomni", "model_id": entry.key, - "voice": "", "clone_dir": td, + "voice": "", "instructions": ""} if entry.capability == "speaker": overrides["voice"] = \ @@ -2894,9 +2849,6 @@ class ConvertFlowTests(unittest.TestCase): next(f for f in fields if f["key"] == "sglomni.model_id")["value"] = \ entry.key - next(f for f in fields - if f["key"] == "sglomni.clone_dir")["value"] \ - = td shown = {f["key"] for f in fields if f["key"].startswith("sglomni.") and f["visible"](fields)} @@ -2913,8 +2865,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(kwargs.get("voice"), preset) self.assertNotIn("clone", kwargs) elif entry.capability == "clone": - expected = {"sglomni.model_id", "sglomni.voice", - "sglomni.clone_dir"} + expected = {"sglomni.model_id", "sglomni.voice"} if entry.requires_reference: # Cannot narrate without a reference: the # menu is the clone directory's .wavs only. @@ -3458,6 +3409,7 @@ class SettingsTests(unittest.TestCase): # fake config write restores these afterwards. _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "CLONE_WAV_DIR", "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", "QWEN_API_URL", @@ -3532,6 +3484,7 @@ class SettingsTests(unittest.TestCase): values = {"audio_format": "ogg", "audio_bitrate": " 192k ", "language": "en", "chunk_size": "300", "input_dir": " /books ", "output_dir": "/audiobooks", + "clone_wav_dir": " /refs/wavs ", "speed": "1.5", "debug": True, "stop_and_exit": False, "unload_models": True, @@ -3554,6 +3507,7 @@ class SettingsTests(unittest.TestCase): "CHUNK_SIZE": 300, "INPUT_DIR": "/books", "OUTPUT_DIR": "/audiobooks", + "CLONE_WAV_DIR": "/refs/wavs", "SPEED": 1.5, "DEBUG": True, "STOP_SERVER_AND_EXIT": False, @@ -3579,6 +3533,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(hub.config.CHUNK_SIZE, 300) self.assertEqual(hub.config.INPUT_DIR, "/books") self.assertEqual(hub.config.OUTPUT_DIR, "/audiobooks") + self.assertEqual(hub.config.CLONE_WAV_DIR, "/refs/wavs") self.assertEqual(hub.config.SPEED, 1.5) self.assertEqual(hub.config.DEBUG, True) self.assertEqual(hub.config.STOP_SERVER_AND_EXIT, False) @@ -3596,6 +3551,7 @@ class SettingsTests(unittest.TestCase): base = {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", "input_dir": "./input", "output_dir": "./output", + "clone_wav_dir": "./voices", "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, @@ -3615,6 +3571,8 @@ class SettingsTests(unittest.TestCase): hub._apply_settings({**base, "input_dir": " "}) with self.assertRaises(ValueError): hub._apply_settings({**base, "output_dir": ""}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, "clone_wav_dir": " "}) with self.assertRaises(ValueError): hub._apply_settings({**base, "audiocpp_port": "70000"}) with self.assertRaises(ValueError): @@ -3671,27 +3629,42 @@ class SettingsTests(unittest.TestCase): self.assertEqual(field["value"], hub.config.LANGUAGE) def test_directory_fields_are_browsers(self): - # The Input/Output Directory settings use the DOS-style directory - # browser, seeded with the configured folder resolved against the - # project root. + # The Input/Output/Clone .wav directory settings use the DOS-style + # directory browser, seeded with the configured folder resolved + # against the project root. fields = hub._settings_fields() for key, config_name in (("input_dir", "INPUT_DIR"), - ("output_dir", "OUTPUT_DIR")): + ("output_dir", "OUTPUT_DIR"), + ("clone_wav_dir", "CLONE_WAV_DIR")): field = next(f for f in fields if f["key"] == key) self.assertEqual(field["kind"], "dir") - self.assertTrue(field["label"].endswith("Directory")) self.assertEqual(field["value"], hub.converter_mod.resolve_dir( getattr(hub.config, config_name), key.removesuffix("_dir"))) self.assertIsNone(field["validate"](str(field["value"]))) self.assertIsNotNone(field["validate"](" ")) + input_field = next(f for f in fields if f["key"] == "input_dir") + output_field = next(f for f in fields if f["key"] == "output_dir") + self.assertTrue(input_field["label"].endswith("Directory")) + self.assertTrue(output_field["label"].endswith("Directory")) + # The clone .wav browser alerts on the .wavs it lists. + clone_field = next(f for f in fields if f["key"] == "clone_wav_dir") + self.assertEqual(clone_field["label"], "Clone .wav directory") + self.assertIs(clone_field["info"], hub.common.wav_dir_info) + self.assertIs(clone_field["preview"], hub.common.wav_dir_preview) with patch.object(hub.config, "INPUT_DIR", "books"): fields = hub._settings_fields() field = next(f for f in fields if f["key"] == "input_dir") self.assertEqual( field["value"], hub.converter_mod.BASE_DIR / "books") + with patch.object(hub.config, "CLONE_WAV_DIR", "refs"): + fields = hub._settings_fields() + field = next(f for f in fields if f["key"] == "clone_wav_dir") + self.assertEqual( + field["value"], + hub.converter_mod.BASE_DIR / "refs") def test_settings_menu_builds_form_and_saves(self): captured = {} @@ -3701,6 +3674,7 @@ class SettingsTests(unittest.TestCase): return {"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", "input_dir": "/books", "output_dir": "/audiobooks", + "clone_wav_dir": "/refs/wavs", "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, @@ -3723,6 +3697,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", "chunk_size", "input_dir", "output_dir", + "clone_wav_dir", "speed", "debug", "stop_and_exit", "unload_models", "audiocpp_port", @@ -3735,6 +3710,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(kinds["audio_bitrate"], "text") self.assertEqual(kinds["input_dir"], "dir") self.assertEqual(kinds["output_dir"], "dir") + self.assertEqual(kinds["clone_wav_dir"], "dir") self.assertEqual(kinds["speed"], "text") self.assertEqual(kinds["debug"], "bool") self.assertEqual(kinds["audiocpp_port"], "text") @@ -3747,6 +3723,7 @@ class SettingsTests(unittest.TestCase): "audio.cpp remote URL") self.assertEqual(labels["input_dir"], "Input Directory") self.assertEqual(labels["output_dir"], "Output Directory") + self.assertEqual(labels["clone_wav_dir"], "Clone .wav directory") self.assertNotIn("(clone)", " ".join(labels.values())) # The language setting is a picker labelled "Language". self.assertEqual(labels["language"], "Language") @@ -3756,6 +3733,7 @@ class SettingsTests(unittest.TestCase): notes = {f["key"]: f.get("note") for f in captured["fields"]} self.assertIsNone(notes["input_dir"]) self.assertIsNone(notes["output_dir"]) + self.assertIsNone(notes["clone_wav_dir"]) self.assertIsNone(notes["debug"]) self.assertTrue(notes["stop_and_exit"]) self.assertTrue(notes["unload_models"]) @@ -3770,6 +3748,7 @@ class SettingsTests(unittest.TestCase): "chunk_size": "300", "input_dir": "/books", "output_dir": "/audiobooks", + "clone_wav_dir": "/refs/wavs", "speed": "1.0", "debug": False, "stop_and_exit": True, @@ -3887,6 +3866,7 @@ class SettingsTests(unittest.TestCase): return {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", "input_dir": "./input", "output_dir": "./output", + "clone_wav_dir": "./voices", "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, "qwen_port": "7860", @@ -3920,6 +3900,7 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "CLONE_WAV_DIR", "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", "QWEN_API_URL", @@ -3942,6 +3923,7 @@ class SettingsTests(unittest.TestCase): "CHUNK_SIZE = 250\n" 'INPUT_DIR = "./input"\n' 'OUTPUT_DIR = "./output"\n' + 'CLONE_WAV_DIR = "./voices"\n' "SPEED = 1.0\n" "DEBUG = False\n" "STOP_SERVER_AND_EXIT = True\n" @@ -3970,6 +3952,7 @@ class SettingsTests(unittest.TestCase): # The settings-only fields are written back unchanged. self.assertIn('INPUT_DIR = "', text) self.assertIn('OUTPUT_DIR = "', text) + self.assertIn('CLONE_WAV_DIR = "', text) self.assertIn("SPEED = 1.0", text) self.assertIn("DEBUG = False", text) # The running session also picked up the change in-memory. diff --git a/app/ui/hub.py b/app/ui/hub.py index afb8a30..e883996 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -988,10 +988,9 @@ def _help_lines() -> list: (None = body). Numbered steps start at the margin; every other line is indented three spaces — Frame's two-space indent unit plus a leading space in the row's first segment — so it lines up - with the step text after the "N. " prefixes. The - input/output folders are read from the converter module at call - time, so a Settings change this session is reflected without a - restart. + with the step text after the "N. " prefixes. The input/output and + clone .wav folders are read at call time, so a Settings change this + session is reflected without a restart. """ return [ ([("1. ", "title"), @@ -1000,7 +999,7 @@ def _help_lines() -> list: "", ([("2. ", "title"), ("Put any .wavs of voices to clone here:", None)], 0), - ([(" " + str(common.VOICES_DIR), "input")], 1), + ([(" " + str(_clone_wav_dir()), "input")], 1), "", ([("3. ", "title"), ("If no backend is installed, go to ", None), @@ -1948,8 +1947,9 @@ def _qwen_fields(remote_modes: Optional[list] = None, Returns ``(fields, mapper)`` where FIELDS are the qwen options — which model the demo server hosts (Base (voice cloning) / CustomVoice (built-in voices) / VoiceDesign (design)), plus the per-model controls: Speaker on - CustomVoice, a Clone .wav directory browser (default ./voices) + Voice- - to-clone .wav picker on Base, Instructions on VoiceDesign — and + CustomVoice, a Voice-to-clone .wav picker on Base (listing the reference + .wavs in the configured Clone .wav directory — a Settings entry), + Instructions on VoiceDesign — and MAPPER turns a submitted form values dict into the qwen converter kwargs. None (form omitted) when a filtered remote model list comes back empty — no known mode matched what the remote demo reported. @@ -1990,29 +1990,18 @@ def _qwen_fields(remote_modes: Optional[list] = None, speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = speakers[0] - # Voice cloning references: the directory the .wavs live in — browsed - # with the directory widget, defaulting to the project's ./voices (the - # folder the Help screen points at) — plus a picker of the .wav files - # found there (the same directory + voice picker the audio.cpp form - # uses; the demo uploads exactly one reference file). + # Voice cloning reference: the .wav picker lists the files in the + # configured Clone .wav directory (a Settings entry, defaulting to the + # project's ./voices — the folder the Help screen points at); the demo + # uploads exactly one reference file. def clone_wav_choices(fs) -> list: """(file name, full path) pairs for the clone directory's .wavs.""" - return [(p.name, str(p)) for p in _list_wavs( - _field_value(fs, prefix + "clone_dir"))] - - def reset_clone_wav(fs) -> None: - """Re-point the .wav picker at the newly chosen directory.""" - wav_field = next(f for f in fields - if f.get("key") == prefix + "clone") - wav_field["value"] = next( - (path for _name, path in clone_wav_choices(fs)), "") + return [(p.name, str(p)) for p in _list_wavs(_clone_wav_dir())] def no_wavs_hint(_fs=None) -> str: """Why the .wav picker is empty (validate echoes it on Generate!).""" - directory = next((f.get("value") for f in fields - if f.get("key") == prefix + "clone_dir"), None) - return (f"No .wav files in {directory} — put a reference .wav " - "there or pick another directory.") + return (f"No .wav files in {_clone_wav_dir()}. " + "Check directory in Settings.") def clone_wav_validate(value) -> Optional[str]: """Refuse Generate! when no reference .wav is available to clone.""" @@ -2020,7 +2009,7 @@ def _qwen_fields(remote_modes: Optional[list] = None, return None return no_wavs_hint() - initial_wavs = _list_wavs(common.VOICES_DIR) + initial_wavs = _list_wavs(_clone_wav_dir()) initial_clone = str(initial_wavs[0]) if initial_wavs else "" fields = [ {"key": prefix + "mode", "label": "Model", "kind": "choice", @@ -2030,11 +2019,6 @@ def _qwen_fields(remote_modes: Optional[list] = None, {"key": prefix + "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, prefix + "mode") == "custom"}, - {"key": prefix + "clone_dir", "label": "Clone .wav directory", - "kind": "dir", "value": common.VOICES_DIR, - "info": common.wav_dir_info, "preview": common.wav_dir_preview, - "on_change": reset_clone_wav, - "visible": lambda fs: _field_value(fs, prefix + "mode") == "clone"}, {"key": prefix + "clone", "label": "Voice to clone", "kind": "choice", "value": initial_clone, "choices": clone_wav_choices, "on_empty_choices": no_wavs_hint, @@ -2141,11 +2125,11 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, (preset voices on speaker models; on clone models the model's built-in default voice on top when it can narrate without a reference, then the server's uploaded named voices, then the - reference .wavs from the Clone .wav directory browser — picking a - .wav clones it, and on models that cannot narrate without a - reference a .wav pick is required), Instructions on the VoiceDesign - model — and MAPPER turns a submitted form values dict into the - sglomni converter kwargs. Returns None when + reference .wavs in the configured Clone .wav directory — a Settings + entry — picking a .wav clones it, and on models that cannot narrate + without a reference a .wav pick is required), Instructions on the + VoiceDesign model — and MAPPER turns a submitted form values dict + into the sglomni converter kwargs. Returns None when the entry's options cannot be gathered (a flash explains why), so the caller drops SGLang-Omni from the Backend choices. PREFIX namespaces the field keys ("" for the managed entry) so two entries of this @@ -2188,11 +2172,11 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, def model_capability(fs) -> str: return model_entry(fs).capability - # Voice-clone references: the qwen form's directory + .wav picker. + # Voice-clone references: the qwen form's .wav picker, fed by the + # configured Clone .wav directory (a Settings entry). def clone_wav_choices(fs) -> list: - """The reference .wavs offered by the clone-directory field.""" - return [(p.name, str(p)) for p in _list_wavs( - _field_value(fs, prefix + "clone_dir"))] + """The reference .wavs offered by the clone directory.""" + return [(p.name, str(p)) for p in _list_wavs(_clone_wav_dir())] def voice_choices(fs): """One Voice menu per capability. @@ -2218,10 +2202,8 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, return choices def no_wavs_hint(_fs=None) -> str: - directory = next((f.get("value") for f in fields - if f.get("key") == prefix + "clone_dir"), None) - return (f"No .wav files in {directory} — put a reference .wav " - "there or pick another directory.") + return (f"No .wav files in {_clone_wav_dir()}. " + "Check directory in Settings.") def voice_validate(value) -> Optional[str]: """Blank is the built-in default voice — unless the model needs @@ -2313,7 +2295,7 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, initial_voice = voices[0] if voices else "" elif (default_entry.capability == "clone" and default_entry.requires_reference): - initial_wavs = _list_wavs(common.VOICES_DIR) + initial_wavs = _list_wavs(_clone_wav_dir()) initial_voice = str(initial_wavs[0]) if initial_wavs else "" fields = [ @@ -2328,11 +2310,6 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, "validate": voice_validate, "visible": lambda fs: model_capability(fs) in ("speaker", "clone")}, - {"key": prefix + "clone_dir", "label": "Clone .wav directory", - "kind": "dir", "value": common.VOICES_DIR, - "info": common.wav_dir_info, "preview": common.wav_dir_preview, - "on_change": reset_voice_fields, - "visible": lambda fs: model_capability(fs) == "clone"}, {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": "", "help": ["Describe the voice to design, e.g.", @@ -2351,8 +2328,7 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, elif entry.capability == "clone": # A .wav pick clones it; a named (uploaded) voice reuses a # server-side voice; blank uses the model's built-in default. - wavs = {str(p) for p in _list_wavs( - result.get(prefix + "clone_dir"))} + wavs = {str(p) for p in _list_wavs(_clone_wav_dir())} if pick in wavs: kwargs["clone"] = pick elif pick: @@ -2370,6 +2346,17 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None, # Settings menu (global output options -> app/converter/config.py) # --------------------------------------------------------------------------- +def _clone_wav_dir() -> Path: + """The configured Clone .wav directory (a Settings entry). + + The directory the qwen-tts Base / SGLang-Omni voice pickers list + their reference .wavs from. Resolved like the input/output folders: + relative paths resolve against the project root, blank falls back + to the project's ./voices. + """ + return converter_mod.resolve_dir(config.CLONE_WAV_DIR, "voices") + + def _settings_changed(fields: list, original: dict) -> bool: """True when any field's current value differs from its ORIGINAL. @@ -2409,6 +2396,10 @@ def _settings_fields() -> list: {"key": "output_dir", "label": "Output Directory", "kind": "dir", "value": converter_mod.resolve_dir(config.OUTPUT_DIR, "output"), "validate": _validate_dir}, + {"key": "clone_wav_dir", "label": "Clone .wav directory", + "kind": "dir", "value": _clone_wav_dir(), + "info": common.wav_dir_info, "preview": common.wav_dir_preview, + "validate": _validate_dir}, {"key": "speed", "label": "Speed", "kind": "text", "value": str(config.SPEED), "validate": _validate_speed}, {"key": "debug", "label": "Debug", "kind": "bool", @@ -2550,10 +2541,13 @@ def _apply_settings(values: dict) -> None: raise ValueError("Speed must be a positive number") input_dir = str(values["input_dir"]).strip() output_dir = str(values["output_dir"]).strip() + clone_dir = str(values["clone_wav_dir"]).strip() if not input_dir: raise ValueError("Input Directory must not be empty") if not output_dir: raise ValueError("Output Directory must not be empty") + if not clone_dir: + raise ValueError("Clone .wav directory must not be empty") ports = { "qwen_port": _read_port(values, "qwen_port"), @@ -2578,6 +2572,7 @@ def _apply_settings(values: dict) -> None: "CHUNK_SIZE": chunk_size, "INPUT_DIR": input_dir, "OUTPUT_DIR": output_dir, + "CLONE_WAV_DIR": clone_dir, "SPEED": speed, "DEBUG": bool(values["debug"]), "STOP_SERVER_AND_EXIT": bool(values["stop_and_exit"]), -- cgit v1.2.3