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( '
' @@ -448,6 +635,29 @@ def _run_asr_if_needed(checked, audio_path): run_btn = gr.Button(I18N("generate_btn"), variant="primary", size="lg") + with gr.Accordion(I18N("preset_section_title"), open=False): + with gr.Row(): + preset_name = gr.Textbox( + label=I18N("preset_name_label"), + placeholder=I18N("preset_name_placeholder"), + scale=3, + ) + save_preset_btn = gr.Button( + I18N("save_preset_btn"), scale=1 + ) + with gr.Row(): + load_preset_dropdown = gr.Dropdown( + choices=preset_store.list_presets(), + value="", + label=I18N("load_preset_label"), + allow_custom_value=False, + interactive=bool(preset_store.list_presets()), + scale=3, + ) + apply_preset_btn = gr.Button(I18N("apply_preset_btn"), scale=1) + delete_preset_btn = gr.Button(I18N("delete_preset_btn"), scale=1) + refresh_preset_btn = gr.Button(I18N("refresh_preset_btn"), scale=1) + with gr.Column(): audio_output = gr.Audio(label=I18N("generated_audio_label")) gr.Markdown(I18N("examples_footer")) @@ -480,6 +690,59 @@ def _run_asr_if_needed(checked, audio_path): api_name="generate", ) + # ----- Preset event bindings ----- + # Must match the order documented in on_preset_apply / _APPLY_OUTPUT_COUNT. + _preset_apply_outputs = [ + reference_wav, + show_prompt_text, + prompt_text, + control_instruction, + text, + cfg_value, + DoNormalizeText, + DoDenoisePromptAudio, + dit_steps, + ] + _preset_save_inputs = [ + preset_name, + reference_wav, + text, + control_instruction, + show_prompt_text, + prompt_text, + cfg_value, + DoNormalizeText, + DoDenoisePromptAudio, + dit_steps, + ] + + save_preset_btn.click( + fn=on_preset_save, + inputs=_preset_save_inputs, + outputs=[preset_name, load_preset_dropdown], + ) + apply_preset_btn.click( + fn=on_preset_apply, + inputs=[load_preset_dropdown], + outputs=_preset_apply_outputs, + ) + delete_preset_btn.click( + fn=on_preset_delete, + inputs=[load_preset_dropdown], + outputs=[load_preset_dropdown], + ) + refresh_preset_btn.click( + fn=on_preset_refresh, + inputs=[], + outputs=[load_preset_dropdown], + ) + # Refresh the preset list whenever the page (re)loads. + interface.load( + fn=on_preset_refresh, + inputs=[], + outputs=[load_preset_dropdown], + ) + return interface def run_demo( diff --git a/presets.py b/presets.py new file mode 100644 index 00000000..6f44b5fa --- /dev/null +++ b/presets.py @@ -0,0 +1,157 @@ +""" +Preset management utilities for the VoxCPM WebUI. + +A preset stores a reusable configuration including the reference audio, the +cloning-mode toggle and transcript, the control instruction, the target text, +and the advanced generation parameters. Each preset lives in its own directory +under ``presets//``. +""" + +import json +import os +import re +import shutil +from pathlib import Path +from typing import Dict, List, Optional + + +PRESET_VERSION = "1.0" +PRESETS_DIRNAME = "presets" + + +def _project_root() -> Path: + """Return the directory that holds this module (the app root).""" + return Path(__file__).parent.resolve() + + +def get_presets_dir() -> Path: + """Return the directory where presets are stored, creating it if needed.""" + path = _project_root() / PRESETS_DIRNAME + path.mkdir(parents=True, exist_ok=True) + return path + + +def safe_preset_name(name: str) -> str: + """ + Sanitize a preset name so it can be used as a directory name. + + Strips surrounding whitespace, replaces filesystem-unfriendly characters + with underscores, and prevents empty or purely-special names. + """ + name = name.strip() + name = re.sub(r'[\\/:*?"<>|]+', "_", name) + name = re.sub(r"\s+", "_", name) + name = name.strip("._") + return name or "untitled" + + +def list_presets() -> List[str]: + """Return a sorted list of existing preset names.""" + presets_dir = get_presets_dir() + if not presets_dir.exists(): + return [] + names = [ + p.name + for p in presets_dir.iterdir() + if p.is_dir() and (p / "preset.json").is_file() + ] + return sorted(names) + + +def _preset_dir(name: str) -> Path: + return get_presets_dir() / safe_preset_name(name) + + +def _copy_audio(src: Optional[str], dst_dir: Path, dst_name: str) -> Optional[str]: + """ + Copy an uploaded audio file into the preset directory. + + Returns the relative filename written, or None if *src* is empty/missing. + """ + if not src: + return None + src_path = Path(src) + if not src_path.exists(): + return None + dst_dir.mkdir(parents=True, exist_ok=True) + # Keep the original suffix so non-wav reference audio still plays back. + suffix = src_path.suffix or ".wav" + dst_name = f"{dst_name}{suffix}" + dst_path = dst_dir / dst_name + shutil.copy2(src_path, dst_path) + return dst_name + + +def save_preset( + name: str, + data: Dict, + reference_audio: Optional[str] = None, +) -> None: + """ + Save a preset to disk. + + Parameters + ---------- + name: + Preset display name. + data: + Dictionary containing all non-audio preset fields. See ``load_preset`` + for the expected structure. + reference_audio: + Path to the uploaded reference audio, if any. + """ + name = safe_preset_name(name) + preset_dir = _preset_dir(name) + preset_dir.mkdir(parents=True, exist_ok=True) + + # Copy the reference audio and store the relative filename. + ref_rel = _copy_audio(reference_audio, preset_dir, "reference") + + payload = { + "version": PRESET_VERSION, + **data, + "reference_audio": ref_rel or "", + } + + preset_file = preset_dir / "preset.json" + with open(preset_file, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def load_preset(name: str) -> Optional[Dict]: + """ + Load a preset from disk. + + Returns None if the preset does not exist. Relative audio paths are + resolved to absolute paths so the UI can load them directly. + """ + preset_dir = _preset_dir(name) + preset_file = preset_dir / "preset.json" + if not preset_file.exists(): + return None + + with open(preset_file, "r", encoding="utf-8") as f: + data = json.load(f) + + if data.get("reference_audio"): + data["reference_audio"] = str(preset_dir / data["reference_audio"]) + + return data + + +def delete_preset(name: str) -> bool: + """ + Delete a preset directory. + + Returns True if the preset existed and was removed, False otherwise. + """ + preset_dir = _preset_dir(name) + if not preset_dir.exists(): + return False + shutil.rmtree(preset_dir) + return True + + +def preset_exists(name: str) -> bool: + """Return whether a preset with the given name already exists.""" + return _preset_dir(name).exists()