56f14206dd
Set container max-width to 1800px, redesign one-click tab with hero bar, card inputs, toolbar row, and three-column output grid. Co-authored-by: Cursor <cursoragent@cursor.com>
1767 lines
60 KiB
Python
1767 lines
60 KiB
Python
"""
|
||
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}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 模块 3:Gemma4 纪律审判
|
||
# ---------------------------------------------------------------------------
|
||
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}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 模块 4:ChatTTS 音频合成
|
||
# ---------------------------------------------------------------------------
|
||
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;
|
||
}
|
||
|
||
/* 外层全宽背景,内容区水平居中 */
|
||
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;
|
||
}
|
||
|
||
/* 核心内容容器居中 — 最大宽度 1800px */
|
||
.gradio-container {
|
||
background: #0f1419 !important;
|
||
color: #eef2f7 !important;
|
||
font-size: 15px !important;
|
||
width: 100% !important;
|
||
max-width: min(1800px, 96vw) !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 {
|
||
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 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-toolbar {
|
||
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;
|
||
}
|
||
|
||
/* 主导航 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;
|
||
}
|
||
|
||
/* 一键生成:1800 宽单页工作区 */
|
||
.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 {
|
||
background: transparent !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
padding: 0 !important;
|
||
margin: 0 !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;
|
||
}
|
||
.oneclick-input-grid {
|
||
display: flex !important;
|
||
flex-direction: row !important;
|
||
flex-wrap: nowrap !important;
|
||
align-items: stretch !important;
|
||
gap: 16px !important;
|
||
width: 100% !important;
|
||
margin-bottom: 14px !important;
|
||
}
|
||
.oneclick-input-grid > .column,
|
||
.oneclick-input-grid > .gr-column {
|
||
flex: 1 1 0 !important;
|
||
min-width: 0 !important;
|
||
}
|
||
.oneclick-card {
|
||
background: #1a2332 !important;
|
||
border: 1px solid #374151 !important;
|
||
border-radius: 10px !important;
|
||
padding: 10px 12px 12px !important;
|
||
height: 100% !important;
|
||
box-sizing: border-box !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 {
|
||
min-height: 220px !important;
|
||
border: 1px dashed #4b5563 !important;
|
||
border-radius: 8px !important;
|
||
background: #111827 !important;
|
||
}
|
||
.oneclick-input-grid textarea {
|
||
min-height: 220px !important;
|
||
border-radius: 8px !important;
|
||
}
|
||
.oneclick-toolbar {
|
||
display: flex !important;
|
||
flex-wrap: wrap !important;
|
||
align-items: center !important;
|
||
gap: 12px 16px !important;
|
||
padding: 12px 14px !important;
|
||
margin-bottom: 14px !important;
|
||
background: #111827 !important;
|
||
border: 1px solid #374151 !important;
|
||
border-radius: 10px !important;
|
||
}
|
||
.oneclick-toolbar .block { margin: 0 !important; padding: 0 !important; }
|
||
.oneclick-toolbar .accordion,
|
||
.oneclick-toolbar .gr-accordion {
|
||
flex: 1 1 280px !important;
|
||
margin: 0 !important;
|
||
border: 1px solid #4b5563 !important;
|
||
border-radius: 8px !important;
|
||
}
|
||
.oneclick-toolbar .accordion > .label-wrap,
|
||
.oneclick-toolbar .gr-accordion .label-wrap {
|
||
background: #1a2332 !important;
|
||
padding: 8px 12px !important;
|
||
min-height: unset !important;
|
||
}
|
||
.oneclick-run-btn {
|
||
flex: 0 0 auto !important;
|
||
min-width: 180px !important;
|
||
}
|
||
.oneclick-run-btn button {
|
||
width: 100% !important;
|
||
padding: 12px 20px !important;
|
||
font-size: 1rem !important;
|
||
border-radius: 8px !important;
|
||
}
|
||
.oneclick-bottom-grid {
|
||
display: flex !important;
|
||
flex-direction: row !important;
|
||
gap: 16px !important;
|
||
width: 100% !important;
|
||
margin-bottom: 14px !important;
|
||
}
|
||
.oneclick-bottom-grid > .column,
|
||
.oneclick-bottom-grid > .gr-column {
|
||
flex: 1 1 0 !important;
|
||
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;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.oneclick-input-grid,
|
||
.oneclick-bottom-grid,
|
||
.oneclick-toolbar {
|
||
flex-direction: column !important;
|
||
}
|
||
.oneclick-run-btn { width: 100% !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="#3b82f6",
|
||
)
|
||
|
||
|
||
def build_app() -> gr.Blocks:
|
||
"""构建 Gradio 主界面。"""
|
||
with gr.Blocks(
|
||
title="Trading Studio | 交易复盘配音中控",
|
||
) as demo:
|
||
with gr.Tabs(elem_classes=["main-nav-tabs"]):
|
||
# ---- Tab 1: 一键生成(默认首页)----
|
||
with gr.Tab("一键生成"):
|
||
with gr.Group(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.Row(elem_classes=["oneclick-toolbar"]):
|
||
skip_polish_cb = gr.Checkbox(
|
||
label="跳过 Gemma4 润色",
|
||
value=False,
|
||
scale=0,
|
||
)
|
||
with gr.Column(scale=1):
|
||
with gr.Accordion("🎚️ 配音音色", open=False):
|
||
pipe_voice = gr.Radio(
|
||
label="",
|
||
choices=voice_choice_labels(),
|
||
value=default_voice_label(),
|
||
elem_classes=["voice-radio"],
|
||
)
|
||
with gr.Column(scale=0, min_width=180, 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()
|