來囉!這是你 #Day21 鐵人賽的完整發文初稿(標題+正文+可直接貼 Medium/部落格)。主軸延續前幾天:把「錄音→AI 回覆→語音回傳」做成零成本 TTS方案(gTTS),再加上Groq 模型下架因應與部署實戰排雷,一篇搞定 👇
⸻
Day21|不花錢也能講話:用 gTTS + Cloudinary 讓 LINE Bot 說出 AI 回覆(含 Groq 模型下架防呆)
TL;DR
• 用 **gTTS(Google Text-to-Speech 的 Python 套件)**當免費 TTS。
• 產生 MP3 → 上傳 Cloudinary 取得 HTTPS 音訊網址 → 回傳 LINE AudioMessage。
• **STT(語音轉文字)**走 Groq(Whisper large v3)或 OpenAI Whisper,雙路備援。
• Groq 主模型如果被「下架」(decommissioned),用候選清單輪流嘗試,避免整串報錯。
• 加碼修掉 yfinance 與 pandas 的警告,部署更乾淨。
⸻
在 LINE 你可以:
1. 對 Bot 傳一段語音(m4a / caf / aac…)。
2. Bot 轉文字後,以你設定的人設(甜/鹹/萌/酷)產生回覆。
3. Bot 同時回 文字+語音(這裡的語音是 gTTS 生成,免費)。
4. 金價/匯率/股票/樂透分析等指令照樣可用。
流程圖:
使用者語音 → LINE → Bot 接收音訊
↓
STT 轉文字(Groq Whisper / OpenAI Whisper)
↓
LLM 生文字回覆(Groq / OpenAI 備援)
↓
TTS(gTTS)→ MP3 → Cloudinary(取得 HTTPS)
↓
LINE AudioMessage(原文+語音一起回)
⸻
至少準備:
CHANNEL_ACCESS_TOKEN=你的LINE Channel token
CHANNEL_SECRET=你的LINE Channel secret
GROQ_API_KEY=你的Groq API key
CLOUDINARY_URL=cloudinary://<api_key>:<api_secret>@<cloud_name>
TTS_PROVIDER=gtts
小提醒:不要在值前後放引號或多餘空白,否則會出現 401 或 URL 解析錯誤。
⸻
加上 gTTS 與 cloudinary(其餘你前幾天都已安裝):
fastapi
uvicorn
line-bot-sdk>=3.0.0
groq
openai
requests
pandas
beautifulsoup4
lxml
html5lib
yfinance
gTTS
cloudinary
httpx
⸻
4.1 TTS 抽象層:支援 gTTS 與 OpenAI TTS
from gtts import gTTS
TTS_PROVIDER = os.getenv("TTS_PROVIDER", "gtts").lower() # gtts | openai
def tts_gtts_bytes(text: str) -> bytes:
# gTTS 直接回 bytes(MP3)
fp = io.BytesIO()
# zh-TW 常用參數:lang='zh-TW' 但 gTTS 支援度以 'zh' 较穩
gTTS(text=re.sub(r'[*_`~#]', '', text), lang='zh').write_to_fp(fp)
fp.seek(0)
return fp.read()
def tts_openai_bytes(text: str) -> bytes | None:
if not openai_client:
return None
try:
clean = re.sub(r'[*_`~#]', '', text)
resp = openai_client.audio.speech.create(model="tts-1", voice="nova", input=clean)
return resp.read()
except Exception as e:
logger.warning(f"OpenAI TTS 失敗:{e}")
return None
def build_tts_bytes(text: str) -> bytes | None:
# 先依環境變數選擇供應商,另一個當備援
providers = [TTS_PROVIDER]
if "openai" not in providers: providers.append("openai")
if "gtts" not in providers: providers.append("gtts")
last_err = None
for p in providers:
try:
if p == "gtts":
return tts_gtts_bytes(text)
if p == "openai":
b = tts_openai_bytes(text)
if b:
return b
except Exception as e:
last_err = e
logger.warning(f"TTS 提供者 {p} 失敗:{e}")
if last_err:
logger.error(f"所有 TTS 供應商皆失敗:{last_err}")
return None
4.2 上傳 MP3 到 Cloudinary,拿 HTTPS 給 LINE
def _upload_audio_sync(audio_bytes: bytes) -> dict | None:
if not CLOUDINARY_URL:
return None
try:
# LINE 對音訊類型以 HTTPS 連結即可(mp3 可用)
return cloudinary.uploader.upload(
io.BytesIO(audio_bytes),
resource_type="video", # Cloudinary 將音訊歸在 video 資源類型
folder="line-bot-tts",
format="mp3"
)
except Exception as e:
logger.error(f"Cloudinary 上傳失敗: {e}", exc_info=True)
return None
async def upload_audio_to_cloudinary(audio_bytes: bytes) -> str | None:
result = await run_in_threadpool(_upload_audio_sync, audio_bytes)
return result.get("secure_url") if result else None
4.3 把文字回覆 + 語音一起回給使用者
messages_to_send = [TextMessage(text=final_reply_text, quick_reply=build_quick_reply())]
if final_reply_text and CLOUDINARY_URL:
audio_bytes = build_tts_bytes(final_reply_text)
if audio_bytes:
public_url = await upload_audio_to_cloudinary(audio_bytes)
if public_url:
# duration 毫秒,先給 20 秒保守值
messages_to_send.append(AudioMessage(original_content_url=public_url, duration=20000))
logger.info("✅ TTS 語音已上傳並加入回覆")
⸻
你可以完全不放 OpenAI Key,也能靠 Groq 完成 STT。
def _transcribe_with_openai(audio_bytes: bytes, filename: str = "audio.m4a") -> str | None:
if not openai_client:
return None
try:
f = io.BytesIO(audio_bytes); f.name = filename
resp = openai_client.audio.transcriptions.create(model="whisper-1", file=f)
return (resp.text or "").strip() or None
except Exception as e:
logger.warning(f"OpenAI 轉錄失敗:{e}")
return None
def _transcribe_with_groq(audio_bytes: bytes, filename: str = "audio.m4a") -> str | None:
if not sync_groq_client:
return None
try:
f = io.BytesIO(audio_bytes); f.name = filename
resp = sync_groq_client.audio.transcriptions.create(file=f, model="whisper-large-v3")
return (resp.text or "").strip() or None
except Exception as e:
logger.warning(f"Groq 轉錄失敗:{e}")
return None
在音訊事件處理中:
content_stream = await line_bot_api.get_message_content(event.message.id)
audio_in = await content_stream.read()
text = _transcribe_with_openai(audio_in) or _transcribe_with_groq(audio_in)
if not text:
await line_bot_api.reply_message(ReplyMessageRequest(
reply_token=reply_token,
messages=[TextMessage(text="抱歉我剛剛沒聽清楚 🙈 能再說一次或改用文字嗎?")]
))
return
⸻
官方最近把 llama-3.1-70b-versatile 下架,最穩的是把聊天模型做成候選清單,逐一嘗試。
GROQ_MODEL_PRIMARY = os.getenv("GROQ_MODEL_PRIMARY", "llama-3.1-8b-instant")
GROQ_MODEL_FALLBACK = os.getenv("GROQ_MODEL_FALLBACK", "llama-3.1-8b-instant")
GROQ_MODEL_CANDIDATES = [
GROQ_MODEL_PRIMARY,
GROQ_MODEL_FALLBACK,
"llama-3.1-8b-instant", # 保底
# 需要時再加入: "llama-3.2-70b-text-preview" 等官方推薦
]
GROQ_MODEL_CANDIDATES = [m for i, m in enumerate(GROQ_MODEL_CANDIDATES) if m and m not in GROQ_MODEL_CANDIDATES[:i]]
def groq_chat_sync_with_models(messages, max_tokens=1500, temperature=0.7) -> str:
if not sync_groq_client:
raise RuntimeError("Groq client not initialized.")
last_err = None
for model in GROQ_MODEL_CANDIDATES:
try:
resp = sync_groq_client.chat.completions.create(
model=model, messages=messages, max_tokens=max_tokens, temperature=temperature
)
return resp.choices[0].message.content
except Exception as e:
last_err = e
logger.warning(f"Groq 模型 {model} 失敗:{e}")
raise RuntimeError(f"所有 Groq 候選模型皆失敗:{last_err}")
然後在你的 get_analysis_reply() 中優先用 OpenAI;失敗就呼叫上面這個函式:
def get_analysis_reply(messages):
try:
if openai_client:
r = openai_client.chat.completions.create(
model="gpt-4o-mini", messages=messages, max_tokens=1500, temperature=0.7
)
return r.choices[0].message.content
return groq_chat_sync_with_models(messages, 2000, 0.7)
except Exception as e:
logger.warning(f"OpenAI 失敗:{e} → 改走 Groq 候選")
try:
return groq_chat_sync_with_models(messages, 1500, 0.9)
except Exception as gg:
logger.error(f"所有 AI API 都失敗:{gg}")
return "(分析模組暫時連線不穩定)"
⸻
df = yf.download(stock_id_tw, start=start, end=end, auto_adjust=False)
• pandas pct_change:
stock.quarterly_financials.loc["Total Revenue"].pct_change(-1, fill_method=None)
⸻
⸻
⸻
⸻
今天把語音能力補齊到「雙向」:使用者講話 → Bot 聽得懂;Bot 也會講。而且我們選擇零成本的 gTTS 做 TTS,適合 Side Project 或 PoC。在營運規模成長後,再切到 OpenAI TTS 或其他商用服務也不遲。
明天預告:把使用者語音內容做「摘要+待辦抽取」,讓 Bot 說的不只溫暖,還能幫你整理重點與待辦清單 ✅。
⸻