""" Trading Studio — 自动化交易复盘视频配音系统 Gradio Web 中控:音色锁定 → Whisper 识别 → Gemma4 润色 → ChatTTS 合成 """ from __future__ import annotations import logging import shutil import sys import uuid from pathlib import Path import gradio as gr from config import ( GIT_REPO_URL, HOST, MODEL_NAME, OLLAMA_URL, PORT, SPEAKER_EMB_PATH, UPLOAD_DIR, ) from llm_service import check_ollama_health, polish_text from tts_service import generate_voice, save_fixed_speaker, speaker_is_ready from 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") # --------------------------------------------------------------------------- # 全局 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)} 字。" 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 ui_synthesize(polished_text: str) -> tuple[str | None, str]: """【TTS 合成】生成最终 wav 配音文件。""" if not polished_text or not polished_text.strip(): return None, "请先完成 Gemma4 润色。" ok, msg, wav_path = generate_voice(polished_text) if ok and wav_path: return wav_path, f"✅ {msg}" return None, f"❌ {msg}" # --------------------------------------------------------------------------- # 一键流水线 # --------------------------------------------------------------------------- def ui_full_pipeline( audio_file, skip_polish: bool, manual_raw: str, ) -> tuple[str, str, str | None, 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 "", "", None, "❌ 请上传录音或手动填写转写文本。" ok, result = transcribe_audio(path) if not ok: return "", "", None, f"❌ 识别失败: {result}" 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, "", None, f"❌ 润色失败: {result}\n" + "\n".join(logs) polished = result logs.append(f"✅ Gemma4 润色完成({len(polished)} 字)。") # Step 3: 合成 ok, msg, wav_path = generate_voice(polished) if not ok: return raw, polished, None, f"❌ 合成失败: {msg}\n" + "\n".join(logs) logs.append(f"✅ {msg}") return raw, polished, wav_path, "\n".join(logs) # --------------------------------------------------------------------------- # Gradio 界面 # --------------------------------------------------------------------------- CUSTOM_CSS = """ /* ========== 高对比度暗色主题(确保文字清晰可读) ========== */ .gradio-container { background: #0f1419 !important; color: #eef2f7 !important; font-size: 15px !important; max-width: 1400px !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; } .gradio-container textarea::placeholder, .gradio-container input::placeholder { color: #9ca3af !important; opacity: 1 !important; } /* 只读日志框 */ .gradio-container .wrap .readonly textarea { background: #111827 !important; color: #e5e7eb !important; border-color: #374151 !important; } /* Tab 标签页 */ .gradio-container .tab-nav button { color: #9ca3af !important; font-weight: 600 !important; font-size: 0.95rem !important; padding: 10px 18px !important; } .gradio-container .tab-nav button.selected { color: #ffffff !important; background: #1e3a5f !important; border-bottom: 3px solid #3b82f6 !important; } /* 按钮 */ .gradio-container button.primary, .gradio-container .primary { background: #2563eb !important; color: #ffffff !important; font-weight: 700 !important; font-size: 0.95rem !important; border: 1px solid #3b82f6 !important; } .gradio-container button.primary:hover { background: #1d4ed8 !important; } .gradio-container button.secondary, .gradio-container button:not(.primary) { color: #e5e7eb !important; background: #374151 !important; border: 1px solid #6b7280 !important; font-weight: 600 !important; } /* 顶部信息面板 */ .dark-panel { border: 1px solid #3b82f6 !important; border-radius: 10px !important; padding: 18px 20px !important; background: #1a2332 !important; margin-bottom: 16px !important; } .dark-panel code { color: #93c5fd !important; background: #0f172a !important; padding: 2px 6px !important; border-radius: 4px !important; } /* 状态卡片 */ .status-card { border-radius: 10px; padding: 14px 16px; margin: 4px 0; border-left: 5px solid #6b7280; background: #1f2937; min-height: 72px; } .status-card .status-title { font-size: 0.85rem; font-weight: 700; color: #93c5fd !important; margin-bottom: 8px; letter-spacing: 0.03em; } .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; } /* 音频上传区 */ .gradio-container .audio-container, .gradio-container .upload-container { border: 2px dashed #4b5563 !important; background: #1a2332 !important; } footer { visibility: hidden; } """ 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'