""" 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 ( '
' "

合成完成后可在此试听,拖动滑块调节语速(0.5x~2.0x),点「保存」下载。

" "
" ) path = Path(wav_path) if not path.is_file(): return ( '
' "

音频文件不存在,请重新合成或刷新历史列表。

" "
" ) name = path.name src = f"/outputs/{quote(name)}" return f"""
🎧 {name}
播放语速 1.00x
⬇ 原速下载

试听语速与保存文件一致;「原速下载」获取未变速的原始 WAV。

""" 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 = """ """ MIC_HINT_HTML = """
📱 手机 / 平板录音说明
""" INSTALL_APP_BUTTON_HTML = """ """ CUSTOM_CSS = """ /* ========== 居中布局 + 响应式 + 高对比度 ========== */ html, body { width: 100% !important; min-height: 100vh !important; margin: 0 !important; padding: 0 !important; background: #0f1419 !important; overflow-x: hidden !important; } /* 外层全宽背景,内容区水平居中 */ gradio-app, .gradio-app, #main, .app, .fillable, .contain { width: 100% !important; max-width: 100% !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: flex-start !important; background: #0f1419 !important; box-sizing: border-box !important; } /* 核心内容容器居中 */ .gradio-container { background: #0f1419 !important; color: #eef2f7 !important; font-size: 15px !important; width: 100% !important; max-width: min(1200px, 94vw) !important; margin-left: auto !important; margin-right: auto !important; padding: 16px clamp(12px, 3vw, 32px) !important; box-sizing: border-box !important; float: none !important; } /* 带鱼屏 / 超宽屏 */ @media (min-width: 1920px) { .gradio-container { max-width: min(1280px, 82vw) !important; } } @media (min-width: 2560px) { .gradio-container { max-width: min(1360px, 68vw) !important; } } @media (min-width: 3440px) { .gradio-container { max-width: 1440px !important; } } /* 平板 */ @media (max-width: 1024px) { .gradio-container { max-width: 100% !important; padding: 14px 18px !important; } } /* 手机 */ @media (max-width: 640px) { .gradio-container { max-width: 100% !important; padding: 10px 12px !important; font-size: 14px !important; } .gradio-container h1 { font-size: 1.35rem !important; } .gradio-container .tab-nav { flex-wrap: wrap !important; gap: 6px !important; } .gradio-container .tab-nav button { flex: 1 1 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, .pipeline-input-row, .pipeline-input-row > div { flex-direction: column !important; width: 100% !important; } .status-row button, .pipeline-steps button, .pipeline-output-row button { width: 100% !important; } .dark-panel table { font-size: 0.85rem !important; display: block !important; overflow-x: auto !important; } .install-hint { font-size: 0.85rem !important; } } /* 触摸设备:按钮最小点击区域 44px */ @media (hover: none) and (pointer: coarse) { .gradio-container button { min-height: 44px !important; } } /* 全局文字 */ .gradio-container p, .gradio-container span, .gradio-container label, .gradio-container .prose, .gradio-container .markdown-text, .gradio-container .md { color: #eef2f7 !important; } .gradio-container h1 { color: #ffffff !important; font-size: 1.75rem !important; font-weight: 700 !important; } .gradio-container h2, .gradio-container h3 { color: #dbeafe !important; font-size: 1.15rem !important; font-weight: 600 !important; } .gradio-container .block-label, .gradio-container label, .gradio-container .label-wrap span { color: #93c5fd !important; font-weight: 600 !important; font-size: 0.95rem !important; } .gradio-container textarea, .gradio-container input[type="text"], .gradio-container .wrap textarea, .gradio-container .wrap input { color: #ffffff !important; background: #1a2332 !important; border: 1px solid #4b5563 !important; font-size: 0.95rem !important; line-height: 1.6 !important; } /* placeholder 高对比度 */ .gradio-container textarea::placeholder, .gradio-container input::placeholder, .gradio-container input[type="text"]::placeholder { color: #d1d5db !important; opacity: 1 !important; -webkit-text-fill-color: #d1d5db !important; } .gradio-container .bright-input textarea, .gradio-container .bright-input input { background: #1e293b !important; border: 1px solid #64748b !important; } /* 音色选择 Radio(避免 Dropdown 在深色/手机端白底白字) */ .gradio-container .voice-radio fieldset { border: 1px solid #374151 !important; border-radius: 10px !important; background: #111827 !important; padding: 10px 12px !important; } .gradio-container .voice-radio label { display: flex !important; align-items: center !important; gap: 10px !important; padding: 10px 12px !important; margin: 6px 0 !important; border-radius: 8px !important; border: 1px solid #374151 !important; background: #1a2332 !important; color: #e5e7eb !important; font-size: 0.92rem !important; cursor: pointer !important; } .gradio-container .voice-radio label:hover { border-color: #3b82f6 !important; background: #1e293b !important; } .gradio-container .voice-radio label.selected { border-color: #3b82f6 !important; background: #1e3a5f !important; color: #ffffff !important; font-weight: 600 !important; } .gradio-container .voice-radio input[type="radio"] { accent-color: #3b82f6 !important; width: 18px !important; height: 18px !important; flex-shrink: 0 !important; } @media (min-width: 520px) { .gradio-container .voice-radio fieldset { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 8px !important; max-height: none !important; } .gradio-container .voice-radio label { margin: 0 !important; } } @media (max-width: 519px) { .gradio-container .voice-radio fieldset { max-height: 200px !important; overflow-y: auto !important; } } /* 分步流水线:纵向卡片,避免三栏挤扁 */ .pipeline-flow { width: 100% !important; gap: 4px !important; } .pipeline-step-card { border: 1px solid #374151 !important; border-radius: 12px !important; padding: 16px 18px !important; background: #111827 !important; margin-bottom: 18px !important; width: 100% !important; box-sizing: border-box !important; } .pipeline-step-card h3 { margin-top: 0 !important; padding-bottom: 8px !important; border-bottom: 1px solid #374151 !important; } .gradio-container .pipeline-step-card button { width: 100% !important; max-width: 100% !important; white-space: normal !important; line-height: 1.35 !important; min-height: 44px !important; } .gradio-container .accordion, .gradio-container .gr-accordion { border: 1px solid #374151 !important; border-radius: 10px !important; background: #1a2332 !important; } .gradio-container .accordion > .label-wrap, .gradio-container .gr-accordion .label-wrap { background: #1e293b !important; color: #e5e7eb !important; } .gradio-container .accordion .content, .gradio-container .gr-accordion .content { background: #111827 !important; } /* Dropdown 兜底(其他下拉组件深色化) */ .gradio-container .gradio-dropdown input, .gradio-container .dropdown input, .gradio-container select { background: #1a2332 !important; color: #ffffff !important; border: 1px solid #4b5563 !important; } .gradio-container ul.options, .gradio-container .gradio-dropdown ul, .gradio-container [role="listbox"] { background: #1e293b !important; border: 1px solid #4b5563 !important; color: #e5e7eb !important; } .gradio-container ul.options li, .gradio-container [role="option"] { color: #e5e7eb !important; background: #1e293b !important; } .gradio-container ul.options li:hover, .gradio-container ul.options li.selected, .gradio-container [role="option"][aria-selected="true"] { background: #2563eb !important; color: #ffffff !important; } /* 输入框下方 info 提示文字 */ .gradio-container .hint, .gradio-container .info, .gradio-container .form .secondary-wrap, .gradio-container span[data-testid="block-info"] { color: #94a3b8 !important; font-size: 0.88rem !important; } /* Markdown 内联代码 — 修复白底看不见 */ .gradio-container code, .gradio-container .prose code, .gradio-container .markdown-text code, .gradio-container pre code { background: #1e3a5f !important; color: #bfdbfe !important; border: 1px solid #3b82f6 !important; padding: 2px 10px !important; border-radius: 6px !important; font-size: 0.9em !important; } .gradio-container pre { background: #111827 !important; border: 1px solid #374151 !important; padding: 12px !important; border-radius: 8px !important; } /* 组件标签 — 修复白底蓝字 */ .gradio-container .block-label, .gradio-container .label-wrap, .gradio-container .label-wrap span, .gradio-container span.label { background: #1e293b !important; background-color: #1e293b !important; color: #93c5fd !important; font-weight: 600 !important; font-size: 0.95rem !important; border: 1px solid #475569 !important; border-radius: 6px !important; padding: 4px 10px !important; } /* 说明文字块 */ .hint-box { background: #1e293b !important; border: 1px solid #475569 !important; border-radius: 10px !important; padding: 14px 18px !important; color: #e2e8f0 !important; font-size: 0.95rem !important; line-height: 1.7 !important; margin-bottom: 12px !important; } .hint-box strong { color: #ffffff !important; } .file-tag { display: inline-block; background: #1e3a5f !important; color: #bfdbfe !important; border: 1px solid #3b82f6 !important; padding: 3px 12px !important; border-radius: 6px !important; font-family: "JetBrains Mono", Consolas, monospace !important; font-weight: 700 !important; font-size: 0.92em !important; } /* 页头 + 安装 App 按钮 */ .header-row { align-items: flex-start !important; width: 100% !important; } .header-row > div { flex: 1 1 auto !important; } .pwa-install-btn { display: none; align-items: center; gap: 10px; padding: 12px 20px; margin-top: 8px; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff !important; border: 2px solid #60a5fa; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: 1rem; font-family: inherit; box-shadow: 0 4px 20px rgba(37, 99, 235, 0.45); transition: transform 0.15s, box-shadow 0.15s; white-space: nowrap; } .pwa-install-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(37, 99, 235, 0.55); } .pwa-install-btn.pwa-ready { animation: pwa-pulse 2s infinite; } @keyframes pwa-pulse { 0%, 100% { box-shadow: 0 4px 20px rgba(37, 99, 235, 0.45); } 50% { box-shadow: 0 4px 28px rgba(96, 165, 250, 0.8); } } .pwa-install-icon { flex-shrink: 0; border-radius: 6px; } .pwa-install-text { color: #ffffff !important; } /* 安装引导弹窗 */ .pwa-modal-overlay { position: fixed; inset: 0; z-index: 99999; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; padding: 20px; } .pwa-modal { position: relative; background: #1e293b; border: 2px solid #3b82f6; border-radius: 16px; padding: 28px 32px; max-width: 420px; width: 100%; color: #e2e8f0; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } .pwa-modal h3 { color: #ffffff !important; margin: 0 0 12px !important; font-size: 1.25rem !important; } .pwa-modal p { color: #cbd5e1 !important; line-height: 1.6 !important; } .pwa-modal ol { color: #e2e8f0 !important; padding-left: 20px !important; line-height: 1.8 !important; } .pwa-modal-tip { font-size: 0.85rem !important; color: #93c5fd !important; margin-top: 16px !important; } .pwa-modal-warn { background: #422006 !important; border: 1px solid #f59e0b !important; border-radius: 8px !important; padding: 12px 14px !important; margin-bottom: 14px !important; color: #fde68a !important; font-size: 0.9rem !important; line-height: 1.6 !important; } .pwa-modal-close { position: absolute; top: 12px; right: 14px; background: #374151; border: none; color: #fff; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; font-size: 1rem; } @media (max-width: 640px) { .header-row { flex-direction: column !important; } .pwa-install-btn { width: 100% !important; justify-content: center !important; margin-top: 12px !important; } } .gradio-container .wrap .readonly textarea { background: #111827 !important; color: #e5e7eb !important; border-color: #374151 !important; } .gradio-container .tab-nav button { color: #9ca3af !important; font-weight: 600 !important; font-size: 0.95rem !important; padding: 10px 18px !important; } /* 主导航 Tab 均匀分布 */ .gradio-container .main-nav-tabs > .tab-nav, .gradio-container .tabs.main-nav-tabs > .tab-nav { display: flex !important; width: 100% !important; gap: 0 !important; } .gradio-container .main-nav-tabs > .tab-nav button, .gradio-container .tabs.main-nav-tabs > .tab-nav button { flex: 1 1 25% !important; min-width: 0 !important; text-align: center !important; justify-content: center !important; border-radius: 0 !important; } /* 一键生成:上传区与转写区等大 */ .pipeline-input-row { align-items: stretch !important; gap: 12px !important; } .pipeline-input-row > .gr-column, .pipeline-input-row > div { flex: 1 1 50% !important; min-width: 0 !important; width: 50% !important; display: flex !important; flex-direction: column !important; } .pipeline-input-row .block, .pipeline-input-row .form { flex: 1 1 auto !important; height: 100% !important; } .pipeline-input-row .audio-container, .pipeline-input-row .upload-container { flex: 1 1 auto !important; min-height: 220px !important; display: flex !important; flex-direction: column !important; justify-content: center !important; } .pipeline-input-row textarea { min-height: 220px !important; height: 100% !important; } .gradio-container .tab-nav button.selected { color: #ffffff !important; background: #1e3a5f !important; border-bottom: 3px solid #3b82f6 !important; } .gradio-container button.primary, .gradio-container .primary { background: #2563eb !important; color: #ffffff !important; font-weight: 700 !important; font-size: 0.95rem !important; border: 1px solid #3b82f6 !important; } .gradio-container button.primary:hover { background: #1d4ed8 !important; } .gradio-container button.secondary, .gradio-container button:not(.primary) { color: #e5e7eb !important; background: #374151 !important; border: 1px solid #6b7280 !important; font-weight: 600 !important; } .dark-panel { border: 1px solid #3b82f6 !important; border-radius: 10px !important; padding: 18px 20px !important; background: #1a2332 !important; margin-bottom: 16px !important; width: 100% !important; box-sizing: border-box !important; } .dark-panel code { color: #93c5fd !important; background: #0f172a !important; padding: 2px 6px !important; border-radius: 4px !important; } .install-hint { margin-top: 12px !important; padding: 10px 14px !important; border-radius: 8px !important; background: #1e3a5f !important; border: 1px dashed #3b82f6 !important; color: #bfdbfe !important; font-size: 0.9rem !important; } .status-card { border-radius: 10px; padding: 14px 16px; margin: 4px 0; border-left: 5px solid #6b7280; background: #1f2937; min-height: 72px; width: 100%; box-sizing: border-box; } .status-card .status-title { font-size: 0.85rem; font-weight: 700; color: #93c5fd !important; margin-bottom: 8px; } .status-card .status-body { font-size: 0.92rem; line-height: 1.55; color: #f3f4f6 !important; word-break: break-word; } .status-ok { border-left-color: #22c55e !important; background: #14291a !important; } .status-ok .status-body { color: #bbf7d0 !important; } .status-warn { border-left-color: #f59e0b !important; background: #2a2010 !important; } .status-warn .status-body { color: #fde68a !important; } .status-err { border-left-color: #ef4444 !important; background: #2a1515 !important; } .status-err .status-body { color: #fecaca !important; } /* 状态栏与播放器隔离重绘,减轻合成完成后的闪屏 */ .status-row { contain: layout style paint; min-height: 88px; } .gradio-container .audio-container, .gradio-container .upload-container { border: 2px dashed #4b5563 !important; background: #1a2332 !important; contain: layout style paint; min-height: 100px; } .gradio-container .audio-container audio, .gradio-container .audio-container canvas, .gradio-container .waveform-container { background: #1a2332 !important; } /* 成品 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'
' f'
{icon} {title}
' f'
{clean}
' f"
" ) 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("一键生成"): gr.HTML(MIC_HINT_HTML) gr.Markdown( "上传碎碎念录音,系统自动完成 **识别 → 润色 → 合成** 全流程。" ) with gr.Row(equal_height=True, elem_classes=["pipeline-input-row"]): pipe_audio = gr.Audio( label="复盘录音", type="filepath", sources=["upload", "microphone"], scale=1, ) pipe_manual = gr.Textbox( label="或手动输入转写(跳过识别)", lines=8, placeholder="若已有转写文本,可直接粘贴,留空则走 Whisper 识别", scale=1, elem_classes=["bright-input"], ) skip_polish_cb = gr.Checkbox( label="跳过 Gemma4 润色(仅测试 TTS)", value=False, ) with gr.Accordion("🎚️ 配音音色", open=False): pipe_voice = gr.Radio( label="配音音色(本地 ChatTTS)", choices=voice_choice_labels(), value=default_voice_label(), elem_classes=["voice-radio"], ) pipeline_btn = gr.Button("▶ 启动全流程", variant="primary", size="lg") pipeline_log = gr.Textbox(label="流水线日志", lines=6, interactive=False) with gr.Row(elem_classes=["pipeline-output-row"]): pipe_raw = gr.Textbox(label="转写原文", lines=6) pipe_polished = gr.Textbox(label="润色稿", lines=6) pipe_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( "" "本机显卡合成,无需 API。润色完成后在此选音色并点合成。" ) 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'
' f'上传 10-30 秒 干净人声样本,系统将提取 Speaker Embedding ' f'并保存至 {SPEAKER_EMB_PATH.name},' f'后续合成 100% 还原音色。' f"
" ) 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], ) with gr.Accordion("📂 配音历史(本地保留,可随时试听下载)", open=True): with gr.Row(): history_select = gr.Dropdown( label="历史配音", choices=list_voice_history(), value=None, interactive=True, scale=4, ) history_refresh_btn = gr.Button("🔄 刷新", scale=0, min_width=100) history_player = _voice_player_block() 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()