iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
AI & Data

雲端情人 - AI 愛系列 第 21

Day21不花錢也能講話:用 gTTS + Cloudinary 讓 her 說出 AI 回覆(含 Groq 模型下架防呆)

  • 分享至 

  • xImage
  •  

來囉!這是你 #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 的警告,部署更乾淨。

  1. 成品長怎樣?

在 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(原文+語音一起回)

  1. 前置:環境變數(Render 或本機 .env)

至少準備:

CHANNEL_ACCESS_TOKEN=你的LINE Channel token
CHANNEL_SECRET=你的LINE Channel secret

Groq Key(建議必填,STT 與聊天都能用)

GROQ_API_KEY=你的Groq API key

選用:若你要使用 OpenAI(Whisper/TTS)

OPENAI_API_KEY=sk-xxxx

Cloudinary(上傳 TTS MP3 用)

登入 Cloudinary 後在 Dashboard 複製一條 cloudinary://xxx:yyy@你的雲端

CLOUDINARY_URL=cloudinary://<api_key>:<api_secret>@<cloud_name>

我們支援一鍵切換 TTS 供應商(預設用 gTTS 免費)

TTS_PROVIDER=gtts

小提醒:不要在值前後放引號或多餘空白,否則會出現 401 或 URL 解析錯誤。

  1. requirements.txt

加上 gTTS 與 cloudinary(其餘你前幾天都已安裝):

fastapi
uvicorn
line-bot-sdk>=3.0.0

groq
openai

requests
pandas
beautifulsoup4
lxml
html5lib
yfinance

gTTS
cloudinary
httpx

  1. 核心程式重點(可直接貼進你的 app_fastapi.py)

4.1 TTS 抽象層:支援 gTTS 與 OpenAI TTS

--- TTS Providers ---

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 把文字回覆 + 語音一起回給使用者

文字回覆已經生成在 final_reply_text

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 語音已上傳並加入回覆")

  1. STT:錄音轉文字(Groq / OpenAI 雙備援)

你可以完全不放 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

  1. Groq 模型「下架」的保命招:候選清單輪替

官方最近把 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 "(分析模組暫時連線不穩定)"

  1. 修掉兩個常見警告(乾淨日誌)
    • yfinance:

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)

  1. 部署檢查清單(Render)
    • ✅ Environment
    • CHANNEL_ACCESS_TOKEN, CHANNEL_SECRET
    • GROQ_API_KEY(建議必填)
    • CLOUDINARY_URL(要回傳語音必填)
    • TTS_PROVIDER=gtts(預設即免費)
    • 可選:OPENAI_API_KEY(想用 OpenAI TTS/STT/聊天時)
    • 可選:GROQ_MODEL_PRIMARY=llama-3.1-8b-instant
    • ✅ Build Command
    • pip install -r requirements.txt
    • ✅ Start Command
    • uvicorn app_fastapi:app --host 0.0.0.0 --port 8000
    • ✅ 健康檢查
    • GET /healthz 200 即正常(出現很多 200 是 LB 心跳)

  1. 驗收腳本(你可以用這些步驟自測)
    1. 在 LINE 對 Bot 說:「幫我查一下日圓」→ 應回 1 JPY ≈ xxx TWD。
    2. 傳一段語音:「今天心情有點低落…」→ 應回聽寫文字+安慰語氣的回覆,並附語音。
    3. 發「金價」→ 應回台銀掛牌時間+買賣價。
    4. 發「2330」→ 應回台積電快照(股價/昨日收盤等),日誌無大量 Warning。
    5. 把 OPENAI_API_KEY 移除 → 再傳語音,仍能正常轉文字與回 TTS(靠 Groq STT + gTTS)。

  1. 常見錯誤&排雷
    • OpenAI 401 invalid_api_key
    → Key 打錯或有空白。先移除 OPENAI_API_KEY 也可,完全靠 Groq + gTTS 一樣能跑。
    • Groq model_decommissioned
    → 模型名稱下架。把 GROQ_MODEL_PRIMARY 改 llama-3.1-8b-instant,或用本文的候選清單輪替。
    • 回不出語音
    → 沒設 CLOUDINARY_URL 或 Cloudinary 權限錯。確定 dashboard 有那條 cloudinary://...,且方案允許上傳。

  1. 後記

今天把語音能力補齊到「雙向」:使用者講話 → Bot 聽得懂;Bot 也會講。而且我們選擇零成本的 gTTS 做 TTS,適合 Side Project 或 PoC。在營運規模成長後,再切到 OpenAI TTS 或其他商用服務也不遲。

明天預告:把使用者語音內容做「摘要+待辦抽取」,讓 Bot 說的不只溫暖,還能幫你整理重點與待辦清單 ✅。


上一篇
Day 20|她真的會「說」了:用免費 gTTS 方案
系列文
雲端情人 - AI 愛21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言