Spaces:
Running on Zero
Running on Zero
Download app.py from voidful/BlueMagpie-TTS-Demo: direct link, hf CLI and curl.
- Browser
- Download file 30.2 kB
-
https://huggingface.co/spaces/voidful/BlueMagpie-TTS-Demo/resolve/7e7df2a5f9ea6839a43a3ed880a32d7ecaea9176/app.py
- Command line
-
hf download hf://spaces/voidful/BlueMagpie-TTS-Demo@7e7df2a5f9ea6839a43a3ed880a32d7ecaea9176/app.py
-
curl -L -o app.py https://huggingface.co/spaces/voidful/BlueMagpie-TTS-Demo/resolve/7e7df2a5f9ea6839a43a3ed880a32d7ecaea9176/app.py
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 | |
| 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, | |
| ) | |
| 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) | |
| 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() | |