Files
Trading_Studio/app.py
T
dekun 4255cf7cd7 Fix Gradio 4.x Audio compatibility on server
Only pass show_download_button and show_share_button when the installed Gradio Audio component supports them, fixing PM2 startup TypeError.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:40:19 +08:00

1364 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Trading Studio — 自动化交易复盘视频配音系统
Gradio Web 中控:音色锁定 → Whisper 识别 → Gemma4 润色 → ChatTTS 合成
"""
from __future__ import annotations
import inspect
import logging
import re
import shutil
import sys
import uuid
from pathlib import Path
import gradio as gr
from config import (
GIT_REPO_URL,
HOST,
MODEL_NAME,
OLLAMA_URL,
PORT,
SPEAKER_EMB_PATH,
UPLOAD_DIR,
)
from llm_service import check_ollama_health, polish_text
from tts_service import generate_voice, save_fixed_speaker, speaker_is_ready
from voice_history import list_voice_history
from voice_presets import default_voice_label, label_to_voice_id, voice_choice_labels
from whisper_service import transcribe_audio
# ---------------------------------------------------------------------------
# 日志
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("trading_studio.log", encoding="utf-8"),
],
)
logger = logging.getLogger("trading_studio")
# ---------------------------------------------------------------------------
# 配音历史
# ---------------------------------------------------------------------------
def ui_history_dropdown(select_path: str | None = None) -> dict:
"""刷新历史下拉列表;可选选中指定路径(合成完成后传入新文件)。"""
choices = list_voice_history()
paths = [p for _, p in choices]
if select_path and select_path in paths:
value = select_path
elif paths:
value = paths[0]
else:
value = None
return gr.update(choices=choices, value=value)
def ui_history_play(filepath: str | None) -> dict:
"""选中历史条目后加载播放器。"""
if filepath and Path(filepath).is_file():
return gr.update(value=filepath)
return gr.update(value=None)
def ui_initial_history() -> tuple[dict, dict]:
"""首屏加载历史列表并自动选中最新一条。"""
choices = list_voice_history()
paths = [p for _, p in choices]
latest = paths[0] if paths else None
return gr.update(choices=choices, value=latest), ui_history_play(latest)
def _tts_output_audio(label: str) -> gr.Audio:
"""成品播放器:兼容 Gradio 4.x(无 show_download_button 等参数)。"""
kwargs: dict = {
"label": label,
"type": "filepath",
"interactive": False,
"elem_classes": ["tts-output-audio"],
}
params = inspect.signature(gr.Audio.__init__).parameters
if "show_download_button" in params:
kwargs["show_download_button"] = True
if "show_share_button" in params:
kwargs["show_share_button"] = False
return gr.Audio(**kwargs)
# ---------------------------------------------------------------------------
# 全局 UI 状态(Gradio State
# ---------------------------------------------------------------------------
# raw_transcript / polished_script 在流水线中传递
def _save_upload(upload_file) -> str | None:
"""将 Gradio 上传文件复制到本地 uploads 目录,返回持久化路径。"""
if upload_file is None:
return None
src = Path(upload_file)
if not src.exists():
return None
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}_{src.name}"
shutil.copy2(src, dest)
return str(dest)
# ---------------------------------------------------------------------------
# 模块 1:音色锁定
# ---------------------------------------------------------------------------
def ui_lock_speaker(audio_file, sample_transcript: str) -> tuple[str, str]:
"""【音色锁定】从参考人声提取并保存 Speaker Embedding。"""
path = _save_upload(audio_file)
if not path:
return "请上传 10-30 秒干净参考人声(wav/mp3 均可)。", ui_speaker_status_html()
ok, msg = save_fixed_speaker(path, sample_transcript or "")
result = msg if ok else f"{msg}"
return result, ui_speaker_status_html()
def ui_speaker_status() -> str:
"""刷新音色状态(纯文本,供日志框使用)。"""
ok, msg = speaker_is_ready()
return f"{msg}" if ok else f"⚠️ {msg}"
# ---------------------------------------------------------------------------
# 模块 2:音频极速识别
# ---------------------------------------------------------------------------
def ui_transcribe(audio_file) -> tuple[str, str]:
"""【Whisper 识别】返回 (转写文本, 状态日志)。"""
path = _save_upload(audio_file)
if not path:
return "", "请上传待识别的碎碎念录音。"
ok, result = transcribe_audio(path)
if ok:
return result, f"✅ 识别完成,共 {len(result)} 字。"
return "", f"{result}"
# ---------------------------------------------------------------------------
# 模块 3Gemma4 纪律审判
# ---------------------------------------------------------------------------
def ui_polish(raw_text: str) -> tuple[str, str]:
"""【LLM 润色】对转写稿进行严厉自我反思式润色。"""
if not raw_text or not raw_text.strip():
return "", "请先完成语音识别或手动粘贴转写文本。"
ok, result = polish_text(raw_text)
if ok:
return (
result,
f"✅ Gemma4 润色完成,共 {len(result)} 字。请向下滚动到 Step 3 选择音色并合成。",
)
return "", f"{result}"
def ui_check_ollama() -> str:
"""检测远程 Ollama 节点状态。"""
ok, msg = check_ollama_health()
return f"{msg}" if ok else f"{msg}"
# ---------------------------------------------------------------------------
# 模块 4ChatTTS 音频合成
# ---------------------------------------------------------------------------
def _short_synth_log(msg: str, ok: bool) -> str:
"""合成日志简短显示,避免长路径触发大面积重绘闪屏。"""
if not ok:
return f"{msg}"
chars = re.search(r"朗读\s*(\d+)\s*字", msg)
segs = re.search(r"\s*(\d+)\s*段", msg)
if chars:
seg_note = f"{segs.group(1)} 段拼接" if segs else ""
return f"✅ 配音完成({chars.group(1)}{seg_note})。请用下方播放器试听、下载。"
return "✅ 配音完成。请用下方播放器试听、下载。"
def ui_synth_pending(polished_text: str) -> str:
"""点击合成后立即更新日志;不触碰播放器,避免波形组件销毁重建导致闪屏。"""
text = (polished_text or "").strip()
if not text:
return "请先完成 Gemma4 润色。"
est_sec = max(20, len(text) // 10)
return (
f"⏳ 配音合成中(约 {len(text)} 字,预计 {est_sec}{est_sec + 45} 秒),请勿重复点击或刷新页面…"
)
def ui_synthesize(polished_text: str, voice_label: str) -> tuple[str, dict, dict, dict]:
"""【TTS 合成】生成最终 wav 配音文件。"""
if not polished_text or not polished_text.strip():
return (
"请先完成 Gemma4 润色。",
gr.update(value=None),
ui_history_dropdown(),
gr.update(value=None),
)
voice_id = label_to_voice_id(voice_label)
ok, msg, wav_path = generate_voice(polished_text, voice_id=voice_id)
if ok:
return (
_short_synth_log(msg, ok),
gr.update(value=wav_path),
ui_history_dropdown(wav_path),
gr.update(value=wav_path),
)
return (
_short_synth_log(msg, ok),
gr.update(value=None),
ui_history_dropdown(),
gr.update(value=None),
)
# ---------------------------------------------------------------------------
# 一键流水线
# ---------------------------------------------------------------------------
def ui_full_pipeline(
audio_file,
skip_polish: bool,
manual_raw: str,
voice_label: str,
) -> tuple[str, str, dict, str, dict, dict]:
"""
串联执行:识别 → 润色(可跳过)→ 合成。
返回 (raw, polished, wav_path, log)
"""
logs: list[str] = []
# Step 1: 识别
if manual_raw and manual_raw.strip():
raw = manual_raw.strip()
logs.append(f"使用手动输入转写稿({len(raw)} 字)。")
else:
path = _save_upload(audio_file)
if not path:
return "", "", gr.update(value=None), "❌ 请上传录音或手动填写转写文本。", ui_history_dropdown(), gr.update(value=None)
ok, result = transcribe_audio(path)
if not ok:
return "", "", gr.update(value=None), f"❌ 识别失败: {result}", ui_history_dropdown(), gr.update(value=None)
raw = result
logs.append(f"✅ Whisper 识别完成({len(raw)} 字)。")
# Step 2: 润色
if skip_polish:
polished = raw
logs.append("已跳过 LLM 润色,直接使用原文。")
else:
ok, result = polish_text(raw)
if not ok:
return raw, "", gr.update(value=None), f"❌ 润色失败: {result}\n" + "\n".join(logs), ui_history_dropdown(), gr.update(value=None)
polished = result
logs.append(f"✅ Gemma4 润色完成({len(polished)} 字)。")
# Step 3: 合成
voice_id = label_to_voice_id(voice_label)
ok, msg, wav_path = generate_voice(polished, voice_id=voice_id)
if not ok:
return raw, polished, gr.update(value=None), f"❌ 合成失败: {msg}\n" + "\n".join(logs), ui_history_dropdown(), gr.update(value=None)
logs.append(f"{msg}")
return (
raw,
polished,
gr.update(value=wav_path),
"\n".join(logs),
ui_history_dropdown(wav_path),
gr.update(value=wav_path),
)
# ---------------------------------------------------------------------------
# Gradio 界面
# ---------------------------------------------------------------------------
APP_ROOT = Path(__file__).resolve().parent
PWA_DIR = APP_ROOT / "pwa"
PWA_HEAD = """
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Trading Studio">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/pwa/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/pwa/icons/icon.svg">
<script>
(function () {
var deferredPrompt = null;
if ("serviceWorker" in navigator) {
function registerSW() {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {});
}
if ("requestIdleCallback" in window) {
requestIdleCallback(registerSW, { timeout: 5000 });
} else {
setTimeout(registerSW, 3000);
}
}
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
function closeModal() {
var m = document.getElementById("pwa-install-modal");
if (m) m.remove();
}
function showModal(html) {
closeModal();
var overlay = document.createElement("div");
overlay.id = "pwa-install-modal";
overlay.className = "pwa-modal-overlay";
overlay.innerHTML = '<div class="pwa-modal">' + html + '</div>';
overlay.addEventListener("click", function (e) {
if (e.target === overlay || e.target.classList.contains("pwa-modal-close")) closeModal();
});
document.body.appendChild(overlay);
}
function isSecure() {
return location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1";
}
function manualInstallGuide() {
var httpWarn = "";
if (!isSecure()) {
httpWarn = "<div class='pwa-modal-warn'><strong>⚠️ 当前为 HTTP 访问</strong><br>浏览器只能创建<strong>快捷方式</strong>,无法弹出系统级「安装 App」。<br>请通过 <strong>HTTPS 域名</strong>(云服务器反代 + NPS 穿透)访问,详见 <strong>PWA_NPS.md</strong>。</div>";
}
var steps = isIOS()
? "<ol><li>点击 Safari 底部分享按钮 <strong>□↑</strong></li><li>选择 <strong>「添加到主屏幕」</strong></li><li>点击 <strong>添加</strong></li></ol>"
: (isSecure()
? "<ol><li>Chrome/Edge 地址栏点击 <strong>安装</strong> 图标</li><li>或菜单 → <strong>安装 Trading Studio</strong></li></ol>"
: "<ol><li>Chrome 菜单 → <strong>添加到主屏幕</strong> 或 <strong>创建快捷方式</strong></li><li>配置 HTTPS 后可真正「安装应用」</li></ol>");
showModal(
'<button class="pwa-modal-close" type="button">✕</button>' +
'<h3>📲 安装 Trading Studio</h3>' + httpWarn +
'<p>按以下步骤操作:</p>' + steps +
'<p class="pwa-modal-tip">HTTPS 安装后可独立窗口运行,体验接近原生 App。</p>'
);
}
window.addEventListener("beforeinstallprompt", function (e) {
e.preventDefault();
deferredPrompt = e;
var btn = document.getElementById("pwa-install-btn");
if (btn) btn.classList.add("pwa-ready");
});
window.addEventListener("appinstalled", function () {
deferredPrompt = null;
var btn = document.getElementById("pwa-install-btn");
if (btn) btn.style.display = "none";
});
document.addEventListener("click", function (e) {
var btn = e.target.closest("#pwa-install-btn");
if (!btn) return;
e.preventDefault();
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function (choice) {
if (choice.outcome === "accepted") btn.style.display = "none";
deferredPrompt = null;
});
} else {
manualInstallGuide();
}
});
window.addEventListener("load", function () {
var btn = document.getElementById("pwa-install-btn");
if (!btn || isStandalone()) {
if (btn) btn.style.display = "none";
return;
}
btn.style.display = "inline-flex";
// 非 HTTPS 时标记页面,用于显示麦克风提示
if (!isSecure()) {
document.documentElement.classList.add("insecure-context");
}
});
})();
</script>
"""
MIC_HINT_HTML = """
<div class="mic-hint">
<strong>📱 手机 / 平板录音说明</strong>
<ul>
<li><strong>HTTPS 域名</strong>访问才能使用麦克风(HTTP 内网 IP 仅支持上传文件)</li>
<li>iOS 请用 <strong>Safari</strong> 打开,微信内置浏览器通常无法录音</li>
<li>首次使用请允许浏览器的「麦克风」权限</li>
<li>穿透方案见 <code>PWA_NPS.md</code></li>
</ul>
</div>
"""
INSTALL_APP_BUTTON_HTML = """
<button id="pwa-install-btn" class="pwa-install-btn" type="button" title="安装到手机/平板/电脑桌面">
<img src="/pwa/icons/install.svg" alt="" class="pwa-install-icon" width="28" height="28"/>
<span class="pwa-install-text">安装 App</span>
</button>
"""
CUSTOM_CSS = """
/* ========== 居中布局 + 响应式 + 高对比度 ========== */
html, body {
width: 100% !important;
min-height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
background: #0f1419 !important;
overflow-x: hidden !important;
}
/* 外层全宽背景,内容区水平居中 */
gradio-app,
.gradio-app,
#main,
.app,
.fillable,
.contain {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: flex-start !important;
background: #0f1419 !important;
box-sizing: border-box !important;
}
/* 核心内容容器居中 */
.gradio-container {
background: #0f1419 !important;
color: #eef2f7 !important;
font-size: 15px !important;
width: 100% !important;
max-width: min(1200px, 94vw) !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 16px clamp(12px, 3vw, 32px) !important;
box-sizing: border-box !important;
float: none !important;
}
/* 带鱼屏 / 超宽屏 */
@media (min-width: 1920px) {
.gradio-container { max-width: min(1280px, 82vw) !important; }
}
@media (min-width: 2560px) {
.gradio-container { max-width: min(1360px, 68vw) !important; }
}
@media (min-width: 3440px) {
.gradio-container { max-width: 1440px !important; }
}
/* 平板 */
@media (max-width: 1024px) {
.gradio-container {
max-width: 100% !important;
padding: 14px 18px !important;
}
}
/* 手机 */
@media (max-width: 640px) {
.gradio-container {
max-width: 100% !important;
padding: 10px 12px !important;
font-size: 14px !important;
}
.gradio-container h1 { font-size: 1.35rem !important; }
.gradio-container .tab-nav {
flex-wrap: wrap !important;
gap: 6px !important;
}
.gradio-container .tab-nav button {
flex: 1 1 28% !important;
min-width: 88px !important;
padding: 8px 8px !important;
font-size: 0.8rem !important;
}
.status-row,
.status-row > div,
.pipeline-steps,
.pipeline-steps > div,
.pipeline-output-row,
.pipeline-output-row > div {
flex-direction: column !important;
width: 100% !important;
}
.status-row button,
.pipeline-steps button,
.pipeline-output-row button {
width: 100% !important;
}
.dark-panel table {
font-size: 0.85rem !important;
display: block !important;
overflow-x: auto !important;
}
.install-hint { font-size: 0.85rem !important; }
}
/* 触摸设备:按钮最小点击区域 44px */
@media (hover: none) and (pointer: coarse) {
.gradio-container button { min-height: 44px !important; }
}
/* 全局文字 */
.gradio-container p,
.gradio-container span,
.gradio-container label,
.gradio-container .prose,
.gradio-container .markdown-text,
.gradio-container .md {
color: #eef2f7 !important;
}
.gradio-container h1 {
color: #ffffff !important;
font-size: 1.75rem !important;
font-weight: 700 !important;
}
.gradio-container h2,
.gradio-container h3 {
color: #dbeafe !important;
font-size: 1.15rem !important;
font-weight: 600 !important;
}
.gradio-container .block-label,
.gradio-container label,
.gradio-container .label-wrap span {
color: #93c5fd !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
}
.gradio-container textarea,
.gradio-container input[type="text"],
.gradio-container .wrap textarea,
.gradio-container .wrap input {
color: #ffffff !important;
background: #1a2332 !important;
border: 1px solid #4b5563 !important;
font-size: 0.95rem !important;
line-height: 1.6 !important;
}
/* placeholder 高对比度 */
.gradio-container textarea::placeholder,
.gradio-container input::placeholder,
.gradio-container input[type="text"]::placeholder {
color: #d1d5db !important;
opacity: 1 !important;
-webkit-text-fill-color: #d1d5db !important;
}
.gradio-container .bright-input textarea,
.gradio-container .bright-input input {
background: #1e293b !important;
border: 1px solid #64748b !important;
}
/* 音色选择 Radio(避免 Dropdown 在深色/手机端白底白字) */
.gradio-container .voice-radio fieldset {
border: 1px solid #374151 !important;
border-radius: 10px !important;
background: #111827 !important;
padding: 10px 12px !important;
}
.gradio-container .voice-radio label {
display: flex !important;
align-items: center !important;
gap: 10px !important;
padding: 10px 12px !important;
margin: 6px 0 !important;
border-radius: 8px !important;
border: 1px solid #374151 !important;
background: #1a2332 !important;
color: #e5e7eb !important;
font-size: 0.92rem !important;
cursor: pointer !important;
}
.gradio-container .voice-radio label:hover {
border-color: #3b82f6 !important;
background: #1e293b !important;
}
.gradio-container .voice-radio label.selected {
border-color: #3b82f6 !important;
background: #1e3a5f !important;
color: #ffffff !important;
font-weight: 600 !important;
}
.gradio-container .voice-radio input[type="radio"] {
accent-color: #3b82f6 !important;
width: 18px !important;
height: 18px !important;
flex-shrink: 0 !important;
}
@media (min-width: 520px) {
.gradio-container .voice-radio fieldset {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
max-height: none !important;
}
.gradio-container .voice-radio label { margin: 0 !important; }
}
@media (max-width: 519px) {
.gradio-container .voice-radio fieldset {
max-height: 200px !important;
overflow-y: auto !important;
}
}
/* 分步流水线:纵向卡片,避免三栏挤扁 */
.pipeline-flow {
width: 100% !important;
gap: 4px !important;
}
.pipeline-step-card {
border: 1px solid #374151 !important;
border-radius: 12px !important;
padding: 16px 18px !important;
background: #111827 !important;
margin-bottom: 18px !important;
width: 100% !important;
box-sizing: border-box !important;
}
.pipeline-step-card h3 {
margin-top: 0 !important;
padding-bottom: 8px !important;
border-bottom: 1px solid #374151 !important;
}
.gradio-container .pipeline-step-card button {
width: 100% !important;
max-width: 100% !important;
white-space: normal !important;
line-height: 1.35 !important;
min-height: 44px !important;
}
.gradio-container .accordion,
.gradio-container .gr-accordion {
border: 1px solid #374151 !important;
border-radius: 10px !important;
background: #1a2332 !important;
}
.gradio-container .accordion > .label-wrap,
.gradio-container .gr-accordion .label-wrap {
background: #1e293b !important;
color: #e5e7eb !important;
}
.gradio-container .accordion .content,
.gradio-container .gr-accordion .content {
background: #111827 !important;
}
/* Dropdown 兜底(其他下拉组件深色化) */
.gradio-container .gradio-dropdown input,
.gradio-container .dropdown input,
.gradio-container select {
background: #1a2332 !important;
color: #ffffff !important;
border: 1px solid #4b5563 !important;
}
.gradio-container ul.options,
.gradio-container .gradio-dropdown ul,
.gradio-container [role="listbox"] {
background: #1e293b !important;
border: 1px solid #4b5563 !important;
color: #e5e7eb !important;
}
.gradio-container ul.options li,
.gradio-container [role="option"] {
color: #e5e7eb !important;
background: #1e293b !important;
}
.gradio-container ul.options li:hover,
.gradio-container ul.options li.selected,
.gradio-container [role="option"][aria-selected="true"] {
background: #2563eb !important;
color: #ffffff !important;
}
/* 输入框下方 info 提示文字 */
.gradio-container .hint,
.gradio-container .info,
.gradio-container .form .secondary-wrap,
.gradio-container span[data-testid="block-info"] {
color: #94a3b8 !important;
font-size: 0.88rem !important;
}
/* Markdown 内联代码 — 修复白底看不见 */
.gradio-container code,
.gradio-container .prose code,
.gradio-container .markdown-text code,
.gradio-container pre code {
background: #1e3a5f !important;
color: #bfdbfe !important;
border: 1px solid #3b82f6 !important;
padding: 2px 10px !important;
border-radius: 6px !important;
font-size: 0.9em !important;
}
.gradio-container pre {
background: #111827 !important;
border: 1px solid #374151 !important;
padding: 12px !important;
border-radius: 8px !important;
}
/* 组件标签 — 修复白底蓝字 */
.gradio-container .block-label,
.gradio-container .label-wrap,
.gradio-container .label-wrap span,
.gradio-container span.label {
background: #1e293b !important;
background-color: #1e293b !important;
color: #93c5fd !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
border: 1px solid #475569 !important;
border-radius: 6px !important;
padding: 4px 10px !important;
}
/* 说明文字块 */
.hint-box {
background: #1e293b !important;
border: 1px solid #475569 !important;
border-radius: 10px !important;
padding: 14px 18px !important;
color: #e2e8f0 !important;
font-size: 0.95rem !important;
line-height: 1.7 !important;
margin-bottom: 12px !important;
}
.hint-box strong { color: #ffffff !important; }
.file-tag {
display: inline-block;
background: #1e3a5f !important;
color: #bfdbfe !important;
border: 1px solid #3b82f6 !important;
padding: 3px 12px !important;
border-radius: 6px !important;
font-family: "JetBrains Mono", Consolas, monospace !important;
font-weight: 700 !important;
font-size: 0.92em !important;
}
/* 页头 + 安装 App 按钮 */
.header-row {
align-items: flex-start !important;
width: 100% !important;
}
.header-row > div { flex: 1 1 auto !important; }
.pwa-install-btn {
display: none;
align-items: center;
gap: 10px;
padding: 12px 20px;
margin-top: 8px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff !important;
border: 2px solid #60a5fa;
border-radius: 12px;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
font-family: inherit;
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.45);
transition: transform 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.pwa-install-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(37, 99, 235, 0.55);
}
.pwa-install-btn.pwa-ready {
animation: pwa-pulse 2s infinite;
}
@keyframes pwa-pulse {
0%, 100% { box-shadow: 0 4px 20px rgba(37, 99, 235, 0.45); }
50% { box-shadow: 0 4px 28px rgba(96, 165, 250, 0.8); }
}
.pwa-install-icon { flex-shrink: 0; border-radius: 6px; }
.pwa-install-text { color: #ffffff !important; }
/* 安装引导弹窗 */
.pwa-modal-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.pwa-modal {
position: relative;
background: #1e293b;
border: 2px solid #3b82f6;
border-radius: 16px;
padding: 28px 32px;
max-width: 420px;
width: 100%;
color: #e2e8f0;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.pwa-modal h3 { color: #ffffff !important; margin: 0 0 12px !important; font-size: 1.25rem !important; }
.pwa-modal p { color: #cbd5e1 !important; line-height: 1.6 !important; }
.pwa-modal ol { color: #e2e8f0 !important; padding-left: 20px !important; line-height: 1.8 !important; }
.pwa-modal-tip { font-size: 0.85rem !important; color: #93c5fd !important; margin-top: 16px !important; }
.pwa-modal-warn {
background: #422006 !important;
border: 1px solid #f59e0b !important;
border-radius: 8px !important;
padding: 12px 14px !important;
margin-bottom: 14px !important;
color: #fde68a !important;
font-size: 0.9rem !important;
line-height: 1.6 !important;
}
.pwa-modal-close {
position: absolute;
top: 12px;
right: 14px;
background: #374151;
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
@media (max-width: 640px) {
.header-row { flex-direction: column !important; }
.pwa-install-btn { width: 100% !important; justify-content: center !important; margin-top: 12px !important; }
}
.gradio-container .wrap .readonly textarea {
background: #111827 !important;
color: #e5e7eb !important;
border-color: #374151 !important;
}
.gradio-container .tab-nav button {
color: #9ca3af !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
padding: 10px 18px !important;
}
.gradio-container .tab-nav button.selected {
color: #ffffff !important;
background: #1e3a5f !important;
border-bottom: 3px solid #3b82f6 !important;
}
.gradio-container button.primary,
.gradio-container .primary {
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
font-size: 0.95rem !important;
border: 1px solid #3b82f6 !important;
}
.gradio-container button.primary:hover { background: #1d4ed8 !important; }
.gradio-container button.secondary,
.gradio-container button:not(.primary) {
color: #e5e7eb !important;
background: #374151 !important;
border: 1px solid #6b7280 !important;
font-weight: 600 !important;
}
.dark-panel {
border: 1px solid #3b82f6 !important;
border-radius: 10px !important;
padding: 18px 20px !important;
background: #1a2332 !important;
margin-bottom: 16px !important;
width: 100% !important;
box-sizing: border-box !important;
}
.dark-panel code {
color: #93c5fd !important;
background: #0f172a !important;
padding: 2px 6px !important;
border-radius: 4px !important;
}
.install-hint {
margin-top: 12px !important;
padding: 10px 14px !important;
border-radius: 8px !important;
background: #1e3a5f !important;
border: 1px dashed #3b82f6 !important;
color: #bfdbfe !important;
font-size: 0.9rem !important;
}
.status-card {
border-radius: 10px;
padding: 14px 16px;
margin: 4px 0;
border-left: 5px solid #6b7280;
background: #1f2937;
min-height: 72px;
width: 100%;
box-sizing: border-box;
}
.status-card .status-title {
font-size: 0.85rem;
font-weight: 700;
color: #93c5fd !important;
margin-bottom: 8px;
}
.status-card .status-body {
font-size: 0.92rem;
line-height: 1.55;
color: #f3f4f6 !important;
word-break: break-word;
}
.status-ok { border-left-color: #22c55e !important; background: #14291a !important; }
.status-ok .status-body { color: #bbf7d0 !important; }
.status-warn { border-left-color: #f59e0b !important; background: #2a2010 !important; }
.status-warn .status-body { color: #fde68a !important; }
.status-err { border-left-color: #ef4444 !important; background: #2a1515 !important; }
.status-err .status-body { color: #fecaca !important; }
/* 状态栏与播放器隔离重绘,减轻合成完成后的闪屏 */
.status-row {
contain: layout style paint;
min-height: 88px;
}
.gradio-container .audio-container,
.gradio-container .upload-container {
border: 2px dashed #4b5563 !important;
background: #1a2332 !important;
contain: layout style paint;
min-height: 100px;
}
.gradio-container .audio-container audio,
.gradio-container .audio-container canvas,
.gradio-container .waveform-container {
background: #1a2332 !important;
}
/* 成品播放器:去掉 Gradio 默认 focus 白框,减轻合成完成时闪一下 */
.gradio-container .tts-output-audio,
.gradio-container .tts-output-audio .audio-container {
border: 1px solid #374151 !important;
background: #1a2332 !important;
contain: strict;
min-height: 120px;
}
.gradio-container .tts-output-audio button,
.gradio-container .tts-output-audio button:focus,
.gradio-container .tts-output-audio button:focus-visible {
outline: none !important;
box-shadow: none !important;
border-color: #4b5563 !important;
}
.gradio-container .tts-output-audio .wrap,
.gradio-container .tts-output-audio .controls {
background: #1a2332 !important;
}
.gradio-container .pipeline-step-card textarea {
contain: layout style;
}
footer { visibility: hidden; }
/* 手机端麦克风提示(HTTP 下强制显示) */
.mic-hint {
display: none;
background: #1e3a5f !important;
border: 1px solid #3b82f6 !important;
border-radius: 10px !important;
padding: 12px 16px !important;
margin: 8px 0 14px 0 !important;
color: #dbeafe !important;
font-size: 0.9rem !important;
line-height: 1.6 !important;
}
.mic-hint strong { color: #ffffff !important; }
.mic-hint ul { margin: 8px 0 0 0 !important; padding-left: 20px !important; }
.mic-hint code {
background: #0f172a !important;
color: #93c5fd !important;
padding: 2px 6px !important;
border-radius: 4px !important;
}
@media (max-width: 1024px) {
.mic-hint { display: block; }
}
html.insecure-context .mic-hint { display: block !important; }
"""
def _status_html(title: str, message: str, level: str = "warn") -> str:
"""生成高对比度状态卡片 HTML。level: ok | warn | err"""
icons = {"ok": "", "warn": "⚠️", "err": ""}
icon = icons.get(level, "")
# 去掉 message 里重复的 emoji,避免双图标
clean = message.lstrip("✅❌⚠️ ").strip()
return (
f'<div class="status-card status-{level}">'
f'<div class="status-title">{icon} {title}</div>'
f'<div class="status-body">{clean}</div>'
f"</div>"
)
def ui_check_ollama_html(force: bool = False) -> str:
ok, msg = check_ollama_health(force=force)
return _status_html("Ollama 节点", msg, "ok" if ok else "err")
def ui_initial_load() -> tuple[str, str]:
"""首屏加载:检测 Ollama + 音色(仅一次,不用高频 Timer 避免闪屏)。"""
return ui_refresh_status_html(force=False)
def ui_refresh_status_html(force: bool = False) -> tuple[str, str]:
"""刷新 Ollama + 音色状态(供 Timer / 按钮调用)。"""
return ui_check_ollama_html(force=force), ui_speaker_status_html()
def ui_speaker_status_html() -> str:
ok, msg = speaker_is_ready()
return _status_html("音色状态", msg, "ok" if ok else "warn")
def build_theme() -> gr.themes.Base:
"""高对比度暗色主题;使用系统字体,避免平板拉取 Google Fonts 卡顿。"""
return gr.themes.Base(
primary_hue="blue",
secondary_hue="blue",
neutral_hue="slate",
font=["system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
font_mono=["Consolas", "Monaco", "Courier New", "monospace"],
).set(
body_background_fill="#0f1419",
body_background_fill_dark="#0f1419",
body_text_color="#eef2f7",
body_text_color_dark="#eef2f7",
block_background_fill="#1a2332",
block_background_fill_dark="#1a2332",
block_border_color="#4b5563",
block_title_text_color="#ffffff",
block_label_text_color="#93c5fd",
input_background_fill="#1a2332",
input_background_fill_dark="#1a2332",
button_primary_background_fill="#2563eb",
button_primary_background_fill_hover="#1d4ed8",
button_primary_text_color="#ffffff",
button_secondary_background_fill="#374151",
button_secondary_text_color="#e5e7eb",
border_color_primary="#3b82f6",
)
def build_app() -> gr.Blocks:
"""构建 Gradio 主界面。"""
with gr.Blocks(
title="Trading Studio | 交易复盘配音中控",
) as demo:
with gr.Row(elem_classes=["header-row"]):
gr.Markdown(
f"""
# ⚡ Trading Studio
**本地量化交易复盘 → B 站配音生产流水线**
| 模块 | 说明 |
|------|------|
| Whisper | 本地 GPU 语音识别 |
| Gemma4 | `{MODEL_NAME}` @ `{OLLAMA_URL.replace('/api/chat', '')}` |
| ChatTTS | 本地 GPU 固定音色合成 |
> 仓库: [{GIT_REPO_URL}]({GIT_REPO_URL})
""",
elem_classes=["dark-panel"],
)
gr.HTML(INSTALL_APP_BUTTON_HTML)
with gr.Row(elem_classes=["status-row"]):
ollama_status = gr.HTML(value=_status_html("Ollama 节点", "正在检测...", "warn"))
speaker_status = gr.HTML(value=_status_html("音色状态", "正在检测...", "warn"))
refresh_btn = gr.Button("🔄 刷新状态", variant="secondary", scale=0, min_width=120)
refresh_btn.click(
fn=lambda: ui_refresh_status_html(force=True),
outputs=[ollama_status, speaker_status],
)
demo.load(
fn=ui_initial_load,
outputs=[ollama_status, speaker_status],
)
# 仅低频刷新状态(去掉 1s Timer,避免合成后整页闪屏)
status_timer_slow = gr.Timer(value=60, active=True)
status_timer_slow.tick(
fn=lambda: ui_refresh_status_html(force=True),
outputs=[ollama_status, speaker_status],
)
with gr.Tabs():
# ---- Tab 1: 一键生产(默认首页)----
with gr.Tab("🚀 一键生产"):
gr.HTML(MIC_HINT_HTML)
gr.Markdown(
"上传碎碎念录音,系统自动完成 **识别 → 润色 → 合成** 全流程。"
)
with gr.Row():
pipe_audio = gr.Audio(
label="复盘录音",
type="filepath",
sources=["upload", "microphone"],
)
pipe_manual = gr.Textbox(
label="或手动输入转写(跳过识别)",
lines=4,
placeholder="若已有转写文本,可直接粘贴,留空则走 Whisper 识别",
)
skip_polish_cb = gr.Checkbox(
label="跳过 Gemma4 润色(仅测试 TTS)",
value=False,
)
pipe_voice = gr.Radio(
label="配音音色(本地 ChatTTS",
choices=voice_choice_labels(),
value=default_voice_label(),
elem_classes=["voice-radio"],
)
pipeline_btn = gr.Button("▶ 启动全流程", variant="primary", size="lg")
pipeline_log = gr.Textbox(label="流水线日志", lines=6, interactive=False)
with gr.Row(elem_classes=["pipeline-output-row"]):
pipe_raw = gr.Textbox(label="转写原文", lines=6)
pipe_polished = gr.Textbox(label="润色稿", lines=6)
pipe_output = _tts_output_audio("成品配音")
# ---- Tab 2: 分步流水线 ----
with gr.Tab("🔧 分步流水线"):
gr.HTML(MIC_HINT_HTML)
with gr.Column(elem_classes=["pipeline-flow"]):
with gr.Group(elem_classes=["pipeline-step-card"]):
gr.Markdown("### Step 1 · 音频极速识别")
rec_audio = gr.Audio(
label="交易复盘碎碎念录音",
type="filepath",
sources=["upload", "microphone"],
)
transcribe_btn = gr.Button("⚡ Faster-Whisper 识别", variant="primary")
transcribe_log = gr.Textbox(label="识别日志", lines=2, interactive=False)
with gr.Group(elem_classes=["pipeline-step-card"]):
gr.Markdown("### Step 2 · Gemma4 纪律审判")
raw_text = gr.Textbox(
label="转写原文(可编辑)",
lines=8,
placeholder="识别结果将显示在此,也可手动粘贴...",
)
polish_btn = gr.Button("⚖️ 远程 Gemma4 严厉润色", variant="primary")
polish_log = gr.Textbox(label="润色日志", lines=2, interactive=False)
with gr.Group(elem_classes=["pipeline-step-card"]):
gr.Markdown("### Step 3 · 本地 GPU 配音合成")
gr.Markdown(
"<span style='color:#94a3b8;font-size:0.9rem'>"
"本机显卡合成,无需 API。润色完成后在此选音色并点合成。</span>"
)
with gr.Accordion("🎚️ 选择配音音色", open=True):
tts_voice = gr.Radio(
label="配音音色(本地 ChatTTS",
choices=voice_choice_labels(),
value=default_voice_label(),
info="预设音色:bash scripts/generate_voice_presets.sh",
elem_classes=["voice-radio"],
)
polished_text = gr.Textbox(
label="润色配音稿(可编辑,合成时自动清洗 Markdown)",
lines=12,
placeholder="润色结果将显示在此...",
)
synth_btn = gr.Button("🔊 合成配音 WAV", variant="primary")
synth_log = gr.Textbox(label="合成日志", lines=3, interactive=False)
output_audio = _tts_output_audio("成品配音")
transcribe_btn.click(ui_transcribe, rec_audio, [raw_text, transcribe_log])
polish_btn.click(ui_polish, raw_text, [polished_text, polish_log])
# ---- Tab 3: 音色锁定 ----
with gr.Tab("🎙️ 音色锁定"):
gr.HTML(MIC_HINT_HTML)
gr.HTML(
f'<div class="hint-box">'
f'上传 <strong>10-30 秒</strong> 干净人声样本,系统将提取 Speaker Embedding '
f'并保存至 <span class="file-tag">{SPEAKER_EMB_PATH.name}</span>'
f'后续合成 <strong>100% 还原音色</strong>。'
f"</div>"
)
with gr.Row():
spk_audio = gr.Audio(
label="参考人声(碎碎念盲录样本)",
type="filepath",
sources=["upload", "microphone"],
)
spk_transcript = gr.Textbox(
label="参考音频精确转写(强烈建议填写,与录音一致,避免合成报错)",
placeholder="示例:今天开了三单,第一单手贱提前平了,第二单…",
info="请尽量与参考音频内容完全一致,可提升音色还原度",
lines=6,
elem_classes=["bright-input"],
)
lock_btn = gr.Button("🔒 锁定音色", variant="primary")
lock_log = gr.Textbox(label="锁定结果", lines=4, interactive=False)
lock_btn.click(
ui_lock_speaker,
[spk_audio, spk_transcript],
[lock_log, speaker_status],
)
with gr.Accordion("📂 配音历史(本地保留,可随时试听下载)", open=True):
with gr.Row():
history_select = gr.Dropdown(
label="历史配音",
choices=list_voice_history(),
value=None,
interactive=True,
scale=4,
)
history_refresh_btn = gr.Button("🔄 刷新", scale=0, min_width=100)
history_player = _tts_output_audio("历史试听 / 下载")
history_refresh_btn.click(ui_history_dropdown, outputs=[history_select])
history_select.change(ui_history_play, history_select, history_player)
demo.load(ui_initial_history, outputs=[history_select, history_player])
def ui_pipeline_pending(skip_polish: bool, manual_raw: str) -> str:
if manual_raw and manual_raw.strip():
return "⏳ 全流程运行中(识别/润色/合成),请稍候,勿刷新页面…"
if skip_polish:
return "⏳ 全流程运行中(识别→合成),请稍候,勿刷新页面…"
return "⏳ 全流程运行中(识别→润色→合成),请稍候,勿刷新页面…"
pipeline_btn.click(
ui_pipeline_pending,
[skip_polish_cb, pipe_manual],
[pipeline_log],
queue=True,
).then(
ui_full_pipeline,
[pipe_audio, skip_polish_cb, pipe_manual, pipe_voice],
[pipe_raw, pipe_polished, pipe_output, pipeline_log, history_select, history_player],
queue=True,
)
synth_btn.click(
ui_synth_pending,
[polished_text],
[synth_log],
queue=True,
).then(
ui_synthesize,
[polished_text, tts_voice],
[synth_log, output_audio, history_select, history_player],
queue=True,
)
demo.queue(default_concurrency_limit=1)
return demo
def create_fastapi_app():
"""创建 FastAPI 应用:Gradio 中控 + PWA 静态资源。"""
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
fastapi_app = FastAPI(title="Trading Studio", docs_url=None, redoc_url=None)
@fastapi_app.middleware("http")
async def add_media_permissions(request, call_next):
"""允许浏览器在 HTTPS 下请求麦克风(配合云反代使用)。"""
response = await call_next(request)
response.headers["Permissions-Policy"] = "microphone=(self), camera=(self)"
return response
if PWA_DIR.is_dir():
fastapi_app.mount("/pwa", StaticFiles(directory=str(PWA_DIR)), name="pwa")
@fastapi_app.get("/manifest.webmanifest")
async def manifest():
return FileResponse(
PWA_DIR / "manifest.webmanifest",
media_type="application/manifest+json",
)
@fastapi_app.get("/sw.js")
async def service_worker():
return FileResponse(
PWA_DIR / "sw.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/"},
)
blocks = build_app()
gr.mount_gradio_app(
fastapi_app,
blocks,
path="/",
css=CUSTOM_CSS,
theme=build_theme(),
head=PWA_HEAD,
allowed_paths=[str(APP_ROOT / "outputs")],
)
return fastapi_app
def main() -> None:
"""主入口:FastAPI + Gradio + PWA。"""
import uvicorn
logger.info("Trading Studio 启动中... HOST=%s PORT=%s", HOST, PORT)
app = create_fastapi_app()
uvicorn.run(
app,
host=HOST,
port=PORT,
log_level="info",
access_log=True,
)
if __name__ == "__main__":
main()