UncleanCode's picture
Add inference code (prosody v2), README, LICENSE, prompts, samples
256a3b6 verified
Raw History Blame
17 kB
"""Arm B: CosyVoice2-0.5B (Apache-2.0) fine-tune with the Zhang et al. 2019 recipe added to its text->speech-token LM.
Additions (new, ours):
* per-token language embedding added to the Qwen text-token embeddings (zero-init)
* learned speaker embedding inserted as one token AFTER the text, before the task token (zero-init), so text
positions cannot see it; speech positions attend to it (anchor-voice conditioning)
* domain-adversarial speaker classifier with gradient reversal on the LM hidden states at text positions
Flow-matching decoder + HiFT vocoder stay frozen; at inference they are prompted with an anchor-voice clip.
python train_b.py train --out ~/runs/b1 --minutes 30
python train_b.py synth --ckpt ~/runs/b1/last.pt --out ~/eval/sys/armB_ft
python train_b.py zeroshot --out ~/eval/sys/cosyvoice2_zeroshot (pretrained model, cross-lingual, no fine-tune)
"""
import argparse, inspect, json, math, os, random, sys, time
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F, soundfile as sf, librosa
HOME = os.path.expanduser("~")
sys.path[:0] = [f"{HOME}/CosyVoice", f"{HOME}/CosyVoice/third_party/Matcha-TTS"]
MODEL_DIR = f"{HOME}/models/CosyVoice2-0.5B"
LANGS = ["eng", "ibo", "hau", "yor", "pcm"]
L2I = {l: i for i, l in enumerate(LANGS)}
sys.path.insert(0, f"{HOME}/da")
from da.text import segments # noqa: E402
from da.model import grad_reverse # noqa: E402
def _sf_load_wav(wav, target_sr, min_sr=16000):
"""soundfile replacement for cosyvoice load_wav (torchaudio>=2.9 load needs torchcodec)."""
y, sr = sf.read(wav, dtype="float32", always_2d=True)
y = y.mean(1)
if sr != target_sr:
y = librosa.resample(y, orig_sr=sr, target_sr=target_sr)
return torch.from_numpy(y)[None]
def load_cosy():
import cosyvoice.utils.file_utils as fu
fu.load_wav = _sf_load_wav
import cosyvoice.cli.frontend as cf
cf.load_wav = _sf_load_wav
from cosyvoice.cli.cosyvoice import CosyVoice2
kw = dict(load_jit=False, load_trt=False, fp16=False)
sig = inspect.signature(CosyVoice2.__init__).parameters
kw = {k: v for k, v in kw.items() if k in sig}
return CosyVoice2(MODEL_DIR, **kw)
def text_tokens(tok, text, default="eng"):
"""tagged text -> (qwen ids, per-token lang ids)."""
ids, langs = [], []
for l, s in segments(text, default):
t = tok(s, add_special_tokens=False)["input_ids"]
ids += t; langs += [L2I[l]] * len(t)
return ids, langs
class DALM(nn.Module):
def __init__(self, qlm, n_spk, d=896):
super().__init__()
self.q = qlm # cosyvoice Qwen2LM
self.lang_emb = nn.Embedding(len(LANGS), d); nn.init.zeros_(self.lang_emb.weight)
self.spk_emb = nn.Embedding(n_spk, d); nn.init.normal_(self.spk_emb.weight, std=0.02)
self.adv = nn.Sequential(nn.Linear(d, 256), nn.ReLU(), nn.Linear(256, n_spk))
self.V = qlm.speech_token_size
def embed_text(self, ids, langs):
return self.q.llm.model.model.embed_tokens(ids) + self.lang_emb(langs)
def build(self, ids, langs, spk, speech=None):
"""one sequence: [sos, text+lang, spk, task, speech...]"""
sos = self.q.llm_embedding.weight[self.q.sos][None]
task = self.q.llm_embedding.weight[self.q.task_id][None]
parts = [sos, self.embed_text(ids, langs), self.spk_emb(spk)[None], task]
if speech is not None:
parts.append(self.q.speech_embedding(speech))
return torch.cat(parts, 0)
def hidden(self, x, mask):
out = self.q.llm.model.model(inputs_embeds=x, attention_mask=mask, return_dict=True)
return out.last_hidden_state
def forward(self, batch, adv_lambda=1.0):
seqs, tgts, tpos = [], [], []
for ids, langs, spk, sp in batch:
x = self.build(ids, langs, spk, sp)
n_text = len(ids)
tgt = torch.full((x.shape[0],), -100, device=x.device, dtype=torch.long)
# position of task token = 1 + n_text + 1; it predicts speech[0]; last speech pos predicts EOS
start = 1 + n_text + 1
tgt[start: start + len(sp)] = sp
tgt[start + len(sp)] = self.V # EOS
seqs.append(x); tgts.append(tgt); tpos.append((1, 1 + n_text))
T = max(s.shape[0] for s in seqs) + 1
D = seqs[0].shape[1]
X = seqs[0].new_zeros(len(seqs), T, D)
Y = torch.full((len(seqs), T), -100, device=X.device, dtype=torch.long)
M = torch.zeros(len(seqs), T, device=X.device, dtype=torch.long)
for i, (s, t) in enumerate(zip(seqs, tgts)):
X[i, :len(s)] = s; Y[i, :len(t)] = t[:len(s)] if len(t) > len(s) else t; M[i, :len(s)] = 1
# targets were laid out on positions; shift is implicit (position p predicts tgt[p])
H = self.hidden(X, M)
logits = self.q.llm_decoder(H)
ce = F.cross_entropy(logits.float().reshape(-1, logits.shape[-1]), Y.reshape(-1), ignore_index=-100)
valid = Y != -100
acc = (logits.argmax(-1)[valid] == Y[valid]).float().mean()
# adversarial speaker classifier on text positions
hs, ys = [], []
for i, (a, b) in enumerate(tpos):
hs.append(H[i, a:b]); ys.append(torch.full((b - a,), int(batch[i][2]), device=X.device))
hs = torch.cat(hs); ys = torch.cat(ys)
al = self.adv(grad_reverse(hs.float(), adv_lambda))
adv = F.cross_entropy(al, ys)
adv_acc = (al.argmax(-1) == ys).float().mean()
return ce, acc, adv, adv_acc
@torch.no_grad()
def generate(self, ids, langs, spk, top_p=0.8, top_k=25, win=10, tau_r=0.1, seed=0):
g = torch.Generator(device=ids.device).manual_seed(seed)
x = self.build(ids, langs, spk)[None]
n = len(ids)
min_len, max_len = int(n * 2), int(n * 20)
past, out = None, []
inp = x
for i in range(max_len):
o = self.q.llm.model.model(inputs_embeds=inp, past_key_values=past, use_cache=True, return_dict=True)
past = o.past_key_values
logp = self.q.llm_decoder(o.last_hidden_state[:, -1]).float().log_softmax(-1)[0]
if i < min_len:
logp[self.V] = -float("inf")
logp[self.V + 1:] = -float("inf")
p = logp.exp()
sp, si = p.sort(descending=True)
keep = (sp.cumsum(0) - sp) < top_p
keep[top_k:] = False
cand_p, cand_i = sp[keep], si[keep]
tokid = int(cand_i[torch.multinomial(cand_p / cand_p.sum(), 1, generator=g)])
if out and sum(1 for t in out[-win:] if t == tokid) >= win * tau_r and tokid != self.V: # RAS
tokid = int(torch.multinomial(p, 1, generator=g))
if tokid >= self.V:
break
out.append(tokid)
inp = self.q.speech_embedding.weight[tokid][None, None]
return out
# ------------------------------------------------------------------ data
def load_items(root, datasets, split, max_tok=500):
items, mm = [], {}
for ds in datasets:
if not os.path.exists(f"{root}/{ds}/s3tok.jsonl"):
continue
mm[ds] = np.memmap(f"{root}/{ds}/s3tok.i32", dtype=np.int32, mode="r")
tk = {}
for l in open(f"{root}/{ds}/s3tok.jsonl"):
r = json.loads(l); tk[r["id"]] = (r["toff"], r["ntok"])
for l in open(f"{root}/{ds}/meta.jsonl", encoding="utf-8"):
m = json.loads(l)
if m["split"] != split or m["id"] not in tk:
continue
m["toff"], m["ntok"] = tk[m["id"]]
if 5 < m["ntok"] <= max_tok:
items.append(m)
return items, mm
def batches(items, budget, weights, rng):
idx = []
for i, m in enumerate(items):
w = weights.get(m["ds"], 1.0)
idx += [i] * int(w) + ([i] if rng.random() < w - int(w) else [])
idx.sort(key=lambda i: items[i]["ntok"] + rng.random() * 10)
out, cur, mx = [], [], 0
for i in idx:
L = items[i]["ntok"] + len(items[i]["text"]) // 2 + 4
if cur and max(mx, L) * (len(cur) + 1) > budget:
out.append(cur); cur, mx = [], 0
cur.append(i); mx = max(mx, L)
if cur:
out.append(cur)
rng.shuffle(out)
return out
def train(a):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
cosy = load_cosy()
items, mm = load_items(a.data, a.datasets.split(","), "train")
val, _ = load_items(a.data, a.datasets.split(","), "val")
spks = sorted({m["spk"] for m in items}); spk2id = {s: i for i, s in enumerate(spks)}
val = [m for m in val if m["spk"] in spk2id][:200]
os.makedirs(a.out, exist_ok=True)
json.dump(spk2id, open(f"{a.out}/spk2id.json", "w"))
print("armB items", len(items), "hours", round(sum(m["dur"] for m in items) / 3600, 1), "spk", len(spks), flush=True)
model = DALM(cosy.model.llm, len(spks)).cuda().train()
for p in cosy.model.flow.parameters():
p.requires_grad = False
for p in cosy.model.hift.parameters():
p.requires_grad = False
new = list(model.lang_emb.parameters()) + list(model.spk_emb.parameters()) + list(model.adv.parameters())
newids = {id(p) for p in new}
old = [p for p in model.parameters() if id(p) not in newids and p.requires_grad]
opt = torch.optim.AdamW([{"params": old, "lr": a.lr}, {"params": new, "lr": a.lr_new}], weight_decay=0.01)
base = [a.lr, a.lr_new]
cache = {}
def enc(m):
if m["id"] not in cache:
cache[m["id"]] = text_tokens(tok, f"<{m['lang']}>{m['text']}</{m['lang']}>")
ids, langs = cache[m["id"]]
sp = np.asarray(mm[m["ds"]][m["toff"]: m["toff"] + m["ntok"]], dtype=np.int64)
return (torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(spk2id[m["spk"]]).cuda(),
torch.from_numpy(sp).cuda())
rng = random.Random(0); step = 0; nskip = 0; t0 = time.time(); logf = open(f"{a.out}/log.jsonl", "a")
def validate():
model.eval(); L = []
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
for i in range(0, len(val), 16):
ce, acc, _, _ = model([enc(m) for m in val[i:i + 16]]); L.append(float(ce))
model.train(); return float(np.mean(L))
v0 = validate(); print(json.dumps({"step": 0, "val_ce": v0}), flush=True); logf.write(json.dumps({"step": 0, "val_ce": v0}) + "\n")
done = False
while not done:
for bi in batches(items, a.tokens, {"waxal": a.waxal_w}, rng):
lrm = min(1.0, (step + 1) / 200)
for g, b in zip(opt.param_groups, base):
g["lr"] = b * lrm
with torch.autocast("cuda", dtype=torch.bfloat16):
ce, acc, adv, adv_acc = model([enc(items[i]) for i in bi])
loss = ce + a.adv_w * adv
opt.zero_grad(set_to_none=True)
if not torch.isfinite(loss):
nskip += 1
print(json.dumps({"skip_nonfinite_fwd": step, "ce": float(ce), "adv": float(adv), "B": len(bi),
"maxtok": max(items[i]["ntok"] for i in bi)}), flush=True)
continue
loss.backward()
gn = torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 1.0)
if not torch.isfinite(gn):
nskip += 1; opt.zero_grad(set_to_none=True)
print(json.dumps({"skip_nonfinite_grad": step}), flush=True); continue
opt.step(); step += 1
if step % 25 == 0:
r = dict(step=step, ce=round(float(ce), 4), acc=round(float(acc), 4), adv=round(float(adv), 3),
adv_acc=round(float(adv_acc), 3), gn=round(float(gn), 2), B=len(bi), min=round((time.time() - t0) / 60, 2))
print(json.dumps(r), flush=True); logf.write(json.dumps(r) + "\n"); logf.flush()
if step % 500 == 0:
v = validate(); print(json.dumps({"step": step, "val_ce": v}), flush=True)
logf.write(json.dumps({"step": step, "val_ce": v}) + "\n"); logf.flush()
if (time.time() - t0) / 60 > a.minutes:
done = True; break
v = validate(); logf.write(json.dumps({"step": step, "val_ce": v, "final": True}) + "\n")
sd = {k: v for k, v in model.state_dict().items()}
torch.save(dict(model=sd, step=step, n_spk=len(spks)), f"{a.out}/last.pt")
print("TRAIN_B_DONE", step, v, flush=True)
# ------------------------------------------------------------------ inference
def prompt_feats(cosy, wav_path):
"""flow prompt (speech tokens, 24k mel feat, campplus embedding) from an anchor clip."""
fe = cosy.frontend
tokn, tokl = fe._extract_speech_token(wav_path)
feat, featl = fe._extract_speech_feat(wav_path)
# CosyVoice2 aligns: feat frames = 2 * tokens
n = min(feat.shape[1] // 2, tokn.shape[1])
feat, tokn = feat[:, :2 * n], tokn[:, :n]
emb = fe._extract_spk_embedding(wav_path)
return tokn.cuda(), feat.cuda(), emb.cuda()
@torch.no_grad()
def token2wav(cosy, tokens, ptok, pfeat, emb):
flow = cosy.model.flow
t = torch.tensor([tokens], dtype=torch.int32).cuda()
kw = dict(token=t, token_len=torch.tensor([t.shape[1]], dtype=torch.int32).cuda(),
prompt_token=ptok, prompt_token_len=torch.tensor([ptok.shape[1]], dtype=torch.int32).cuda(),
prompt_feat=pfeat, prompt_feat_len=torch.tensor([pfeat.shape[1]], dtype=torch.int32).cuda(),
embedding=emb)
sig = inspect.signature(flow.inference).parameters
if "streaming" in sig:
kw["streaming"] = False
if "finalize" in sig:
kw["finalize"] = True
if "flow_cache" in sig:
kw["flow_cache"] = torch.zeros(1, 80, 0, 2).cuda()
mel = flow.inference(**kw)
mel = mel[0] if isinstance(mel, tuple) else mel
hs = inspect.signature(cosy.model.hift.inference).parameters
w = cosy.model.hift.inference(speech_feat=mel, **({"cache_source": torch.zeros(1, 1, 0).cuda()} if "cache_source" in hs else {}))
w = w[0] if isinstance(w, tuple) else w
return w.squeeze().float().cpu().numpy()
def eval_rows():
return [json.loads(l) for l in open(f"{HOME}/eval/eval_set.jsonl", encoding="utf-8")]
def synth(a):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
cosy = load_cosy()
ck = torch.load(a.ckpt, map_location="cuda")
spk2id = json.load(open(os.path.join(os.path.dirname(a.ckpt), "spk2id.json")))
model = DALM(cosy.model.llm, ck["n_spk"]).cuda().eval()
model.load_state_dict(ck["model"])
anc = json.load(open(f"{HOME}/eval/anchor.json"))
ptok, pfeat, emb = prompt_feats(cosy, anc["ref_wavs"][0])
os.makedirs(a.out, exist_ok=True)
for r in eval_rows():
text = r["text"] if r["lang"] == "cs" else f"<{r['lang']}>{r['text']}</{r['lang']}>"
ids, langs = text_tokens(tok, text)
with torch.autocast("cuda", dtype=torch.bfloat16):
sp = model.generate(torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(spk2id[anc["spk"]]).cuda())
sf.write(f"{a.out}/{r['id']}.wav", token2wav(cosy, sp, ptok, pfeat, emb), cosy.sample_rate)
print("synth_b done", a.out, flush=True)
def zeroshot(a):
"""Unmodified pretrained CosyVoice2, cross-lingual mode with the anchor prompt (reference point)."""
cosy = load_cosy()
anc = json.load(open(f"{HOME}/eval/anchor.json"))
from cosyvoice.utils.file_utils import load_wav
pw = anc["ref_wavs"][0]
sig = inspect.signature(cosy.inference_cross_lingual).parameters
os.makedirs(a.out, exist_ok=True)
for r in eval_rows():
text = " ".join(s.strip() for _, s in segments(r["text"])) if r["lang"] == "cs" else r["text"]
prompt = pw
outs = [o["tts_speech"] for o in cosy.inference_cross_lingual(text, prompt, stream=False)]
w = torch.cat(outs, 1).squeeze().cpu().numpy()
sf.write(f"{a.out}/{r['id']}.wav", w, cosy.sample_rate)
print("zeroshot done", flush=True)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("cmd")
ap.add_argument("--data", default=f"{HOME}/data")
ap.add_argument("--datasets", default="waxal,fleurs,slr86,slr70,bible_yor")
ap.add_argument("--out", default=f"{HOME}/runs/b1")
ap.add_argument("--ckpt", default="")
ap.add_argument("--minutes", type=float, default=30)
ap.add_argument("--lr", type=float, default=1e-5)
ap.add_argument("--lr_new", type=float, default=1e-3)
ap.add_argument("--tokens", type=int, default=12000)
ap.add_argument("--waxal_w", type=float, default=2.0)
ap.add_argument("--adv_w", type=float, default=0.02)
a = ap.parse_args()
{"train": train, "synth": synth, "zeroshot": zeroshot}[a.cmd](a)