790 lines
31 KiB
Python
790 lines
31 KiB
Python
import os
|
|
import re
|
|
import random
|
|
import shutil
|
|
import sys
|
|
import traceback
|
|
from pathlib import Path
|
|
from typing import Literal, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
import librosa
|
|
import soundfile as sf
|
|
import gradio as gr
|
|
|
|
from preprocess.pipeline import PreprocessPipeline
|
|
from soulxsinger.utils.file_utils import load_config
|
|
from cli.inference import build_model as build_svs_model, process as svs_process
|
|
|
|
|
|
ROOT = Path(__file__).parent
|
|
|
|
ENGLISH_EXAMPLE_PROMPT_AUDIO = "example/audio/en_prompt.mp3"
|
|
ENGLISH_EXAMPLE_PROMPT_META = "example/audio/en_prompt.json"
|
|
ENGLISH_EXAMPLE_TARGET_AUDIO = "example/audio/en_target.mp3"
|
|
ENGLISH_EXAMPLE_TARGET_META = "example/audio/en_target.json"
|
|
|
|
MANDARIN_EXAMPLE_PROMPT_AUDIO = "example/audio/zh_prompt.mp3"
|
|
MANDARIN_EXAMPLE_PROMPT_META = "example/audio/zh_prompt.json"
|
|
MANDARIN_EXAMPLE_TARGET_AUDIO = "example/audio/zh_target.mp3"
|
|
MANDARIN_EXAMPLE_TARGET_META = "example/audio/zh_target.json"
|
|
|
|
CANTONESE_EXAMPLE_PROMPT_AUDIO = "example/audio/yue_prompt.mp3"
|
|
CANTONESE_EXAMPLE_PROMPT_META = "example/audio/yue_prompt.json"
|
|
CANTONESE_EXAMPLE_TARGET_AUDIO = "example/audio/yue_target.mp3"
|
|
CANTONESE_EXAMPLE_TARGET_META = "example/audio/yue_target.json"
|
|
|
|
MUSIC_EXAMPLE_TARGET_AUDIO = "example/audio/music.mp3"
|
|
MUSIC_EXAMPLE_TARGET_META = "example/audio/music.json"
|
|
|
|
# Lyric language: value (Mandarin/Cantonese/English) is passed to PreprocessPipeline; display labels from i18n via get_lyric_lang_choices()
|
|
|
|
# Use absolute paths so Examples load correctly (including File components for metadata)
|
|
EXAMPLES_LIST = [
|
|
[
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_AUDIO),
|
|
str(ROOT / MANDARIN_EXAMPLE_TARGET_AUDIO),
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_META),
|
|
str(ROOT / MANDARIN_EXAMPLE_TARGET_META),
|
|
"Mandarin",
|
|
"Mandarin",
|
|
"melody",
|
|
False,
|
|
True,
|
|
True,
|
|
0,
|
|
],
|
|
[
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_AUDIO),
|
|
str(ROOT / CANTONESE_EXAMPLE_TARGET_AUDIO),
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_META),
|
|
str(ROOT / CANTONESE_EXAMPLE_TARGET_META),
|
|
"Mandarin",
|
|
"Cantonese",
|
|
"melody",
|
|
False,
|
|
True,
|
|
True,
|
|
0,
|
|
],
|
|
[
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_AUDIO),
|
|
str(ROOT / ENGLISH_EXAMPLE_TARGET_AUDIO),
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_META),
|
|
str(ROOT / ENGLISH_EXAMPLE_TARGET_META),
|
|
"Mandarin",
|
|
"English",
|
|
"melody",
|
|
False,
|
|
True,
|
|
True,
|
|
0,
|
|
],
|
|
[
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_AUDIO),
|
|
str(ROOT / MUSIC_EXAMPLE_TARGET_AUDIO),
|
|
str(ROOT / MANDARIN_EXAMPLE_PROMPT_META),
|
|
str(ROOT / MUSIC_EXAMPLE_TARGET_META),
|
|
"Mandarin",
|
|
"Mandarin",
|
|
"score",
|
|
False,
|
|
True,
|
|
True,
|
|
0,
|
|
],
|
|
]
|
|
|
|
|
|
def _load_example(choice_value):
|
|
"""Return 11 example values + skip_clear_count (2 when loading example so next 2 audio.change events don't clear metadata).
|
|
choice_value: selected dropdown string (or index in older flow); map to example index 0/1/2."""
|
|
if choice_value is None:
|
|
return [gr.update()] * 11 + [0]
|
|
idx = 0
|
|
if isinstance(choice_value, int):
|
|
idx = 0 if choice_value <= 0 else min(choice_value - 1, len(EXAMPLES_LIST) - 1)
|
|
else:
|
|
if choice_value == i18n("example_choice_1"):
|
|
idx = 1
|
|
elif choice_value == i18n("example_choice_2"):
|
|
idx = 2
|
|
elif choice_value == i18n("example_choice_3"):
|
|
idx = 3
|
|
elif choice_value == i18n("example_choice_4"):
|
|
idx = 4
|
|
if idx <= 0:
|
|
return [gr.update()] * 11 + [0]
|
|
list_idx = idx - 1
|
|
if list_idx >= len(EXAMPLES_LIST):
|
|
return [gr.update()] * 11 + [0]
|
|
row = EXAMPLES_LIST[list_idx]
|
|
return [
|
|
row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8], row[9], row[10],
|
|
2, # skip_clear_metadata_count: next 2 audio.change events (prompt + target) will not clear metadata
|
|
]
|
|
|
|
|
|
def _clear_prompt_meta_unless_example(_audio, skip_count):
|
|
if skip_count and skip_count > 0:
|
|
return gr.skip(), max(0, skip_count - 1)
|
|
return None, 0
|
|
|
|
|
|
def _clear_target_meta_unless_example(_audio, skip_count):
|
|
if skip_count and skip_count > 0:
|
|
return gr.skip(), max(0, skip_count - 1)
|
|
return None, 0
|
|
|
|
|
|
def _get_device() -> str:
|
|
"""Use CUDA if available, else CPU (e.g. for CI or CPU-only environments)."""
|
|
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
|
|
|
|
|
def _session_dir_from_target(target_audio_path: str) -> Path:
|
|
stem = Path(target_audio_path).stem
|
|
safe = re.sub(r"[^\w\-]", "_", stem)
|
|
safe = re.sub(r"_+", "_", safe).strip("_") or "session"
|
|
return ROOT / "outputs" / "gradio" / safe[:64]
|
|
|
|
|
|
class AppState:
|
|
def __init__(self) -> None:
|
|
self.device = _get_device()
|
|
self.preprocess_pipeline = PreprocessPipeline(
|
|
device=self.device,
|
|
language="Mandarin",
|
|
save_dir=str(ROOT / "outputs" / "gradio" / "_placeholder" / "transcriptions"),
|
|
vocal_sep=True,
|
|
max_merge_duration=60000,
|
|
)
|
|
config = load_config("soulxsinger/config/soulxsinger.yaml")
|
|
self.svs_config = config
|
|
self.svs_model = build_svs_model(
|
|
model_path="pretrained_models/SoulX-Singer/model.pt",
|
|
config=config,
|
|
device=self.device,
|
|
)
|
|
self.phoneset_path = "soulxsinger/utils/phoneme/phone_set.json"
|
|
|
|
def run_preprocess(
|
|
self,
|
|
prompt_path: Path,
|
|
target_path: Path,
|
|
session_base: Path,
|
|
prompt_vocal_sep: bool,
|
|
target_vocal_sep: bool,
|
|
prompt_lyric_lang: str,
|
|
target_lyric_lang: str,
|
|
) -> Tuple[bool, str]:
|
|
try:
|
|
self.preprocess_pipeline.save_dir = str(session_base / "transcriptions" / "prompt")
|
|
self.preprocess_pipeline.run(
|
|
audio_path=str(prompt_path),
|
|
vocal_sep=prompt_vocal_sep,
|
|
max_merge_duration=20000,
|
|
language=prompt_lyric_lang or "Mandarin",
|
|
)
|
|
self.preprocess_pipeline.save_dir = str(session_base / "transcriptions" / "target")
|
|
self.preprocess_pipeline.run(
|
|
audio_path=str(target_path),
|
|
vocal_sep=target_vocal_sep,
|
|
max_merge_duration=60000,
|
|
language=target_lyric_lang or "Mandarin",
|
|
)
|
|
return True, "preprocess done"
|
|
except Exception as e:
|
|
return False, f"preprocess failed: {e}"
|
|
|
|
def run_svs(
|
|
self,
|
|
control: str,
|
|
session_base: Path,
|
|
auto_shift: bool,
|
|
pitch_shift: int,
|
|
) -> Tuple[bool, str, Path | None, Path | None, Path | None]:
|
|
if control not in ("melody", "score"):
|
|
control = "score"
|
|
save_dir = session_base / "generated"
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
class Args:
|
|
pass
|
|
args = Args()
|
|
args.device = self.device
|
|
args.model_path = "pretrained_models/soulx-singer/model.pt"
|
|
args.config = "soulxsinger/config/soulxsinger.yaml"
|
|
args.prompt_wav_path = str(session_base / "audio" / "prompt.wav")
|
|
prompt_meta_path = session_base / "transcriptions" / "prompt" / "metadata.json"
|
|
target_meta_path = session_base / "transcriptions" / "target" / "metadata.json"
|
|
args.prompt_metadata_path = str(prompt_meta_path)
|
|
args.target_metadata_path = str(target_meta_path)
|
|
args.phoneset_path = self.phoneset_path
|
|
args.save_dir = str(save_dir)
|
|
args.auto_shift = auto_shift
|
|
args.pitch_shift = int(pitch_shift)
|
|
args.control = control
|
|
try:
|
|
svs_process(args, self.svs_config, self.svs_model)
|
|
generated = save_dir / "generated.wav"
|
|
if not generated.exists():
|
|
return False, f"inference finished but {generated} not found", None, prompt_meta_path, target_meta_path
|
|
return True, "svs inference done", generated, prompt_meta_path, target_meta_path
|
|
except Exception as e:
|
|
return False, f"svs inference failed: {e}", None, prompt_meta_path, target_meta_path
|
|
|
|
def run_svs_from_paths(
|
|
self,
|
|
prompt_wav_path: str,
|
|
prompt_metadata_path: str,
|
|
target_metadata_path: str,
|
|
control: str,
|
|
auto_shift: bool,
|
|
pitch_shift: int,
|
|
save_dir: Path | None = None,
|
|
) -> Tuple[bool, str, Path | None]:
|
|
"""Run SVS from explicit prompt wav and metadata paths."""
|
|
if save_dir is None:
|
|
import uuid
|
|
save_dir = ROOT / "outputs" / "gradio" / "synthesis" / str(uuid.uuid4())[:8]
|
|
save_dir = Path(save_dir)
|
|
audio_dir = save_dir / "audio"
|
|
prompt_meta_dir = save_dir / "transcriptions" / "prompt"
|
|
target_meta_dir = save_dir / "transcriptions" / "target"
|
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
prompt_meta_dir.mkdir(parents=True, exist_ok=True)
|
|
target_meta_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(prompt_wav_path, audio_dir / "prompt.wav")
|
|
shutil.copy2(prompt_metadata_path, prompt_meta_dir / "metadata.json")
|
|
shutil.copy2(target_metadata_path, target_meta_dir / "metadata.json")
|
|
ok, msg, merged, _, _ = self.run_svs(
|
|
control=control,
|
|
session_base=save_dir,
|
|
auto_shift=auto_shift,
|
|
pitch_shift=pitch_shift,
|
|
)
|
|
if not ok or merged is None:
|
|
return False, msg or "svs failed", None
|
|
return True, "svs inference done", merged
|
|
|
|
|
|
APP_STATE = AppState()
|
|
|
|
|
|
# i18n
|
|
_i18n_key2lang_dict = dict(
|
|
display_lang_label=dict(en="Display Language", zh="显示语言"),
|
|
seed_label=dict(en="Seed", zh="种子"),
|
|
prompt_audio_label=dict(en="Prompt audio (reference voice), limit to 30 seconds", zh="Prompt 音频(参考音色),限制在 30 秒以内"),
|
|
target_audio_label=dict(en="Target audio (melody / lyrics source), limit to 60 seconds", zh="Target 音频(旋律/歌词来源),限制在 60 秒以内"),
|
|
generate_btn_label=dict(en="Start SVS", zh="开始 SVS"),
|
|
transcription_btn_label=dict(en="Run singing transcription", zh="开始歌声转录"),
|
|
synthesis_btn_label=dict(en="Run singing synthesis", zh="歌声合成"),
|
|
prompt_meta_label=dict(en="Prompt metadata", zh="Prompt metadata"),
|
|
target_meta_label=dict(en="Target metadata", zh="Target metadata"),
|
|
edit_tutorial_html=dict(
|
|
en='<p class="mb-0">Refer to <a href="https://github.com/Soul-AILab/SoulX-Singer/tree/main/preprocess#step-2-edit-in-the-midi-editor" target="_blank" rel="noopener">Edit Tutorial</a> for metadata editing</p>',
|
|
zh='<p class="mb-0">metadata 编辑请参考 <a href="https://github.com/Soul-AILab/SoulX-Singer/tree/main/preprocess#step-2-edit-in-the-midi-editor" target="_blank" rel="noopener">编辑教程</a></p>',
|
|
),
|
|
prompt_wav_label=dict(en="Prompt WAV (reference)", zh="Prompt WAV(参考音色)"),
|
|
generated_audio_label=dict(en="Generated merged audio", zh="合成结果音频"),
|
|
prompt_lyric_lang_label=dict(en="Prompt lyric language", zh="Prompt 歌词语种"),
|
|
target_lyric_lang_label=dict(en="Target lyric language", zh="Target 歌词语种"),
|
|
lyric_lang_mandarin=dict(en="Mandarin", zh="普通话"),
|
|
lyric_lang_cantonese=dict(en="Cantonese", zh="粤语"),
|
|
lyric_lang_english=dict(en="English", zh="英语"),
|
|
warn_missing_synthesis=dict(en="Please provide prompt WAV, prompt metadata, and target metadata", zh="请提供 Prompt WAV、Prompt metadata 与 Target metadata"),
|
|
prompt_vocal_sep_label=dict(en="Prompt vocal separation", zh="Prompt人声分离"),
|
|
target_vocal_sep_label=dict(en="Target vocal separation", zh="Target人声分离"),
|
|
auto_shift_label=dict(en="Auto pitch shift", zh="自动变调"),
|
|
pitch_shift_label=dict(en="Pitch shift (semitones)", zh="指定变调(半音)"),
|
|
control_type_label=dict(en="Control type", zh="控制类型"),
|
|
examples_label=dict(en="Reference examples (click to load)", zh="参考样例(点击加载)"),
|
|
example_choice_0=dict(en="—", zh="—"),
|
|
example_choice_1=dict(en="Example 1: Mandarin → Mandarin (melody), Start singing synthesis!", zh="样例 1: 普通话 → 普通话 (melody), 开始歌声合成吧!"),
|
|
example_choice_2=dict(en="Example 2: Mandarin → Cantonese (melody), Start singing synthesis!", zh="样例 2: 普通话 → 粤语 (melody), 开始歌声合成吧!"),
|
|
example_choice_3=dict(en="Example 3: Mandarin → English (melody), Start singing synthesis!", zh="样例 3: 普通话 → 英语 (melody), 开始歌声合成吧!"),
|
|
example_choice_4=dict(en="Example 4: Mandarin → Music (score), Start singing synthesis!", zh="样例 4: 普通话 → 音乐 (score), 开始歌声合成吧!"),
|
|
warn_missing_audio=dict(
|
|
en="Please upload both prompt audio and target audio",
|
|
zh="请上传 Prompt 音频与 Target 音频",
|
|
),
|
|
# Instruction panel (workflow description)
|
|
instruction_title=dict(en="Usage", zh="使用说明"),
|
|
instruction_p1=dict(
|
|
en="After uploading prompt and target audio and clicking **Run singing transcription**, the system generates two metadata files (prompt and target).",
|
|
zh="上传 Prompt 与 Target 音频并点击「开始歌声转录」后,将生成 Prompt 与 Target 两份 metadata 文件。",
|
|
),
|
|
instruction_p2=dict(
|
|
en="Auto-transcribed lyrics and notes are often misaligned. For better results, import the generated metadata into the **MIDI Editor** for manual adjustment: [SoulX-Singer-Midi-Editor](https://huggingface.co/spaces/Soul-AILab/SoulX-Singer-Midi-Editor).",
|
|
zh="自动转录的歌词与音高对齐效果通常不理想,建议将生成的 metadata 导入 **MIDI 编辑器** 进行手动调整:[SoulX-Singer-Midi-Editor](https://huggingface.co/spaces/Soul-AILab/SoulX-Singer-Midi-Editor)。",
|
|
),
|
|
instruction_p3=dict(
|
|
en="Re-upload the adjusted metadata to the corresponding Prompt / Target Meta fields, then click **Run singing synthesis** to generate the final audio.",
|
|
zh="将调整后的 metadata 重新上传至对应的 Prompt / Target Meta 位置后,点击「歌声合成」开始最终生成。",
|
|
),
|
|
)
|
|
|
|
def _detect_initial_lang() -> Literal["zh", "en"]:
|
|
"""Detect initial UI language from server locale (browser language applied later via JS)."""
|
|
try:
|
|
import locale
|
|
loc = (locale.getdefaultlocale()[0] or os.environ.get("LANG", "") or "").lower()
|
|
return "en" if loc.startswith("en") else "zh"
|
|
except Exception:
|
|
return "zh"
|
|
|
|
|
|
global_lang: Literal["zh", "en"] = _detect_initial_lang()
|
|
|
|
|
|
def i18n(key: str) -> str:
|
|
return _i18n_key2lang_dict[key][global_lang]
|
|
|
|
|
|
def get_lyric_lang_choices():
|
|
"""Lyric language dropdown (display, value) for current UI language."""
|
|
return [
|
|
(i18n("lyric_lang_mandarin"), "Mandarin"),
|
|
(i18n("lyric_lang_cantonese"), "Cantonese"),
|
|
(i18n("lyric_lang_english"), "English"),
|
|
]
|
|
|
|
|
|
def _resolve_file_path(x):
|
|
"""Gradio file input can be path string or (path, None) tuple."""
|
|
if x is None:
|
|
return None
|
|
if isinstance(x, tuple):
|
|
x = x[0]
|
|
return x if (x and os.path.isfile(x)) else None
|
|
|
|
|
|
def transcription_function(
|
|
prompt_audio,
|
|
target_audio,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
prompt_lyric_lang: str,
|
|
target_lyric_lang: str,
|
|
prompt_vocal_sep: bool,
|
|
target_vocal_sep: bool,
|
|
):
|
|
"""Step 1: Run transcription only; output (prompt_meta_path, target_meta_path)."""
|
|
try:
|
|
if isinstance(prompt_audio, tuple):
|
|
prompt_audio = prompt_audio[0]
|
|
if isinstance(target_audio, tuple):
|
|
target_audio = target_audio[0]
|
|
if prompt_audio is None or target_audio is None:
|
|
gr.Warning(message=i18n("warn_missing_audio"))
|
|
return None, None
|
|
prompt_meta_resolved = _resolve_file_path(prompt_metadata)
|
|
target_meta_resolved = _resolve_file_path(target_metadata)
|
|
use_input_metadata = prompt_meta_resolved is not None and target_meta_resolved is not None
|
|
|
|
session_base = _session_dir_from_target(target_audio)
|
|
audio_dir = session_base / "audio"
|
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
transfer_prompt_path = audio_dir / "prompt.wav"
|
|
transfer_target_path = audio_dir / "target.wav"
|
|
SR = 44100
|
|
PROMPT_MAX_SEC = 30
|
|
TARGET_MAX_SEC = 60
|
|
prompt_audio_data, _ = librosa.load(prompt_audio, sr=SR, mono=True)
|
|
target_audio_data, _ = librosa.load(target_audio, sr=SR, mono=True)
|
|
prompt_audio_data = prompt_audio_data[: PROMPT_MAX_SEC * SR]
|
|
target_audio_data = target_audio_data[: TARGET_MAX_SEC * SR]
|
|
sf.write(transfer_prompt_path, prompt_audio_data, SR)
|
|
sf.write(transfer_target_path, target_audio_data, SR)
|
|
|
|
prompt_meta_path = session_base / "transcriptions" / "prompt" / "metadata.json"
|
|
target_meta_path = session_base / "transcriptions" / "target" / "metadata.json"
|
|
if use_input_metadata:
|
|
(session_base / "transcriptions" / "prompt").mkdir(parents=True, exist_ok=True)
|
|
(session_base / "transcriptions" / "target").mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(prompt_meta_resolved, prompt_meta_path)
|
|
shutil.copy2(target_meta_resolved, target_meta_path)
|
|
else:
|
|
ok, msg = APP_STATE.run_preprocess(
|
|
transfer_prompt_path,
|
|
transfer_target_path,
|
|
session_base,
|
|
prompt_vocal_sep=prompt_vocal_sep,
|
|
target_vocal_sep=target_vocal_sep,
|
|
prompt_lyric_lang=prompt_lyric_lang or "Mandarin",
|
|
target_lyric_lang=target_lyric_lang or "Mandarin",
|
|
)
|
|
if not ok:
|
|
print(msg, file=sys.stderr, flush=True)
|
|
return None, None
|
|
|
|
prompt_meta_file = str(prompt_meta_path) if prompt_meta_path.exists() else None
|
|
target_meta_file = str(target_meta_path) if target_meta_path.exists() else None
|
|
return prompt_meta_file, target_meta_file
|
|
except Exception:
|
|
print(traceback.format_exc(), file=sys.stderr, flush=True)
|
|
return None, None
|
|
|
|
|
|
def synthesis_function(
|
|
prompt_audio,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
control: str,
|
|
auto_shift: bool,
|
|
pitch_shift,
|
|
seed: int,
|
|
):
|
|
"""Step 2: Run SVS from top prompt_audio + prompt_metadata + target_metadata."""
|
|
try:
|
|
if isinstance(prompt_audio, tuple):
|
|
prompt_audio = prompt_audio[0]
|
|
prompt_wav_path = prompt_audio
|
|
prompt_meta_path = _resolve_file_path(prompt_metadata)
|
|
target_meta_path = _resolve_file_path(target_metadata)
|
|
if not prompt_wav_path or not os.path.isfile(prompt_wav_path):
|
|
gr.Warning(message=i18n("warn_missing_synthesis"))
|
|
return None
|
|
if not prompt_meta_path or not os.path.isfile(prompt_meta_path):
|
|
gr.Warning(message=i18n("warn_missing_synthesis"))
|
|
return None
|
|
if not target_meta_path or not os.path.isfile(target_meta_path):
|
|
gr.Warning(message=i18n("warn_missing_synthesis"))
|
|
return None
|
|
if control not in ("melody", "score"):
|
|
control = "score"
|
|
seed = int(seed)
|
|
torch.manual_seed(seed)
|
|
np.random.seed(seed)
|
|
random.seed(seed)
|
|
ok, msg, merged = APP_STATE.run_svs_from_paths(
|
|
prompt_wav_path=prompt_wav_path,
|
|
prompt_metadata_path=prompt_meta_path,
|
|
target_metadata_path=target_meta_path,
|
|
control=control,
|
|
auto_shift=auto_shift,
|
|
pitch_shift=int(pitch_shift),
|
|
)
|
|
if not ok or merged is None:
|
|
print(msg or "synthesis failed", file=sys.stderr, flush=True)
|
|
return None
|
|
return str(merged)
|
|
except Exception:
|
|
print(traceback.format_exc(), file=sys.stderr, flush=True)
|
|
return None
|
|
|
|
|
|
def _instruction_md() -> str:
|
|
"""Markdown content for the instruction panel (supports links)."""
|
|
return "\n\n".join([
|
|
f"**1.** {i18n('instruction_p1')}",
|
|
f"**2.** {i18n('instruction_p2')}",
|
|
f"**3.** {i18n('instruction_p3')}",
|
|
])
|
|
|
|
|
|
def render_interface() -> gr.Blocks:
|
|
with gr.Blocks(title="SoulX-Singer 歌声合成Demo", theme=gr.themes.Default()) as page:
|
|
gr.HTML(
|
|
'<div style="'
|
|
'text-align: center; '
|
|
'padding: 1.25rem 0 1.5rem; '
|
|
'margin-bottom: 0.5rem;'
|
|
'">'
|
|
'<div style="'
|
|
'display: inline-block; '
|
|
'font-size: 1.75rem; '
|
|
'font-weight: 700; '
|
|
'letter-spacing: 0.02em; '
|
|
'color: #1a1a2e; '
|
|
'line-height: 1.3;'
|
|
'">SoulX-Singer</div>'
|
|
'<div style="'
|
|
'width: 80px; '
|
|
'height: 3px; '
|
|
'margin: 1rem auto 0; '
|
|
'background: linear-gradient(90deg, transparent, #6366f1, transparent); '
|
|
'border-radius: 2px;'
|
|
'"></div>'
|
|
'</div>'
|
|
)
|
|
# Auto-detect browser language: run after Gradio mounts
|
|
gr.HTML(
|
|
'<script type="text/javascript">'
|
|
'(function(){'
|
|
'function setLang(){'
|
|
'var lang=(navigator.language||navigator.userLanguage||"").toLowerCase();'
|
|
'if(lang.startsWith("en")){'
|
|
'var inputs=document.querySelectorAll("#lang_choice_radio input");'
|
|
'if(inputs.length>1)inputs[1].click();'
|
|
'}'
|
|
'}'
|
|
'if(document.readyState==="complete")setTimeout(setLang,800);'
|
|
'else window.addEventListener("load",function(){setTimeout(setLang,800);});'
|
|
'})();'
|
|
'</script>',
|
|
visible=False,
|
|
)
|
|
with gr.Row(equal_height=True):
|
|
lang_choice = gr.Radio(
|
|
choices=["中文", "English"],
|
|
value="中文",
|
|
label=i18n("display_lang_label"),
|
|
type="index",
|
|
interactive=True,
|
|
elem_id="lang_choice_radio",
|
|
)
|
|
|
|
# Instruction panel (usage workflow); updates on language change
|
|
instruction_md = gr.Markdown(f"### {i18n('instruction_title')}\n\n{_instruction_md()}")
|
|
|
|
# Reference examples — at the front of operations (handler registered after components exist)
|
|
skip_clear_metadata_count = gr.State(0)
|
|
with gr.Row():
|
|
_example_choices = [i18n("example_choice_0"), i18n("example_choice_1"), i18n("example_choice_2"), i18n("example_choice_3"), i18n("example_choice_4")]
|
|
example_choice = gr.Dropdown(
|
|
label=i18n("examples_label"),
|
|
choices=_example_choices,
|
|
value=_example_choices[0],
|
|
interactive=True,
|
|
)
|
|
|
|
# Step 1: Transcription (audio → metadata)
|
|
with gr.Row(equal_height=True):
|
|
with gr.Column(scale=1):
|
|
prompt_audio = gr.Audio(
|
|
label=i18n("prompt_audio_label"),
|
|
type="filepath",
|
|
editable=False,
|
|
interactive=True,
|
|
)
|
|
with gr.Column(scale=1):
|
|
target_audio = gr.Audio(
|
|
label=i18n("target_audio_label"),
|
|
type="filepath",
|
|
editable=False,
|
|
interactive=True,
|
|
)
|
|
with gr.Row(equal_height=True):
|
|
prompt_lyric_lang = gr.Dropdown(
|
|
label=i18n("prompt_lyric_lang_label"),
|
|
choices=get_lyric_lang_choices(),
|
|
value="Mandarin",
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
target_lyric_lang = gr.Dropdown(
|
|
label=i18n("target_lyric_lang_label"),
|
|
choices=get_lyric_lang_choices(),
|
|
value="Mandarin",
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
prompt_vocal_sep = gr.Checkbox(
|
|
label=i18n("prompt_vocal_sep_label"),
|
|
value=False,
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
target_vocal_sep = gr.Checkbox(
|
|
label=i18n("target_vocal_sep_label"),
|
|
value=True,
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
with gr.Row():
|
|
transcription_btn = gr.Button(
|
|
value=i18n("transcription_btn_label"),
|
|
variant="primary",
|
|
size="lg",
|
|
)
|
|
|
|
# Edit tutorial link (gr.HTML supports links; component labels do not)
|
|
metadata_tutorial_html = gr.HTML(value=i18n("edit_tutorial_html"))
|
|
# Synthesis: params row, then synthesis button on next row
|
|
with gr.Row(equal_height=True):
|
|
prompt_metadata = gr.File(
|
|
label=i18n("prompt_meta_label"),
|
|
type="filepath",
|
|
file_types=[".json"],
|
|
interactive=True,
|
|
)
|
|
target_metadata = gr.File(
|
|
label=i18n("target_meta_label"),
|
|
type="filepath",
|
|
file_types=[".json"],
|
|
interactive=True,
|
|
)
|
|
control_radio = gr.Radio(
|
|
choices=["melody", "score"],
|
|
value="score",
|
|
label=i18n("control_type_label"),
|
|
scale=1,
|
|
)
|
|
auto_shift = gr.Checkbox(
|
|
label=i18n("auto_shift_label"),
|
|
value=True,
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
pitch_shift = gr.Number(
|
|
label=i18n("pitch_shift_label"),
|
|
value=0,
|
|
minimum=-36,
|
|
maximum=36,
|
|
step=1,
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
seed_input = gr.Number(
|
|
label=i18n("seed_label"),
|
|
value=12306,
|
|
step=1,
|
|
interactive=True,
|
|
scale=1,
|
|
)
|
|
with gr.Row():
|
|
synthesis_btn = gr.Button(
|
|
value=i18n("synthesis_btn_label"),
|
|
variant="primary",
|
|
size="lg",
|
|
)
|
|
with gr.Row():
|
|
output_audio = gr.Audio(
|
|
label=i18n("generated_audio_label"),
|
|
type="filepath",
|
|
interactive=False,
|
|
)
|
|
|
|
example_choice.change(
|
|
fn=_load_example,
|
|
inputs=[example_choice],
|
|
outputs=[
|
|
prompt_audio,
|
|
target_audio,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
prompt_lyric_lang,
|
|
target_lyric_lang,
|
|
control_radio,
|
|
prompt_vocal_sep,
|
|
target_vocal_sep,
|
|
auto_shift,
|
|
pitch_shift,
|
|
skip_clear_metadata_count,
|
|
],
|
|
)
|
|
|
|
def _change_component_language(lang):
|
|
global global_lang
|
|
global_lang = ["zh", "en"][lang]
|
|
choices = get_lyric_lang_choices()
|
|
return [
|
|
gr.update(label=i18n("prompt_audio_label")),
|
|
gr.update(label=i18n("target_audio_label")),
|
|
gr.update(label=i18n("prompt_lyric_lang_label"), choices=choices),
|
|
gr.update(label=i18n("target_lyric_lang_label"), choices=choices),
|
|
gr.update(label=i18n("prompt_vocal_sep_label")),
|
|
gr.update(label=i18n("target_vocal_sep_label")),
|
|
gr.update(value=i18n("transcription_btn_label")),
|
|
gr.update(label=i18n("prompt_meta_label")),
|
|
gr.update(label=i18n("target_meta_label")),
|
|
gr.update(value=i18n("edit_tutorial_html")),
|
|
gr.update(label=i18n("control_type_label")),
|
|
gr.update(label=i18n("auto_shift_label")),
|
|
gr.update(label=i18n("pitch_shift_label")),
|
|
gr.update(label=i18n("seed_label")),
|
|
gr.update(value=i18n("synthesis_btn_label")),
|
|
gr.update(label=i18n("generated_audio_label")),
|
|
gr.update(label=i18n("display_lang_label")),
|
|
gr.update(
|
|
label=i18n("examples_label"),
|
|
choices=[i18n("example_choice_0"), i18n("example_choice_1"), i18n("example_choice_2"), i18n("example_choice_3"), i18n("example_choice_4")],
|
|
value=i18n("example_choice_0"),
|
|
),
|
|
gr.update(value=f"### {i18n('instruction_title')}\n\n{_instruction_md()}"),
|
|
]
|
|
|
|
lang_choice.change(
|
|
fn=_change_component_language,
|
|
inputs=[lang_choice],
|
|
outputs=[
|
|
prompt_audio,
|
|
target_audio,
|
|
prompt_lyric_lang,
|
|
target_lyric_lang,
|
|
prompt_vocal_sep,
|
|
target_vocal_sep,
|
|
transcription_btn,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
metadata_tutorial_html,
|
|
control_radio,
|
|
auto_shift,
|
|
pitch_shift,
|
|
seed_input,
|
|
synthesis_btn,
|
|
output_audio,
|
|
lang_choice,
|
|
example_choice,
|
|
instruction_md,
|
|
],
|
|
)
|
|
|
|
# Upload new prompt/target audio → clear corresponding metadata; skip clear when change came from load example
|
|
prompt_audio.change(
|
|
fn=_clear_prompt_meta_unless_example,
|
|
inputs=[prompt_audio, skip_clear_metadata_count],
|
|
outputs=[prompt_metadata, skip_clear_metadata_count],
|
|
)
|
|
target_audio.change(
|
|
fn=_clear_target_meta_unless_example,
|
|
inputs=[target_audio, skip_clear_metadata_count],
|
|
outputs=[target_metadata, skip_clear_metadata_count],
|
|
)
|
|
|
|
transcription_btn.click(
|
|
fn=transcription_function,
|
|
inputs=[
|
|
prompt_audio,
|
|
target_audio,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
prompt_lyric_lang,
|
|
target_lyric_lang,
|
|
prompt_vocal_sep,
|
|
target_vocal_sep,
|
|
],
|
|
outputs=[prompt_metadata, target_metadata],
|
|
)
|
|
|
|
synthesis_btn.click(
|
|
fn=synthesis_function,
|
|
inputs=[
|
|
prompt_audio,
|
|
prompt_metadata,
|
|
target_metadata,
|
|
control_radio,
|
|
auto_shift,
|
|
pitch_shift,
|
|
seed_input,
|
|
],
|
|
outputs=[output_audio],
|
|
)
|
|
|
|
return page
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int, default=7860, help="Gradio server port")
|
|
parser.add_argument("--share", action="store_true", help="Create public link")
|
|
args = parser.parse_args()
|
|
|
|
page = render_interface()
|
|
page.queue()
|
|
page.launch(share=args.share, server_name="0.0.0.0", server_port=args.port)
|