voidful's picture
Add fail-closed stable speaker inference
7e7df2a
Raw History Blame
30.2 kB
"""BlueMagpie-TTS Gradio demo using the validated production inference profile."""
from __future__ import annotations
import inspect
import json
import os
import secrets
import threading
import gradio as gr
import librosa
import numpy as np
import torch
from huggingface_hub import snapshot_download
from transformers import PreTrainedTokenizerFast
from bluemagpie import BlueMagpieModel
from production import (
StopHysteresisController,
apply_loudness_floor,
count_speech_units,
effective_generation_cfg,
endpoint_generation_plan,
estimate_step_seconds,
extract_windowed_speaker_embedding,
fade_internal_edges,
finish_audio,
join_audio_chunks,
match_chunk_rms,
normalize_spoken_forms,
normalize_tts_text,
punctuation_pause_seconds,
select_generation_cps,
set_generation_seed,
split_leading_clause,
split_text_for_tts,
target_pace_speed,
)
from quality_runtime import (
BASE_GENERATION_POLICY,
SAFE_DURATION_GENERATION_POLICY,
WHISPER_MODEL_ID,
WHISPER_REVISION,
CandidateObservation,
ChunkCandidateArtifact,
FinalOutputRejectedError,
GenerationPolicy,
NoQualifiedCandidateError,
active_voiced_duration_seconds,
active_audio_rms_db,
candidate_limit_for_chunk_budget,
generation_policy_for_candidate_offset,
prepare_candidate_audio,
qualify_trajectory_with_joined_output,
require_verified_final_output,
resolve_request_seed,
run_adaptive_cascade,
speaker_evidence_from_audio,
transcribe_whisper,
verify_trajectory,
)
try:
import spaces
gpu = spaces.GPU(duration=120)
DEVICE = "cuda"
except ImportError:
def gpu(function):
return function
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
REPO_ID = "OpenFormosa/BlueMagpie-TTS"
MODEL_REVISION = "aaf1a0878e37875382bb0e5c8a3a2ba43be67297"
ECAPA_REPO_ID = "speechbrain/spkrec-ecapa-voxceleb"
ECAPA_REVISION = "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
DEFAULT_CFG = 2.0
DEFAULT_STEPS = 10
TARGET_CPS = 4.0
MIN_ENDPOINT_CUE_UNITS = 6
SHORT_TEXT_CFG_MIN = 3.0
SHORT_TEXT_CFG_UNITS = 6
CHUNK_CHARS = 80
ONSET_CLAUSE_SEARCH_CHARS = 40
MIN_CHUNK_CHARS = 12
CROSSFADE_MS = 80.0
CHUNK_EDGE_FADE_MS = 80.0
CHUNK_RMS_MATCH_DB = 4.0
STOP_THRESHOLD = 0.50
STOP_LATE_THRESHOLD = 0.05
STOP_LATE_START_RATIO = 0.75
STOP_LATE_FULL_RATIO = 0.95
STOP_CONSECUTIVE = 1
MIN_PACE_SPEED = 0.80
MAX_TEXT_CHARS = 360
QUALITY_MAX_CANDIDATES = 10
QUALITY_MAX_GENERATED_CHUNKS = 20
QUALITY_FINAL_ASR_MAX_NEW_TOKENS = 440
QUALITY_MAX_CER = 0.20
QUALITY_MAX_PACE_CPS = 4.30
QUALITY_PREFIX_SUFFIX_UNITS = 6
QUALITY_MIN_SPEAKER_SIMILARITY = 0.10
QUALITY_MAX_BOUNDARY_SPEAKER_DROP = 0.10
QUALITY_PREFERRED_MIN_SPEAKER_SIMILARITY = 0.25
QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP = 0.05
SHORT_AUDIO_SPEAKER_GATE_SECONDS = 1.50
print(f"[BlueMagpie] downloading model from {REPO_ID}@{MODEL_REVISION} ...")
MODEL_DIR = snapshot_download(REPO_ID, revision=MODEL_REVISION)
print(f"[BlueMagpie] caching speaker encoder from {ECAPA_REPO_ID}@{ECAPA_REVISION} ...")
ECAPA_DIR = snapshot_download(ECAPA_REPO_ID, revision=ECAPA_REVISION)
print(f"[BlueMagpie] caching quality ASR from {WHISPER_MODEL_ID}@{WHISPER_REVISION} ...")
ASR_DIR = snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)
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 = int(model.sample_rate)
STEP_SECONDS = estimate_step_seconds(model, SR)
METADATA: dict = {}
try:
with open(os.path.join(MODEL_DIR, "release_metadata.json"), encoding="utf-8") as handle:
METADATA = json.load(handle)
except (OSError, ValueError) as error:
print(f"[BlueMagpie] release metadata unavailable: {error}")
CHECKPOINT = str(METADATA.get("checkpoint", "release"))
def _load_speakers() -> tuple[dict[str, torch.Tensor], str]:
path = os.path.join(MODEL_DIR, "checkpoints", "speaker_centroids.pt")
if not os.path.exists(path):
raise RuntimeError("speaker_centroids.pt is missing from the model release")
table = torch.load(path, map_location="cpu", weights_only=True)
speaker_ids = [str(value) for value in table["speaker_ids"]]
centroids = table["centroids"]
labels = {f"內建語者 {chr(65 + index)}": centroid for index, centroid in enumerate(centroids)}
requested_id = (
METADATA.get("recommended_generation_defaults", {}).get("speaker_id")
if isinstance(METADATA.get("recommended_generation_defaults"), dict)
else None
)
if requested_id not in speaker_ids and "female_voice" in speaker_ids:
requested_id = "female_voice"
default_index = speaker_ids.index(requested_id) if requested_id in speaker_ids else 0
return labels, f"內建語者 {chr(65 + default_index)}"
SPEAKERS, DEFAULT_SPEAKER = _load_speakers()
DEFAULT_CENTROID = SPEAKERS[DEFAULT_SPEAKER]
# The pinned public package predates native stop hysteresis. Wrap only that
# version; newer packages receive the same policy through native arguments.
_GENERATE_PARAMETERS = set(inspect.signature(model._generate).parameters)
_NATIVE_STOP_POLICY = {"stop_threshold", "stop_consecutive"}.issubset(_GENERATE_PARAMETERS)
_STOP_CONTROLLER: StopHysteresisController | None = None
if not _NATIVE_STOP_POLICY:
_STOP_CONTROLLER = StopHysteresisController(
model.stop_head,
threshold=STOP_THRESHOLD,
late_threshold=STOP_LATE_THRESHOLD,
consecutive=STOP_CONSECUTIVE,
late_start_ratio=STOP_LATE_START_RATIO,
late_full_ratio=STOP_LATE_FULL_RATIO,
)
model.stop_head = _STOP_CONTROLLER
_GENERATION_LOCK = threading.Lock()
_ECAPA_ENCODER = None
_ECAPA_LOCK = threading.Lock()
print(
f"[BlueMagpie] ready checkpoint={CHECKPOINT} sample_rate={SR} "
f"step_seconds={STEP_SECONDS} native_stop_policy={_NATIVE_STOP_POLICY}"
)
def _get_ecapa_encoder():
global _ECAPA_ENCODER
with _ECAPA_LOCK:
if _ECAPA_ENCODER is None:
import torchaudio
# SpeechBrain 1.0.3 still probes this API during import, while the
# ZeroGPU torchaudio build has removed it. Audio loading below is
# handled by librosa, so an empty compatibility result is correct.
if not hasattr(torchaudio, "list_audio_backends"):
torchaudio.list_audio_backends = lambda: []
from speechbrain.inference.speaker import EncoderClassifier
cache_dir = os.path.join(os.environ.get("HF_HOME", "/tmp"), "speechbrain", "ecapa")
_ECAPA_ENCODER = EncoderClassifier.from_hparams(
source=ECAPA_DIR,
# The upstream hyperparams otherwise points back to the repo
# and SpeechBrain 1.0.3 uses a removed Hub keyword. Keep every
# weight fetch inside the already pinned local snapshot.
overrides={"pretrained_path": ECAPA_DIR},
savedir=cache_dir,
run_opts={"device": "cpu"},
)
return _ECAPA_ENCODER
def _apply_speed(audio: np.ndarray, speed: float) -> np.ndarray:
speed = float(speed or 1.0)
if abs(speed - 1.0) < 1.0e-3:
return audio
return librosa.effects.time_stretch(np.asarray(audio, dtype=np.float32), rate=speed)
def _generate_chunk(
text: str,
centroid: torch.Tensor,
*,
cfg: float,
steps: int,
request_seed: int,
policy: GenerationPolicy,
) -> np.ndarray:
generation_cps = select_generation_cps(
text,
cjk_cps=policy.cjk_cps,
ascii_cps=policy.ascii_cps,
)
model_text, expected_steps, hard_stop_steps = endpoint_generation_plan(
text,
generation_cps=generation_cps,
step_seconds=STEP_SECONDS,
margin_steps=policy.hard_stop_margin_steps,
add_terminal_punctuation=count_speech_units(text) >= MIN_ENDPOINT_CUE_UNITS,
)
# Do not hold generation open to enforce pace. The model can finish the
# requested text early; extending its latent sequence creates tail speech.
min_len = 2
generation_cfg = effective_generation_cfg(
text,
cfg,
short_text_unit_threshold=SHORT_TEXT_CFG_UNITS,
short_text_min_cfg=SHORT_TEXT_CFG_MIN,
)
set_generation_seed(request_seed)
kwargs = {
"target_text": model_text,
"speaker_centroid": centroid,
"cfg_value": generation_cfg,
"inference_timesteps": int(steps),
"min_len": min_len,
"max_len": hard_stop_steps,
"retry_badcase": False,
"retry_badcase_max_times": 1,
"retry_badcase_ratio_threshold": 6.0,
}
if _NATIVE_STOP_POLICY:
kwargs["stop_threshold"] = STOP_THRESHOLD
kwargs["stop_consecutive"] = STOP_CONSECUTIVE
if "generation_seed" in _GENERATE_PARAMETERS:
kwargs["generation_seed"] = request_seed
if _STOP_CONTROLLER is not None:
_STOP_CONTROLLER.begin(
min_len,
expected_steps=expected_steps,
hard_stop_steps=hard_stop_steps,
)
try:
audio = model.generate(**kwargs)
finally:
if _STOP_CONTROLLER is not None:
_STOP_CONTROLLER.end()
if _STOP_CONTROLLER is not None:
print(
"[BlueMagpie] endpoint "
f"expected_steps={expected_steps} hard_stop_steps={hard_stop_steps} "
f"generated_steps={_STOP_CONTROLLER.last_generated_steps} "
f"reason={_STOP_CONTROLLER.last_stop_reason} cfg={generation_cfg:.2f}"
)
print(
"[BlueMagpie] generation policy "
f"name={policy.name} seed={request_seed} generation_cps={generation_cps:.2f} "
f"expected_steps={expected_steps} hard_stop_steps={hard_stop_steps} min_len={min_len}"
)
audio = audio.detach().float().cpu().numpy().reshape(-1)
pace_speed = target_pace_speed(
audio.size,
SR,
text,
target_cps=TARGET_CPS,
min_speed=MIN_PACE_SPEED,
)
return _apply_speed(audio, pace_speed)
def _speaker_anchor_array(centroid: torch.Tensor) -> np.ndarray:
anchor = torch.as_tensor(centroid).detach().float().cpu().numpy().reshape(-1)
if anchor.size == 0 or not np.isfinite(anchor).all():
raise ValueError("speaker anchor is invalid")
norm = float(np.linalg.norm(anchor))
if not np.isfinite(norm) or norm <= 1.0e-8:
raise ValueError("speaker anchor has zero norm")
return np.asarray(anchor / norm, dtype=np.float32)
def _generate_trajectory(
chunks: tuple[str, ...],
centroid: torch.Tensor,
*,
cfg: float,
steps: int,
request_seed: int,
policy: GenerationPolicy,
) -> tuple[np.ndarray, ...]:
return tuple(
_generate_chunk(
chunk,
centroid,
cfg=cfg,
steps=steps,
request_seed=request_seed,
policy=policy,
)
for chunk in chunks
)
def _verify_trajectory_audio(
trajectory: tuple[np.ndarray, ...],
chunks: tuple[str, ...],
anchor: np.ndarray,
playback_speed: float,
asr_max_new_tokens: int = 128,
):
if len(trajectory) != len(chunks):
return verify_trajectory(())
encoder = None
observations: list[CandidateObservation] = []
artifacts: list[ChunkCandidateArtifact] = []
for chunk, audio in zip(chunks, trajectory, strict=True):
prepared = prepare_candidate_audio(
audio,
SR,
transcriber=lambda waveform, sample_rate: transcribe_whisper(
waveform,
sample_rate,
max_new_tokens=asr_max_new_tokens,
),
)
if prepared is None:
observations.append(
CandidateObservation(
target_text=chunk,
transcript_text="",
audio_duration_seconds=0.0,
pace_cps=None,
)
)
artifacts.append(ChunkCandidateArtifact())
continue
waveform = prepared.waveform
try:
duration = active_voiced_duration_seconds(waveform, SR)
except ValueError:
duration = 0.0
transcript = prepared.transcript_text
speaker_similarity = None
begin_similarity = None
end_similarity = None
speaker_embedding = None
rms_db = None
if duration >= SHORT_AUDIO_SPEAKER_GATE_SECONDS:
try:
if encoder is None:
encoder = _get_ecapa_encoder()
evidence = speaker_evidence_from_audio(
waveform,
SR,
encoder,
anchor,
device="cpu",
)
duration = evidence.active_duration_seconds
speaker_similarity = evidence.similarity
begin_similarity = evidence.begin_similarity
end_similarity = evidence.end_similarity
speaker_embedding = evidence.speaker_embedding
rms_db = evidence.active_rms_db
except ValueError:
# A malformed/empty speaker measurement remains missing and is
# rejected by the fail-closed gate for non-short candidates.
pass
if rms_db is None:
try:
rms_db = active_audio_rms_db(waveform)
except ValueError:
pass
observations.append(
CandidateObservation(
target_text=chunk,
transcript_text=transcript,
audio_duration_seconds=duration,
speaker_similarity=speaker_similarity,
begin_speaker_similarity=begin_similarity,
end_speaker_similarity=end_similarity,
pace_cps=(
count_speech_units(chunk) / duration * float(playback_speed)
if duration > 0.0
else None
),
)
)
artifacts.append(
ChunkCandidateArtifact(
speaker_embedding=speaker_embedding,
rms_db=rms_db,
)
)
return verify_trajectory(
observations,
chunk_artifacts=artifacts,
short_text_units=6,
short_text_max_cer=0.0,
max_cer=QUALITY_MAX_CER,
prefix_units=QUALITY_PREFIX_SUFFIX_UNITS,
suffix_units=QUALITY_PREFIX_SUFFIX_UNITS,
max_prefix_cer=0.0,
max_suffix_cer=0.0,
max_extra_tail_units=0,
short_audio_seconds=SHORT_AUDIO_SPEAKER_GATE_SECONDS,
min_speaker_similarity=QUALITY_MIN_SPEAKER_SIMILARITY,
max_boundary_speaker_drop=QUALITY_MAX_BOUNDARY_SPEAKER_DROP,
max_pace_cps=QUALITY_MAX_PACE_CPS,
)
def _assemble_trajectory_audio(
trajectory: tuple[np.ndarray, ...],
chunks: tuple[str, ...],
playback_speed: float,
) -> np.ndarray:
"""Assemble chunks exactly as they will be returned to the listener."""
if not trajectory or len(trajectory) != len(chunks):
raise ValueError("trajectory and text chunks must be non-empty and aligned")
audio_chunks = [np.asarray(audio, dtype=np.float32).copy() for audio in trajectory]
pauses: list[int] = []
for index, chunk in enumerate(chunks):
if index > 0:
audio_chunks[index] = match_chunk_rms(
audio_chunks[0],
audio_chunks[index],
max_adjust_db=CHUNK_RMS_MATCH_DB,
)
if index + 1 < len(chunks):
pauses.append(int(round(punctuation_pause_seconds(chunk) * SR)))
audio_chunks = fade_internal_edges(audio_chunks, SR, fade_ms=CHUNK_EDGE_FADE_MS)
waveform = join_audio_chunks(
audio_chunks,
pauses,
crossfade_samples=int(round(CROSSFADE_MS * SR / 1000.0)),
)
waveform = apply_loudness_floor(
waveform,
min_rms=0.07,
peak_limit=0.95,
max_gain=3.0,
)
waveform = _apply_speed(waveform, playback_speed)
return finish_audio(waveform, SR)
def _verification_metric_log_fields(verification) -> str:
"""Format normalized semantic metrics without logging transcript content."""
if len(verification.candidate_results) != 1:
return (
"cer=nan prefix_cer=nan suffix_cer=nan tail_units=nan "
f"reasons={verification.rejection_reasons}"
)
comparison = verification.candidate_results[0].comparison
return (
f"cer={comparison.cer:.6f} prefix_cer={comparison.prefix_cer:.6f} "
f"suffix_cer={comparison.suffix_cer:.6f} "
f"tail_units={comparison.extra_tail_units} "
f"reasons={verification.rejection_reasons}"
)
def _qualify_candidate_trajectory_audio(
trajectory: tuple[np.ndarray, ...],
chunks: tuple[str, ...],
whole_target_text: str,
anchor: np.ndarray,
playback_speed: float,
*,
candidate_seed: int,
):
"""Run whole-output qualification only after every local chunk passes."""
local_verification = _verify_trajectory_audio(
trajectory,
chunks,
anchor,
playback_speed,
)
if not local_verification.passed:
return local_verification
waveform = _assemble_trajectory_audio(trajectory, chunks, playback_speed)
joined_verification = _verify_trajectory_audio(
(waveform,),
(whole_target_text,),
anchor,
1.0,
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
)
qualified = qualify_trajectory_with_joined_output(
local_verification,
joined_verification,
)
if not qualified.passed:
print(
"[BlueMagpie] candidate joined output rejected "
f"seed={candidate_seed} "
f"{_verification_metric_log_fields(joined_verification)}"
)
return qualified
def _verify_sequence_trajectory_audio(
sequence_result,
chunks: tuple[str, ...],
whole_target_text: str,
anchor: np.ndarray,
playback_speed: float,
):
"""Verify one ranked DP path after exact production assembly."""
waveform = _assemble_trajectory_audio(
sequence_result.trajectory,
chunks,
playback_speed,
)
verification = _verify_trajectory_audio(
(waveform,),
(whole_target_text,),
anchor,
1.0,
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
)
status = "verified" if verification.passed else "rejected"
print(
f"[BlueMagpie] sequence path {status} "
f"rank={sequence_result.sequence_path_rank} "
f"chunk_candidates={sequence_result.chunk_candidate_indices} "
f"{_verification_metric_log_fields(verification)}"
)
return verification
def _synthesize(
text: str,
centroid: torch.Tensor,
*,
cfg: float,
steps: int,
speed: float,
request_seed: int | None = None,
) -> tuple[int, np.ndarray]:
text = normalize_spoken_forms(text, locale="zh-TW")
if not text:
raise gr.Error("請先輸入要合成的文字。")
if len(text) > MAX_TEXT_CHARS:
raise gr.Error(f"單次最多 {MAX_TEXT_CHARS} 個字元,請分段合成。")
if not np.isfinite(float(speed)) or not 0.85 <= float(speed) <= 1.05:
raise gr.Error("後處理語速必須介於 0.85 與 1.05。")
chunks = split_text_for_tts(text, max_chars=CHUNK_CHARS, min_chunk_chars=MIN_CHUNK_CHARS)
if chunks:
onset_chunks = split_leading_clause(
chunks[0],
search_chars=ONSET_CLAUSE_SEARCH_CHARS,
min_chunk_chars=MIN_CHUNK_CHARS,
)
chunks = onset_chunks + chunks[1:]
request_seed = resolve_request_seed(request_seed, secrets.randbelow)
max_candidates = candidate_limit_for_chunk_budget(
len(chunks),
max_candidates=QUALITY_MAX_CANDIDATES,
max_generated_chunks=QUALITY_MAX_GENERATED_CHUNKS,
)
anchor = _speaker_anchor_array(centroid)
try:
with _GENERATION_LOCK:
cascade = run_adaptive_cascade(
chunks,
request_seed,
lambda candidate_chunks, seed: _generate_trajectory(
candidate_chunks,
centroid,
cfg=cfg,
steps=steps,
request_seed=seed,
policy=generation_policy_for_candidate_offset(seed - request_seed),
),
lambda trajectory, candidate_chunks, seed: _qualify_candidate_trajectory_audio(
trajectory,
candidate_chunks,
text,
anchor,
speed,
candidate_seed=seed,
),
max_candidates=max_candidates,
preferred_min_speaker_similarity=(
QUALITY_PREFERRED_MIN_SPEAKER_SIMILARITY
),
preferred_max_boundary_speaker_drop=(
QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP
),
sequence_final_verifier=lambda sequence_result, candidate_chunks: (
_verify_sequence_trajectory_audio(
sequence_result,
candidate_chunks,
text,
anchor,
speed,
)
),
max_sequence_paths=3,
)
except NoQualifiedCandidateError as error:
raise gr.Error("目前沒有候選通過內容與音色驗證,請稍後重試或調整文字。") from error
except (RuntimeError, ValueError) as error:
raise gr.Error("品質驗證暫時無法完成,未回傳未驗證的語音。") from error
selected_policies = tuple(
generation_policy_for_candidate_offset(index).name
for index in cascade.chunk_candidate_indices
)
attempted_policies = tuple(
generation_policy_for_candidate_offset(seed - request_seed).name
for seed in cascade.attempted_seeds
)
print(
"[BlueMagpie] quality cascade "
f"candidate_index={cascade.candidate_index} attempts={len(cascade.attempted_seeds)} "
f"candidate_limit={max_candidates} selection={cascade.selection_mode} "
f"chunk_candidates={cascade.chunk_candidate_indices} "
f"chunk_seeds={cascade.chunk_seeds} score={cascade.verification.score:.6f}"
f" chunk_policies={selected_policies} attempted_policies={attempted_policies}"
f" sequence_rank={cascade.sequence_path_rank}"
f" sequence_paths_checked={cascade.sequence_paths_checked}"
)
waveform = _assemble_trajectory_audio(cascade.trajectory, chunks, speed)
final_verification = _verify_trajectory_audio(
(waveform,),
(text,),
anchor,
1.0,
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
)
try:
require_verified_final_output(final_verification)
except FinalOutputRejectedError as error:
print(
"[BlueMagpie] final output rejected "
f"{_verification_metric_log_fields(final_verification)}"
)
raise gr.Error("最終合成結果未通過整段內容、語速與音色驗證,未回傳音訊。") from error
print(
"[BlueMagpie] final output verified "
f"score={final_verification.score:.6f}"
)
return SR, waveform
@gpu
def tts_speaker(
text: str,
speaker: str = DEFAULT_SPEAKER,
cfg: float = DEFAULT_CFG,
steps: int = DEFAULT_STEPS,
speed: float = 1.0,
):
return _synthesize(
text,
SPEAKERS.get(speaker, DEFAULT_CENTROID),
cfg=cfg,
steps=steps,
speed=speed,
)
@gpu
def tts_reference(
text: str,
reference_wav: str,
cfg: float = DEFAULT_CFG,
steps: int = DEFAULT_STEPS,
speed: float = 1.0,
):
if not reference_wav:
raise gr.Error("請先錄音或上傳參考音檔。")
try:
centroid = extract_windowed_speaker_embedding(
reference_wav,
_get_ecapa_encoder(),
device="cpu",
min_duration_seconds=3.0,
window_seconds=3.0,
hop_seconds=1.5,
max_windows=12,
full_clip_max_seconds=12.0,
)
except ValueError as error:
raise gr.Error(str(error)) from error
return _synthesize(text, centroid, cfg=cfg, steps=steps, speed=speed)
@gpu
def tts_longform(
text: str,
speaker: str = DEFAULT_SPEAKER,
cfg: float = DEFAULT_CFG,
steps: int = DEFAULT_STEPS,
speed: float = 1.0,
):
return _synthesize(
text,
SPEAKERS.get(speaker, DEFAULT_CENTROID),
cfg=cfg,
steps=steps,
speed=speed,
)
EXAMPLE_TEXTS = [
"今天天氣真好,我們一起去散步吧。",
"我要吃蚵仔煎,然後去丟垃圾。",
"這學期的成績包括研究報告和期末考。",
"這是 AI TTS code switching 測試,混合中英文也沒問題。",
"注音符號測試:ㄅ、ㄆ、ㄇ、ㄈ。",
]
LONGFORM_EXAMPLES = [
"今天的會議會先整理目前進度,再確認下一階段的工作。遇到需要討論的項目時,"
"請先記下問題,等報告結束後再一起處理。最後,我們會確認負責人和預計完成時間。",
"歡迎收聽今天的內容。第一段會介紹背景,第二段整理實際案例,最後一段則說明後續安排。"
"如果中途聽到英文術語,不必擔心,我們會用中文補充它的意思。",
]
HEADER = f"""
# BlueMagpie-TTS Demo
台灣華語與中英混合文字轉語音。模型版本:`{CHECKPOINT}`。
執行環境固定於已驗證的 model revision `{MODEL_REVISION[:8]}`、ECAPA revision
`{ECAPA_REVISION[:8]}` 與 Whisper revision `{WHISPER_REVISION[:8]}`,避免服務重啟時
無聲變更權重、speaker embedding 或語意驗證空間。
目前預設採用穩定推論設定:`CFG 2.0`(極短句最低 `3.0`)、`NFE 10`、目標語速 `4.0 字/秒`、
candidate 0 使用 base duration estimate({BASE_GENERATION_POLICY.cjk_cps:.1f} CJK /
{BASE_GENERATION_POLICY.ascii_cps:.1f} ASCII),後續候選使用 safe duration estimate
({SAFE_DURATION_GENERATION_POLICY.cjk_cps:.1f} CJK /
{SAFE_DURATION_GENERATION_POLICY.ascii_cps:.1f} ASCII);兩者只調整生成上限,生成完成後才校正至
目標語速。另補齊句末提示、套用尾端 weak-stop 保護、
只在自然標點切開首段、每 80 字切段;先選完整 same-seed trajectory,失敗時才以 speaker/RMS
transition 做逐 chunk DP fallback。短句最多擴展到 1→5→10,長文依 chunk 數縮小候選上限,
確保每個 request 最多生成 20 個 TTS chunks。
"""
with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
gr.Markdown(HEADER)
with gr.Accordion("進階生成參數", open=False):
with gr.Row():
cfg_input = gr.Slider(1.0, 4.0, value=DEFAULT_CFG, step=0.1, label="CFG")
steps_input = gr.Slider(4, 20, value=DEFAULT_STEPS, step=1, label="NFE steps")
speed_input = gr.Slider(0.85, 1.05, value=1.0, step=0.05, label="後處理語速")
with gr.Tab("內建語者"):
with gr.Row():
with gr.Column():
speaker_input = gr.Dropdown(list(SPEAKERS), value=DEFAULT_SPEAKER, label="語者")
speaker_text = gr.Textbox(label="文字", lines=4, max_lines=8)
speaker_button = gr.Button("合成", variant="primary")
with gr.Column():
speaker_output = gr.Audio(label="合成結果", type="numpy")
gr.Examples(EXAMPLE_TEXTS, inputs=speaker_text, label="範例")
speaker_button.click(
tts_speaker,
[speaker_text, speaker_input, cfg_input, steps_input, speed_input],
speaker_output,
)
with gr.Tab("參考音色"):
gr.Markdown("參考音檔至少 3 秒;只使用已取得授權的聲音。參考內容不需要逐字稿。")
with gr.Row():
with gr.Column():
reference_text = gr.Textbox(label="文字", lines=4, max_lines=8)
reference_audio = gr.Audio(
label="參考音檔",
type="filepath",
sources=["microphone", "upload"],
)
reference_button = gr.Button("合成", variant="primary")
with gr.Column():
reference_output = gr.Audio(label="合成結果", type="numpy")
reference_button.click(
tts_reference,
[reference_text, reference_audio, cfg_input, steps_input, speed_input],
reference_output,
)
with gr.Tab("穩定長文"):
with gr.Row():
with gr.Column():
longform_speaker = gr.Dropdown(list(SPEAKERS), value=DEFAULT_SPEAKER, label="語者")
longform_text = gr.Textbox(
label=f"長文(最多 {MAX_TEXT_CHARS} 字元)",
lines=8,
max_lines=12,
)
longform_button = gr.Button("合成完整長文", variant="primary")
with gr.Column():
longform_output = gr.Audio(label="合成結果", type="numpy")
gr.Examples(LONGFORM_EXAMPLES, inputs=longform_text, label="長文範例")
longform_button.click(
tts_longform,
[longform_text, longform_speaker, cfg_input, steps_input, speed_input],
longform_output,
)
gr.Markdown(
"合成語音僅供研究與評估展示;正式使用前請人工檢視。 "
"[模型](https://huggingface.co/OpenFormosa/BlueMagpie-TTS) · "
"[程式碼](https://github.com/OpenFormosa/BlueMagpie-TTS)"
)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1).launch()