diff --git a/.gitignore b/.gitignore index d3972929..3417d4da 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__ voxcpm.egg-info .DS_Store ./pretrained_models/ -app_local.py \ No newline at end of file +app_local.py +presets/ \ No newline at end of file diff --git a/app.py b/app.py index dba6fe32..ce3cfadd 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,9 @@ import voxcpm +import presets as preset_store + + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", @@ -117,6 +120,15 @@ "dit_steps_info": "LocDiT flow-matching steps — more steps → maybe better audio quality, but slower", "usage_instructions": _USAGE_INSTRUCTIONS_EN, "examples_footer": _EXAMPLES_FOOTER_EN, + # ----- Preset management ----- + "preset_section_title": "💾 Presets (save & reuse a configuration)", + "preset_name_label": "Preset name", + "preset_name_placeholder": "e.g. Gentle girl / 暴躁老哥", + "save_preset_btn": "💾 Save as preset", + "load_preset_label": "Load preset", + "apply_preset_btn": "Apply", + "delete_preset_btn": "Delete", + "refresh_preset_btn": "Refresh", }, "zh-CN": { "reference_audio_label": "🎤 参考音频(可选 — 上传后用于克隆)", @@ -140,6 +152,15 @@ "dit_steps_info": "LocDiT 流匹配生成迭代步数 — 步数越多 → 可能生成更好的音频质量,但速度变慢", "usage_instructions": _USAGE_INSTRUCTIONS_ZH, "examples_footer": _EXAMPLES_FOOTER_ZH, + # ----- 预设管理 ----- + "preset_section_title": "💾 预设(保存并复用配置)", + "preset_name_label": "预设名称", + "preset_name_placeholder": "如:温柔少女 / 暴躁老哥", + "save_preset_btn": "💾 保存为预设", + "load_preset_label": "加载预设", + "apply_preset_btn": "应用", + "delete_preset_btn": "删除", + "refresh_preset_btn": "刷新", }, "zh-Hans": None, # alias, filled below "zh": None, # alias, filled below @@ -154,6 +175,53 @@ I18N = gr.I18n(**_I18N_TRANSLATIONS) +# ---------- Runtime toast messages ---------- +# gr.Info / gr.Warning do NOT accept I18nData objects (the LogMessage model +# requires a plain str), so toast text cannot use I18N(...) like UI labels do. +# Instead we resolve the language at request time from the Accept-Language +# header and look the message up here. +_TOAST_MESSAGES = { + "en": { + "preset_name_empty": "Preset name cannot be empty.", + "save_failed": "Failed to save preset: {error}", + "save_overwritten": "Preset name already exists; overwritten.", + "save_done": "Preset saved.", + "select_preset_first": "Please select a preset first.", + "preset_not_exist": "Preset does not exist.", + "ref_audio_missing": "Reference audio file is missing; skipped.", + "delete_done": "Preset deleted.", + }, + "zh-CN": { + "preset_name_empty": "预设名称不能为空。", + "save_failed": "保存预设失败:{error}", + "save_overwritten": "预设名称已存在,已覆盖。", + "save_done": "预设已保存。", + "select_preset_first": "请先选择预设。", + "preset_not_exist": "预设不存在。", + "ref_audio_missing": "参考音频文件缺失,已跳过。", + "delete_done": "预设已删除。", + }, +} + + +def _detect_lang(request: Optional[gr.Request]) -> str: + """Pick a toast language from the request's Accept-Language header. + + Defaults to English; returns "zh-CN" when a Chinese locale is preferred. + """ + if request is None: + return "en" + accept = (request.headers.get("accept-language") or "").lower() + return "zh-CN" if "zh" in accept else "en" + + +def _toast(request: Optional[gr.Request], key: str, **fmt: str) -> str: + """Resolve a localized toast message, with optional str.format args.""" + lang = _detect_lang(request) + template = _TOAST_MESSAGES[lang].get(key) or _TOAST_MESSAGES["en"][key] + return template.format(**fmt) if fmt else template + + DEFAULT_TARGET_TEXT = ( "VoxCPM2 is a creative multilingual TTS model from ModelBest, " "designed to generate highly realistic speech." @@ -375,6 +443,125 @@ def _run_asr_if_needed(checked, audio_path): logger.warning(f"ASR recognition failed: {e}") return gr.update(value="") + # ----- Preset save / load / delete handlers ----- + # The order below is the single source of truth shared by save inputs and + # apply outputs, so the two never drift apart (DRY). + # apply outputs: reference_wav, show_prompt_text, prompt_text, + # control_instruction, text, cfg_value, + # DoNormalizeText, DoDenoisePromptAudio, dit_steps + _APPLY_OUTPUT_COUNT = 9 + + def _collect_preset_data( + text_value, + control_value, + use_prompt_text, + prompt_text_value, + cfg_value_input, + do_normalize, + denoise, + dit_steps_value, + ): + """Gather the non-audio UI values into a dict for preset storage.""" + return { + "text": text_value or "", + "control_instruction": control_value or "", + "use_prompt_text": bool(use_prompt_text), + "prompt_text": prompt_text_value or "", + "cfg_value": float(cfg_value_input) if cfg_value_input is not None else 2.0, + "do_normalize": bool(do_normalize), + "denoise": bool(denoise), + "dit_steps": int(dit_steps_value) if dit_steps_value is not None else 10, + } + + def _refresh_dropdown(selected: str = ""): + """Return a dropdown update reflecting the presets currently on disk.""" + choices = preset_store.list_presets() + value = selected if selected in choices else "" + return gr.update(choices=choices, value=value, interactive=bool(choices)) + + def on_preset_save( + name, + ref_wav, + text_value, + control_value, + use_prompt_text, + prompt_text_value, + cfg_value_input, + do_normalize, + denoise, + dit_steps_value, + request: gr.Request, + ): + """Save the current UI state as a named preset, then refresh the list.""" + name = (name or "").strip() + if not name: + gr.Warning(_toast(request, "preset_name_empty")) + return gr.update(), gr.update() + + data = _collect_preset_data( + text_value, control_value, use_prompt_text, prompt_text_value, + cfg_value_input, do_normalize, denoise, dit_steps_value, + ) + safe_name = preset_store.safe_preset_name(name) + existed = safe_name in preset_store.list_presets() + try: + preset_store.save_preset(name, data, reference_audio=ref_wav) + except Exception as e: + gr.Warning(_toast(request, "save_failed", error=str(e))) + return gr.update(), gr.update() + + gr.Info( + _toast(request, "save_overwritten" if existed else "save_done"), + duration=2, + ) + # Clear the name box and refresh the dropdown with the new preset selected. + return gr.update(value=""), _refresh_dropdown(safe_name) + + def on_preset_apply(name, request: gr.Request): + """Load a preset and update every relevant component.""" + unchanged = tuple(gr.update() for _ in range(_APPLY_OUTPUT_COUNT)) + if not name: + gr.Warning(_toast(request, "select_preset_first")) + return unchanged + + data = preset_store.load_preset(name) + if data is None: + gr.Warning(_toast(request, "preset_not_exist")) + return unchanged + + ref_path = data.get("reference_audio", "") or None + if ref_path and not os.path.exists(ref_path): + gr.Warning(_toast(request, "ref_audio_missing")) + ref_path = None + + use_prompt = bool(data.get("use_prompt_text", False)) + return ( + gr.update(value=ref_path), # reference_wav + gr.update(value=use_prompt), # show_prompt_text + gr.update(value=data.get("prompt_text", ""), visible=use_prompt), # prompt_text + gr.update(value=data.get("control_instruction", ""), visible=not use_prompt), # control_instruction + gr.update(value=data.get("text", "")), # text + gr.update(value=data.get("cfg_value", 2.0)), # cfg_value + gr.update(value=data.get("do_normalize", False)), # DoNormalizeText + gr.update(value=data.get("denoise", False)), # DoDenoisePromptAudio + gr.update(value=data.get("dit_steps", 10)), # dit_steps + ) + + def on_preset_delete(name, request: gr.Request): + """Delete the selected preset and refresh the dropdown.""" + if not name: + gr.Warning(_toast(request, "select_preset_first")) + return gr.update() + if preset_store.delete_preset(name): + gr.Info(_toast(request, "delete_done"), duration=2) + else: + gr.Warning(_toast(request, "preset_not_exist")) + return _refresh_dropdown("") + + def on_preset_refresh(): + """Re-list presets from disk.""" + return _refresh_dropdown("") + with gr.Blocks() as interface: gr.HTML( '