File size: 6,569 Bytes
409d4fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """PyTorch inference for LGTM.
from lgtm import LGTMTTS
tts = LGTMTTS.from_pretrained("path/or/hf-repo") # downloads from the Hub if needed
wav = tts.synthesize("Xin chào!", lang="vi", voice="F1")
tts.save_wav(wav, "out.wav")
voice = tts.clone_voice("reference.wav") # zero-shot voice from 5-15 s of audio
wav = tts.synthesize("Hello there.", lang="en", voice=voice)
"""
import json
import os
import re
import numpy as np
import soundfile as sf
import torch
import torch.nn.functional as F
import torchaudio
from safetensors.torch import load_file
from .model import LGTM
from .text import AVAILABLE_LANGS, TextProcessor
SAMPLE_RATE = 44100
def load_voice_style(path, device="cpu"):
d = json.load(open(path))
ttl = torch.tensor(np.array(d["style_ttl"]["data"], np.float32).reshape(d["style_ttl"]["dims"]))
dp = torch.tensor(np.array(d["style_dp"]["data"], np.float32).reshape(d["style_dp"]["dims"]))
return ttl.to(device), dp.to(device)
def save_voice_style(path, voice):
ttl, dp = voice
d = {"style_ttl": {"data": ttl.detach().float().cpu().numpy().tolist(), "dims": list(ttl.shape), "type": "float32"},
"style_dp": {"data": dp.detach().float().cpu().numpy().tolist(), "dims": list(dp.shape), "type": "float32"}}
json.dump(d, open(path, "w"))
def _prepare_reference(w, top_db=40.0, pad_s=0.1, target_rms_db=-23.0, peak=0.95):
"""Trim leading/trailing silence and normalise loudness (same as the training data)."""
hop = SAMPLE_RATE // 100
n = len(w) // hop
if n >= 3:
db = 10 * torch.log10(w[: n * hop].view(n, hop).pow(2).mean(1) + 1e-10)
active = torch.nonzero(db > db.max() - top_db).flatten()
if len(active):
pad = int(pad_s * SAMPLE_RATE)
w = w[max(int(active[0]) * hop - pad, 0): min((int(active[-1]) + 1) * hop + pad, len(w))]
w = w * (10 ** (target_rms_db / 20) / (w.pow(2).mean().sqrt() + 1e-6))
m = w.abs().max()
return w * (peak / m) if m > peak else w
def split_text(text, max_len=300):
"""Split long text into sentence chunks of at most max_len characters."""
chunks = []
for para in [p.strip() for p in re.split(r"\n\s*\n+", text.strip()) if p.strip()]:
cur = ""
for sent in re.split(r"(?<=[.!?。!?])\s+", para):
if cur and len(cur) + len(sent) + 1 > max_len:
chunks.append(cur)
cur = sent
else:
cur = f"{cur} {sent}".strip()
if cur:
chunks.append(cur)
return chunks
class LGTMTTS:
def __init__(self, model_dir, device=None):
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
cfg = json.load(open(os.path.join(model_dir, "config.json")))
self.model = LGTM(cfg)
self.model.load_state_dict(load_file(os.path.join(model_dir, "pytorch", "model.safetensors")))
self.model.to(self.device).eval()
self.tp = TextProcessor(os.path.join(model_dir, "unicode_indexer.json"))
self.voice_dir = os.path.join(model_dir, "voice_styles")
@classmethod
def from_pretrained(cls, repo_or_dir, device=None):
if not os.path.isdir(repo_or_dir):
from huggingface_hub import snapshot_download
repo_or_dir = snapshot_download(repo_or_dir, allow_patterns=["config.json", "unicode_indexer.json", "voice_styles/*", "pytorch/*"])
return cls(repo_or_dir, device)
@property
def voices(self):
return sorted(f[:-5] for f in os.listdir(self.voice_dir) if f.endswith(".json"))
def _voice(self, voice):
if isinstance(voice, str):
path = voice if voice.endswith(".json") else os.path.join(self.voice_dir, f"{voice}.json")
return load_voice_style(path, self.device)
return voice[0].to(self.device), voice[1].to(self.device)
@torch.no_grad()
def clone_voice(self, wav_path, max_seconds=15.0):
"""Voice style from a reference recording (5-15 s of clean speech works best)."""
wav, sr = sf.read(wav_path, dtype="float32")
if wav.ndim == 2:
wav = wav.mean(1)
w = torch.from_numpy(wav)
if sr != SAMPLE_RATE:
w = torchaudio.functional.resample(w, sr, SAMPLE_RATE)
w = _prepare_reference(w)[: int(max_seconds * SAMPLE_RATE)]
lat = self.model.ae.encode_ttl(w[None].to(self.device))
mask = torch.ones(1, 1, lat.shape[-1], device=self.device)
return self.model.ttl.style_encoder(lat, mask), self.model.dp.style_encoder(lat, mask)
@torch.no_grad()
def _synth_batch(self, texts, langs, voice, steps, speed, cfg_scale):
s_ttl, s_dp = voice
b = len(texts)
ids, mask = self.tp.batch(texts, langs, device=self.device)
s_ttl, s_dp = s_ttl.expand(b, -1, -1), s_dp.expand(b, -1, -1)
m = self.model
dur = m.dp(ids, mask, s_dp) / speed
text_emb = m.ttl.encode_text(ids, mask, s_ttl)
chunk = m.ae.hop * m.ae.ccf
wav_len = (dur * SAMPLE_RATE).long()
lat_len = (wav_len + chunk - 1) // chunk
L = int(lat_len.max())
lmask = (torch.arange(L, device=self.device)[None] < lat_len[:, None]).float().unsqueeze(1)
x = torch.randn(b, m.ae.ldim * m.ae.ccf, L, device=self.device) * lmask
total = torch.full((b,), float(steps), device=self.device)
for i in range(steps):
x = m.ttl.euler_step(x, torch.full((b,), float(i), device=self.device), total, text_emb, mask, s_ttl, lmask, cfg_scale)
wav = m.ae.decode_ttl(x)
return [wav[k, : wav_len[k]].float().cpu().numpy() for k in range(b)]
def synthesize(self, text, lang="en", voice="F1", steps=8, speed=1.05, cfg_scale=4.0, silence=0.3):
"""Text -> 44.1 kHz float32 waveform. Long text is split into sentence chunks.
voice: preset name (F1..F5, M1..M5), a .json path, or the output of clone_voice()."""
if lang not in AVAILABLE_LANGS:
raise ValueError(f"unsupported language {lang!r}")
v = self._voice(voice)
chunks = split_text(text, 120 if lang in ("ja", "ko") else 300)
wavs = self._synth_batch(chunks, [lang] * len(chunks), v, steps, speed, cfg_scale)
gap = np.zeros(int(silence * SAMPLE_RATE), np.float32)
out = []
for i, w in enumerate(wavs):
out += [w] + ([gap] if i < len(wavs) - 1 else [])
return np.concatenate(out)
@staticmethod
def save_wav(wav, path):
sf.write(path, wav, SAMPLE_RATE)
|