CoolFace
Apppublic

RustyMark/dots.tts

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
0likes
runtime.py568 linesDownload Raw Back to dots_tts
1from __future__ import annotations2 3import hashlib4import json5import os6import time7from pathlib import Path8from typing import Any, Iterator, TypedDict9 10import librosa11import torch12from huggingface_hub import snapshot_download13from loguru import logger14 15from dots_tts.data.pipelines.tokenizing import build_generation_schedule16from dots_tts.data.pipelines.tts_pipeline import (17    DEFAULT_INSTRUCTION_TTS_TEMPLATE,18    DEFAULT_INTERLEAVE_TRAIN_TEMPLATE,19    DEFAULT_TEXT_TO_AUDIO_TEMPLATE,20    DEFAULT_TRAIN_TEMPLATE,21)22from dots_tts.models.dots_tts.model import DotsTtsModel23from dots_tts.utils.audio import high_quality_resample24from dots_tts.utils.profiling import (25    InferenceProfiler,26    activate_inference_profiler,27    inference_profiling,28    log_inference_profile,29)30from dots_tts.utils.text import (31    attach_language_tag,32    detect,33    normalize_language_code,34    normalize_text,35)36from dots_tts.utils.util import get_dtype37 38RUNTIME_TEMPLATE_BY_NAME = {39    "tts": DEFAULT_TRAIN_TEMPLATE,40    "instruction_tts": DEFAULT_INSTRUCTION_TTS_TEMPLATE,41    "text_to_audio": DEFAULT_TEXT_TO_AUDIO_TEMPLATE,42    "tts_interleave": DEFAULT_INTERLEAVE_TRAIN_TEMPLATE,43}44 45 46class RuntimeInputs(TypedDict, total=False):47    fid: str48    language: str49    text: str50    prompt_text: str51    template_name: str52    generation_schedule: torch.Tensor53    prompt_audio: torch.Tensor54 55 56class DotsTtsRuntime:57    # region Lifecycle and pretrained loading58    def __init__(59        self,60        model: DotsTtsModel,61        pretrained_path: Path,62        *,63        precision: str = "bfloat16",64        optimize: bool = False,65        max_generate_length: int = 500,66    ):67        self.model = model68        self.pretrained_path = pretrained_path69        self.precision = precision70        if torch.cuda.is_available():71            self.device = torch.device("cuda")72        else:73            self.device = torch.device("cpu")74            torch.set_num_threads(1)75        if self.device.type == "cuda" and self.precision.lower() in {76            "fp32",77            "torch.float32",78            "float32",79        }:80            torch.set_float32_matmul_precision("high")81        target_dtype = get_dtype(self.precision)82        self.model.core.to(dtype=target_dtype)83        self.model = self.model.to(self.device).eval()84        self.optimize = bool(optimize)85        self.max_generate_length = int(max_generate_length)86        self.model.set_optimize(self.optimize)87        self.sample_rate = int(self.model.config.vocoder.sample_rate)88        skip_init_warmup = os.environ.get("DOTS_TTS_SKIP_INIT_WARMUP", "0") == "1"89        if self.optimize and hasattr(self.model, "run_warmup") and not skip_init_warmup:90            self.model.run_warmup(91                max_generate_length=self.max_generate_length,92                precision=self.precision,93            )94        logger.info(95            "Runtime initialized: pretrained_path={} device={} sample_rate={} "96            "precision={} "97            "optimize={} max_audio_patch_count={}",98            self.pretrained_path,99            self.device,100            self.sample_rate,101            self.precision,102            self.optimize,103            self.max_generate_length,104        )105 106    @classmethod107    def from_pretrained(108        cls,109        model_name_or_path: str,110        *,111        revision: str | None = None,112        cache_dir: str | None = None,113        precision: str = "bfloat16",114        optimize: bool = False,115        max_generate_length: int = 500,116    ) -> DotsTtsRuntime:117        logger.info(118            "Runtime load started: model={} revision={} cache_dir={} precision={}",119            model_name_or_path,120            revision,121            cache_dir,122            precision,123        )124        pretrained_path = cls._resolve_pretrained_path(125            model_name_or_path,126            revision=revision,127            cache_dir=cache_dir,128        )129        loaded_model = DotsTtsModel.from_pretrained(pretrained_path)130        logger.info("Runtime load completed: pretrained_path={}", pretrained_path)131        return cls(132            model=loaded_model,133            pretrained_path=pretrained_path,134            precision=precision,135            optimize=optimize,136            max_generate_length=max_generate_length,137        )138 139    @classmethod140    def _resolve_pretrained_path(141        cls,142        model_name_or_path: str,143        revision: str | None = None,144        cache_dir: str | None = None,145    ) -> Path:146        logger.info(147            "Resolving pretrained path: model={} revision={} cache_dir={}",148            model_name_or_path,149            revision,150            cache_dir,151        )152        resolved_path = Path(model_name_or_path).expanduser().resolve()153        if resolved_path.exists():154            logger.info("Using local pretrained directory: path={}", resolved_path)155            return resolved_path156 157        logger.info(158            "Downloading pretrained snapshot: repo_id={} revision={} cache_dir={}",159            model_name_or_path,160            revision,161            cache_dir,162        )163        snapshot_dir = snapshot_download(164            repo_id=model_name_or_path,165            revision=revision,166            cache_dir=cache_dir,167        )168        resolved_path = Path(snapshot_dir).expanduser().resolve()169        logger.info("Pretrained snapshot ready: path={}", resolved_path)170        return resolved_path171    # endregion Lifecycle and pretrained loading172 173    # region Request normalization and metadata174    @staticmethod175    def _build_request_id(176        *,177        text: str,178        prompt_audio_path: str | None,179        prompt_text: str | None,180        template_name: str,181        language: str | None = None,182    ) -> str:183        payload = {184            "text": text,185            "prompt_audio_path": prompt_audio_path,186            "prompt_text": prompt_text,187            "template_name": template_name,188        }189        if language is not None:190            payload["language"] = language191        digest = hashlib.sha1(192            json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")193        ).hexdigest()194        return digest[:16]195 196    def _load_prompt_audio(197        self,198        prompt_audio_path: str,199    ) -> torch.Tensor:200        logger.info("Loading prompt audio: path={}", prompt_audio_path)201        prompt_audio, sample_rate = librosa.load(prompt_audio_path, sr=None, mono=True)202        prompt_audio = librosa.effects.trim(prompt_audio, top_db=30)[0]203        prompt_audio = torch.from_numpy(prompt_audio).unsqueeze(0)204        prompt_audio = high_quality_resample(205            prompt_audio,206            orig_sr=sample_rate,207            target_sr=self.sample_rate,208        )209        if prompt_audio.ndim == 1:210            prompt_audio = prompt_audio.unsqueeze(0)211        logger.info(212            "Prompt audio loaded: path={} original_sample_rate={} resampled_sample_rate={} "213            "samples={}",214            prompt_audio_path,215            sample_rate,216            self.sample_rate,217            prompt_audio.shape[-1],218        )219        return prompt_audio220 221    def _resolve_language(222        self,223        language: str | None,224        *,225        text: str,226    ) -> str | None:227        if language is None:228            return None229 230        stripped = language.strip()231        if not stripped or stripped.lower() == "none":232            return None233        if stripped.lower() == "auto_detect":234            return normalize_language_code(detect(text))235 236        normalized_language = normalize_language_code(stripped)237        if normalized_language is None:238            raise ValueError(239                f"Unsupported language={language!r}. "240                "Expected 'none', 'auto_detect', or a valid language code/name."241            )242        return normalized_language243 244    def _process_prompt_text(245        self,246        prompt_text: str | None,247        *,248        language: str | None = None,249    ) -> str:250        if prompt_text is None:251            return ""252        prompt_text = prompt_text.strip()253        if not prompt_text:254            return ""255 256        prompt_language = language257        if prompt_language is None:258            prompt_language = normalize_language_code(detect(prompt_text))259 260        if prompt_language not in {"ZH", "YUE", "JA", "口音:粤语"}:261            prompt_text += " "262        if language is not None:263            prompt_text = attach_language_tag(prompt_text, language)264        return prompt_text265 266    def _process_text(267        self,268        text: str,269        *,270        language: str | None = None,271        normalize: bool = False,272    ) -> tuple[str, str | None]:273        stripped = text.strip()274        if normalize:275            stripped = normalize_text(stripped)276        resolved_language = self._resolve_language(language, text=stripped)277        return stripped, resolved_language278 279    def _estimate_prompt_audio_patch_count(280        self,281        *,282        prompt_audio: torch.Tensor | None,283        prompt_text: str,284    ) -> int:285        if prompt_audio is None or not prompt_text:286            return 0287        samples_per_patch = int(self.model.config.patch_size * self.model.hop_size)288        prompt_samples = int(prompt_audio.shape[-1])289        return (prompt_samples + samples_per_patch - 1) // samples_per_patch290    # endregion Request normalization and metadata291 292    # region Generation schedule assembly293    def _normalize_template_name(self, template_name: str | None) -> str:294        if template_name is None:295            return "tts"296        if template_name not in RUNTIME_TEMPLATE_BY_NAME:297            raise ValueError(298                f"Unknown template_name={template_name!r}. "299                f"Expected one of {sorted(RUNTIME_TEMPLATE_BY_NAME)}."300            )301        return template_name302 303    def _prepare_inputs(304        self,305        *,306        text: str,307        prompt_audio_path: str | None,308        prompt_text: str | None,309        template_name: str | None,310        language: str | None = None,311        normalize_text: bool = False,312    ) -> RuntimeInputs:313        normalized_template_name = self._normalize_template_name(template_name)314        template = RUNTIME_TEMPLATE_BY_NAME[normalized_template_name]315        if prompt_text and not prompt_audio_path:316            raise ValueError("prompt_text requires prompt_audio_path.")317 318        normalized_text, normalized_language = self._process_text(319            text,320            language=language,321            normalize=normalize_text,322        )323        normalized_prompt_text = self._process_prompt_text(324            prompt_text,325            language=normalized_language,326        )327        if normalized_language is not None and not normalized_prompt_text:328            normalized_text = attach_language_tag(normalized_text, normalized_language)329        inputs: RuntimeInputs = {330            "fid": self._build_request_id(331                text=normalized_text,332                prompt_audio_path=prompt_audio_path,333                prompt_text=normalized_prompt_text,334                template_name=normalized_template_name,335                language=normalized_language,336            ),337            "language": normalized_language or "",338            "text": normalized_text,339            "prompt_text": normalized_prompt_text,340            "template_name": normalized_template_name,341        }342 343        if prompt_audio_path:344            inputs["prompt_audio"] = self._load_prompt_audio(prompt_audio_path)345        prompt_audio_patch_count = self._estimate_prompt_audio_patch_count(346            prompt_audio=inputs.get("prompt_audio"),347            prompt_text=normalized_prompt_text,348        )349        if (350            prompt_audio_patch_count > 0351            and self.max_generate_length <= prompt_audio_patch_count352        ):353            raise ValueError(354                "max_generate_length must exceed prompt audio patch count when prompt_text is provided: "355                f"max_generate_length={self.max_generate_length} "356                f"prompt_audio_patch_count={prompt_audio_patch_count}."357            )358 359        schedule_spec = build_generation_schedule(360            text=f"{normalized_prompt_text}{normalized_text}",361            tokenizer=self.model.tokenizer,362            template=template,363            max_audio_tokens=self.max_generate_length,364        )365        schedule = torch.tensor(366            schedule_spec["schedule_ids"],367            dtype=torch.long,368            device=self.device,369        )370        inputs["generation_schedule"] = schedule.unsqueeze(0)371        logger.info(372            "Inputs prepared: request_id={} template_name={} "373            "language={} text_len={} prompt_text_len={} schedule_length={} "374            "prompt_audio_patch_count={} max_audio_patch_count={} has_prompt_audio={}",375            inputs["fid"],376            normalized_template_name,377            normalized_language,378            len(normalized_text),379            len(normalized_prompt_text),380            schedule.numel(),381            prompt_audio_patch_count,382            self.max_generate_length,383            bool(prompt_audio_path),384        )385        return inputs386    # endregion Generation schedule assembly387 388    # region Public generation APIs389    def generate_stream(390        self,391        *,392        text: str,393        prompt_audio_path: str | None = None,394        prompt_text: str | None = None,395        template_name: str | None = None,396        language: str | None = None,397        speaker_scale: float = 1.5,398        ode_method: str = "euler",399        num_steps: int = 10,400        guidance_scale: float = 1.2,401        normalize_text: bool = False,402        profile_inference: bool = False,403    ) -> Iterator[torch.Tensor]:404        inputs = self._prepare_inputs(405            text=text,406            prompt_audio_path=prompt_audio_path,407            prompt_text=prompt_text,408            template_name=template_name,409            language=language,410            normalize_text=normalize_text,411        )412        logger.info(413            "Streaming generation started: request_id={} text_len={} has_prompt_audio={} "414            "has_prompt_text={} template_name={} language={} precision={} ode_method={} num_steps={} "415            "guidance_scale={} speaker_scale={} max_audio_patch_count={} normalize_text={}",416            inputs["fid"],417            len(inputs["text"]),418            bool(prompt_audio_path),419            bool(inputs["prompt_text"]),420            inputs["template_name"],421            inputs["language"] or None,422            self.precision,423            ode_method,424            num_steps,425            guidance_scale,426            speaker_scale,427            self.max_generate_length,428            normalize_text,429        )430        start_time = time.time()431        emitted_samples = 0432        chunk_count = 0433        profiler: InferenceProfiler | None = None434        try:435            profiler = (436                InferenceProfiler(self.device) if profile_inference else None437            )438            stream = self.model.generate_audio_stream(439                inputs,440                precision=self.precision,441                ode_method=ode_method,442                num_steps=num_steps,443                guidance_scale=guidance_scale,444                speaker_scale=speaker_scale,445            )446            while True:447                try:448                    with activate_inference_profiler(profiler):449                        chunk = next(stream)450                except StopIteration:451                    break452                emitted_samples += int(chunk.shape[-1])453                chunk_count += 1454                yield chunk455        except Exception:456            logger.exception(457                "Streaming generation failed: request_id={}",458                inputs["fid"],459            )460            raise461        time_used = time.time() - start_time462        duration_seconds = emitted_samples / self.sample_rate463        rtf = time_used / duration_seconds if duration_seconds > 0 else float("inf")464        if profile_inference and profiler is not None:465            log_inference_profile(466                request_id=inputs["fid"],467                profiling=profiler.summary(duration_seconds=duration_seconds),468                duration_seconds=duration_seconds,469            )470        logger.info(471            "Streaming generation finished: request_id={} chunk_count={} elapsed_seconds={:.3f} "472            "audio_seconds={:.3f} rtf={:.4f} sample_rate={}",473            inputs["fid"],474            chunk_count,475            time_used,476            duration_seconds,477            rtf,478            self.sample_rate,479        )480 481    def generate(482        self,483        *,484        text: str,485        prompt_audio_path: str | None = None,486        prompt_text: str | None = None,487        template_name: str | None = None,488        language: str | None = None,489        speaker_scale: float = 1.5,490        ode_method: str = "euler",491        num_steps: int = 10,492        guidance_scale: float = 1.2,493        normalize_text: bool = False,494        profile_inference: bool = False,495    ) -> dict[str, Any]:496        inputs = self._prepare_inputs(497            text=text,498            prompt_audio_path=prompt_audio_path,499            prompt_text=prompt_text,500            template_name=template_name,501            language=language,502            normalize_text=normalize_text,503        )504        logger.info(505            "Generation started: request_id={} text_len={} has_prompt_audio={} "506            "has_prompt_text={} template_name={} language={} precision={} ode_method={} num_steps={} "507            "guidance_scale={} speaker_scale={} max_audio_patch_count={} normalize_text={}",508            inputs["fid"],509            len(inputs["text"]),510            bool(prompt_audio_path),511            bool(inputs["prompt_text"]),512            inputs["template_name"],513            inputs["language"] or None,514            self.precision,515            ode_method,516            num_steps,517            guidance_scale,518            speaker_scale,519            self.max_generate_length,520            normalize_text,521        )522        start_time = time.time()523        profiling = None524        try:525            with inference_profiling(526                enabled=profile_inference,527                device=self.device,528            ) as profiler:529                audio = self.model.generate_audio(530                    inputs,531                    precision=self.precision,532                    ode_method=ode_method,533                    num_steps=num_steps,534                    guidance_scale=guidance_scale,535                    speaker_scale=speaker_scale,536                )537        except Exception:538            logger.exception("Generation failed: request_id={}", inputs["fid"])539            raise540        time_used = time.time() - start_time541        duration_seconds = audio.shape[-1] / self.sample_rate542        rtf = time_used / duration_seconds if duration_seconds > 0 else float("inf")543        if profiler is not None:544            profiling = profiler.summary(duration_seconds=duration_seconds)545            log_inference_profile(546                request_id=inputs["fid"],547                profiling=profiling,548                duration_seconds=duration_seconds,549            )550        logger.info(551            "Generation completed: request_id={} elapsed_seconds={:.3f} audio_seconds={:.3f} "552            "rtf={:.4f} sample_rate={}",553            inputs["fid"],554            time_used,555            duration_seconds,556            rtf,557            self.sample_rate,558        )559        return {560            "fid": inputs["fid"],561            "audio": audio,562            "sample_rate": self.sample_rate,563            "time_used": time_used,564            "rtf": rtf,565            "profiling": profiling,566        }567    # endregion Public generation APIs568