| | 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 '❌ 未載入'}") |
| |
|
| | |
| | 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: |
| | |
| | try: |
| | header, b64 = data_url.split(",", 1) |
| | except ValueError: |
| | raise ValueError("data URL 格式錯誤(缺少逗號)。") |
| | |
| | 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 產生一個臨時檔。 |
| | """ |
| | |
| | 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 |
| | |
| | |
| | if isinstance(file_obj, dict): |
| | |
| | p = str(file_obj.get("path") or "").strip().strip('"') |
| | if p and os.path.isfile(p): |
| | return p |
| | |
| | data = file_obj.get("data") |
| | if isinstance(data, str) and data.startswith("data:"): |
| | return _dataurl_to_file(data, file_obj.get("orig_name")) |
| | |
| | u = str(file_obj.get("url") or "").strip().strip('"') |
| | if u and os.path.isfile(u): |
| | return u |
| | |
| | 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 = 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"): |
| | |
| | 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() |
| |
|
| | |
| | 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 |
| |
|
| | |
| | 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) |
| | |
| | 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) |
| |
|