aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 19:32:48 -0400
committerhistoria <historiavg@proton.me>2026-08-18 19:32:48 -0400
commit7e8bf663a65111fb648b01d9f45ad08eddbf2515 (patch)
tree83a979f15d8c7d532c3cf2ffaabac927753e27b4
parentabc610097fe3a6ff1c1282f728fb0d75440f6ba8 (diff)
downloadtts-audiobook-generator-7e8bf663a65111fb648b01d9f45ad08eddbf2515.tar.gz
feat: include narrator in output filenames
-rw-r--r--converter/converter.py22
-rw-r--r--converter/tts.py9
-rw-r--r--tests/test_converter.py54
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__":