Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ __pycache__
voxcpm.egg-info
.DS_Store
./pretrained_models/
app_local.py
app_local.py
presets/
263 changes: 263 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

import voxcpm

import presets as preset_store


logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
Expand Down Expand Up @@ -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": "🎤 参考音频(可选 — 上传后用于克隆)",
Expand All @@ -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
Expand All @@ -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."
Expand Down Expand Up @@ -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(
'<div class="logo-container">'
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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(
Expand Down
Loading