wardrobe-os-ml / src /services /speech.py
fengchen31's picture
feat: deploy ML service (FashionSigLIP, Qwen2.5, XGBoost, Chronos)
3c8d4b3
Raw
History Blame Contribute Delete
10.6 kB
"""語音服務 — STT (faster-whisper) + TTS (Kokoro)(透過 ModelRegistry)
【這個模組在做什麼?】
STT (Speech-to-Text): 把使用者錄音轉成文字
TTS (Text-to-Speech): 把 AI 回答轉成語音播放
【模型選型】
STT: faster-whisper V3 Turbo(809M params, ~2-3GB RAM)
- Whisper 的 CTranslate2 加速版,比原版快 6 倍
- 支援 99+ 語言含中文
TTS: Kokoro-82M(82M params, ~500MB RAM)
- 超低延遲 <0.3s,36x 即時
- 自然度接近商用 TTS
【為什麼不用雲端 TTS/STT?】
跟整個 Phase C 的理念一致:零外部 API 依賴。
語音在使用者裝置本地處理(隱私),ML service 只做轉換。
【安裝依賴】
uv add faster-whisper kokoro soundfile
【注意】
模型會在第一次呼叫時下載(~3GB),之後快取。
faster-whisper 需要 ctranslate2,CPU 推論足夠(~2-5 秒/段語音)。
【ModelRegistry 整合】
STT → stt capability → whisper-turbo model(provider: specialized, loader: faster_whisper)
TTS → tts capability → kokoro-tts model(provider: specialized, loader: kokoro)
_get_whisper_model() / _get_tts_model() 透過 registry.get() 取得已載入的模型。
若 registry 未初始化,fallback 到舊版 lazy loading。
"""
from __future__ import annotations
import io
import logging
import os
import tempfile
from pathlib import Path
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# ============================================================================
# STT — faster-whisper
# ============================================================================
# 保留舊版全域變數作為 fallback(registry 未啟動時)
_whisper_model = None
WHISPER_MODEL_SIZE = os.environ.get("WHISPER_MODEL_SIZE", "turbo")
class TranscribeResult(BaseModel):
text: str
language: str
confidence: float
async def _get_whisper_model():
"""
透過 ModelRegistry 取得 faster-whisper 模型。
【Registry 優先,Lazy loading fallback】
1. 嘗試從 registry 取得 stt capability 的已載入 WhisperModel
2. 若 registry 未初始化或載入失敗,fallback 到舊版全域 lazy loading
"""
global _whisper_model
# 優先嘗試從 ModelRegistry 取得
try:
from src.registry import get_registry
reg = get_registry()
loaded = await reg.get("stt")
if loaded.model is not None:
# 更新全域快取,讓同步 transcribe() 也能直接使用 registry 載入的模型
_whisper_model = loaded.model
return loaded.model
except Exception:
# Registry 未初始化或 stt 模型載入失敗,走 fallback
pass
# Fallback:舊版 lazy loading
if _whisper_model is not None:
return _whisper_model
try:
from faster_whisper import WhisperModel
logger.info(
"Loading faster-whisper model (%s) [fallback]...", WHISPER_MODEL_SIZE
)
_whisper_model = WhisperModel(
WHISPER_MODEL_SIZE,
device="cpu", # CPU 推論(MPS 不支援 ctranslate2)
compute_type="int8", # 量化加速(CPU 上 int8 最快)
)
logger.info("faster-whisper ready [fallback]")
return _whisper_model
except ImportError:
logger.warning("faster-whisper not installed. Run: uv add faster-whisper")
return None
except Exception as e:
logger.error("Failed to load faster-whisper: %s", e)
return None
def _load_whisper():
"""
同步版 whisper 載入(供 transcribe() 在非 async 呼叫環境使用)。
備注:主要入口 transcribe() 會在 asyncio.to_thread 中呼叫,
若在那之前 registry 已載入模型,此函式為 no-op。
"""
global _whisper_model
if _whisper_model is not None:
return
try:
from faster_whisper import WhisperModel
logger.info("Loading faster-whisper model (%s)...", WHISPER_MODEL_SIZE)
_whisper_model = WhisperModel(
WHISPER_MODEL_SIZE,
device="cpu",
compute_type="int8",
)
logger.info("faster-whisper ready")
except ImportError:
logger.warning("faster-whisper not installed. Run: uv add faster-whisper")
except Exception as e:
logger.error("Failed to load faster-whisper: %s", e)
def transcribe(audio_bytes: bytes, filename: str = "audio.webm") -> TranscribeResult:
# 【學習重點:STT 的完整流程】
# 瀏覽器錄音(MediaRecorder API)→ WebM/OGG bytes → 這裡
# → faster-whisper 做 beam search 解碼 → 文字 + 語言偵測
"""
語音轉文字。
【流程】
1. 嘗試從 _whisper_model 全域取得已載入模型
(registry 會預先載入,或由同步 _load_whisper 延遲載入)
2. 把音檔暫存到磁碟(faster-whisper 需要檔案路徑)
3. 執行轉錄
4. 回傳文字 + 偵測到的語言 + 信心分數
注意:此函式設計為同步執行於 asyncio.to_thread 中。
模型應由 _get_whisper_model() 或 startup event 預先載入。
"""
# 嘗試使用全域模型(registry 啟動時預載,或 fallback lazy load)
model = _whisper_model
if model is None:
_load_whisper()
model = _whisper_model
if model is None:
return TranscribeResult(
text="[STT not available]", language="unknown", confidence=0.0
)
# 暫存音檔
suffix = Path(filename).suffix or ".webm"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(audio_bytes)
tmp_path = f.name
try:
segments, info = model.transcribe(
tmp_path,
beam_size=5,
language=None, # 自動偵測語言
)
# 合併所有 segments 的文字
text = " ".join(seg.text.strip() for seg in segments)
return TranscribeResult(
text=text,
language=info.language,
confidence=round(info.language_probability, 3),
)
finally:
os.unlink(tmp_path)
# ============================================================================
# TTS — Kokoro
# ============================================================================
# 保留舊版全域變數作為 fallback
_tts_model = None
class SynthesizeResult(BaseModel):
audio_base64: str
sample_rate: int
duration_seconds: float
async def _get_tts_model():
"""
透過 ModelRegistry 取得 Kokoro TTS pipeline。
【Registry 優先,Lazy loading fallback】
1. 嘗試從 registry 取得 tts capability 的已載入 KPipeline
2. 若 registry 未初始化或載入失敗,fallback 到舊版全域 lazy loading
"""
global _tts_model
# 優先嘗試從 ModelRegistry 取得
try:
from src.registry import get_registry
reg = get_registry()
loaded = await reg.get("tts")
if loaded.model is not None:
# 更新全域快取,讓同步 synthesize() 也能直接使用 registry 載入的模型
_tts_model = loaded.model
return loaded.model
except Exception:
# Registry 未初始化或 tts 模型載入失敗,走 fallback
pass
# Fallback:舊版 lazy loading
if _tts_model is not None:
return _tts_model
try:
import kokoro
logger.info("Loading Kokoro TTS model [fallback]...")
_tts_model = kokoro.KPipeline(lang_code="a")
logger.info("Kokoro TTS ready [fallback]")
return _tts_model
except ImportError:
logger.warning("kokoro not installed. Run: uv add kokoro soundfile")
return None
except Exception as e:
logger.error("Failed to load Kokoro TTS: %s", e)
return None
def _load_tts():
"""
同步版 Kokoro 載入(備用,供非 async 環境使用)。
【Kokoro-82M 特點】
- 只有 82M 參數(~500MB),載入極快
- 延遲 <0.3s,即時度 36x
- 支援英文,中文需要額外配置
"""
global _tts_model
if _tts_model is not None:
return
try:
import kokoro
logger.info("Loading Kokoro TTS model...")
_tts_model = kokoro.KPipeline(lang_code="a") # 'a' = American English
logger.info("Kokoro TTS ready")
except ImportError:
logger.warning("kokoro not installed. Run: uv add kokoro soundfile")
except Exception as e:
logger.error("Failed to load Kokoro TTS: %s", e)
def synthesize(text: str, voice: str = "af_heart") -> SynthesizeResult | None:
# 【學習重點:TTS 的完整流程】
# 文字 → Kokoro 生成 audio tensor(numpy array)
# → soundfile 寫成 WAV bytes → base64 編碼 → JSON 回傳
# → 前端用 Audio API 播放 base64 audio
"""
文字轉語音。
【流程】
1. 嘗試從 _tts_model 全域取得已載入模型
(registry 預先載入,或由 _load_tts fallback 延遲載入)
2. 用 Kokoro 生成音頻 tensor
3. 轉成 WAV bytes → base64 編碼
4. 回傳 base64 audio + metadata
【voice 選項】
- af_heart: 女聲(預設,自然)
- am_adam: 男聲
注意:此函式設計為同步執行於 asyncio.to_thread 中。
"""
model = _tts_model
if model is None:
_load_tts()
model = _tts_model
if model is None:
return None
import base64
import soundfile as sf
import numpy as np
try:
# 生成音頻
generator = model(text, voice=voice)
audio_segments = []
sample_rate = 24000 # Kokoro 預設 24kHz
for _gs, _ps, audio in generator:
if audio is not None:
audio_segments.append(audio)
if not audio_segments:
return None
# 合併所有 segments
full_audio = (
np.concatenate(audio_segments)
if len(audio_segments) > 1
else audio_segments[0]
)
# 轉成 WAV bytes
buf = io.BytesIO()
sf.write(buf, full_audio, sample_rate, format="WAV")
wav_bytes = buf.getvalue()
duration = len(full_audio) / sample_rate
return SynthesizeResult(
audio_base64=base64.b64encode(wav_bytes).decode("ascii"),
sample_rate=sample_rate,
duration_seconds=round(duration, 2),
)
except Exception as e:
logger.error("TTS synthesis failed: %s", e)
return None