Update Live-streaming code (auto-daily features)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Gradio application for dots.tts."""
|
||||
+663
@@ -0,0 +1,663 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_ROOT = REPO_ROOT / "src"
|
||||
|
||||
for import_root in (REPO_ROOT, SRC_ROOT):
|
||||
import_root_str = str(import_root)
|
||||
if import_root_str not in sys.path:
|
||||
sys.path.insert(0, import_root_str)
|
||||
|
||||
from apps.gradio.constants import ( # noqa: E402
|
||||
DEFAULT_EXECUTION_MODE,
|
||||
DEFAULT_GUIDANCE_SCALE,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_INPUT_TEXT,
|
||||
DEFAULT_LOG_FILE,
|
||||
DEFAULT_MAX_GENERATE_LENGTH,
|
||||
DEFAULT_NUM_STEPS,
|
||||
DEFAULT_ODE_METHOD,
|
||||
DEFAULT_OUTPUT_DIR,
|
||||
DEFAULT_OUTPUT_RETENTION,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PRECISION,
|
||||
DEFAULT_PROMPT_NAME,
|
||||
DEFAULT_SEED,
|
||||
DEFAULT_SPEAKER_SCALE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import gradio as gr
|
||||
|
||||
DEBUG_GRADIO_ENABLED = os.environ.get("DEBUG_GRADIO", "0") == "1"
|
||||
|
||||
|
||||
PLAYGROUND_CSS = """
|
||||
.gradio-container {
|
||||
width: min(1600px, calc(100vw - 32px)) !important;
|
||||
max-width: none !important;
|
||||
margin: 0 auto !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.gradio-container,
|
||||
.gradio-container .gradio-container {
|
||||
--block-label-background-fill: #CCE5FF;
|
||||
--block-label-text-color: #6666FF;
|
||||
--block-label-border-color: #99c7ee;
|
||||
--block-label-text-weight: 600;
|
||||
--block-title-background-fill: #CCE5FF;
|
||||
--block-title-text-color: #6666FF;
|
||||
--block-title-border-color: #99c7ee;
|
||||
--block-title-border-width: var(--block-label-border-width);
|
||||
--block-title-radius: var(--block-label-radius);
|
||||
--block-title-padding: var(--block-label-padding);
|
||||
--block-title-text-size: var(--block-label-text-size);
|
||||
--block-title-text-weight: 600;
|
||||
}
|
||||
|
||||
.gradio-container label[data-testid="block-label"],
|
||||
.gradio-container label[data-testid="block-label"] *,
|
||||
.gradio-container span[data-testid="block-info"],
|
||||
.gradio-container span[data-testid="block-info"] * {
|
||||
background: #CCE5FF !important;
|
||||
border-color: #99c7ee !important;
|
||||
color: #6666FF !important;
|
||||
fill: #6666FF !important;
|
||||
font-family: Verdana, Geneva, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif !important;
|
||||
font-style: normal !important;
|
||||
font-size: 0.78rem !important;
|
||||
line-height: 1.2 !important;
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
.gradio-container label[data-testid="block-label"],
|
||||
.gradio-container span[data-testid="block-info"],
|
||||
.gradio-container [data-testid="block-title"],
|
||||
.gradio-container .block-title {
|
||||
border: var(--block-label-border-width) solid #99c7ee !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
border-radius: var(--block-label-radius) !important;
|
||||
box-shadow: var(--block-label-shadow) !important;
|
||||
padding: var(--block-label-padding) !important;
|
||||
}
|
||||
.gradio-container label[data-testid="block-label"],
|
||||
.gradio-container label[data-testid="block-label"] *,
|
||||
.gradio-container span[data-testid="block-info"],
|
||||
.gradio-container span[data-testid="block-info"] *,
|
||||
.gradio-container [data-testid="block-title"],
|
||||
.gradio-container [data-testid="block-title"] *,
|
||||
.gradio-container .block-title,
|
||||
.gradio-container .block-title * {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.gradio-container .block label > span,
|
||||
.gradio-container .block label > span *,
|
||||
.gradio-container .form label > span,
|
||||
.gradio-container .form label > span *,
|
||||
.gradio-container label > span:first-child,
|
||||
.gradio-container label > span:first-child * {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.strong-label [data-testid="block-label"],
|
||||
.strong-label [data-testid="block-label"] *,
|
||||
.strong-label span[data-testid="block-info"],
|
||||
.strong-label span[data-testid="block-info"] *,
|
||||
.strong-label [data-testid="block-title"],
|
||||
.strong-label [data-testid="block-title"] *,
|
||||
.strong-label .block-label,
|
||||
.strong-label .block-label *,
|
||||
.strong-label .block-title,
|
||||
.strong-label .block-title *,
|
||||
.strong-label label > span:first-child,
|
||||
.strong-label label > span:first-child * {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.gradio-container .info-text,
|
||||
.gradio-container .info-text * {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
.gradio-container input,
|
||||
.gradio-container textarea,
|
||||
.gradio-container select,
|
||||
.gradio-container [role="textbox"],
|
||||
.gradio-container [contenteditable="true"] {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
.gradio-container label[data-testid="block-label"] > span:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
background: #6666FF !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #5555ee !important;
|
||||
font-family: Verdana, Geneva, sans-serif !important;
|
||||
}
|
||||
.generate-button:hover {
|
||||
background: #5555ee !important;
|
||||
}
|
||||
|
||||
#playground-banner {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
margin-bottom: 18px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
#playground-banner h1 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
#playground-banner .subtitle {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #99c7ee;
|
||||
border-left: 4px solid #2563eb;
|
||||
background: transparent;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #0f172a;
|
||||
}
|
||||
.info-card .card-title,
|
||||
.info-card .notice-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
.info-card .card-title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-card .notice-title {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-card ol,
|
||||
.info-card ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.info-card li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.main-workspace {
|
||||
gap: 18px !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.prompt-column,
|
||||
.synthesis-column {
|
||||
gap: 14px !important;
|
||||
}
|
||||
|
||||
.control-row,
|
||||
.settings-slider-row {
|
||||
gap: 14px !important;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
margin-top: 2px !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
flex: 0 0 auto !important;
|
||||
min-height: 44px !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.output-audio {
|
||||
flex: 0 0 auto !important;
|
||||
min-height: 190px !important;
|
||||
}
|
||||
.output-audio audio {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gradio-container {
|
||||
width: calc(100vw - 20px) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def build_playground_theme(gr):
|
||||
return gr.themes.Soft(
|
||||
primary_hue="slate",
|
||||
secondary_hue="slate",
|
||||
neutral_hue="slate",
|
||||
radius_size="md",
|
||||
text_size="md",
|
||||
spacing_size="md",
|
||||
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="dots.tts Gradio app.")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Server host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Server port")
|
||||
parser.add_argument(
|
||||
"--execution-mode",
|
||||
choices=("generate", "generate_stream"),
|
||||
default=DEFAULT_EXECUTION_MODE,
|
||||
help="Runtime execution mode fixed for the app",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--precision",
|
||||
default=DEFAULT_PRECISION,
|
||||
help="Inference precision fixed for the app runtime",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--optimize",
|
||||
action="store_true",
|
||||
help="Enable runtime optimize acceleration",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model-name-or-path",
|
||||
default=None,
|
||||
help="Default model directory or Hugging Face repo id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=str(DEFAULT_OUTPUT_DIR),
|
||||
help="Directory for generated wav outputs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-file",
|
||||
default=str(DEFAULT_LOG_FILE),
|
||||
help="Path to the Gradio log file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-retention-count",
|
||||
type=int,
|
||||
default=DEFAULT_OUTPUT_RETENTION,
|
||||
help="Maximum number of generated wav files to keep",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-generate-length",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_GENERATE_LENGTH,
|
||||
help="Maximum generation schedule length fixed for the app runtime",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-prompt-name",
|
||||
default=DEFAULT_PROMPT_NAME,
|
||||
help="Default built-in voice preset name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-precision",
|
||||
default=DEFAULT_PRECISION,
|
||||
choices=["bfloat16", "float32", "float16"],
|
||||
help="Default precision selected in the UI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-num-steps",
|
||||
type=int,
|
||||
default=DEFAULT_NUM_STEPS,
|
||||
help="Default Num Steps selected in the UI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-guidance-scale",
|
||||
type=float,
|
||||
default=DEFAULT_GUIDANCE_SCALE,
|
||||
help="Default Guidance Scale selected in the UI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-speaker-scale",
|
||||
type=float,
|
||||
default=DEFAULT_SPEAKER_SCALE,
|
||||
help="Default Speaker Scale selected in the UI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-max-generate-length",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_GENERATE_LENGTH,
|
||||
help="Default Max Generate Length selected in the UI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-warmup",
|
||||
action="store_true",
|
||||
help="Start the Gradio server without running an initial synthesis warmup.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def build_startup_config_panel(gr, app_config) -> None:
|
||||
with gr.Accordion("启动固定参数", open=False):
|
||||
gr.Markdown("只读。修改这部分需要重启服务并传入新的启动参数。")
|
||||
gr.Textbox(
|
||||
label="Model",
|
||||
value=app_config.default_model_name_or_path,
|
||||
interactive=False,
|
||||
)
|
||||
with gr.Row():
|
||||
gr.Textbox(
|
||||
label="Execution Mode",
|
||||
value=app_config.execution_mode,
|
||||
interactive=False,
|
||||
)
|
||||
gr.Textbox(
|
||||
label="Precision",
|
||||
value=app_config.precision,
|
||||
interactive=False,
|
||||
)
|
||||
with gr.Row():
|
||||
gr.Number(
|
||||
label="Max Generate Length",
|
||||
value=app_config.max_generate_length,
|
||||
precision=0,
|
||||
interactive=False,
|
||||
)
|
||||
gr.Checkbox(
|
||||
label="Optimize",
|
||||
value=app_config.optimize,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
|
||||
def build_demo(gr, app_config, app_service) -> "gr.Blocks":
|
||||
from apps.gradio.service import (
|
||||
GRADIO_SYNTHESIS_MODE_CHOICES,
|
||||
SynthesisRequest,
|
||||
build_prompt_choice_items,
|
||||
resolve_prompt_selection,
|
||||
)
|
||||
|
||||
def select_prompt_preset(prompt_name: str):
|
||||
audio_path, prompt_text = resolve_prompt_selection(
|
||||
prompt_name,
|
||||
app_config.prompt_presets,
|
||||
)
|
||||
return audio_path, prompt_text
|
||||
|
||||
def run_synthesis(
|
||||
text: str,
|
||||
synthesis_mode: str,
|
||||
prompt_audio_path: str | None,
|
||||
prompt_text: str,
|
||||
ode_method: str,
|
||||
num_steps: float,
|
||||
guidance_scale: float,
|
||||
speaker_scale: float,
|
||||
normalize_text: bool,
|
||||
seed: float,
|
||||
):
|
||||
resolved_synthesis_mode = synthesis_mode if DEBUG_GRADIO_ENABLED else "tts"
|
||||
request = SynthesisRequest(
|
||||
model_name_or_path=app_config.default_model_name_or_path,
|
||||
text=text,
|
||||
prompt_audio_path=prompt_audio_path,
|
||||
prompt_text=prompt_text,
|
||||
execution_mode=app_config.execution_mode,
|
||||
template_name=resolved_synthesis_mode,
|
||||
ode_method=ode_method,
|
||||
num_steps=int(num_steps),
|
||||
guidance_scale=float(guidance_scale),
|
||||
speaker_scale=float(speaker_scale),
|
||||
normalize_text=normalize_text,
|
||||
seed=int(seed),
|
||||
)
|
||||
result = app_service.generate(request)
|
||||
return result.audio_path, result.metrics
|
||||
|
||||
show_prompt_preset = bool(app_config.prompt_presets)
|
||||
|
||||
with gr.Blocks(title="dots.tts") as demo:
|
||||
gr.HTML(
|
||||
"<style>\n"
|
||||
+ PLAYGROUND_CSS
|
||||
+ "\n</style>\n"
|
||||
+ """
|
||||
<div id="playground-banner">
|
||||
<h1>dots.tts</h1>
|
||||
<p class="subtitle">Fully-continuous Autoregressive TTS · 48 kHz · Voice Cloning</p>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
gr.HTML(
|
||||
"""
|
||||
<div class="info-card">
|
||||
<span class="card-title">使用说明 · Instructions</span>
|
||||
<ol>
|
||||
<li>上传参考音频并填写对应转写文本 · Upload prompt audio and fill in its transcript.</li>
|
||||
<li>在文本框中输入要合成的内容 · Enter the text to synthesize.</li>
|
||||
<li>点击 <b>Generate</b> 合成声音 · Click <b>Generate</b> to synthesize speech.</li>
|
||||
</ol>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
with gr.Row(equal_height=True, elem_classes="main-workspace"):
|
||||
with gr.Column(scale=1, min_width=480, elem_classes="prompt-column"):
|
||||
prompt_preset = gr.Dropdown(
|
||||
label="音色 · Voice Preset",
|
||||
choices=build_prompt_choice_items(app_config.prompt_presets),
|
||||
value=app_config.default_prompt_name,
|
||||
info="内置音色clone样本;选择后自动填入参考音频与转写。",
|
||||
elem_id="voice-preset-dropdown",
|
||||
elem_classes="strong-label",
|
||||
visible=show_prompt_preset,
|
||||
)
|
||||
prompt_audio_path = gr.Audio(
|
||||
label="参考音频 · Prompt Audio",
|
||||
sources=["upload"],
|
||||
type="filepath",
|
||||
value=app_config.default_prompt_audio_path,
|
||||
elem_classes="strong-label",
|
||||
)
|
||||
prompt_text = gr.Textbox(
|
||||
label="参考音频转写 · Prompt Text",
|
||||
lines=5,
|
||||
value=app_config.default_prompt_text,
|
||||
placeholder="Prompt audio 对应的文本转写(continuation cloning 必填)",
|
||||
elem_classes="strong-label",
|
||||
)
|
||||
|
||||
with gr.Column(scale=1, min_width=480, elem_classes="synthesis-column"):
|
||||
text = gr.Textbox(
|
||||
label="待合成文本 · Text",
|
||||
lines=5,
|
||||
max_lines=8,
|
||||
value=DEFAULT_INPUT_TEXT,
|
||||
placeholder="输入待合成的文本",
|
||||
elem_classes="strong-label",
|
||||
)
|
||||
with gr.Accordion("⚙️ Settings", open=False, elem_classes="settings-card"):
|
||||
with gr.Row(elem_classes="settings-slider-row"):
|
||||
num_steps = gr.Slider(
|
||||
label="Num Steps",
|
||||
minimum=1,
|
||||
maximum=32,
|
||||
step=1,
|
||||
value=app_config.default_num_steps,
|
||||
)
|
||||
with gr.Row(elem_classes="settings-slider-row"):
|
||||
guidance_scale = gr.Slider(
|
||||
label="Guidance Scale",
|
||||
minimum=1.0,
|
||||
maximum=3.0,
|
||||
step=0.1,
|
||||
value=app_config.default_guidance_scale,
|
||||
)
|
||||
with gr.Row(elem_classes="control-row"):
|
||||
seed = gr.Number(
|
||||
label="Seed",
|
||||
value=DEFAULT_SEED,
|
||||
precision=0,
|
||||
scale=1,
|
||||
min_width=180,
|
||||
)
|
||||
normalize_text = gr.Checkbox(
|
||||
label="Normalize Text",
|
||||
value=False,
|
||||
scale=1,
|
||||
min_width=180,
|
||||
)
|
||||
generate = gr.Button(
|
||||
"Generate",
|
||||
variant="primary",
|
||||
size="lg",
|
||||
elem_classes="generate-button",
|
||||
)
|
||||
audio_out = gr.Audio(
|
||||
label="生成音频 · Output",
|
||||
type="filepath",
|
||||
elem_classes="output-audio",
|
||||
)
|
||||
|
||||
if DEBUG_GRADIO_ENABLED:
|
||||
with gr.Accordion("Debug", open=False):
|
||||
synthesis_mode = gr.Dropdown(
|
||||
label="SynthesisMode",
|
||||
choices=list(GRADIO_SYNTHESIS_MODE_CHOICES),
|
||||
value="tts",
|
||||
info="选择合成模式;界面显示名会自动映射到 runtime 对应模板。",
|
||||
)
|
||||
ode_method = gr.Textbox(
|
||||
label="ODE Method",
|
||||
value=DEFAULT_ODE_METHOD,
|
||||
lines=1,
|
||||
)
|
||||
speaker_scale = gr.Slider(
|
||||
label="Speaker Scale",
|
||||
minimum=0.0,
|
||||
maximum=3.0,
|
||||
step=0.1,
|
||||
value=app_config.default_speaker_scale,
|
||||
info="说话人 x-vector 强度",
|
||||
)
|
||||
metrics = gr.JSON(label="Metrics", value=app_service.metadata())
|
||||
build_startup_config_panel(gr, app_config)
|
||||
else:
|
||||
synthesis_mode = gr.State(value="tts")
|
||||
ode_method = gr.State(value=DEFAULT_ODE_METHOD)
|
||||
speaker_scale = gr.State(value=app_config.default_speaker_scale)
|
||||
metrics = gr.State(value={})
|
||||
|
||||
generate.click(
|
||||
fn=run_synthesis,
|
||||
inputs=[
|
||||
text,
|
||||
synthesis_mode,
|
||||
prompt_audio_path,
|
||||
prompt_text,
|
||||
ode_method,
|
||||
num_steps,
|
||||
guidance_scale,
|
||||
speaker_scale,
|
||||
normalize_text,
|
||||
seed,
|
||||
],
|
||||
outputs=[audio_out, metrics],
|
||||
concurrency_limit=1,
|
||||
)
|
||||
prompt_preset.change(
|
||||
fn=select_prompt_preset,
|
||||
inputs=[prompt_preset],
|
||||
outputs=[prompt_audio_path, prompt_text],
|
||||
concurrency_limit=1,
|
||||
)
|
||||
|
||||
return demo.queue(default_concurrency_limit=1, max_size=8)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
import gradio as gr
|
||||
from loguru import logger
|
||||
|
||||
from apps.gradio.service import GradioAppService, build_gradio_app_config
|
||||
from dots_tts.utils.logging import configure_logging
|
||||
|
||||
configure_logging(log_file=args.log_file)
|
||||
logger.info(
|
||||
"Gradio app starting: host={} port={} model_name_or_path={} output_dir={} "
|
||||
"log_file={} output_retention_count={} max_generate_length={} execution_mode={} precision={} optimize={} "
|
||||
"default_prompt_name={} skip_warmup={}",
|
||||
args.host,
|
||||
args.port,
|
||||
args.model_name_or_path,
|
||||
args.output_dir,
|
||||
args.log_file,
|
||||
args.output_retention_count,
|
||||
args.max_generate_length,
|
||||
args.execution_mode,
|
||||
args.precision,
|
||||
args.optimize,
|
||||
args.default_prompt_name,
|
||||
args.skip_warmup,
|
||||
)
|
||||
app_config = build_gradio_app_config(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
execution_mode=args.execution_mode,
|
||||
precision=args.precision,
|
||||
optimize=args.optimize,
|
||||
model_name_or_path=args.model_name_or_path,
|
||||
output_dir=Path(args.output_dir),
|
||||
output_retention_count=args.output_retention_count,
|
||||
max_generate_length=args.max_generate_length,
|
||||
default_prompt_name=args.default_prompt_name,
|
||||
default_precision=args.default_precision,
|
||||
default_num_steps=args.default_num_steps,
|
||||
default_guidance_scale=args.default_guidance_scale,
|
||||
default_speaker_scale=args.default_speaker_scale,
|
||||
default_max_generate_length=args.default_max_generate_length,
|
||||
)
|
||||
app_service = GradioAppService(app_config)
|
||||
if args.skip_warmup:
|
||||
logger.info("Gradio app warmup skipped by --skip-warmup.")
|
||||
else:
|
||||
warmup_metrics = app_service.warmup()
|
||||
logger.info("Gradio app warmup metrics: {}", warmup_metrics)
|
||||
demo = build_demo(gr, app_config, app_service)
|
||||
logger.info(
|
||||
"Gradio app ready: host={} port={} execution_mode={} precision={} optimize={} default_model_name_or_path={}",
|
||||
app_config.host,
|
||||
app_config.port,
|
||||
app_config.execution_mode,
|
||||
app_config.precision,
|
||||
app_config.optimize,
|
||||
app_config.default_model_name_or_path,
|
||||
)
|
||||
demo.launch(
|
||||
server_name=app_config.host,
|
||||
server_port=app_config.port,
|
||||
theme=build_playground_theme(gr),
|
||||
css=PLAYGROUND_CSS,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 7860
|
||||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "apps" / "gradio" / "outputs"
|
||||
DEFAULT_LOG_FILE = REPO_ROOT / "apps" / "gradio" / "gradio.log"
|
||||
DEFAULT_PROMPTS_DIR = REPO_ROOT / "apps" / "gradio" / "default_prompts"
|
||||
DEFAULT_PROMPT_SOURCE_DIR = DEFAULT_PROMPTS_DIR
|
||||
DEFAULT_PROMPT_MAPPING_FILE = DEFAULT_PROMPTS_DIR / "prompt_text"
|
||||
DEFAULT_OUTPUT_RETENTION = 20
|
||||
DEFAULT_EXECUTION_MODE = "generate_stream"
|
||||
DEFAULT_PRECISION = "bfloat16"
|
||||
DEFAULT_ODE_METHOD = "euler"
|
||||
DEFAULT_NUM_STEPS = 10
|
||||
DEFAULT_GUIDANCE_SCALE = 1.2
|
||||
DEFAULT_SPEAKER_SCALE = 1.5
|
||||
DEFAULT_MAX_GENERATE_LENGTH = 500
|
||||
DEFAULT_SEED = 42
|
||||
DEFAULT_INPUT_TEXT = ""
|
||||
DEFAULT_WARMUP_TEXT = "dots.tts is a 2B-parameter fully continuous, end-to-end autoregressive (AR) text-to-speech system. The backbone pairs a semantic encoder, an LLM, and an autoregressive flow-matching acoustic head over a 48 kHz AudioVAE"
|
||||
DEFAULT_PROMPT_NAME = "male_zh"
|
||||
DEFAULT_PROMPT_NONE = "__none__"
|
||||
PROMPT_AUDIO_SUFFIXES = (".wav", ".mp3", ".flac", ".m4a", ".ogg")
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
SUPPORTED_LANGUAGE_CODE_BY_NAME = {
|
||||
"普通话": "ZH",
|
||||
"粤语": "口音:粤语",
|
||||
"北京话": "口音:北京官话",
|
||||
"东北话": "口音:东北话",
|
||||
"四川话": "口音:四川话",
|
||||
"闽南话": "口音:闽南话",
|
||||
"吴语": "口音:吴语",
|
||||
"英语": "EN",
|
||||
"西班牙语": "ES",
|
||||
"印地语": "HI",
|
||||
"阿拉伯语": "AR",
|
||||
"孟加拉语": "BN",
|
||||
"葡萄牙语": "PT",
|
||||
"俄语": "RU",
|
||||
"日语": "JA",
|
||||
"法语": "FR",
|
||||
"德语": "DE",
|
||||
"韩语": "KO",
|
||||
"意大利语": "IT",
|
||||
"土耳其语": "TR",
|
||||
"越南语": "VI",
|
||||
"印尼语": "ID",
|
||||
"乌尔都语": "UR",
|
||||
"波斯语": "FA",
|
||||
"泰米尔语": "TA",
|
||||
"泰卢固语": "TE",
|
||||
"菲律宾语": "FIL",
|
||||
"马来语": "MS",
|
||||
"旁遮普语": "PA",
|
||||
"马拉地语": "MR",
|
||||
"古吉拉特语": "GU",
|
||||
"马拉雅拉姆语": "ML",
|
||||
"卡纳达语": "KN",
|
||||
"波兰语": "PL",
|
||||
"乌克兰语": "UK",
|
||||
"荷兰语": "NL",
|
||||
"泰语": "TH",
|
||||
"罗马尼亚语": "RO",
|
||||
"斯瓦希里语": "SW",
|
||||
"希伯来语": "HE",
|
||||
"捷克语": "CS",
|
||||
"希腊语": "EL",
|
||||
"匈牙利语": "HU",
|
||||
"瑞典语": "SV",
|
||||
"丹麦语": "DA",
|
||||
"芬兰语": "FI",
|
||||
"书面挪威语": "NB",
|
||||
"斯洛伐克语": "SK",
|
||||
"斯洛文尼亚语": "SL",
|
||||
"塞尔维亚语": "SR",
|
||||
"波斯尼亚语": "BS",
|
||||
"克罗地亚语": "HR",
|
||||
"保加利亚语": "BG",
|
||||
"马其顿语": "MK",
|
||||
"立陶宛语": "LT",
|
||||
"拉脱维亚语": "LV",
|
||||
"爱沙尼亚语": "ET",
|
||||
"冰岛语": "IS",
|
||||
"爱尔兰语": "GA",
|
||||
"威尔士语": "CY",
|
||||
"加泰罗尼亚语": "CA",
|
||||
"加利西亚语": "GL",
|
||||
"奥克语": "OC",
|
||||
"阿斯图里亚斯语": "AST",
|
||||
"尼泊尔语": "NE",
|
||||
"信德语": "SD",
|
||||
"奥里亚语": "OR",
|
||||
"阿萨姆语": "AS",
|
||||
"普什图语": "PS",
|
||||
"缅甸语": "MY",
|
||||
"高棉语": "KM",
|
||||
"老挝语": "LO",
|
||||
"哈萨克语": "KK",
|
||||
"乌兹别克语": "UZ",
|
||||
"吉尔吉斯语": "KY",
|
||||
"塔吉克语": "TG",
|
||||
"阿塞拜疆语": "AZ",
|
||||
"格鲁吉亚语": "KA",
|
||||
"亚美尼亚语": "HY",
|
||||
"白俄罗斯语": "BE",
|
||||
"卢森堡语": "LB",
|
||||
"马耳他语": "MT",
|
||||
"毛利语": "MI",
|
||||
"南非荷兰语": "AF",
|
||||
"祖鲁语": "ZU",
|
||||
"科萨语": "XH",
|
||||
"约鲁巴语": "YO",
|
||||
"豪萨语": "HA",
|
||||
"伊博语": "IG",
|
||||
"阿姆哈拉语": "AM",
|
||||
"奥罗莫语": "OM",
|
||||
"北索托语": "NSO",
|
||||
"尼扬贾语": "NY",
|
||||
"修纳语": "SN",
|
||||
"索马里语": "SO",
|
||||
"卢干达语": "LG",
|
||||
"林加拉语": "LN",
|
||||
"卢奥语": "LUO",
|
||||
"坎巴语": "KAM",
|
||||
"翁本杜语": "UMB",
|
||||
"富拉语": "FF",
|
||||
"沃洛夫语": "WO",
|
||||
"中库尔德语": "CKB",
|
||||
"宿务语": "CEB",
|
||||
"佛得角克里奥尔语": "KEA",
|
||||
"蒙古语": "MN",
|
||||
"爪哇语": "JV",
|
||||
}
|
||||
|
||||
|
||||
def build_language_choice_items() -> list[tuple[str, str]]:
|
||||
return [("不指定", ""), *[(name, code) for name, code in SUPPORTED_LANGUAGE_CODE_BY_NAME.items()]]
|
||||
+773
@@ -0,0 +1,773 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_ROOT = REPO_ROOT / "src"
|
||||
|
||||
for import_root in (REPO_ROOT, SRC_ROOT):
|
||||
import_root_str = str(import_root)
|
||||
if import_root_str not in sys.path:
|
||||
sys.path.insert(0, import_root_str)
|
||||
|
||||
import soundfile as sf # noqa: E402
|
||||
import torch # noqa: E402
|
||||
from loguru import logger # noqa: E402
|
||||
|
||||
from apps.gradio.constants import ( # noqa: E402
|
||||
DEFAULT_EXECUTION_MODE,
|
||||
DEFAULT_GUIDANCE_SCALE,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_MAX_GENERATE_LENGTH,
|
||||
DEFAULT_NUM_STEPS,
|
||||
DEFAULT_ODE_METHOD,
|
||||
DEFAULT_OUTPUT_DIR,
|
||||
DEFAULT_OUTPUT_RETENTION,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PRECISION,
|
||||
DEFAULT_PROMPT_MAPPING_FILE,
|
||||
DEFAULT_PROMPT_NAME,
|
||||
DEFAULT_PROMPT_NONE,
|
||||
DEFAULT_PROMPT_SOURCE_DIR,
|
||||
DEFAULT_PROMPTS_DIR,
|
||||
DEFAULT_SEED,
|
||||
DEFAULT_SPEAKER_SCALE,
|
||||
DEFAULT_WARMUP_TEXT,
|
||||
PROMPT_AUDIO_SUFFIXES,
|
||||
)
|
||||
from apps.gradio.languages import ( # noqa: E402
|
||||
SUPPORTED_LANGUAGE_CODE_BY_NAME,
|
||||
build_language_choice_items,
|
||||
)
|
||||
from dots_tts.runtime import DotsTtsRuntime # noqa: E402
|
||||
from dots_tts.utils.util import seed_everything # noqa: E402
|
||||
|
||||
ExecutionMode = Literal["generate", "generate_stream"]
|
||||
GRADIO_SYNTHESIS_MODE_CHOICES = (
|
||||
("tts", "tts"),
|
||||
("instruct_tts", "instruction_tts"),
|
||||
("instruct_tts_general", "text_to_audio"),
|
||||
)
|
||||
GRADIO_SYNTHESIS_MODE_TEMPLATE_NAMES = tuple(
|
||||
value for _, value in GRADIO_SYNTHESIS_MODE_CHOICES
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptPreset:
|
||||
name: str
|
||||
audio_path: str
|
||||
prompt_text: str
|
||||
|
||||
|
||||
def _is_prompt_asset(path: Path) -> bool:
|
||||
return path.is_file() and (
|
||||
path.name == "prompt_text" or path.suffix.lower() in PROMPT_AUDIO_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def sync_default_prompt_library(
|
||||
source_dir: Path = DEFAULT_PROMPT_SOURCE_DIR,
|
||||
target_dir: Path = DEFAULT_PROMPTS_DIR,
|
||||
) -> None:
|
||||
source_dir = Path(source_dir)
|
||||
if not source_dir.is_dir():
|
||||
logger.info(
|
||||
"Prompt library sync skipped: source_dir={} does not exist.",
|
||||
source_dir,
|
||||
)
|
||||
return
|
||||
|
||||
target_dir = Path(target_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(
|
||||
"Prompt library sync started: source_dir={} target_dir={}",
|
||||
source_dir,
|
||||
target_dir,
|
||||
)
|
||||
|
||||
source_assets = {
|
||||
asset.name: asset for asset in sorted(source_dir.iterdir()) if _is_prompt_asset(asset)
|
||||
}
|
||||
copied_count = 0
|
||||
for asset_name, source_asset in source_assets.items():
|
||||
target_asset = target_dir / asset_name
|
||||
if (
|
||||
not target_asset.exists()
|
||||
or target_asset.stat().st_size != source_asset.stat().st_size
|
||||
or target_asset.stat().st_mtime_ns != source_asset.stat().st_mtime_ns
|
||||
):
|
||||
shutil.copy2(source_asset, target_asset)
|
||||
copied_count += 1
|
||||
|
||||
removed_count = 0
|
||||
for target_asset in sorted(target_dir.iterdir()):
|
||||
if _is_prompt_asset(target_asset) and target_asset.name not in source_assets:
|
||||
target_asset.unlink(missing_ok=True)
|
||||
removed_count += 1
|
||||
logger.info(
|
||||
"Prompt library sync completed: copied_assets={} removed_assets={} "
|
||||
"available_assets={}",
|
||||
copied_count,
|
||||
removed_count,
|
||||
len(source_assets),
|
||||
)
|
||||
|
||||
|
||||
def _load_prompt_text_map(mapping_file: Path) -> dict[str, str]:
|
||||
if not mapping_file.is_file():
|
||||
return {}
|
||||
|
||||
prompt_text_map: dict[str, str] = {}
|
||||
with mapping_file.open(encoding="utf-8") as file_obj:
|
||||
for raw_line in file_obj:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "|" not in line:
|
||||
continue
|
||||
name, text = line.split("|", 1)
|
||||
prompt_text_map[name.strip()] = text.strip()
|
||||
return prompt_text_map
|
||||
|
||||
|
||||
def discover_prompt_presets(
|
||||
prompts_dir: Path = DEFAULT_PROMPTS_DIR,
|
||||
mapping_file: Path = DEFAULT_PROMPT_MAPPING_FILE,
|
||||
) -> tuple[PromptPreset, ...]:
|
||||
prompts_dir = Path(prompts_dir)
|
||||
if not prompts_dir.is_dir():
|
||||
return ()
|
||||
|
||||
prompt_text_map = _load_prompt_text_map(Path(mapping_file))
|
||||
prompt_audio_paths = [
|
||||
audio_path
|
||||
for audio_path in sorted(prompts_dir.iterdir(), key=lambda path: (path.stem == "child", path.stem))
|
||||
if audio_path.is_file() and audio_path.suffix.lower() in PROMPT_AUDIO_SUFFIXES
|
||||
]
|
||||
return tuple(
|
||||
PromptPreset(
|
||||
name=audio_path.stem,
|
||||
audio_path=str(audio_path.resolve()),
|
||||
prompt_text=prompt_text_map.get(audio_path.stem, ""),
|
||||
)
|
||||
for audio_path in prompt_audio_paths
|
||||
)
|
||||
|
||||
|
||||
def build_prompt_choice_items(
|
||||
prompt_presets: tuple[PromptPreset, ...],
|
||||
) -> list[tuple[str, str]]:
|
||||
return [("No Preset", DEFAULT_PROMPT_NONE), *[(preset.name, preset.name) for preset in prompt_presets]]
|
||||
|
||||
|
||||
def resolve_default_prompt_selection(
|
||||
prompt_presets: tuple[PromptPreset, ...],
|
||||
default_prompt_name: str = DEFAULT_PROMPT_NAME,
|
||||
) -> tuple[str, str | None, str]:
|
||||
if not prompt_presets:
|
||||
return DEFAULT_PROMPT_NONE, None, ""
|
||||
|
||||
preset_by_name = {preset.name: preset for preset in prompt_presets}
|
||||
selected_name = default_prompt_name if default_prompt_name in preset_by_name else prompt_presets[0].name
|
||||
selected_preset = preset_by_name[selected_name]
|
||||
return selected_name, selected_preset.audio_path, selected_preset.prompt_text
|
||||
|
||||
|
||||
def resolve_prompt_selection(
|
||||
prompt_name: str,
|
||||
prompt_presets: tuple[PromptPreset, ...],
|
||||
) -> tuple[str | None, str]:
|
||||
if prompt_name == DEFAULT_PROMPT_NONE:
|
||||
return None, ""
|
||||
|
||||
for preset in prompt_presets:
|
||||
if preset.name == prompt_name:
|
||||
return preset.audio_path, preset.prompt_text
|
||||
return None, ""
|
||||
|
||||
|
||||
def discover_local_model_choices(repo_root: Path = REPO_ROOT) -> list[str]:
|
||||
model_root = Path(repo_root) / "pretrained_models"
|
||||
if not model_root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path.relative_to(repo_root).as_posix()
|
||||
for path in model_root.glob("**/model")
|
||||
if path.is_dir()
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_name_or_path(model_name_or_path: str, repo_root: Path = REPO_ROOT) -> str:
|
||||
normalized = model_name_or_path.strip()
|
||||
if not normalized:
|
||||
raise ValueError("model_name_or_path 不能为空。")
|
||||
|
||||
direct_path = Path(normalized).expanduser()
|
||||
if direct_path.exists():
|
||||
return str(direct_path.resolve())
|
||||
|
||||
repo_relative_path = Path(repo_root) / normalized
|
||||
if repo_relative_path.exists():
|
||||
return str(repo_relative_path.resolve())
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def default_model_name_or_path(repo_root: Path = REPO_ROOT) -> str:
|
||||
discovered = discover_local_model_choices(repo_root=repo_root)
|
||||
if not discovered:
|
||||
return ""
|
||||
return discovered[0]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GradioAppConfig:
|
||||
host: str
|
||||
port: int
|
||||
execution_mode: ExecutionMode
|
||||
precision: str
|
||||
optimize: bool
|
||||
output_dir: Path
|
||||
prompts_dir: Path
|
||||
output_retention_count: int
|
||||
max_generate_length: int
|
||||
default_model_name_or_path: str
|
||||
prompt_presets: tuple[PromptPreset, ...]
|
||||
default_prompt_name: str
|
||||
default_prompt_audio_path: str | None
|
||||
default_prompt_text: str
|
||||
default_precision: str
|
||||
default_num_steps: int
|
||||
default_guidance_scale: float
|
||||
default_speaker_scale: float
|
||||
default_max_generate_length: int
|
||||
local_model_choices: tuple[str, ...]
|
||||
repo_root: Path = REPO_ROOT
|
||||
|
||||
|
||||
def build_gradio_app_config(
|
||||
*,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
execution_mode: ExecutionMode = DEFAULT_EXECUTION_MODE,
|
||||
precision: str = DEFAULT_PRECISION,
|
||||
optimize: bool = False,
|
||||
output_dir: Path = DEFAULT_OUTPUT_DIR,
|
||||
output_retention_count: int = DEFAULT_OUTPUT_RETENTION,
|
||||
max_generate_length: int = DEFAULT_MAX_GENERATE_LENGTH,
|
||||
model_name_or_path: str | None = None,
|
||||
default_prompt_name: str = DEFAULT_PROMPT_NAME,
|
||||
default_precision: str = DEFAULT_PRECISION,
|
||||
default_num_steps: int = DEFAULT_NUM_STEPS,
|
||||
default_guidance_scale: float = DEFAULT_GUIDANCE_SCALE,
|
||||
default_speaker_scale: float = DEFAULT_SPEAKER_SCALE,
|
||||
default_max_generate_length: int = DEFAULT_MAX_GENERATE_LENGTH,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
prompts_dir: Path = DEFAULT_PROMPTS_DIR,
|
||||
prompt_source_dir: Path = DEFAULT_PROMPT_SOURCE_DIR,
|
||||
) -> GradioAppConfig:
|
||||
sync_default_prompt_library(
|
||||
source_dir=prompt_source_dir,
|
||||
target_dir=prompts_dir,
|
||||
)
|
||||
discovered_models = discover_local_model_choices(repo_root=repo_root)
|
||||
prompt_presets = discover_prompt_presets(
|
||||
prompts_dir=prompts_dir,
|
||||
mapping_file=prompts_dir / "prompt_text",
|
||||
)
|
||||
resolved_default_prompt_name, default_prompt_audio_path, default_prompt_text = (
|
||||
resolve_default_prompt_selection(
|
||||
prompt_presets,
|
||||
default_prompt_name=default_prompt_name,
|
||||
)
|
||||
)
|
||||
selected_model_name_or_path = (
|
||||
model_name_or_path.strip()
|
||||
if model_name_or_path is not None
|
||||
else default_model_name_or_path(repo_root=repo_root)
|
||||
)
|
||||
if not selected_model_name_or_path:
|
||||
raise ValueError("No default model found. Please pass --model-name-or-path.")
|
||||
if execution_mode not in ("generate", "generate_stream"):
|
||||
raise ValueError(f"Unsupported execution_mode: {execution_mode}")
|
||||
resolved_max_generate_length = int(max_generate_length)
|
||||
if resolved_max_generate_length <= 0:
|
||||
raise ValueError("max_generate_length must be positive.")
|
||||
resolved_precision = precision.strip() or DEFAULT_PRECISION
|
||||
logger.info(
|
||||
"Gradio app config prepared: host={} port={} output_dir={} "
|
||||
"output_retention_count={} max_generate_length={} execution_mode={} precision={} optimize={} "
|
||||
"default_model_name_or_path={} prompt_preset_count={} language_count={} local_model_choice_count={}",
|
||||
host,
|
||||
port,
|
||||
output_dir,
|
||||
output_retention_count,
|
||||
resolved_max_generate_length,
|
||||
execution_mode,
|
||||
resolved_precision,
|
||||
bool(optimize),
|
||||
selected_model_name_or_path,
|
||||
len(prompt_presets),
|
||||
len(SUPPORTED_LANGUAGE_CODE_BY_NAME),
|
||||
len(discovered_models),
|
||||
)
|
||||
return GradioAppConfig(
|
||||
host=host,
|
||||
port=int(port),
|
||||
execution_mode=execution_mode,
|
||||
precision=resolved_precision,
|
||||
optimize=bool(optimize),
|
||||
output_dir=Path(output_dir),
|
||||
prompts_dir=Path(prompts_dir),
|
||||
output_retention_count=int(output_retention_count),
|
||||
max_generate_length=resolved_max_generate_length,
|
||||
default_model_name_or_path=selected_model_name_or_path,
|
||||
prompt_presets=prompt_presets,
|
||||
default_prompt_name=resolved_default_prompt_name,
|
||||
default_prompt_audio_path=default_prompt_audio_path,
|
||||
default_prompt_text=default_prompt_text,
|
||||
default_precision=default_precision,
|
||||
default_num_steps=int(default_num_steps),
|
||||
default_guidance_scale=float(default_guidance_scale),
|
||||
default_speaker_scale=float(default_speaker_scale),
|
||||
default_max_generate_length=int(default_max_generate_length),
|
||||
local_model_choices=tuple(discovered_models),
|
||||
repo_root=repo_root,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisRequest:
|
||||
model_name_or_path: str
|
||||
text: str
|
||||
prompt_audio_path: str | None = None
|
||||
prompt_text: str | None = None
|
||||
execution_mode: ExecutionMode = DEFAULT_EXECUTION_MODE
|
||||
template_name: str = "tts"
|
||||
language: str | None = None
|
||||
ode_method: str = DEFAULT_ODE_METHOD
|
||||
num_steps: int = DEFAULT_NUM_STEPS
|
||||
guidance_scale: float = DEFAULT_GUIDANCE_SCALE
|
||||
speaker_scale: float = DEFAULT_SPEAKER_SCALE
|
||||
normalize_text: bool = False
|
||||
seed: int = DEFAULT_SEED
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisResult:
|
||||
audio_path: str
|
||||
metrics: dict[str, Any]
|
||||
status: str
|
||||
|
||||
|
||||
class GradioAppService:
|
||||
def __init__(self, config: GradioAppConfig):
|
||||
self.config = config
|
||||
self.config.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._runtime: DotsTtsRuntime | None = None
|
||||
self._runtime_model_name_or_path: str | None = None
|
||||
logger.info(
|
||||
"Gradio service initialized: output_dir={} default_model_name_or_path={} "
|
||||
"output_retention_count={} max_generate_length={} execution_mode={} precision={} optimize={}",
|
||||
self.config.output_dir,
|
||||
self.config.default_model_name_or_path,
|
||||
self.config.output_retention_count,
|
||||
self.config.max_generate_length,
|
||||
self.config.execution_mode,
|
||||
self.config.precision,
|
||||
self.config.optimize,
|
||||
)
|
||||
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
return {
|
||||
"repo_root": str(self.config.repo_root),
|
||||
"default_model_name_or_path": self.config.default_model_name_or_path,
|
||||
"local_model_choices": list(self.config.local_model_choices),
|
||||
"prompts_dir": str(self.config.prompts_dir),
|
||||
"prompt_preset_names": [preset.name for preset in self.config.prompt_presets],
|
||||
"default_prompt_name": self.config.default_prompt_name,
|
||||
"output_dir": str(self.config.output_dir),
|
||||
"output_retention_count": self.config.output_retention_count,
|
||||
"configured_max_generate_length": self.config.max_generate_length,
|
||||
"configured_execution_mode": self.config.execution_mode,
|
||||
"configured_precision": self.config.precision,
|
||||
"optimize": self.config.optimize,
|
||||
"loaded_model_name_or_path": self._runtime_model_name_or_path,
|
||||
"loaded_max_generate_length": (
|
||||
self.config.max_generate_length if self._runtime is not None else None
|
||||
),
|
||||
"loaded_precision": (
|
||||
self.config.precision if self._runtime is not None else None
|
||||
),
|
||||
"model_loaded": self._runtime is not None,
|
||||
"host": self.config.host,
|
||||
"port": self.config.port,
|
||||
"default_precision": self.config.default_precision,
|
||||
"default_num_steps": self.config.default_num_steps,
|
||||
"default_guidance_scale": self.config.default_guidance_scale,
|
||||
"default_speaker_scale": self.config.default_speaker_scale,
|
||||
"default_max_generate_length": self.config.default_max_generate_length,
|
||||
"supported_languages": build_language_choice_items()[1:],
|
||||
"supported_template_names": list(GRADIO_SYNTHESIS_MODE_TEMPLATE_NAMES),
|
||||
}
|
||||
|
||||
def _get_runtime(
|
||||
self,
|
||||
model_name_or_path: str,
|
||||
) -> tuple[DotsTtsRuntime, str]:
|
||||
resolved_model_name_or_path = resolve_model_name_or_path(
|
||||
model_name_or_path,
|
||||
repo_root=self.config.repo_root,
|
||||
)
|
||||
if (
|
||||
self._runtime is None
|
||||
or self._runtime_model_name_or_path != resolved_model_name_or_path
|
||||
):
|
||||
logger.info(
|
||||
"Gradio runtime cache miss: requested_model={} resolved_model={} "
|
||||
"max_generate_length={} execution_mode={} precision={} optimize={}",
|
||||
model_name_or_path,
|
||||
resolved_model_name_or_path,
|
||||
self.config.max_generate_length,
|
||||
self.config.execution_mode,
|
||||
self.config.precision,
|
||||
self.config.optimize,
|
||||
)
|
||||
self._runtime = DotsTtsRuntime.from_pretrained(
|
||||
resolved_model_name_or_path,
|
||||
precision=self.config.precision,
|
||||
optimize=self.config.optimize,
|
||||
max_generate_length=self.config.max_generate_length,
|
||||
)
|
||||
self._runtime_model_name_or_path = resolved_model_name_or_path
|
||||
else:
|
||||
logger.info(
|
||||
"Gradio runtime cache hit: requested_model={} resolved_model={} "
|
||||
"max_generate_length={} execution_mode={} precision={} optimize={}",
|
||||
model_name_or_path,
|
||||
resolved_model_name_or_path,
|
||||
self.config.max_generate_length,
|
||||
self.config.execution_mode,
|
||||
self.config.precision,
|
||||
self.config.optimize,
|
||||
)
|
||||
return self._runtime, resolved_model_name_or_path
|
||||
|
||||
def _build_stream_request_id(
|
||||
self,
|
||||
runtime: DotsTtsRuntime,
|
||||
request: SynthesisRequest,
|
||||
) -> str:
|
||||
normalized_text, normalized_language = runtime._process_text( # noqa: SLF001
|
||||
request.text,
|
||||
language=request.language,
|
||||
normalize=request.normalize_text,
|
||||
)
|
||||
normalized_prompt_text = runtime._process_prompt_text( # noqa: SLF001
|
||||
request.prompt_text,
|
||||
language=normalized_language,
|
||||
)
|
||||
if normalized_language is not None and not normalized_prompt_text:
|
||||
from dots_tts.utils.text import attach_language_tag # noqa: PLC0415
|
||||
|
||||
normalized_text = attach_language_tag(
|
||||
normalized_text,
|
||||
normalized_language,
|
||||
)
|
||||
request_id_kwargs = {
|
||||
"text": normalized_text,
|
||||
"prompt_audio_path": request.prompt_audio_path,
|
||||
"prompt_text": normalized_prompt_text,
|
||||
"template_name": request.template_name,
|
||||
}
|
||||
if normalized_language is not None:
|
||||
request_id_kwargs["language"] = normalized_language
|
||||
return runtime._build_request_id( # noqa: SLF001
|
||||
**request_id_kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_runtime_generate_kwargs(request: SynthesisRequest) -> dict[str, Any]:
|
||||
runtime_kwargs: dict[str, Any] = {
|
||||
"text": request.text,
|
||||
"prompt_audio_path": request.prompt_audio_path,
|
||||
"prompt_text": request.prompt_text,
|
||||
"template_name": request.template_name,
|
||||
"ode_method": request.ode_method,
|
||||
"num_steps": request.num_steps,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"speaker_scale": request.speaker_scale,
|
||||
"normalize_text": request.normalize_text,
|
||||
}
|
||||
if request.language is not None:
|
||||
runtime_kwargs["language"] = request.language
|
||||
return runtime_kwargs
|
||||
|
||||
def _run_stream_generation(
|
||||
self,
|
||||
runtime: DotsTtsRuntime,
|
||||
request: SynthesisRequest,
|
||||
) -> dict[str, Any]:
|
||||
start_time = time.time()
|
||||
chunks = [
|
||||
chunk.detach().float().cpu()
|
||||
for chunk in runtime.generate_stream(
|
||||
**self._build_runtime_generate_kwargs(request)
|
||||
)
|
||||
]
|
||||
if not chunks:
|
||||
raise ValueError("流式生成未返回任何音频块。")
|
||||
|
||||
audio = torch.cat(chunks, dim=-1)
|
||||
elapsed_seconds = time.time() - start_time
|
||||
audio_seconds = audio.shape[-1] / runtime.sample_rate
|
||||
rtf = elapsed_seconds / audio_seconds if audio_seconds > 0 else float("inf")
|
||||
return {
|
||||
"fid": self._build_stream_request_id(runtime, request),
|
||||
"audio": audio,
|
||||
"sample_rate": runtime.sample_rate,
|
||||
"time_used": elapsed_seconds,
|
||||
"rtf": rtf,
|
||||
"chunk_count": len(chunks),
|
||||
}
|
||||
|
||||
def warmup(self, text: str | None = None) -> dict[str, Any]:
|
||||
warmup_text = (text or "").strip() or DEFAULT_WARMUP_TEXT.strip()
|
||||
if not warmup_text:
|
||||
raise ValueError("DEFAULT_WARMUP_TEXT 不能为空。")
|
||||
|
||||
with self._lock:
|
||||
logger.info(
|
||||
"Gradio warmup requested: default_model_name_or_path={} execution_mode={} precision={} optimize={} seed={}",
|
||||
self.config.default_model_name_or_path,
|
||||
self.config.execution_mode,
|
||||
self.config.precision,
|
||||
self.config.optimize,
|
||||
DEFAULT_SEED,
|
||||
)
|
||||
try:
|
||||
seed_everything(DEFAULT_SEED)
|
||||
runtime, resolved_model_name_or_path = self._get_runtime(
|
||||
self.config.default_model_name_or_path,
|
||||
)
|
||||
warmup_request = SynthesisRequest(
|
||||
model_name_or_path=self.config.default_model_name_or_path,
|
||||
text=warmup_text,
|
||||
execution_mode=self.config.execution_mode,
|
||||
template_name="tts",
|
||||
ode_method=DEFAULT_ODE_METHOD,
|
||||
num_steps=self.config.default_num_steps,
|
||||
guidance_scale=self.config.default_guidance_scale,
|
||||
speaker_scale=self.config.default_speaker_scale,
|
||||
normalize_text=False,
|
||||
seed=DEFAULT_SEED,
|
||||
)
|
||||
request_id = self._build_stream_request_id(runtime, warmup_request)
|
||||
if self.config.execution_mode == "generate_stream":
|
||||
result = self._run_stream_generation(runtime, warmup_request)
|
||||
else:
|
||||
start_time = time.time()
|
||||
result = runtime.generate(**self._build_runtime_generate_kwargs(warmup_request))
|
||||
result["time_used"] = time.time() - start_time
|
||||
result["chunk_count"] = 1
|
||||
audio_samples = int(result["audio"].shape[-1])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Gradio warmup failed: default_model_name_or_path={}",
|
||||
self.config.default_model_name_or_path,
|
||||
)
|
||||
raise
|
||||
audio_seconds = audio_samples / runtime.sample_rate
|
||||
metrics = {
|
||||
"request_id": request_id,
|
||||
"execution_mode": self.config.execution_mode,
|
||||
"chunk_count": int(result["chunk_count"]),
|
||||
"resolved_model_name_or_path": resolved_model_name_or_path,
|
||||
"sample_rate": runtime.sample_rate,
|
||||
"elapsed_seconds": round(float(result["time_used"]), 3),
|
||||
"audio_seconds": round(float(audio_seconds), 3),
|
||||
"rtf": round(float(result["rtf"]), 4),
|
||||
"seed": DEFAULT_SEED,
|
||||
"text": warmup_text,
|
||||
}
|
||||
logger.info(
|
||||
"Gradio warmup ready: request_id={} execution_mode={} resolved_model_name_or_path={}",
|
||||
metrics["request_id"],
|
||||
metrics["execution_mode"],
|
||||
metrics["resolved_model_name_or_path"],
|
||||
)
|
||||
return metrics
|
||||
|
||||
def _normalize_request(self, request: SynthesisRequest) -> SynthesisRequest:
|
||||
normalized_text = request.text.strip()
|
||||
if not normalized_text:
|
||||
raise ValueError("text 不能为空。")
|
||||
|
||||
normalized_prompt_audio_path = request.prompt_audio_path or None
|
||||
normalized_prompt_text = (request.prompt_text or "").strip() or None
|
||||
if normalized_prompt_text and not normalized_prompt_audio_path:
|
||||
raise ValueError("prompt_text requires prompt_audio_path.")
|
||||
normalized_template_name = request.template_name.strip() or "tts"
|
||||
if normalized_template_name not in GRADIO_SYNTHESIS_MODE_TEMPLATE_NAMES:
|
||||
raise ValueError(
|
||||
f"Unsupported template_name={normalized_template_name!r}. "
|
||||
f"Expected one of {list(GRADIO_SYNTHESIS_MODE_TEMPLATE_NAMES)}."
|
||||
)
|
||||
normalized_language = (request.language or "").strip() or None
|
||||
supported_language_codes = set(SUPPORTED_LANGUAGE_CODE_BY_NAME.values())
|
||||
if (
|
||||
normalized_language is not None
|
||||
and normalized_language not in supported_language_codes
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported language={normalized_language!r}. "
|
||||
f"Expected one of {sorted(supported_language_codes)}."
|
||||
)
|
||||
|
||||
resolved_seed = int(request.seed)
|
||||
return SynthesisRequest(
|
||||
model_name_or_path=request.model_name_or_path.strip(),
|
||||
text=normalized_text,
|
||||
prompt_audio_path=normalized_prompt_audio_path,
|
||||
prompt_text=normalized_prompt_text,
|
||||
execution_mode=request.execution_mode,
|
||||
template_name=normalized_template_name,
|
||||
language=normalized_language,
|
||||
ode_method=request.ode_method.strip() or DEFAULT_ODE_METHOD,
|
||||
num_steps=int(request.num_steps),
|
||||
guidance_scale=float(request.guidance_scale),
|
||||
speaker_scale=float(request.speaker_scale),
|
||||
normalize_text=bool(request.normalize_text),
|
||||
seed=resolved_seed,
|
||||
)
|
||||
|
||||
def _build_output_path(self) -> Path:
|
||||
output_name = f"{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}.wav"
|
||||
return self.config.output_dir / output_name
|
||||
|
||||
def _cleanup_outputs(self) -> None:
|
||||
if self.config.output_retention_count <= 0:
|
||||
return
|
||||
|
||||
wav_files = sorted(
|
||||
self.config.output_dir.glob("*.wav"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
removed_count = 0
|
||||
for stale_file in wav_files[self.config.output_retention_count :]:
|
||||
stale_file.unlink(missing_ok=True)
|
||||
removed_count += 1
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
"Gradio output cleanup completed: removed_files={} retention_limit={}",
|
||||
removed_count,
|
||||
self.config.output_retention_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _waveform_to_numpy(audio: torch.Tensor):
|
||||
waveform = audio.detach().float().cpu().squeeze()
|
||||
if waveform.ndim == 0:
|
||||
raise ValueError("生成音频为空。")
|
||||
return waveform.numpy()
|
||||
|
||||
def _write_audio(self, audio: torch.Tensor, sample_rate: int) -> str:
|
||||
output_path = self._build_output_path()
|
||||
logger.info(
|
||||
"Writing synthesized audio: output_path={} sample_rate={} samples={}",
|
||||
output_path,
|
||||
sample_rate,
|
||||
audio.shape[-1],
|
||||
)
|
||||
sf.write(output_path, self._waveform_to_numpy(audio), sample_rate)
|
||||
self._cleanup_outputs()
|
||||
logger.info("Synthesized audio written: output_path={}", output_path)
|
||||
return str(output_path)
|
||||
|
||||
def generate(self, request: SynthesisRequest) -> SynthesisResult:
|
||||
normalized_request = self._normalize_request(request)
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
seed_everything(normalized_request.seed)
|
||||
runtime, resolved_model_name_or_path = self._get_runtime(
|
||||
normalized_request.model_name_or_path,
|
||||
)
|
||||
logger.info(
|
||||
"Gradio request accepted: resolved_model_name_or_path={} execution_mode={} seed={}",
|
||||
resolved_model_name_or_path,
|
||||
normalized_request.execution_mode,
|
||||
normalized_request.seed,
|
||||
)
|
||||
if normalized_request.execution_mode == "generate_stream":
|
||||
result = self._run_stream_generation(runtime, normalized_request)
|
||||
else:
|
||||
result = runtime.generate(
|
||||
**self._build_runtime_generate_kwargs(normalized_request)
|
||||
)
|
||||
result["chunk_count"] = 1
|
||||
audio_path = self._write_audio(result["audio"], result["sample_rate"])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Gradio request failed: model_name_or_path={} execution_mode={} text_len={} has_prompt_audio={} has_prompt_text={} template_name={} language={} "
|
||||
"precision={} ode_method={} num_steps={} guidance_scale={} speaker_scale={} max_generate_length={} "
|
||||
"normalize_text={} seed={}",
|
||||
normalized_request.model_name_or_path,
|
||||
normalized_request.execution_mode,
|
||||
len(normalized_request.text),
|
||||
bool(normalized_request.prompt_audio_path),
|
||||
bool(normalized_request.prompt_text),
|
||||
normalized_request.template_name,
|
||||
normalized_request.language,
|
||||
self.config.precision,
|
||||
normalized_request.ode_method,
|
||||
normalized_request.num_steps,
|
||||
normalized_request.guidance_scale,
|
||||
normalized_request.speaker_scale,
|
||||
self.config.max_generate_length,
|
||||
normalized_request.normalize_text,
|
||||
normalized_request.seed,
|
||||
)
|
||||
raise
|
||||
audio_seconds = result["audio"].shape[-1] / result["sample_rate"]
|
||||
metrics = {
|
||||
"request_id": result["fid"],
|
||||
"execution_mode": normalized_request.execution_mode,
|
||||
"chunk_count": int(result["chunk_count"]),
|
||||
"template_name": normalized_request.template_name,
|
||||
"language": normalized_request.language,
|
||||
"resolved_model_name_or_path": resolved_model_name_or_path,
|
||||
"sample_rate": result["sample_rate"],
|
||||
"elapsed_seconds": round(float(result["time_used"]), 3),
|
||||
"audio_seconds": round(float(audio_seconds), 3),
|
||||
"rtf": round(float(result["rtf"]), 4),
|
||||
"seed": normalized_request.seed,
|
||||
"output_path": audio_path,
|
||||
}
|
||||
logger.info(
|
||||
"Gradio request output ready: request_id={} execution_mode={} resolved_model_name_or_path={} output_path={}",
|
||||
metrics["request_id"],
|
||||
metrics["execution_mode"],
|
||||
metrics["resolved_model_name_or_path"],
|
||||
metrics["output_path"],
|
||||
)
|
||||
status = (
|
||||
f"完成:{Path(audio_path).name} | "
|
||||
f"模式 {metrics['execution_mode']} | "
|
||||
f"耗时 {metrics['elapsed_seconds']}s | "
|
||||
f"音频 {metrics['audio_seconds']}s | "
|
||||
f"RTF {metrics['rtf']}"
|
||||
)
|
||||
return SynthesisResult(
|
||||
audio_path=audio_path,
|
||||
metrics=metrics,
|
||||
status=status,
|
||||
)
|
||||
Reference in New Issue
Block a user