diff options
| -rw-r--r-- | converter/converter.py | 22 | ||||
| -rw-r--r-- | converter/tts.py | 9 | ||||
| -rw-r--r-- | tests/test_converter.py | 54 |
3 files changed, 74 insertions, 11 deletions
diff --git a/converter/converter.py b/converter/converter.py index 29d3bc4..4d20083 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -13,7 +13,7 @@ from typing import Dict, List, Optional, Tuple from . import audio, chunking, config, cover, extractors from .audio import TrackMeta -from .tts import QwenTTSClient, normalize_language +from .tts import QwenTTSClient, normalize_language, speaker_display_name logger = logging.getLogger(__name__) @@ -132,11 +132,24 @@ class AudiobookConverter: ) @staticmethod - def _sanitize_filename(name: str) -> str: + def _sanitize_filename(name: str, fallback: str = "chapter") -> str: """Make a chapter title safe to use as part of a file name.""" cleaned = re.sub(r'[\\/:*?"<>|]', " ", name) cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".") - return cleaned[:80] or "chapter" + return cleaned[:80] or fallback + + def _narrator_tag(self) -> str: + """Narrator name used in output file names. + + Custom voice mode uses the built-in speaker's display name; voice + clone mode uses the reference audio file's stem. Spaces become + underscores (e.g. "Uncle Fu" -> "Uncle_Fu"). + """ + if self.voice_mode == config.VOICE_MODE_CLONE: + narrator = Path(self.voice_clone_ref_audio).stem + else: + narrator = speaker_display_name() + return self._sanitize_filename(narrator, fallback="narrator").replace(" ", "_") def convert_book(self, file_path: Path, output_name: Optional[str] = None) -> bool: """Convert a single book to one or more audiobook files.""" @@ -155,7 +168,7 @@ class AudiobookConverter: logger.error("No text extracted") return False - stem = output_name or file_path.stem + stem = output_name or f"{file_path.stem}_{self._narrator_tag()}" # Cover art: generated once per book. Named with the chunk_ # prefix so cleanup_chunks() removes it with the other scratch @@ -416,6 +429,7 @@ class AudiobookConverter: output_name = book_file.stem if stem_counts[book_file.stem] > 1: output_name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}" + output_name = f"{output_name}_{self._narrator_tag()}" existing = find_existing_outputs(output_name, self.output_format) if existing and not prompt_overwrite(existing, output_name): print(f"[INFO] Skipping {book_file.name} (existing output kept)") diff --git a/converter/tts.py b/converter/tts.py index 49ebad0..b6049a1 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -15,6 +15,12 @@ from . import config logger = logging.getLogger(__name__) +def speaker_display_name() -> str: + """Return the Gradio display name for the configured custom speaker.""" + return config.SPEAKER_DISPLAY_NAMES.get( + config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER) + + def normalize_language(value: Optional[str]) -> str: """Normalize a user-provided language name to a Qwen3-TTS display name. @@ -291,8 +297,7 @@ class QwenTTSClient: payload = dict( text=text, lang_disp=self.language, - spk_disp=config.SPEAKER_DISPLAY_NAMES.get( - config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER), + spk_disp=speaker_display_name(), instruct=config.CUSTOM_VOICE_INSTRUCT, ) else: diff --git a/tests/test_converter.py b/tests/test_converter.py index 351849a..ae307a2 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -97,6 +97,50 @@ class FindExistingOutputsTests(unittest.TestCase): found = [p.name for p in find_existing_outputs("book [1]", "mp3")] self.assertEqual(sorted(found), ["book [1].mp3", "book [1]_1.5x.mp3"]) + def test_narrator_named_outputs_detected(self): + for name in ("dune_Vivian.mp3", "dune_Vivian_1.5.mp3", "dune_Vivian_01_Dune.mp3"): + self._touch(name) + found = [p.name for p in find_existing_outputs("dune_Vivian", "mp3")] + self.assertEqual(len(found), 3) + + def test_legacy_outputs_without_narrator_ignored(self): + self._touch("dune.mp3") + self._touch("dune_1.5.mp3") + self.assertEqual(find_existing_outputs("dune_Vivian", "mp3"), []) + + +class NarratorTagTests(unittest.TestCase): + def _converter(self, voice_mode, ref_audio=None): + converter = AudiobookConverter.__new__(AudiobookConverter) + converter.voice_mode = voice_mode + converter.voice_clone_ref_audio = ref_audio + return converter + + def test_custom_voice_uses_speaker_display_name(self): + self.assertEqual(self._converter(config.VOICE_MODE_CUSTOM)._narrator_tag(), + "Vivian") + + def test_multi_word_display_name_gets_underscores(self): + with patch.object(config, "CUSTOM_VOICE_SPEAKER", "uncle_fu"): + self.assertEqual(self._converter(config.VOICE_MODE_CUSTOM)._narrator_tag(), + "Uncle_Fu") + + def test_clone_uses_reference_audio_stem(self): + self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(), + "ref") + + def test_clone_stem_spaces_become_underscores(self): + self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/my voice.wav")._narrator_tag(), + "my_voice") + + def test_invalid_characters_sanitized(self): + self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/bad:name?.wav")._narrator_tag(), + "bad_name") + + def test_empty_after_sanitize_falls_back(self): + self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(), + "narrator") + class PromptOverwriteTests(unittest.TestCase): def test_single_file_yes(self): @@ -160,22 +204,22 @@ class RunOverwritePromptTests(unittest.TestCase): self._output_tmp.cleanup() def test_declined_book_is_skipped(self): - (config.AUDIOBOOKS_FOLDER / "book.mp3").write_bytes(b"existing") + (config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value="n"): self.assertTrue(self.converter.run()) self.assertEqual(self.converted, []) - self.assertTrue((config.AUDIOBOOKS_FOLDER / "book.mp3").exists()) + self.assertTrue((config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").exists()) def test_accepted_book_is_converted(self): - (config.AUDIOBOOKS_FOLDER / "book.mp3").write_bytes(b"existing") + (config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value="y"): self.assertTrue(self.converter.run()) - self.assertEqual(self.converted, [("book.txt", "book")]) + self.assertEqual(self.converted, [("book.txt", "book_Vivian")]) def test_new_book_converted_without_prompt(self): with patch("builtins.input", side_effect=AssertionError("should not prompt")): self.assertTrue(self.converter.run()) - self.assertEqual(self.converted, [("book.txt", "book")]) + self.assertEqual(self.converted, [("book.txt", "book_Vivian")]) if __name__ == "__main__": |
