import os, shutil, base64, uuid, mimetypes from pydub import AudioSegment from openai import OpenAI import gradio as gr # ====== 基本設定 ====== PASSWORD = os.getenv("APP_PASSWORD", "chou") MAX_SIZE = 25 * 1024 * 1024 client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) print("===== 🚀 啟動中 =====") print(f"APP_PASSWORD: {'✅ 已載入' if PASSWORD else '❌ 未載入'}") # ====== 工具:把 data:URL 轉成臨時檔 ====== MIME_EXT = { "audio/mp4": "m4a", "audio/m4a": "m4a", "audio/aac": "aac", "audio/mpeg": "mp3", "audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/webm": "webm", "audio/opus": "opus", "video/mp4": "mp4", } def _dataurl_to_file(data_url: str, orig_name: str | None = None) -> str: # data_url: "data:audio/mp4;base64,AAAA..." try: header, b64 = data_url.split(",", 1) except ValueError: raise ValueError("data URL 格式錯誤(缺少逗號)。") # 取 MIME mime = header.split(";")[0].split(":", 1)[-1].strip() ext = MIME_EXT.get(mime) or (mimetypes.guess_extension(mime) or "m4a").lstrip(".") # 臨時檔名 fname = orig_name if (orig_name and "." in orig_name) else f"upload_{uuid.uuid4().hex}.{ext}" with open(fname, "wb") as f: f.write(base64.b64decode(b64)) return fname def _extract_effective_path(file_obj) -> str: """ 從 Gradio 的 File 輸入(可能是 str / dict / 物件)中,得到真正存在的檔案路徑。 若沒有實檔,就從 data:URL 產生一個臨時檔。 """ # 情況 A:字串(可能是路徑或 data:URL) if isinstance(file_obj, str): s = file_obj.strip().strip('"') if s.startswith("data:"): return _dataurl_to_file(s, None) if os.path.isfile(s): return s # 空字串或無效 → 等下嘗試其他來源 # 情況 B:dict(/gradio_api/call 會送這型) if isinstance(file_obj, dict): # 優先用 path p = str(file_obj.get("path") or "").strip().strip('"') if p and os.path.isfile(p): return p # 不行就用 data data = file_obj.get("data") if isinstance(data, str) and data.startswith("data:"): return _dataurl_to_file(data, file_obj.get("orig_name")) # 有些版本可能把真路徑放在 url u = str(file_obj.get("url") or "").strip().strip('"') if u and os.path.isfile(u): return u # 情況 C:物件(本機 UI 上傳常見) for attr in ("name", "path"): p = getattr(file_obj, attr, None) if isinstance(p, str): s = p.strip().strip('"') if os.path.isfile(s): return s # 物件上有 data:URL? data = getattr(file_obj, "data", None) if isinstance(data, str) and data.startswith("data:"): return _dataurl_to_file(data, getattr(file_obj, "orig_name", None)) raise FileNotFoundError("無法解析上傳檔案:沒有有效路徑,也沒有 data:URL。") # ====== 分段處理 ====== def split_audio(path): size = os.path.getsize(path) if size <= MAX_SIZE: return [path] audio = AudioSegment.from_file(path) n = int(size / MAX_SIZE) + 1 chunk_ms = len(audio) / n parts = [] for i in range(n): fn = f"chunk_{i+1}.wav" audio[int(i*chunk_ms):int((i+1)*chunk_ms)].export(fn, format="wav") parts.append(fn) return parts # ====== 轉錄核心 ====== def transcribe_core(path, model="whisper-1"): # iPhone LINE 常見:mp4(其實是音訊容器) if path.lower().endswith(".mp4"): fixed = path[:-4] + ".m4a" try: shutil.copy(path, fixed) path = fixed except Exception as e: print(f"⚠️ mp4→m4a 失敗: {e}") chunks = split_audio(path) raw = [] for c in chunks: with open(c, "rb") as af: txt = client.audio.transcriptions.create( model=model, file=af, response_format="text" ) raw.append(txt) raw_txt = "\n".join(raw) # 簡轉繁(不意譯) conv = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role":"system","content":"你是嚴格的繁體中文轉換器"}, {"role":"user","content":f"將以下內容轉為台灣繁體,不意譯:\n{raw_txt}"} ], temperature=0.0 ) trad = conv.choices[0].message.content.strip() # 摘要(內容多→條列;內容少→一句話) summ = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role":"system","content":"你是繁體摘要助手"}, {"role":"user","content":f"請用台灣繁體中文摘要;內容多則條列重點,內容短則一句話:\n{trad}"} ], temperature=0.2 ) return trad, summ.choices[0].message.content.strip() # ====== 對外函式(UI / API 共用) ====== def transcribe(password, file): if password.strip() != PASSWORD: return "❌ 密碼錯誤", "", "" if not file: return "⚠️ 未選擇檔案", "", "" try: path = _extract_effective_path(file) except Exception as e: return f"❌ 檔案解析失敗:{e}", "", "" text, summary = transcribe_core(path) return "✅ 完成", text, summary # ====== Gradio UI ====== with gr.Blocks(theme=gr.themes.Soft()) as demo: gr.Markdown("## 🎧 LINE 語音轉錄與摘要(Hugging Face 版)") pw = gr.Textbox(label="密碼", type="password") f = gr.File(label="上傳音訊檔") run = gr.Button("開始轉錄 🚀") s = gr.Textbox(label="狀態", interactive=False) t = gr.Textbox(label="轉錄結果", lines=10) su = gr.Textbox(label="AI 摘要", lines=8) # 🔴 關鍵:這個事件關閉 queue → /gradio_api/call/transcribe 直接回結果 run.click(transcribe, [pw, f], [s, t, su], queue=False) app = demo if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)