Files
dekun d26bec085c Fix layout width when voice accordion collapses
Lock gradio-container to 1800px on desktop, stretch outer flex instead of center-shrink, and move voice accordion to its own full-width row.

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

1890 lines
64 KiB
Python
Raw Permalink 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 logging
import re
import shutil
import sys
import uuid
from pathlib import Path
from urllib.parse import quote
import gradio as gr
from config import (
GIT_REPO_URL,
HOST,
MODEL_NAME,
OLLAMA_URL,
OUTPUT_DIR,
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 _voice_player_html(wav_path: str | None) -> str:
"""带播放控件、语速滑块与按语速保存下载的 HTML 播放器。"""
if not wav_path:
return (
'<div class="tts-player-wrap tts-player-empty">'
"<p>合成完成后可在此试听,拖动滑块调节语速(0.5x~2.0x),点「保存」下载。</p>"
"</div>"
)
path = Path(wav_path)
if not path.is_file():
return (
'<div class="tts-player-wrap tts-player-empty">'
"<p>音频文件不存在,请重新合成或刷新历史列表。</p>"
"</div>"
)
name = path.name
src = f"/outputs/{quote(name)}"
return f"""
<div class="tts-player-wrap" data-filename="{name}">
<div class="tts-player-title">🎧 {name}</div>
<audio class="tts-audio-el" controls preload="metadata" src="{src}"
onloadedmetadata="(function(a){{var sl=a.closest('.tts-player-wrap').querySelector('.tts-speed-slider'); if(sl){{a.playbackRate=parseFloat(sl.value);}}}})(this)"></audio>
<div class="tts-speed-row">
<span class="tts-speed-label-text">播放语速</span>
<input type="range" class="tts-speed-slider" min="0.5" max="2.0" step="0.05" value="1"
aria-label="播放语速"
oninput="ttsSyncSpeed(this)">
<span class="tts-speed-val">1.00x</span>
</div>
<div class="tts-action-row">
<button type="button" class="tts-save-btn" onclick="ttsSaveAtSpeed(this)">💾 按当前语速保存</button>
<a class="tts-dl-btn" href="{src}" download="{name}">⬇ 原速下载</a>
<span class="tts-save-status"></span>
</div>
<p class="tts-player-tip">试听语速与保存文件一致;「原速下载」获取未变速的原始 WAV。</p>
</div>
"""
def ui_history_play(filepath: str | None) -> str:
"""选中历史条目后加载播放器。"""
return _voice_player_html(filepath)
def ui_initial_history() -> tuple[dict, str]:
"""首屏加载历史列表并自动选中最新一条。"""
choices = list_voice_history()
paths = [p for _, p in choices]
latest = paths[0] if paths else None
return gr.update(choices=choices, value=latest), _voice_player_html(latest)
def _voice_player_block() -> gr.HTML:
"""创建成品配音 HTML 播放器区域。"""
return gr.HTML(value=_voice_player_html(None), elem_classes=["tts-player-block"])
# ---------------------------------------------------------------------------
# 全局 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, str, dict, str]:
"""【TTS 合成】生成最终 wav 配音文件。"""
if not polished_text or not polished_text.strip():
return (
"请先完成 Gemma4 润色。",
_voice_player_html(None),
ui_history_dropdown(),
_voice_player_html(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),
_voice_player_html(wav_path),
ui_history_dropdown(wav_path),
_voice_player_html(wav_path),
)
return (
_short_synth_log(msg, ok),
_voice_player_html(None),
ui_history_dropdown(),
_voice_player_html(None),
)
# ---------------------------------------------------------------------------
# 一键流水线
# ---------------------------------------------------------------------------
def ui_full_pipeline(
audio_file,
skip_polish: bool,
manual_raw: str,
voice_label: str,
) -> tuple[str, str, str, str, dict, str]:
"""
串联执行:识别 → 润色(可跳过)→ 合成。
返回 (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 "", "", _voice_player_html(None), "❌ 请上传录音或手动填写转写文本。", ui_history_dropdown(), _voice_player_html(None)
ok, result = transcribe_audio(path)
if not ok:
return "", "", _voice_player_html(None), f"❌ 识别失败: {result}", ui_history_dropdown(), _voice_player_html(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, "", _voice_player_html(None), f"❌ 润色失败: {result}\n" + "\n".join(logs), ui_history_dropdown(), _voice_player_html(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, _voice_player_html(None), f"❌ 合成失败: {msg}\n" + "\n".join(logs), ui_history_dropdown(), _voice_player_html(None)
logs.append(f"{msg}")
return (
raw,
polished,
_voice_player_html(wav_path),
"\n".join(logs),
ui_history_dropdown(wav_path),
_voice_player_html(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>
<script>
(function () {
function audioBufferToWav(buffer) {
var numChannels = buffer.numberOfChannels;
var sampleRate = buffer.sampleRate;
var format = 1;
var bitDepth = 16;
var samples = buffer.length;
var blockAlign = numChannels * bitDepth / 8;
var byteRate = sampleRate * blockAlign;
var dataSize = samples * blockAlign;
var ab = new ArrayBuffer(44 + dataSize);
var view = new DataView(ab);
function writeStr(off, str) {
for (var i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i));
}
writeStr(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeStr(8, "WAVE");
writeStr(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeStr(36, "data");
view.setUint32(40, dataSize, true);
var offset = 44;
var chData = [];
for (var c = 0; c < numChannels; c++) chData.push(buffer.getChannelData(c));
for (var i = 0; i < samples; i++) {
for (var c = 0; c < numChannels; c++) {
var s = Math.max(-1, Math.min(1, chData[c][i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
}
return new Blob([ab], { type: "audio/wav" });
}
window.ttsSyncSpeed = function (slider) {
var wrap = slider.closest(".tts-player-wrap");
if (!wrap) return;
var audio = wrap.querySelector("audio");
var label = wrap.querySelector(".tts-speed-val");
var speed = parseFloat(slider.value);
if (audio) audio.playbackRate = speed;
if (label) label.textContent = speed.toFixed(2) + "x";
};
window.ttsSaveAtSpeed = async function (btn) {
var wrap = btn.closest(".tts-player-wrap");
if (!wrap) return;
var audio = wrap.querySelector("audio");
var slider = wrap.querySelector(".tts-speed-slider");
var status = wrap.querySelector(".tts-save-status");
var speed = parseFloat(slider ? slider.value : "1");
var src = audio ? audio.currentSrc || audio.src : "";
var baseName = wrap.getAttribute("data-filename") || "voiceover.wav";
if (!src) {
if (status) status.textContent = "无音频";
return;
}
btn.disabled = true;
if (status) status.textContent = "正在生成…";
try {
if (Math.abs(speed - 1.0) < 0.001) {
var link0 = document.createElement("a");
link0.href = src;
link0.download = baseName;
document.body.appendChild(link0);
link0.click();
link0.remove();
if (status) status.textContent = "已保存(原速)";
return;
}
var resp = await fetch(src);
if (!resp.ok) throw new Error("HTTP " + resp.status);
var buf = await resp.arrayBuffer();
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) throw new Error("浏览器不支持 AudioContext");
var ctx = new Ctx();
var decoded = await ctx.decodeAudioData(buf.slice(0));
var newLen = Math.max(1, Math.ceil(decoded.length / speed));
var offline = new OfflineAudioContext(
decoded.numberOfChannels,
newLen,
decoded.sampleRate
);
var source = offline.createBufferSource();
source.buffer = decoded;
source.playbackRate.value = speed;
source.connect(offline.destination);
source.start(0);
var rendered = await offline.startRendering();
await ctx.close();
var blob = audioBufferToWav(rendered);
var url = URL.createObjectURL(blob);
var stem = baseName.replace(/\\.wav$/i, "");
var tag = speed.toFixed(2).replace(".", "");
var dlName = stem + "_" + tag + "x.wav";
var link = document.createElement("a");
link.href = url;
link.download = dlName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
if (status) status.textContent = "已保存 " + speed.toFixed(2) + "x";
} catch (err) {
if (status) status.textContent = "保存失败";
console.error("ttsSaveAtSpeed", err);
} finally {
btn.disabled = false;
}
};
})();
</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;
}
/* 外层全宽背景,子元素拉伸占满视口(避免 align-items:center 随内容收缩变窄) */
gradio-app,
.gradio-app,
#main,
.app,
.fillable,
.contain,
.container {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
justify-content: flex-start !important;
background: #0f1419 !important;
box-sizing: border-box !important;
}
/* 核心内容容器 — 桌面端固定 1800px(折叠音色时不随内容变窄) */
.gradio-container {
background: #0f1419 !important;
color: #eef2f7 !important;
font-size: 15px !important;
width: min(1800px, 96vw) !important;
min-width: min(1800px, 96vw) !important;
max-width: min(1800px, 96vw) !important;
flex: 0 0 auto !important;
align-self: center !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 16px clamp(12px, 2vw, 28px) !important;
box-sizing: border-box !important;
float: none !important;
}
/* 平板 */
@media (max-width: 1024px) {
.gradio-container {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 14px 18px !important;
}
}
/* 手机 */
@media (max-width: 640px) {
.gradio-container {
width: 100% !important;
min-width: 100% !important;
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 25% !important;
min-width: 0 !important;
padding: 8px 6px !important;
font-size: 0.82rem !important;
}
.status-row,
.status-row > div,
.pipeline-steps,
.pipeline-steps > div,
.pipeline-output-row,
.pipeline-output-row > div,
.oneclick-input-grid,
.oneclick-input-grid > div,
.oneclick-bottom-grid,
.oneclick-bottom-grid > div,
.oneclick-action-row {
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;
}
/* Tabs 全宽,避免 Tab 内容区 shrink-to-fit */
.gradio-container .tabs,
.gradio-container .main-nav-tabs,
.gradio-container .tabitem,
.gradio-container .tab-content,
.gradio-container [role="tabpanel"] {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
/* 主导航 Tab 均匀分布(兼容 Gradio 4/5 */
.gradio-container .tab-nav {
display: flex !important;
width: 100% !important;
gap: 0 !important;
flex-wrap: nowrap !important;
}
.gradio-container .tab-nav button {
flex: 1 1 0 !important;
min-width: 0 !important;
text-align: center !important;
justify-content: center !important;
border-radius: 0 !important;
color: #9ca3af !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
padding: 10px 12px !important;
}
.gradio-container .tab-nav button.selected {
color: #ffffff !important;
background: #1e3a5f !important;
border-bottom: 3px solid #3b82f6 !important;
}
/* 去掉 Tab / Group 默认蓝色粗框 */
.gradio-container .tabs {
border: none !important;
background: transparent !important;
}
.gradio-container .tabitem,
.gradio-container .tab-content,
.gradio-container [role="tabpanel"] {
border: none !important;
background: transparent !important;
padding: 0 !important;
}
.gradio-container .group.oneclick-panel,
.gradio-container .oneclick-panel {
border: 1px solid #2d3748 !important;
box-shadow: none !important;
}
.gradio-container .oneclick-panel .group,
.gradio-container .oneclick-body.group,
.gradio-container .oneclick-body {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.oneclick-panel {
border: 1px solid #2d3748 !important;
border-radius: 14px !important;
padding: 0 !important;
background: #0f1419 !important;
width: 100% !important;
box-sizing: border-box !important;
overflow: hidden !important;
}
.oneclick-panel .block,
.oneclick-panel .form,
.oneclick-panel fieldset {
border-color: #374151 !important;
}
.oneclick-panel button.primary {
border-color: #2563eb !important;
}
.oneclick-hero {
padding: 14px 20px !important;
background: linear-gradient(90deg, #1e3a5f 0%, #1a2332 100%) !important;
border-bottom: 1px solid #374151 !important;
color: #e2e8f0 !important;
font-size: 0.95rem !important;
letter-spacing: 0.02em !important;
}
.oneclick-hero em {
color: #93c5fd !important;
font-style: normal !important;
font-weight: 600 !important;
}
.oneclick-hero-sep { color: #64748b !important; margin: 0 6px !important; }
.oneclick-body {
padding: 16px 20px 20px !important;
width: 100% !important;
max-width: 100% !important;
align-self: stretch !important;
}
/* 防止折叠音色后整页随内容收缩变窄 */
.gradio-container .tabitem,
.gradio-container .oneclick-panel,
.gradio-container .oneclick-panel > .wrap,
.gradio-container .oneclick-panel > .form,
.gradio-container .oneclick-body,
.gradio-container .oneclick-body > .wrap,
.gradio-container .oneclick-body > .form,
.gradio-container .oneclick-body .row,
.gradio-container .oneclick-body .gr-row {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
align-self: stretch !important;
}
.oneclick-input-grid {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 16px !important;
width: 100% !important;
margin-bottom: 14px !important;
align-items: stretch !important;
}
.oneclick-input-grid > .column,
.oneclick-input-grid > .gr-column,
.oneclick-input-grid > div {
min-width: 0 !important;
width: 100% !important;
max-width: none !important;
}
.oneclick-card {
background: #1a2332 !important;
border: 1px solid #374151 !important;
border-radius: 10px !important;
padding: 12px 14px 14px !important;
min-height: 280px !important;
height: 100% !important;
box-sizing: border-box !important;
display: flex !important;
flex-direction: column !important;
}
.oneclick-card > .block,
.oneclick-card > .form {
flex: 1 1 auto !important;
display: flex !important;
flex-direction: column !important;
background: transparent !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.oneclick-card .wrap,
.oneclick-card .input-container {
flex: 1 1 auto !important;
}
.oneclick-card .block-label,
.oneclick-card .label-wrap span {
color: #cbd5e1 !important;
font-size: 0.88rem !important;
}
.oneclick-input-grid .audio-container,
.oneclick-input-grid .upload-container {
flex: 1 1 auto !important;
min-height: 220px !important;
height: 100% !important;
border: 1px dashed #4b5563 !important;
border-radius: 8px !important;
background: #111827 !important;
}
.oneclick-input-grid textarea {
min-height: 220px !important;
height: 100% !important;
border-radius: 8px !important;
resize: none !important;
}
/* 配音音色 — 独立全宽行,折叠不影响页面宽度 */
.oneclick-voice-accordion {
width: 100% !important;
min-width: 100% !important;
margin-bottom: 12px !important;
border: 1px solid #374151 !important;
border-radius: 10px !important;
background: #111827 !important;
}
.oneclick-voice-accordion > .label-wrap,
.oneclick-voice-accordion .label-wrap {
width: 100% !important;
background: #1a2332 !important;
padding: 10px 14px !important;
}
.oneclick-voice-accordion .content {
width: 100% !important;
padding: 8px 12px 12px !important;
}
.oneclick-voice-accordion .block { margin: 0 !important; padding: 0 !important; }
/* 操作行:跳过润色 + 启动按钮 */
.oneclick-action-row {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: space-between !important;
gap: 16px !important;
padding: 12px 14px !important;
margin-bottom: 14px !important;
background: #111827 !important;
border: 1px solid #374151 !important;
border-radius: 10px !important;
width: 100% !important;
min-width: 100% !important;
flex-wrap: nowrap !important;
}
.oneclick-action-row > .column,
.oneclick-action-row > .gr-column,
.oneclick-action-row > .block,
.oneclick-action-row > div {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
}
.oneclick-run-btn {
flex: 0 0 auto !important;
width: auto !important;
min-width: 190px !important;
margin-left: auto !important;
}
.oneclick-run-btn button {
width: 100% !important;
min-width: 190px !important;
padding: 12px 24px !important;
font-size: 1rem !important;
border-radius: 8px !important;
}
.oneclick-bottom-grid {
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 16px !important;
width: 100% !important;
margin-bottom: 14px !important;
}
.oneclick-bottom-grid > .column,
.oneclick-bottom-grid > .gr-column,
.oneclick-bottom-grid > div {
min-width: 0 !important;
}
.oneclick-bottom-grid textarea {
min-height: 160px !important;
border-radius: 8px !important;
}
.oneclick-panel .tts-player-block { margin-top: 4px !important; }
.oneclick-panel .accordion {
border-radius: 10px !important;
margin-top: 8px !important;
border-color: #374151 !important;
}
@media (max-width: 900px) {
.oneclick-input-grid {
grid-template-columns: 1fr !important;
}
.oneclick-bottom-grid {
grid-template-columns: 1fr !important;
}
.oneclick-action-row {
flex-direction: column !important;
align-items: stretch !important;
}
.oneclick-run-btn {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
}
.oneclick-run-btn button {
min-width: 0 !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;
}
/* 成品 HTML 播放器:播放 + 语速滑块 */
.tts-player-block { contain: layout style paint; }
.tts-player-wrap {
background: #1a2332 !important;
border: 1px solid #374151 !important;
border-radius: 10px !important;
padding: 14px 16px !important;
margin: 8px 0 !important;
}
.tts-player-wrap.tts-player-empty {
color: #94a3b8 !important;
font-size: 0.92rem !important;
min-height: 72px;
}
.tts-player-title {
color: #93c5fd !important;
font-size: 0.88rem !important;
margin-bottom: 10px !important;
word-break: break-all;
}
.tts-player-wrap audio.tts-audio-el {
width: 100% !important;
height: 44px !important;
margin: 6px 0 12px 0 !important;
border-radius: 6px !important;
}
.tts-speed-row {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 10px 14px !important;
margin-top: 4px !important;
}
.tts-speed-label-text {
color: #e5e7eb !important;
font-size: 0.9rem !important;
min-width: 64px;
}
.tts-speed-slider {
flex: 1 1 120px !important;
min-width: 120px !important;
max-width: 280px !important;
accent-color: #2563eb !important;
}
.tts-speed-val {
color: #93c5fd !important;
font-weight: 600 !important;
min-width: 48px !important;
}
.tts-action-row {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 10px 12px !important;
margin-top: 10px !important;
}
.tts-save-btn {
color: #ffffff !important;
background: #2563eb !important;
border: none !important;
padding: 8px 14px !important;
border-radius: 6px !important;
font-size: 0.88rem !important;
cursor: pointer !important;
}
.tts-save-btn:hover { background: #1d4ed8 !important; }
.tts-save-btn:disabled { opacity: 0.6 !important; cursor: wait !important; }
.tts-save-status {
color: #86efac !important;
font-size: 0.82rem !important;
min-width: 80px;
}
.tts-dl-btn {
color: #ffffff !important;
background: #374151 !important;
padding: 6px 12px !important;
border-radius: 6px !important;
text-decoration: none !important;
font-size: 0.85rem !important;
}
.tts-dl-btn:hover { background: #4b5563 !important; }
.tts-player-tip {
color: #64748b !important;
font-size: 0.78rem !important;
margin: 10px 0 0 0 !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="#374151",
)
def build_app() -> gr.Blocks:
"""构建 Gradio 主界面。"""
with gr.Blocks(
title="Trading Studio | 交易复盘配音中控",
fill_width=True,
) as demo:
with gr.Tabs(elem_classes=["main-nav-tabs"]):
# ---- Tab 1: 一键生成(默认首页)----
with gr.Tab("一键生成"):
with gr.Column(elem_classes=["oneclick-panel"]):
gr.HTML(
'<div class="oneclick-hero">'
"上传录音或粘贴转写"
'<span class="oneclick-hero-sep">→</span>'
"自动 <em>识别 · 润色 · 合成</em>"
"</div>"
)
with gr.Column(elem_classes=["oneclick-body"]):
with gr.Row(elem_classes=["oneclick-input-grid"]):
with gr.Column(scale=1, elem_classes=["oneclick-card"]):
pipe_audio = gr.Audio(
label="复盘录音",
type="filepath",
sources=["upload", "microphone"],
)
with gr.Column(scale=1, elem_classes=["oneclick-card"]):
pipe_manual = gr.Textbox(
label="或手动输入转写(跳过识别)",
lines=8,
placeholder="已有转写可直接粘贴,留空则 Whisper 识别",
elem_classes=["bright-input"],
)
with gr.Accordion(
"🎚️ 配音音色",
open=False,
elem_classes=["oneclick-voice-accordion"],
):
pipe_voice = gr.Radio(
label="",
choices=voice_choice_labels(),
value=default_voice_label(),
elem_classes=["voice-radio"],
)
with gr.Row(elem_classes=["oneclick-action-row"]):
skip_polish_cb = gr.Checkbox(
label="跳过 Gemma4 润色",
value=False,
scale=0,
)
with gr.Column(
scale=0, min_width=190, elem_classes=["oneclick-run-btn"]
):
pipeline_btn = gr.Button(
"▶ 启动全流程", variant="primary"
)
with gr.Row(elem_classes=["oneclick-bottom-grid"]):
with gr.Column(scale=1):
pipeline_log = gr.Textbox(
label="流水线日志", lines=6, interactive=False
)
with gr.Column(scale=1):
pipe_raw = gr.Textbox(label="转写原文", lines=6)
with gr.Column(scale=1):
pipe_polished = gr.Textbox(label="润色稿", lines=6)
pipe_player = _voice_player_block()
with gr.Accordion("📂 配音历史", open=False):
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 = _voice_player_block()
# ---- 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=False):
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_player = _voice_player_block()
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)
# ---- Tab 4: 配置说明 ----
with gr.Tab("配置说明"):
gr.Markdown(
f"""
# ⚡ Trading Studio
**本地量化交易复盘 → B 站配音生产流水线**
| 模块 | 说明 |
|------|------|
| Whisper | 本地 GPU 语音识别 |
| Gemma4 | `{MODEL_NAME}` @ `{OLLAMA_URL.replace('/api/chat', '')}` |
| ChatTTS | 本地 GPU 固定音色合成 |
> 仓库: [{GIT_REPO_URL}]({GIT_REPO_URL})
> 中控端口: `{PORT}` · 部署路径: `/opt/Trading_Studio`
""",
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],
)
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],
)
lock_btn.click(
ui_lock_speaker,
[spk_audio, spk_transcript],
[lock_log, speaker_status],
)
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_player, 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_player, 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": "/"},
)
@fastapi_app.get("/outputs/{filename}")
async def serve_output_wav(filename: str):
"""供 HTML 播放器直接加载 outputs 下的配音文件。"""
from fastapi import HTTPException
if (
".." in filename
or "/" in filename
or "\\" in filename
or not filename.startswith("voiceover_")
or not filename.endswith(".wav")
):
raise HTTPException(status_code=404)
path = OUTPUT_DIR / filename
if not path.is_file():
raise HTTPException(status_code=404)
return FileResponse(path, media_type="audio/wav", filename=filename)
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()