D
昨天(Day 18)我們讓 Her 會「聽」:把 LINE 錄音轉成文字、再用 AI 回覆。
今天把循環補完——讓 Bot 也能開口說話:把 AI 文字回覆丟進 TTS(Text-to-Speech),產生 mp3,再用 LINE 的 AudioSendMessage 回給使用者。從此你跟 Her 可以用聲音聊天。
⸻
成品亮點
• 支援「請用語音回覆」關鍵字,Bot 以語音回你
• 不需要額外服務架設檔案伺服器,直接用 FastAPI 提供 /media/{file} 暫存音檔
• 首選 OpenAI TTS(gpt-4o-mini-tts);若沒設定 Key,自動回文字、不影響服務上線
• 與你現有 app_fastapi.py 結構相容(FastAPI + LINE SDK v3)
⸻
需要的環境變數(延用 Day 18)
Key 說明
BASE_URL 你的公開網址(Render 主 URL)
CHANNEL_ACCESS_TOKEN LINE Messaging API token
CHANNEL_SECRET LINE Channel Secret
OPENAI_API_KEY (選)OpenAI 金鑰:用來做 TTS
GROQ_API_KEY (選)Groq 金鑰:聊天/Whisper(沿用 Day 18)
沒 OPENAI_API_KEY 也能跑:只是 TTS 功能會自動降級為純文字回覆。
⸻
requirements.txt(與你目前相容)
fastapi
uvicorn
line-bot-sdk>=3.0.0
groq
openai
requests
pandas
beautifulsoup4
lxml
html5lib
taiwanlottery
yfinance
⸻
主要流程
1. 產生 TTS 音檔(mp3) →
2. 存到本機 /tmp →
3. 由 FastAPI 提供 /media/{檔名} 下載 →
4. 回 LINE AudioSendMessage,帶上檔案 URL 與大致毫秒數
⸻
直接可貼的程式片段
把下面三段加入你現有的 app_fastapi.py(和你現有架構一致):
A. 匯入與工具(檔案最上方 import 區補兩個)
from fastapi.responses import FileResponse # 新增
from linebot.models import AudioSendMessage # 新增
import uuid # 生成隨機檔名
B. TTS 產生與暫存(放在「Helpers」區)
MEDIA_DIR = "/tmp" # Render/雲端可寫入;重啟會清空是預期行為
def _est_duration_ms(text: str) -> int:
"""
粗估語音長度(毫秒)。中文每字 ~250ms、英文每詞 ~180ms;取簡單下限/上限保護。
"""
n = max(1, len(text.strip()))
ms = int(n * 220) # 估一個保守值
return max(2000, min(ms, 59000)) # LINE 建議 < 60s
def save_bytes_and_get_url(data: bytes, suffix: str = ".mp3") -> (str, int):
"""
把音訊 bytes 存到 /tmp,回傳 (可公開下載的URL, 檔案大小)
"""
fname = f"tts_{uuid.uuid4().hex}{suffix}"
fpath = os.path.join(MEDIA_DIR, fname)
with open(fpath, "wb") as f:
f.write(data)
size = os.path.getsize(fpath)
return f"{BASE_URL}/media/{fname}", size
def tts_synthesize_to_mp3(text: str) -> bytes:
"""
優先使用 OpenAI TTS(gpt-4o-mini-tts)產生 mp3。
若沒設定 OPENAI_API_KEY 或失敗,拋例外由上層做降級。
"""
if not openai_client:
raise RuntimeError("OPENAI_API_KEY 未設定,無法產生語音")
try:
# OpenAI Python SDK v1.x
# 產生整段 mp3(小於 25MB 十分安全)
resp = openai_client.audio.speech.create(
model="gpt-4o-mini-tts",
voice="alloy", # 可改:verse, aria, coral...
input=text,
format="mp3"
)
# SDK 會回傳 bytes(若你用 streaming 版本也可)
return resp.content
except Exception as e:
raise RuntimeError(f"OpenAI TTS 失敗:{e}")
C. 提供媒體下載端點(加入到 Router 區)
@router.get("/media/{filename}")
async def media(filename: str):
# 僅限 /tmp 底下的檔案
if not filename or "/" in filename or "\" in filename:
raise HTTPException(status_code=400, detail="Bad filename")
fpath = os.path.join(MEDIA_DIR, filename)
if not os.path.exists(fpath):
raise HTTPException(status_code=404, detail="File not found")
# mp3 預設 Content-Type
return FileResponse(fpath, media_type="audio/mpeg", filename=filename)
D. 在訊息處理器加入「語音回覆」觸發(放在 handle_message_async 的指令區)
if any(k in low for k in ["語音回覆", "用說的回覆", "請用語音回覆"]):
try:
# 先生成 AI 文字(沿用你的人設+情緒)
history = conversation_history.get(chat_id, [])
sentiment = await analyze_sentiment(msg.replace("語音回覆", ""))
sys_prompt = build_persona_prompt(chat_id, sentiment)
messages = [{"role":"system","content":sys_prompt}] + history + [{"role":"user","content":msg}]
ai_text = await groq_chat_async(messages)
# TTS
audio_bytes = tts_synthesize_to_mp3(ai_text)
audio_url, _ = save_bytes_and_get_url(audio_bytes, ".mp3")
duration_ms = _est_duration_ms(ai_text)
# 回音訊
return line_bot_api.reply_message(
reply_token,
AudioSendMessage(
original_content_url=audio_url,
duration=duration_ms
)
)
except Exception as e:
logger.error(f"TTS 流程失敗:{e}", exc_info=True)
# 降級為純文字
return reply_with_quick_bar(reply_token, "目前語音回覆暫時忙碌,我先用文字回你:\n(若要語音,稍後再試一次)")
想要「所有回覆都變語音」也行:把這段提到一般對話分支前面並條件改成 if True: 或自訂旗標。
⸻
使用方式
• 在 LINE 輸入:
「請用語音回覆:今天心情有點低落…」
Bot 會先生出回覆文字,接著丟進 TTS,最後回你一段 mp3(~幾秒)。
• 若沒設 OPENAI_API_KEY 或 TTS 超時:
會自動降級成純文字,不中斷整體服務。
⸻
除錯建議
• Cause of failure could not be determined
大多是程式在 import 階段就 raise。本篇 TTS 只有在呼叫時才跑,不會在啟動時爆炸。
• LINE 播不了音
確認 /media/xxx.mp3 能以 HTTPS 公開下載;Render 必須用你的 BASE_URL。
⸻
結語
到這裡,你的 Her 既能聽懂你,也能親口回你。
期待未來可以加入的功能:不同情緒換不同音色/語速、或把語音也做摘要(長語音→重點),甚至把股價/金價查詢的結果唸出來。