aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-02 19:56:51 -0400
committerhistoria <historiavg@proton.me>2026-09-02 19:56:51 -0400
commit08e702700303f9bb5767f02a337b8b34be410df9 (patch)
treece87c27929c81a423e9a5fa27927576544856779 /app
parent0c6ab319b7fb9ec9c8afe9a43ad249cbfc1a126d (diff)
downloadtts-audiobook-generator-08e702700303f9bb5767f02a337b8b34be410df9.tar.gz
feat: move clone .wav directory to settings
Diffstat (limited to 'app')
-rw-r--r--app/converter/config.py6
-rw-r--r--app/tests/test_hub.py181
-rw-r--r--app/ui/hub.py99
3 files changed, 135 insertions, 151 deletions
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,
@@ -3616,6 +3572,8 @@ class SettingsTests(unittest.TestCase):
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):
hub._apply_settings({**base,
@@ -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"]),