Robin / package /robin /train.py
Mothersuperior's picture
Claude Opus 5.5
Robin v1
5e88ff2
Raw History Blame Contribute Delete
23.1 kB
import hashlib
import json
import math
import os
import random
import re
import time
from collections import defaultdict
from pathlib import Path
import numpy as np
import soundfile as sf
import torch
import torch.nn.functional as F
from torch.utils.checkpoint import checkpoint
from .lora import LoRA, file_sha256
from .pipeline import plain, prefix_mask
AUDIO = ('.wav', '.flac', '.mp3', '.ogg', '.opus', '.aiff', '.aif')
OTHER_AUDIO = ('.m4a', '.aac', '.wma', '.mp4', '.webm', '.mov')
SPEECH_TOKENS = 'speech_tokenizer_v3_25hz'
REF_WHOLE, REF_EXCERPT = 400, 375
SAMPLE_TEXT = dict(en='Hello there! This is a quick test of my new voice. How does it sound to you?',
ja='こんにちは。これは新しい声のテストです。どう聞こえますか?',
mix='Hello! これは新しい声のテストです。How does it sound?')
CHARS_PER_SECOND = dict(en=((2.5, 35), (6, 24)), ja=((1, 25), (3, 15)), mix=((1, 35), (3, 24)))
PROBLEMS = {
'no_text': 'no transcript (add a .txt file with the same name)',
'empty_text': 'the transcript is empty',
'unreadable': "the audio can't be read",
'other_format': "this audio format can't be read (convert it to .wav or .flac)",
'low_rate': 'recorded below 16 kHz (too low to learn a voice from)',
'silent': 'almost silent',
'short': 'too short',
'long': 'too long (split it into shorter clips)',
'mismatch': "the transcript doesn't fit the length of the audio (check the .txt)",
'tokens': "the audio couldn't be processed",
}
WARNINGS = {
'rate': 'recorded below 44.1 kHz: the voice will learn a duller top end',
'clipped': 'clipped (distorted peaks)',
'digits': 'digits in the transcript: write numbers out as words, the way they are spoken',
'pace': 'the transcript may not fit the audio (unusually fast or slow speech for its length)',
'duplicate': 'the same transcript appears more than once',
'orphan': 'a transcript with no audio file of the same name',
}
def frames_db(x, rate):
n = max(1, rate // 100); k = len(x) // n
if k == 0: return np.full(1, -120.)
return 20 * np.log10(np.sqrt((x[:k * n].reshape(k, n) ** 2).mean(1)) + 1e-9)
def trim(x, rate):
db = frames_db(x, rate); loud = float(db.max())
on = np.where(db > max(min(-45., loud - 30), -60.))[0]
if len(on) == 0: return x[:0], loud
n = rate // 100
return x[max(0, on[0] * n - int(.15 * rate)):min(len(x), (on[-1] + 1) * n + int(.25 * rate))], loud
def read_mono(path):
x, rate = sf.read(str(path), dtype='float32', always_2d=True)
return x.mean(1), rate
def scan(folder, lang='en', min_seconds=1., max_seconds=30.):
folder = Path(folder)
if not folder.is_dir(): raise SystemExit(f'{folder} is not a folder')
desc_file = folder/'voice.txt'
description = plain(' '.join(desc_file.read_text(encoding='utf-8').split())) if desc_file.exists() else None
problems, warnings, clips, seen = defaultdict(list), defaultdict(list), [], defaultdict(list)
files = sorted(p for p in folder.rglob('*') if p.is_file() and not any(part.startswith('.') for part in p.relative_to(folder).parts))
hard, soft = CHARS_PER_SECOND.get(lang, CHARS_PER_SECOND['en'])
for p in files:
rel = p.relative_to(folder).as_posix(); ext = p.suffix.lower()
if ext in OTHER_AUDIO: problems['other_format'].append(rel); continue
if ext not in AUDIO: continue
txt = p.with_suffix('.txt')
if not txt.exists(): problems['no_text'].append(rel); continue
text = plain(' '.join(txt.read_text(encoding='utf-8', errors='replace').split()))
if not text: problems['empty_text'].append(rel); continue
try:
x, rate = read_mono(p)
except Exception:
problems['unreadable'].append(rel); continue
if rate < 16000: problems['low_rate'].append(rel); continue
y, loud = trim(x, rate); seconds = len(y) / rate
if loud < -40 or seconds == 0: problems['silent'].append(rel); continue
if seconds < min_seconds: problems['short'].append(f'{rel} ({seconds:.1f} s)'); continue
if seconds > max_seconds: problems['long'].append(f'{rel} ({seconds:.0f} s)'); continue
cps = len(re.sub(r'\[[^\]]*\]|[\W_]', '', text)) / seconds
if not hard[0] <= cps <= hard[1]: problems['mismatch'].append(rel); continue
if not soft[0] <= cps <= soft[1]: warnings['pace'].append(rel)
if rate < 44100: warnings['rate'].append(rel)
if (np.abs(x) >= .999).mean() > 1e-3: warnings['clipped'].append(rel)
if re.search(r'\d', re.sub(r'\[[^\]]*\]', '', text)): warnings['digits'].append(rel)
seen[text.lower()].append(rel)
st = p.stat()
key = hashlib.sha1(f'{rel}|{st.st_size}|{st.st_mtime_ns}|{text}|{min_seconds}|{max_seconds}'.encode()).hexdigest()[:20]
clips.append(dict(file=rel, text=text, seconds=round(seconds, 2), rate=rate, key=key))
for rels in seen.values():
if len(rels) > 1: warnings['duplicate'] += rels
audio = {p.with_suffix('') for p in files if p.suffix.lower() in AUDIO + OTHER_AUDIO}
warnings['orphan'] += [p.relative_to(folder).as_posix() for p in files
if p.suffix.lower() == '.txt' and p != desc_file and p.with_suffix('') not in audio]
if not warnings['orphan']: del warnings['orphan']
return dict(clips=clips, problems=problems, warnings=warnings, description=description, folder=folder)
def describe(found, log=print):
clips, problems, warnings = found['clips'], found['problems'], found['warnings']
left = sum(len(v) for v in problems.values())
minutes = sum(c['seconds'] for c in clips) / 60
log(f"Checked {len(clips) + left} clips in {found['folder']}")
log(f' ready: {len(clips)} clips, {minutes:.1f} minutes of speech')
if left:
log(f' left out: {left} clips')
for kind, rels in problems.items():
log(f' {PROBLEMS[kind]}: {len(rels)}')
for r in rels[:8]: log(f' {r}')
if len(rels) > 8: log(f' ... and {len(rels) - 8} more')
for kind, rels in warnings.items():
log(f' note, {WARNINGS[kind]}: {len(rels)}' + (f' ({", ".join(rels[:3])}{"..." if len(rels) > 3 else ""})' if len(rels) < len(clips) else ''))
log(f' voice description (voice.txt): {found["description"]!r}' if found['description'] else
' voice description: none (optional: describe the voice in a voice.txt file in the folder)')
if minutes < 2:
raise SystemExit(f'Only {minutes:.1f} minutes of usable speech. A LoRA needs at least 2 minutes; 20 to 60 minutes works best.')
if minutes < 10:
log(' note: under 10 minutes of speech. It can work, but 20 to 60 minutes gives a much closer voice.')
@torch.no_grad()
def encode(robin, found, cache, log=print, stop=None, progress=None):
from torchaudio.functional import resample
cache = Path(cache); cache.mkdir(parents=True, exist_ok=True)
vae_id = file_sha256(robin.vae_file) if getattr(robin, 'vae_file', None) else 'module'
meta_file = cache/'meta.json'; meta = dict(vae=vae_id, speech_tokens=SPEECH_TOKENS)
if meta_file.exists() and json.loads(meta_file.read_text()) != meta:
for f in cache.glob('*.npz'): f.unlink()
meta_file.write_text(json.dumps(meta))
out, bad, t0, done = [], [], time.time(), 0
todo = sum(not (cache/f"{c['key']}.npz").exists() for c in found['clips'])
if todo: log(f'Preparing {todo} clips...')
for c in found['clips']:
f = cache/f"{c['key']}.npz"
if not f.exists():
if stop is not None and stop():
raise SystemExit(f'Stopped while preparing clips ({done} of {todo} done; they are kept for next time).')
try:
x, rate = read_mono(found['folder']/c['file'])
y = torch.from_numpy(trim(x, rate)[0])
tokens = robin.speech(y.numpy(), rate)
z = robin.vae.encode(resample(y, rate, 48000)[None].repeat(2, 1)[None].to(robin.device))[0].T.float().cpu().numpy()
if abs(len(tokens) - len(z)) > 2: raise ValueError('length mismatch')
k = min(len(tokens), len(z))
np.savez(f, tokens=tokens[:k].astype(np.int16), latents=z[:k].astype(np.float16))
except Exception:
bad.append(c['file']); continue
done += 1
if progress is not None: progress('prepare', done, todo)
if done % 25 == 0 or done == todo: log(f' {done}/{todo} prepared ({time.time() - t0:.0f} s)')
d = np.load(f)
out.append(dict(c, tokens=d['tokens'].astype(np.int64), latents=d['latents']))
if bad:
found['problems']['tokens'] += bad
log(f" left out {len(bad)} clips: {PROBLEMS['tokens']}: {', '.join(bad[:5])}{'...' if len(bad) > 5 else ''}")
return out
class Trainer:
def __init__(self, robin, clips, description, out, *, lang='en', steps=1000, rank_planner=32, rank_decoder=64, lr_planner=5e-5,
lr_decoder=2e-4, accum=8, warmup=100, lr_floor=.2, p_reference=.4, save_every=200, sample_text=None, seed=0, log=print, stop=None,
progress=None):
self.robin, self.description, self.out, self.lang, self.log, self.stop = robin, description, Path(out), lang, log, stop
self.progress = progress
self.hp = dict(steps=steps, rank_planner=rank_planner, rank_decoder=rank_decoder, lr_planner=lr_planner, lr_decoder=lr_decoder,
accum=accum, warmup=min(warmup, max(1, steps // 10)), lr_floor=lr_floor, p_reference=p_reference, save_every=save_every,
lang=lang, seed=seed)
self.sample_text = sample_text or SAMPLE_TEXT.get(lang, SAMPLE_TEXT['en'])
rng = random.Random(seed); order = list(range(len(clips))); rng.shuffle(order); torch.manual_seed(seed)
hold = min(10, len(clips) // 20) if len(clips) >= 40 else 0
self.holdout = [clips[i] for i in order[:hold]]; self.clips = [clips[i] for i in order[hold:]]
self.short = [c for c in self.clips if len(c['tokens']) <= REF_WHOLE]
ranks = {k: r for k, r in (('planner', rank_planner), ('decoder', rank_decoder)) if r > 0}
nets = dict(planner=robin.planner.layers, decoder=robin.decoder.model.layers)
self.lora = LoRA({k: nets[k] for k in ranks}, ranks, seed=seed).attach()
groups = [dict(params=[p for n, p in zip(self.lora.names, getattr(self.lora, which)) if n.startswith(net + '.')],
lr=dict(planner=lr_planner, decoder=lr_decoder)[net]) for net in ranks for which in ('A', 'B')]
for g in groups: g['base_lr'] = g['lr']
self.optimizer = torch.optim.AdamW(groups, betas=(.9, .95), weight_decay=0., eps=1e-8)
self.rng, self.step, self.norms = rng, 0, []
self.sel = torch.cat((torch.arange(robin.offset, robin.offset + 32768), torch.tensor([robin.audio_end]))).to(robin.device)
self.base_sha = robin.base_sha256()
def lr_scale(self, step):
hp = self.hp
if step <= hp['warmup']: return step / hp['warmup']
f = min(1., (step - hp['warmup']) / max(1, hp['steps'] - hp['warmup']))
return hp['lr_floor'] + (1 - hp['lr_floor']) * .5 * (1 + math.cos(math.pi * f))
def example(self, clip, rng):
mode = 'reference' if len(self.clips) > 1 and rng.random() < self.hp['p_reference'] else 'tts'
desc = self.description if mode == 'tts' or rng.random() < .5 else None
if mode == 'tts' and rng.random() < .1: desc = None
ref = ref_text = None
if mode == 'reference':
pool = [c for c in (self.short or self.clips) if c is not clip] or [c for c in self.clips if c is not clip]
other = rng.choice(pool); tok = other['tokens']
if len(tok) <= REF_WHOLE:
ref, ref_text = tok, other['text']
else:
s = rng.randrange(len(tok) - REF_EXCERPT + 1); ref = tok[s:s + REF_EXCERPT]
return self.robin.prompt(clip['text'], desc, self.lang, ref, ref_text)
def losses(self, ids, clip, train=True, gen=None, sigmas=None):
r = self.robin; dev = r.device
seq = torch.tensor(list(ids) + [int(t) + r.offset for t in clip['tokens']] + [r.audio_end], device=dev)
ss, n = len(ids), len(seq)
mask = prefix_mask(n, ss, dev)
h = r.planner(seq[None], mask)[0][ss - 1:-1]
targets = seq[ss:]
targets = torch.where(targets == r.audio_end, torch.full_like(targets, 32768), targets - r.offset)
W = r.planner.lm_head.weight[self.sel]
ce = h.new_zeros((), dtype=torch.float32)
for s in range(0, len(targets), 256):
ce = ce + checkpoint(lambda hs, ys: F.cross_entropy(F.linear(hs, W).float(), ys, reduction='sum'),
h[s:s + 256], targets[s:s + 256], use_reentrant=False)
ce = ce / len(targets)
if train: (ce / self.hp['accum']).backward()
del h
with torch.no_grad():
ks, vs, _ = r.planner.prefill(seq, mask)
clean = torch.from_numpy(clip['latents'].astype(np.float32)).to(dev)
flows, sigmas = [], sigmas or [min(.98, max(.02, self.rng.betavariate(2, 2))) for _ in range(2)]
for sigma in sigmas:
noise = torch.randn(clean.shape, device=dev, generator=gen)
xt = sigma * noise + (1 - sigma) * clean
v = r.decoder(xt[None], torch.tensor([sigma], device=dev), ks, vs, checkpointing=train)[0]
fl = F.mse_loss(v, noise - clean)
if train: (fl / (len(sigmas) * self.hp['accum'])).backward()
flows.append(float(fl.detach()))
return float(ce.detach()), sum(flows) / len(flows)
@torch.no_grad()
def evaluate(self):
if not self.holdout: return None
gen = torch.Generator(device=self.robin.device).manual_seed(1)
rows = [self.losses(self.robin.prompt(c['text'], self.description, self.lang), c, train=False, gen=gen, sigmas=(.25, .5, .75))
for c in self.holdout]
return dict(planner=round(sum(r[0] for r in rows) / len(rows), 4), decoder=round(sum(r[1] for r in rows) / len(rows), 4))
@torch.no_grad()
def sample(self, name):
if self.robin.vae is None: return
try:
wav, sr, _ = self.robin.speak(self.sample_text, self.description, lang=self.lang, seed=0)
(self.out/'samples').mkdir(exist_ok=True)
path = self.out/'samples'/f'{name}.wav'
sf.write(str(path), wav, sr, subtype='PCM_16')
except Exception as e:
self.log(f' (could not render a sample: {e})')
return
if self.progress is not None: self.progress('sample', self.step, self.hp['steps'], path=path)
def save(self, final=False, light=False):
(self.out/'checkpoints').mkdir(exist_ok=True)
path = self.out/'checkpoints'/f'step_{self.step:05d}.safetensors'
self.lora.save(path, self.base_sha)
if final: self.lora.save(self.out/'lora.safetensors', self.base_sha)
state = dict(step=self.step, hp=self.hp, lora=self.lora.state_dict(), optimizer=self.optimizer.state_dict(), rng=self.rng.getstate(),
torch_rng=torch.get_rng_state(), cuda_rng=torch.cuda.get_rng_state() if torch.cuda.is_available() else None, norms=self.norms)
tmp = self.out/'resume.pt.tmp'; torch.save(state, tmp); os.replace(tmp, self.out/'resume.pt')
ev = None if light else self.evaluate()
if not light: self.sample(f'step_{self.step:05d}')
self.log(f' saved {path.relative_to(self.out)}' + (f' | held-out loss: planner {ev["planner"]:.3f}, decoder {ev["decoder"]:.4f}' if ev else ''))
with (self.out/'log.jsonl').open('a') as f: f.write(json.dumps(dict(step=self.step, saved=path.name, heldout=ev)) + '\n')
def resume(self):
state = torch.load(self.out/'resume.pt', map_location='cpu', weights_only=False)
keep = ('rank_planner', 'rank_decoder', 'lang')
if any(state['hp'][k] != self.hp[k] for k in keep):
raise SystemExit(f'{self.out} holds a run with different settings ({", ".join(f"{k}={state["hp"][k]}" for k in keep)}). '
'Use the same settings, a new output folder, or start over (--restart).')
self.lora.load_state_dict(state['lora']); self.optimizer.load_state_dict(state['optimizer'])
self.rng.setstate(state['rng']); torch.set_rng_state(state['torch_rng'])
if state.get('cuda_rng') is not None and torch.cuda.is_available(): torch.cuda.set_rng_state(state['cuda_rng'])
self.step, self.norms = state['step'], state.get('norms', [])
def run(self):
hp, r = self.hp, self.robin
self.out.mkdir(parents=True, exist_ok=True)
(self.out/'train_config.json').write_text(json.dumps(dict(hp, clips=len(self.clips), held_out=len(self.holdout),
description=bool(self.description)), indent=1))
if self.step == 0:
self.lora.scale = 0; self.sample('base'); self.lora.scale = 1
params = [p for p in self.lora.parameters()]
n_params = sum(p.numel() for p in params)
self.log(f'Training a LoRA ({n_params / 1e6:.0f}M parameters) on {len(self.clips)} clips'
+ (f', {len(self.holdout)} held out for checking' if self.holdout else '') + f', {hp["steps"]} steps.')
if torch.cuda.is_available(): torch.cuda.reset_peak_memory_stats()
t_start, step_times, run_loss = time.time(), [], []
while self.step < hp['steps']:
t0 = time.time(); self.step += 1
ces, flows = [], []
for _ in range(hp['accum']):
for _attempt in range(4):
clip = self.rng.choice(self.clips)
try:
ce, fl = self.losses(self.example(clip, self.rng), clip)
ces.append(ce); flows.append(fl); break
except torch.cuda.OutOfMemoryError:
self.optimizer.zero_grad(set_to_none=True); torch.cuda.empty_cache(); ces, flows = [], []
self.log(f" ran out of GPU memory on {clip['file']} ({clip['seconds']:.0f} s), leaving it out. "
'If this keeps happening, use a smaller --max-seconds.')
except Exception as e:
self.optimizer.zero_grad(set_to_none=True); ces, flows = [], []
self.log(f" couldn't train on {clip['file']} ({type(e).__name__}: {e}), leaving it out.")
self.clips = [c for c in self.clips if c is not clip]
self.short = [c for c in self.short if c is not clip]
if not self.clips: raise SystemExit('No clips left to train on.')
for g in self.optimizer.param_groups: g['lr'] = g['base_lr'] * self.lr_scale(self.step)
norm = float(torch.nn.utils.clip_grad_norm_(params, 1.0))
med = sorted(self.norms)[len(self.norms) // 2] if len(self.norms) >= 50 else None
if not ces or not math.isfinite(norm) or (med is not None and norm > 20 * med):
self.optimizer.zero_grad(set_to_none=True)
if ces: self.log(f' step {self.step}: an unusual update (gradient {norm:.2f}) was skipped')
else:
self.norms = (self.norms + [norm])[-200:]
self.optimizer.step(); self.optimizer.zero_grad(set_to_none=True)
step_times.append(time.time() - t0); run_loss.append((np.mean(ces) if ces else float('nan'), np.mean(flows) if flows else float('nan')))
with (self.out/'log.jsonl').open('a') as f:
f.write(json.dumps(dict(step=self.step, planner=round(float(run_loss[-1][0]), 4), decoder=round(float(run_loss[-1][1]), 5),
grad=round(norm, 4), seconds=round(step_times[-1], 2))) + '\n')
pace = float(np.mean(step_times[-20:])); left = (hp['steps'] - self.step) * pace
pl, dl = np.nanmean([x[0] for x in run_loss[-10:]]), np.nanmean([x[1] for x in run_loss[-10:]])
if self.progress is not None:
self.progress('train', self.step, hp['steps'], planner=float(pl), decoder=float(dl), pace=pace, left=left)
if self.step % 10 == 0 or self.step <= 3 or self.step == hp['steps']:
mem = f' | GPU {torch.cuda.max_memory_allocated() / 2 ** 30:.1f} GB' if torch.cuda.is_available() else ''
self.log(f"step {self.step}/{hp['steps']} | planner loss {pl:.3f} | decoder loss {dl:.4f} | {pace:.1f} s/step | "
f"{left / 60:.0f} min left{mem}")
if self.step % hp['save_every'] == 0 or self.step == hp['steps']:
self.save(final=self.step == hp['steps'])
elif self.stop is not None and self.stop():
self.save(light=True)
if self.stop is not None and self.stop() and self.step < hp['steps']:
self.log(f'Stopped at step {self.step}. Train again with the same folder to continue from here.')
return None
self.log(f"Done in {(time.time() - t_start) / 60:.0f} min. The LoRA is {self.out/'lora.safetensors'}; every saved step is in "
f"{self.out/'checkpoints'} with a sample of each in {self.out/'samples'}.")
return self.out/'lora.safetensors'
def train(robin, data, out, *, lang='en', min_seconds=1., max_seconds=30., restart=False, log=print, stop=None, progress=None, **settings):
out = Path(out)
if restart:
old = [out/f for f in ('resume.pt', 'lora.safetensors', 'log.jsonl')] + list(out.glob('checkpoints/step_*.safetensors')) + list(out.glob('samples/*.wav'))
for f in old:
if f.exists(): f.unlink()
elif (out/'lora.safetensors').exists() and not (out/'resume.pt').exists():
raise SystemExit(f'{out} already holds a finished LoRA. Use a new output folder, or start over (--restart) to train again.')
found = scan(data, lang, min_seconds, max_seconds); describe(found, log)
clips = encode(robin, found, out/'cache', log, stop, progress)
robin._speech = None
if torch.cuda.is_available(): torch.cuda.empty_cache()
trainer = Trainer(robin, clips, found['description'], out, lang=lang, log=log, stop=stop, progress=progress, **settings)
try:
if (out/'resume.pt').exists():
trainer.resume()
if trainer.step >= trainer.hp['steps']:
if not (out/'lora.safetensors').exists(): trainer.lora.save(out/'lora.safetensors', trainer.base_sha)
log(f'This run already finished ({trainer.step} steps). Start over (--restart) to train again, or give more steps (--steps) to keep going.')
return out/'lora.safetensors'
log(f'Continuing the unfinished run from step {trainer.step}.')
return trainer.run()
finally:
trainer.lora.detach()