| """ |
| Inflect-Micro-v2 TTS Engine — multi-chip, zero PyTorch. |
| |
| Supports: AX650 (full NPU), AX620E (encoder NPU + decoder ONNX), AX637 (encoder NPU + decoder ONNX). |
| """ |
| from __future__ import annotations |
| import sys, os, io, math, re, json |
| from pathlib import Path |
| import numpy as np |
| import onnxruntime as ort |
|
|
| PKG = Path(__file__).resolve().parent |
| sys.path.insert(0, str(PKG)) |
| sys.path.insert(0, str(PKG / "runtime")) |
|
|
| from inflect_vits_frontend import run_vits_frontend |
| from text.symbols import symbols |
| from text import cleaned_text_to_sequence |
|
|
|
|
| |
|
|
| def _intersperse(lst, item): |
| result = [item] * (len(lst) * 2 + 1) |
| result[1::2] = lst |
| return result |
|
|
| def _sequence_mask(length, max_length=None): |
| if max_length is None: |
| max_length = int(length.max()) |
| return np.arange(max_length, dtype=length.dtype)[None, :] < length[:, None] |
|
|
| def _generate_path(duration, mask): |
| b, _, t_y, t_x = mask.shape |
| cum_duration = np.cumsum(duration, axis=-1) |
| cum_duration_flat = cum_duration.reshape(b * t_x) |
| path = _sequence_mask(cum_duration_flat, t_y).astype(mask.dtype) |
| path = path.reshape(b, t_x, t_y) |
| padded = np.pad(path[:, :-1], ((0, 0), (1, 0), (0, 0))) |
| path = path - padded |
| path = path[:, np.newaxis, :, :].transpose(0, 1, 3, 2) * mask |
| return path |
|
|
|
|
| |
|
|
| class InflectTTSEngine: |
| """Inflect-Micro-v2 TTS — 多芯片,零 PyTorch。""" |
|
|
| def __init__(self, model_dir: str | Path | None = None, chip: str = "ax650"): |
| if model_dir: |
| self.root = Path(model_dir) |
| else: |
| |
| self.root = PKG.parent.parent / "models" / chip |
|
|
| self.chip = chip |
|
|
| |
| with open(PKG / "config.json") as f: |
| self.hps = json.load(f) |
| self.sample_rate = int(self.hps["data"]["sampling_rate"]) |
| self.hop_length = int(self.hps["data"]["hop_length"]) |
| self.hidden_channels = int(self.hps["model"]["hidden_channels"]) |
| self.inter_channels = int(self.hps["model"]["inter_channels"]) |
|
|
| |
| meta_path = self.root / "model_meta.json" |
| if meta_path.exists(): |
| with open(meta_path) as f: |
| self.meta = json.load(f) |
| else: |
| self.meta = {} |
|
|
| |
| self._emb = np.load(str(self.root / "emb.npy")) |
| self._emb_scale = math.sqrt(self.hidden_channels) |
|
|
| |
| self._dp_sess = ort.InferenceSession( |
| str(self.root / "dp.onnx"), providers=["CPUExecutionProvider"] |
| ) |
|
|
| |
| try: |
| import axengine as axe |
| enc_path = str(self.root / "inflect_encoder.axmodel") |
| self.enc_session = axe.InferenceSession(enc_path) |
| self._has_npu = True |
| except ImportError: |
| self._has_npu = False |
| print("[WARN] axengine not available, encoder will use CPU ONNX") |
| self.enc_session = ort.InferenceSession( |
| str(self.root / "inflect_encoder.onnx"), |
| providers=["CPUExecutionProvider"], |
| ) |
|
|
| |
| dec_axmodel = self.root / "inflect_decoder.axmodel" |
| if dec_axmodel.exists() and self._has_npu: |
| self.dec_session = axe.InferenceSession(str(dec_axmodel)) |
| self._dec_is_npu = True |
| else: |
| dec_onnx = self.root / "inflect_decoder.onnx" |
| self.dec_session = ort.InferenceSession( |
| str(dec_onnx), providers=["CPUExecutionProvider"] |
| ) |
| self._dec_is_npu = False |
| if self._has_npu: |
| print(f"[INFO] {chip}: decoder running on CPU (ONNX)") |
|
|
| def synthesize(self, text: str, speed: float = 1.0, variation: float = 0.667, |
| seed: int = 0): |
| normalized = " ".join(text.split()) |
| if not normalized: |
| raise ValueError("Text must not be empty.") |
|
|
| sentences = [p.strip() for p in re.split(r"(?<=[.!?;:])\s+", normalized) if p.strip()] |
| if not sentences: |
| sentences = [normalized] |
|
|
| pieces = [] |
| for idx, chunk in enumerate(sentences): |
| if idx > 0: |
| pieces.append(np.zeros(round(self.sample_rate * 0.08), dtype=np.float32)) |
|
|
| phonemes = run_vits_frontend(chunk).phoneme_text |
| seq = cleaned_text_to_sequence(phonemes) |
| if self.hps["data"]["add_blank"]: |
| seq = _intersperse(seq, 0) |
| if not seq: |
| continue |
|
|
| tokens = np.array(seq, dtype=np.int64) |
| tlen = len(tokens) |
|
|
| |
| x_emb = self._emb[tokens] * self._emb_scale |
| x_emb = x_emb.T[np.newaxis, :, :] |
|
|
| |
| MAX_TOK = 200 |
| if tlen > MAX_TOK: |
| raise ValueError(f"Text too long: {tlen} tokens > {MAX_TOK}") |
| x_pad = np.zeros((1, self.hidden_channels, MAX_TOK), dtype=np.float32) |
| x_pad[:, :, :tlen] = x_emb |
|
|
| enc_out = self.enc_session.run(None, { |
| "x_emb": x_pad, |
| "lengths": np.array([tlen], dtype=np.int32), |
| }) |
| m_p = np.asarray(enc_out[0])[:, :, :tlen] |
| logs_p = np.asarray(enc_out[1])[:, :, :tlen] |
| x_enc = np.asarray(enc_out[2])[:, :, :tlen] |
| x_mask = np.asarray(enc_out[3])[:, :, :tlen] |
|
|
| |
| logw = self._dp_sess.run(None, { |
| "x": x_enc.astype(np.float32), |
| "x_mask": x_mask.astype(np.float32), |
| })[0] |
|
|
| |
| rng = np.random.RandomState(seed + idx) |
| w = np.exp(logw) * x_mask * (1.0 / speed) |
| w_ceil = np.ceil(w) |
| y_lengths = np.clip(w_ceil.sum(axis=(1, 2)), 1, None).astype(np.int64) |
| y_mask = _sequence_mask(y_lengths, None)[:, np.newaxis, :].astype(x_mask.dtype) |
| attn_mask = x_mask[:, :, np.newaxis, :] * y_mask[:, :, :, np.newaxis] |
| attn = _generate_path(w_ceil, attn_mask) |
|
|
| m_p = (attn.squeeze(1) @ m_p.transpose(0, 2, 1)).transpose(0, 2, 1) |
| logs_p = (attn.squeeze(1) @ logs_p.transpose(0, 2, 1)).transpose(0, 2, 1) |
| z_p = m_p + rng.randn(*m_p.shape).astype(np.float32) * np.exp(logs_p) * variation |
|
|
| |
| mel_len = z_p.shape[2] |
| MAX_MEL = 500 |
| if mel_len > MAX_MEL: |
| raise ValueError(f"Audio too long: {mel_len} frames > {MAX_MEL}") |
|
|
| if self._dec_is_npu: |
| zp_np = np.zeros((1, self.inter_channels, MAX_MEL), dtype=np.float32) |
| ym_np = np.zeros((1, 1, MAX_MEL), dtype=np.float32) |
| zp_np[:, :, :mel_len] = z_p |
| ym_np[:, :, :mel_len] = y_mask |
| dec_out = self.dec_session.run(None, {"z_p": zp_np, "y_mask": ym_np}) |
| waveform = dec_out[0][0, 0, :mel_len * self.hop_length] |
| else: |
| dec_out = self.dec_session.run(None, { |
| "z_p": z_p.astype(np.float32), |
| "y_mask": y_mask.astype(np.float32), |
| }) |
| waveform = dec_out[0][0, 0, :mel_len * self.hop_length] |
|
|
| pieces.append(waveform) |
|
|
| waveform = np.clip(np.concatenate(pieces), -1.0, 1.0) |
| return self.sample_rate, waveform |
|
|
| def save(self, text: str, output: str | Path, **kwargs): |
| import soundfile as sf |
| dest = Path(output) |
| dest.parent.mkdir(parents=True, exist_ok=True) |
| sr, wav = self.synthesize(text, **kwargs) |
| sf.write(dest, wav, sr) |
| return dest |
|
|