voidful's picture
Voice cloning: extract ECAPA speaker centroid from upload (no transcript); add speechbrain
5fc2aef verified
Raw History Blame
15.5 kB
"""BlueMagpie-TTS 線上試用 Space (Gradio + ZeroGPU)。
提供三種試用情境,並一律套用模型官方建議的最佳生成參數:
1. 指定語者:李宏毅 —— 用模型內附、已取得授權的語者向量控制音色
2. 聲音複製 —— 從參考音檔抽取語者向量(ECAPA centroid),走 speaker_centroid 路徑
3. 長文逐句串流 —— 把長文切句,合成一句播一句,做出串流效果(每句含自動重試)
註:本 checkpoint 只訓練過 speaker_centroid(內附李宏毅向量同一機制)與 prompt 接續兩條
路徑;transcript-free 的 reference_wav_path 路徑從未餵過訓練資料(data._make_sample 丟掉
ref_audio),會吐出亂掉內容。故聲音複製改為「從錄音抽 centroid → speaker_centroid」——
免逐字稿、且走的是有訓練、可靠的路徑。
最佳參數讀自模型發佈中繼資料 release_metadata.json 的 recommended_generation_defaults
(以 500 句難句 + ASR CER 調出,最佳 trial:cfg=2.8 / steps=9),確保 demo 永遠跟著模型走。
"""
import json
import os
import time
import numpy as np
import torch
import gradio as gr
from huggingface_hub import snapshot_download
from transformers import PreTrainedTokenizerFast
from bluemagpie import BlueMagpieModel
# --------------------------------------------------------------------------- #
# ZeroGPU:用 @spaces.GPU 動態取得 GPU;本機無 spaces 時退回 cuda/cpu 自動偵測。
# --------------------------------------------------------------------------- #
try:
import spaces
gpu = spaces.GPU(duration=120)
DEVICE = "cuda"
except ImportError:
def gpu(fn):
return fn
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
REPO_ID = "OpenFormosa/BlueMagpie-TTS"
# --------------------------------------------------------------------------- #
# 載入模型(啟動時做一次)
# --------------------------------------------------------------------------- #
print(f"[BlueMagpie] downloading model from {REPO_ID} ...")
MODEL_DIR = snapshot_download(REPO_ID)
tokenizer = PreTrainedTokenizerFast(
tokenizer_file=os.path.join(MODEL_DIR, "tokenizer.json")
)
print(f"[BlueMagpie] loading model on device={DEVICE} ...")
model = BlueMagpieModel.from_local(
MODEL_DIR, tokenizer=tokenizer, training=False, device=DEVICE
)
SR = model.sample_rate
# --------------------------------------------------------------------------- #
# 最佳生成參數:讀自模型發佈中繼資料;缺檔時退回官方建議值。
# --------------------------------------------------------------------------- #
_GEN_KEYS = {
"cfg_value",
"inference_timesteps",
"max_len",
"min_len",
"retry_badcase",
"retry_badcase_max_times",
"retry_badcase_ratio_threshold",
}
_BEST_FALLBACK = {
"cfg_value": 2.8,
"inference_timesteps": 9,
"max_len": 2000,
"retry_badcase": True,
"retry_badcase_max_times": 3,
"retry_badcase_ratio_threshold": 6.0,
}
SPEAKER_ID = "hung_yi_lee"
try:
with open(os.path.join(MODEL_DIR, "release_metadata.json"), encoding="utf-8") as f:
_meta = json.load(f)
_rec = _meta.get("recommended_generation_defaults", {}) or {}
GEN_KWARGS = {k: v for k, v in _rec.items() if k in _GEN_KEYS} or dict(_BEST_FALLBACK)
SPEAKER_ID = _rec.get("speaker_id", SPEAKER_ID)
except Exception as e: # 任何讀取/解析問題都退回安全的最佳值
print(f"[BlueMagpie] release_metadata.json unavailable ({e}); using fallback best params")
GEN_KWARGS = dict(_BEST_FALLBACK)
print(f"[BlueMagpie] best generation params: {GEN_KWARGS}")
# 語者向量(已取得本人授權)
_centroids = torch.load(
os.path.join(MODEL_DIR, "checkpoints", "hung_yi_lee_speaker_centroids.pt"),
map_location="cpu",
weights_only=True,
)
SPK_CENTROID = _centroids["centroids"][_centroids["speaker_ids"].index(SPEAKER_ID)]
print(f"[BlueMagpie] ready. sample_rate={SR}, speaker={SPEAKER_ID}")
# --------------------------------------------------------------------------- #
# 聲音複製:從參考音檔抽取語者向量(ECAPA),走「已訓練、可靠」的 speaker_centroid
# 路徑。與訓練用的 build_speaker_centroids.py 完全一致:同一個 ECAPA 模型、每段
# embedding 先 L2-normalize、取平均、再 L2-normalize(192 維),確保與內附李宏毅向量
# 同一向量空間。ECAPA 在 CPU 上跑、lazy 載入一次(不佔 GPU 時間配額)。
# --------------------------------------------------------------------------- #
_ECAPA = None
def _get_ecapa():
global _ECAPA
if _ECAPA is None:
from speechbrain.inference.speaker import EncoderClassifier
_ECAPA = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": "cpu"}
)
return _ECAPA
@torch.no_grad()
def extract_centroid(wav_path: str) -> torch.Tensor:
"""參考音檔 -> [192] 語者向量(與訓練 centroid 同空間、同處理)。"""
import librosa
wav, _ = librosa.load(wav_path, sr=16000, mono=True) # 16 kHz 單聲道
x = torch.from_numpy(np.ascontiguousarray(wav)).float()
if x.numel() < 16000: # 太短(<1s)難抽穩定向量
raise ValueError("參考音檔太短,請提供約 3–10 秒的乾淨語音。")
clf = _get_ecapa()
win = 6 * 16000 # 切約 6 秒一段,分段抽再平均
chunks = [x[i : i + win] for i in range(0, x.numel(), win)]
chunks = [c for c in chunks if c.numel() >= 16000] or [x] # 每段至少 1 秒
embs = []
for c in chunks:
e = clf.encode_batch(c.unsqueeze(0)).reshape(-1) # [192]
embs.append(torch.nn.functional.normalize(e, dim=0))
mean = torch.stack(embs).mean(0)
return torch.nn.functional.normalize(mean, dim=0).cpu()
# 註:repo 的 bluemagpie.serving 加速引擎(torch.compile / 連續批次)是為「常駐
# 專用 GPU、追求吞吐」設計。在 ZeroGPU(GPU 逐請求序列化掛載)上實測後,單一
# 請求延遲沒有改善(warm ~7.0s vs eager ~6.2s),反而帶來首呼叫 ~50s 編譯成本與
# CUDA graph 反覆重編譯的尖峰,故此 demo 維持 eager。詳見對話中的量測數據。
def _to_numpy(audio: torch.Tensor):
return audio.squeeze().float().cpu().numpy()
def _pcm16(x: np.ndarray) -> np.ndarray:
"""float 波形 -> 16-bit PCM,供 Gradio 串流輸出(格式最穩定)。"""
return (np.clip(x, -1.0, 1.0) * 32767.0).astype(np.int16)
# 長文逐句串流:以句末標點/換行切句。逐句串流直到接近 ZeroGPU 單次 GPU 時間配額
# 為止(不是固定句數),盡量把整段長文串完;另設一個防濫用的硬上限。
_SENT_ENDERS = "。!?;!?…\n"
_GPU_BUDGET_S = 100 # @spaces.GPU(duration=120),留約 20s 緩衝給最後一句
_MAX_STREAM_SENTENCES = 80 # 防濫用硬上限(通常時間配額會先到)
def _split_sentences(text):
out, cur = [], []
for ch in text:
cur.append(ch)
if ch in _SENT_ENDERS:
s = "".join(cur).strip()
if s:
out.append(s)
cur = []
s = "".join(cur).strip()
if s:
out.append(s)
# 把落單的標點(如連續「!?」被拆出的單一字元)併回前一句
merged = []
for s in out:
if merged and len(s) <= 1:
merged[-1] += s
else:
merged.append(s)
return merged
# --------------------------------------------------------------------------- #
# 推論:三種模式都套用 GEN_KWARGS(官方最佳參數)
# --------------------------------------------------------------------------- #
@gpu
def tts_speaker(text):
text = (text or "").strip()
if not text:
raise gr.Error("請先輸入要合成的文字。")
audio = model.generate(
target_text=text,
speaker_centroid=SPK_CENTROID,
**GEN_KWARGS,
)
return (SR, _to_numpy(audio))
@gpu
def tts_clone(text, reference_audio):
"""聲音複製:從參考音檔抽出語者向量(ECAPA centroid),走 speaker_centroid 路徑。
這條路徑在本 checkpoint 有被訓練(與內附李宏毅向量同一機制),可靠;**免逐字稿**。
"""
text = (text or "").strip()
if not text:
raise gr.Error("請先輸入要合成的文字。")
if not reference_audio:
raise gr.Error("請先上傳或錄製一段參考音檔。")
try:
centroid = extract_centroid(reference_audio)
except Exception as e:
raise gr.Error(f"無法從參考音檔抽取語者向量:{e}")
audio = model.generate(
target_text=text,
speaker_centroid=centroid,
**GEN_KWARGS,
)
return (SR, _to_numpy(audio))
@gpu
def tts_stream(text):
"""長文逐句串流(李宏毅語者向量):把長文切成句子,逐句合成、合成一句就播一句。
逐句串流直到接近單次 GPU 時間配額(約 100s)為止,盡量把整段長文串完,而非固定
句數;真的超過才截斷並提示分批。每句都用一般 generate(含 retry_badcase)產生
完整音檔再 yield;句與句之間可能有短暫間隔(ZeroGPU 約 0.44x 實時所致)。
"""
text = (text or "").strip()
if not text:
raise gr.Error("請先輸入要合成的文字。")
sentences = _split_sentences(text)
if not sentences:
raise gr.Error("找不到可合成的句子。")
total = len(sentences)
sentences = sentences[:_MAX_STREAM_SENTENCES]
start = time.time()
done = 0
for sent in sentences:
# 至少先產出第一句;之後一旦逼近 GPU 時間配額就停,避免配額被回收而報錯
if done and time.time() - start > _GPU_BUDGET_S:
break
audio = model.generate(
target_text=sent, speaker_centroid=SPK_CENTROID, **GEN_KWARGS
)
yield (SR, _pcm16(_to_numpy(audio)))
done += 1
if done < total:
gr.Warning(
f"受單次 GPU 時間配額限制,本次串流前 {done} 句(共 {total} 句);"
"想念完整段可分批貼上。"
)
# --------------------------------------------------------------------------- #
# 介面
# --------------------------------------------------------------------------- #
EXAMPLE_TEXTS = [
"今天天氣真好,我們一起去散步吧。",
"這是 AI TTS code switching 測試,混合中英文也沒問題。",
"台灣藍鵲是一種叫聲響亮、辨識度很高的鳥。",
]
STREAM_EXAMPLES = [
"台灣藍鵲是台灣特有的鳥類,羽色以亮藍為主,尾羽修長。牠們常成群在樹林間活動,"
"叫聲響亮而容易辨認。今天天氣真好,我們一起出去走走,順便看看這些美麗的鳥吧!",
"歡迎使用這個示範。這是一段比較長的文字,會被切成好幾句。"
"系統會合成一句、播放一句,做出串流的效果。希望你會喜歡這個聲音!",
]
_PARAMS_NOTE = (
f"已套用模型官方建議最佳參數:"
f"`cfg_value={GEN_KWARGS.get('cfg_value')}`、"
f"`inference_timesteps={GEN_KWARGS.get('inference_timesteps')}`、"
f"`retry_badcase={'on' if GEN_KWARGS.get('retry_badcase') else 'off'}`。"
)
HEADER = f"""
# 🐦‍⬛ BlueMagpie-TTS · 文字轉語音線上試用
**OpenFormosa Blue Magpie TTS** —— 支援**台灣華語**與**中英混合**的文字轉語音模型,輸出 48 kHz 單聲道語音。
模型:[OpenFormosa/BlueMagpie-TTS](https://huggingface.co/OpenFormosa/BlueMagpie-TTS) ·
程式碼:[GitHub](https://github.com/OpenFormosa/BlueMagpie-TTS)
> {_PARAMS_NOTE}
"""
with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
gr.Markdown(HEADER)
with gr.Tab("指定語者:李宏毅"):
gr.Markdown(
"使用模型內附、**已取得李宏毅老師本人授權**的語者向量來控制音色"
"(官方最佳參數正是針對此語者調校)。"
)
with gr.Row():
with gr.Column():
s_text = gr.Textbox(
label="要合成的文字", lines=3,
placeholder="輸入中文或中英混合的文字…",
)
s_btn = gr.Button("以李宏毅音色合成", variant="primary")
with gr.Column():
s_out = gr.Audio(label="合成結果", type="numpy")
gr.Examples(EXAMPLE_TEXTS, inputs=s_text, label="範例文字")
s_btn.click(tts_speaker, s_text, s_out)
with gr.Tab("聲音複製"):
gr.Markdown(
"上傳或錄製一段參考音檔,系統會從中**抽取語者向量(音色指紋)**,再用它合成你的文字"
"——**不需要逐字稿**。建議參考音檔約 3–10 秒、乾淨單一語者。\n\n"
"(原理:和內附的李宏毅語者向量走同一條、**已訓練**的 `speaker_centroid` 路徑;"
"用 ECAPA-TDNN 從你的音檔算出同樣的 192 維向量。)\n\n"
"⚠️ **請只使用你已取得授權的聲音**,請勿在未經本人同意下複製他人聲音。"
)
with gr.Row():
with gr.Column():
c_text = gr.Textbox(
label="要合成的文字", lines=3,
placeholder="輸入中文或中英混合的文字…",
)
c_ref = gr.Audio(
label="參考音檔(上傳或錄製,約 3–10 秒)", type="filepath",
sources=["upload", "microphone"],
)
c_btn = gr.Button("複製音色合成", variant="primary")
with gr.Column():
c_out = gr.Audio(label="合成結果", type="numpy")
c_btn.click(tts_clone, [c_text, c_ref], c_out)
with gr.Tab("長文逐句串流"):
gr.Markdown(
"**長文逐句串流**:把長文切成句子,合成一句就播一句,做出串流(邊聽邊等)效果。"
"第一句很快就能聽到,後面邊播邊合成。用**李宏毅語者向量**。\n\n"
"> ZeroGPU 約 0.44x 實時,句與句之間可能有短暫間隔;每句都套用最佳參數(含自動重試)。"
"單次串流以 ZeroGPU 時間配額為限(約可串數十句),超長會自動截斷並提示分批。"
)
with gr.Row():
with gr.Column():
t_text = gr.Textbox(
label="要合成的長文", lines=5,
placeholder="貼上一段多句的文字,會逐句合成、逐句播放…",
)
t_btn = gr.Button("逐句串流合成", variant="primary")
with gr.Column():
t_out = gr.Audio(label="逐句串流播放", streaming=True, autoplay=True)
gr.Examples(STREAM_EXAMPLES, inputs=t_text, label="長文範例")
t_btn.click(tts_stream, t_text, t_out)
gr.Markdown(
"---\n"
"合成的語音僅供研究與評估展示用途,輸出可能不完美;正式使用前請人工檢視。"
" · Apache-2.0"
)
if __name__ == "__main__":
demo.queue().launch()