From e56754498f1c6b2a9dabb62529f783e73fae8e6b Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 18 Aug 2026 17:58:04 -0400 Subject: feat: language parameter for potential accent tuning --- converter/audio.py | 2 +- converter/config.py | 46 ++++++++++++++++++++++++++++++++++++++++++++-- converter/converter.py | 16 +++++++++++----- converter/tts.py | 44 ++++++++++++++++++++++++++++++++++++++------ 4 files changed, 94 insertions(+), 14 deletions(-) (limited to 'converter') diff --git a/converter/audio.py b/converter/audio.py index ebcac21..4fd1721 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -282,7 +282,7 @@ def _collect_chunk_files(total_chunks: int, def combine_chunks(total_chunks: int, output_path: Path, chunk_results: Optional[Dict[int, Optional[Path]]] = None, - speed: float = 1.0, output_format: str = "mp3", + speed: float = 1.0, output_format: str = config.AUDIO_FORMAT, intermediate: bool = False, meta: Optional[TrackMeta] = None, cover: Optional[Path] = None) -> bool: diff --git a/converter/config.py b/converter/config.py index ec84986..f795520 100644 --- a/converter/config.py +++ b/converter/config.py @@ -7,7 +7,7 @@ be run from any working directory. from pathlib import Path -# Project root (directory containing audiobook_converter.py) +# Project root (directory containing audiobook.py) BASE_DIR = Path(__file__).resolve().parent.parent # ============================================================================= @@ -18,6 +18,48 @@ VOICE_MODE_CUSTOM = "custom_voice" VOICE_MODE_CLONE = "voice_clone" VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE) +# ============================================================================= +# TTS LANGUAGE SETTINGS +# ============================================================================= +# Languages understood by the Qwen3-TTS API. The Gradio demo silently falls +# back to "Auto" for unrecognized values, so languages are validated +# client-side (see converter.tts.normalize_language) before reaching the API. +# Display names must match the demo dropdown exactly. + +TTS_LANGUAGES = ( + "Auto", + "Chinese", + "English", + "German", + "Italian", + "Portuguese", + "Spanish", + "Japanese", + "Korean", + "French", + "Russian", +) + +# Short aliases accepted on the command line (ISO 639-1 codes and common +# shorthands), mapped to the display names above. +TTS_LANGUAGE_ALIASES = { + "zh": "Chinese", + "en": "English", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "es": "Spanish", + "ja": "Japanese", + "ko": "Korean", + "fr": "French", + "ru": "Russian", + "zh-cn": "Chinese", + "zh-tw": "Chinese", + "pt-br": "Portuguese", + "en-us": "English", + "en-gb": "English", +} + # ============================================================================= # QWEN API CONFIGURATION # ============================================================================= @@ -82,7 +124,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chu # AUDIO OUTPUT SETTINGS # ============================================================================= -AUDIO_FORMAT = "mp3" # Default output container +AUDIO_FORMAT = "m4b" # Default output container AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac") AUDIO_BITRATE = "128k" diff --git a/converter/converter.py b/converter/converter.py index 6d09e09..29d3bc4 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 +from .tts import QwenTTSClient, normalize_language logger = logging.getLogger(__name__) @@ -88,11 +88,16 @@ class AudiobookConverter: def __init__(self, voice_mode: str = config.VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, - speed: float = 1.0, single_file: bool = False, output_format: str = "mp3"): + speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT, + language: Optional[str] = None): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") if output_format not in config.AUDIO_FORMATS: raise ValueError(f"Unsupported output format: {output_format}") + if language is None: + language = (config.VOICE_CLONE_LANGUAGE if voice_mode == config.VOICE_MODE_CLONE + else config.CUSTOM_VOICE_LANGUAGE) + self.language = normalize_language(language) self.voice_mode = voice_mode self.voice_clone_ref_audio = voice_clone_ref_audio self.speed = speed @@ -104,6 +109,7 @@ class AudiobookConverter: voice_clone_ref_audio=voice_clone_ref_audio, voice_clone_ref_text=voice_clone_ref_text, skip_transcription=skip_transcription, + language=self.language, ) def _validate_configuration(self) -> None: @@ -117,7 +123,7 @@ class AudiobookConverter: if not self.voice_clone_ref_audio: raise ValueError( "Voice Clone mode requires a reference audio file. " - "Use --voice-sample to specify it." + "Use --clone to specify it." ) if not Path(self.voice_clone_ref_audio).exists(): @@ -366,10 +372,10 @@ class AudiobookConverter: print("Model size: 1.7B (always)") if self.voice_mode == config.VOICE_MODE_CUSTOM: print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}") - print(f"Language: {config.CUSTOM_VOICE_LANGUAGE}") + print(f"Language: {self.language}") elif self.voice_mode == config.VOICE_MODE_CLONE: print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") - print(f"Language: {config.VOICE_CLONE_LANGUAGE}") + print(f"Language: {self.language}") print(f"Output format: {self.output_format}") if self.single_file and self.output_format != "m4b": print("Chapter mode: single file (--single-file)") diff --git a/converter/tts.py b/converter/tts.py index 83c7330..db5de9b 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -15,11 +15,38 @@ from . import config logger = logging.getLogger(__name__) +def normalize_language(value: Optional[str]) -> str: + """Normalize a user-provided language name to a Qwen3-TTS display name. + + Accepts the display names in config.TTS_LANGUAGES case-insensitively as + well as the short aliases in config.TTS_LANGUAGE_ALIASES (ISO 639-1 codes + and common shorthands). Raises ValueError for anything else, since the + Qwen3-TTS demo silently falls back to "Auto" for unrecognized languages. + """ + if value is None: + raise ValueError("Language must not be None") + candidate = value.strip() + if not candidate: + raise ValueError("Language must not be empty") + for name in config.TTS_LANGUAGES: + if candidate.lower() == name.lower(): + return name + alias = config.TTS_LANGUAGE_ALIASES.get(candidate.lower()) + if alias: + return alias + raise ValueError( + f"Unknown language: {value!r}. Expected one of " + f"{', '.join(config.TTS_LANGUAGES)} (or an alias: " + f"{', '.join(sorted(config.TTS_LANGUAGE_ALIASES))})." + ) + + class QwenTTSClient: """Generates audio chunks through a Qwen3-TTS Gradio server.""" def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, - voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False): + voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, + language: Optional[str] = None): if voice_mode not in config.VOICE_MODES: raise ValueError( f"Unknown voice mode: {voice_mode!r} (expected one of {config.VOICE_MODES})" @@ -28,6 +55,11 @@ class QwenTTSClient: self.voice_clone_ref_audio = voice_clone_ref_audio self.voice_clone_ref_text = (voice_clone_ref_text or "").strip() self.skip_transcription = skip_transcription + if language is None: + language = (config.VOICE_CLONE_LANGUAGE if voice_mode == config.VOICE_MODE_CLONE + else config.CUSTOM_VOICE_LANGUAGE) + # Validate before connecting so bad values fail fast without a server. + self.language = normalize_language(language) self.client = None self.api_info: Dict[str, Any] = {} self.clone_client = None @@ -70,7 +102,7 @@ class QwenTTSClient: self.voice_clone_ref_text = self.transcribe_audio(self.voice_clone_ref_audio) or "" if not self.voice_clone_ref_text: print("[WARNING] No reference text available; using x-vector-only clone mode (lower quality).") - print(' Pass --voice-sample-text "..." for higher-quality in-context cloning.') + print(' Pass --transcription "..." for higher-quality in-context cloning.') else: print(f"[OK] Reference text: {self.voice_clone_ref_text[:100]}...") @@ -258,7 +290,7 @@ class QwenTTSClient: if custom_api == "/run_instruct": payload = dict( text=text, - lang_disp=config.CUSTOM_VOICE_LANGUAGE, + lang_disp=self.language, spk_disp=config.SPEAKER_DISPLAY_NAMES.get( config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER), instruct=config.CUSTOM_VOICE_INSTRUCT, @@ -266,7 +298,7 @@ class QwenTTSClient: else: payload = dict( text=text, - language=config.CUSTOM_VOICE_LANGUAGE, + language=self.language, speaker=config.CUSTOM_VOICE_SPEAKER, instruct=config.CUSTOM_VOICE_INSTRUCT, ) @@ -305,14 +337,14 @@ class QwenTTSClient: ref_txt=self.voice_clone_ref_text, use_xvec=use_xvector, text=text, - lang_disp=config.VOICE_CLONE_LANGUAGE, + lang_disp=self.language, ) else: payload = dict( ref_audio=self._ref_audio_payload(), ref_text=self.voice_clone_ref_text, target_text=text, - language=config.VOICE_CLONE_LANGUAGE, + language=self.language, use_xvector_only=use_xvector, ) optional_params = { -- cgit v1.2.3