mamba-cot-tts / code /scripts /s1mini_render.py
maxpicy's picture
Add files using upload-large-folder tool
6e802a4 verified
Raw History Blame Contribute Delete
14.4 kB
# s1mini_render.py: Path B: forces our codebook 0 into the fine-tuned s1-mini and lets it generate codebooks 1-9.
import argparse
import contextlib
import io
import json
import os
import sys
import time
from pathlib import Path
os.environ.setdefault("TQDM_DISABLE", "1")
_REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_REPO_ROOT))
sys.path.insert(0, str(_REPO_ROOT / "scripts"))
from fish_codec_util import fish_repo_path
from gen_s1mini_synth import (
EMOTION_TAG,
S1MiniTiktokenTokenizer,
load_ref_specs,
resolve_checkpoint,
)
SEM_MAX = 4095
RES_MAX = 1023
FULL_VOCAB_MIN = 100_000
class ForcedSemantic:
def __init__(self, real_sample, sem_begin, im_end_id, fast_temps=None):
self.real = real_sample
self.sem_begin = sem_begin
self.im_end_id = im_end_id
self.queue = None
self.calls = 0
self.fast_temps = fast_temps
self.res = None
self.fast_calls = 0
def arm(self, q0, residuals=None):
self.queue = list(q0)
self.calls = 0
self.res = residuals
self.fast_calls = 0
@property
def frames_forced(self):
return (self.calls + 1) // 2
def __call__(self, logits, temperature, top_p, top_k):
import torch
if logits.shape[-1] >= FULL_VOCAB_MIN:
idx = self.calls // 2
self.calls += 1
if self.queue is not None and idx < len(self.queue):
tid = self.sem_begin + int(self.queue[idx])
else:
tid = self.im_end_id
return (torch.tensor([tid], device=logits.device,
dtype=torch.long), None)
frame, level = divmod(self.fast_calls, 9)
self.fast_calls += 1
if self.res is not None and frame < len(self.res[0]):
return (torch.tensor([int(self.res[level][frame])],
device=logits.device,
dtype=torch.long), None)
if self.fast_temps is not None:
temperature = torch.as_tensor(self.fast_temps[level],
device=logits.device,
dtype=logits.dtype)
if logits.shape[-1] > RES_MAX + 1:
logits = logits.clone()
logits[..., RES_MAX + 1:] = float("-inf")
return self.real(logits, temperature, top_p, top_k)
def load_rows(path):
rows = []
for line in open(path, encoding="utf-8"):
if line.strip():
rows.append(json.loads(line))
return rows
def resume_state(path):
done, kept = set(), []
if not Path(path).exists():
return done, kept
for line in open(path, encoding="utf-8"):
try:
j = json.loads(line)["renderer"]["row"]
except (ValueError, KeyError, TypeError):
continue
if j not in done:
done.add(j)
kept.append(line.rstrip("\n"))
return done, kept
def parse_voice_map(arg, voices):
vm = {}
if not arg:
return vm
for part in arg.split(","):
k, v = part.split("=", 1)
if v not in voices:
raise SystemExit(f"--voice_map: unknown voice {v!r}; "
f"available: {sorted(voices)[:12]}...")
vm[int(k)] = v
return vm
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--codes", required=True,
help="cell jsonl with speech_tokens (q0) + prompt per row")
ap.add_argument("--out", required=True)
ap.add_argument("--ref_dir", default="data/s1mini_refs")
ap.add_argument("--ref_manifest", default=None)
ap.add_argument("--voice_map", default=None,
help="'4200=<ref>,4201=<ref>' — speaker_id to reference "
"voice. Unmapped speakers cycle the ref list "
"DETERMINISTICALLY by speaker id, so a speaker keeps "
"one voice across cells (the flip contrast needs "
"identical voices per row across cells).")
ap.add_argument("--emotion_tag", action="store_true", default=True)
ap.add_argument("--no_emotion_tag", dest="emotion_tag",
action="store_false",
help="drop the '(emotion)' inline marker from the text "
"conditioning. Same tag across a row's cells either "
"way, so the flip contrast is never confounded.")
ap.add_argument("--checkpoint", default=None)
ap.add_argument("--device", default="cuda", choices=["cuda", "mps", "cpu"])
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--force_residuals", action="store_true",
help="PLUMBING SELF-TEST: force q1..q9 from the input "
"rows too. Output must reproduce the GT roundtrip; "
"anything else means the harness is broken.")
ap.add_argument("--temp", type=float, default=1.0,
help="fast-stage sampling (repo default; the "
"GRPO-polished setting). Semantic is forced.")
ap.add_argument("--fast_temps", type=float, nargs=9, default=None,
help="PER-LEVEL fast-stage temps for q1..q9 (9 values), "
"overriding the flat --temp. q0 is forced, so ALL "
"articulation loss lives in the residuals; cooling "
"the coarse ones (q1-q2) sharpens phonetic detail "
"while leaving the fine ones hot preserves texture.")
ap.add_argument("--top_p", type=float, default=0.9)
ap.add_argument("--top_k", type=int, default=30)
ap.add_argument("--seed", type=int, default=20260824,
help="row j is sampled with seed + j, so a row renders the same whatever else ran before it")
ap.add_argument("--resume", action="store_true",
help="keep the rows already in --out (those carrying renderer.row) and render only the rest; "
"a torn last line from an interrupted write is dropped")
ap.add_argument("--max_seq_len", type=int, default=4096)
args = ap.parse_args()
repo = fish_repo_path()
if not Path(repo).exists():
raise SystemExit(f"fish-speech repo not found at {repo}")
if repo not in sys.path:
sys.path.insert(0, repo)
import soundfile as sf
import torch
from loguru import logger
logger.remove()
logger.add(sys.stderr, level="WARNING")
import fish_speech.models.text2semantic.inference as inf_mod
from fish_speech.models.text2semantic.inference import (
generate_long,
init_model,
)
try:
from fish_speech.models.text2semantic.inference import load_codec_model
except ImportError:
from fish_codec_util import load_fish_codec
def load_codec_model(checkpoint_path, device, precision=None):
os.environ.setdefault("FISH_CODEC_PTH", str(checkpoint_path))
return load_fish_codec(device)
from fish_codec_util import encode_np
import inspect
gl_accepts_top_k = "top_k" in inspect.signature(generate_long).parameters
torch.manual_seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(args.seed)
precision = torch.bfloat16 if args.device == "cuda" else torch.float32
ckpt = resolve_checkpoint(args.checkpoint)
print(f"[render] checkpoint {ckpt} device={args.device}")
model, decode_one_token = init_model(ckpt, args.device, precision,
compile=False)
if (getattr(model, "tokenizer", None) is None
or getattr(model.config, "semantic_begin_id", 0) == 0):
tok = S1MiniTiktokenTokenizer(ckpt)
model.tokenizer = tok
model.config.semantic_begin_id = tok.semantic_begin_id
model.config.semantic_end_id = tok.semantic_end_id
print("[render] tiktoken adapter attached")
if args.max_seq_len < model.config.max_seq_len:
model.config.max_seq_len = args.max_seq_len
codec = load_codec_model(ckpt / "codec.pth", args.device, torch.float32)
im_end_id = model.tokenizer.get_token_id("<|im_end|>")
forced = ForcedSemantic(inf_mod.sample, model.config.semantic_begin_id,
im_end_id, fast_temps=args.fast_temps)
if args.fast_temps:
print(f"[render] per-level fast temps q1..q9: {args.fast_temps}")
inf_mod.sample = forced
print(f"[render] sample() patched (semantic_begin "
f"{model.config.semantic_begin_id}, im_end {im_end_id})")
ref_specs = load_ref_specs(args.ref_dir, args.ref_manifest)
voices = {r["name"]: r for r in ref_specs}
vmap = parse_voice_map(args.voice_map, voices)
names = sorted(voices)
ref_cache = {}
def get_ref(name):
if name not in ref_cache:
spec = voices[name]
wav, sr = sf.read(spec["wav"], dtype="float32")
codes = encode_np(codec, wav, sr, args.device)
if codes is None:
raise RuntimeError(f"reference encode failed: {spec['wav']}")
ref_cache[name] = (spec["text"],
torch.tensor(codes, dtype=torch.long))
return ref_cache[name]
def voice_for(speaker_id):
if speaker_id in vmap:
return vmap[speaker_id]
return names[int(speaker_id) % len(names)]
rows = load_rows(args.codes)
if args.limit:
rows = rows[: args.limit]
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
done, kept = resume_state(out_path) if args.resume else (set(), [])
if args.resume:
print(f"[render] resume: {len(done)} rows already rendered, {len(rows) - len(done)} to go")
n_ok = n_mismatch = n_short = 0
t0 = time.time()
with open(out_path, "w", encoding="utf-8") as fout:
for line in kept:
fout.write(line + "\n")
fout.flush()
for j, r in enumerate(rows):
if j in done:
continue
torch.manual_seed(args.seed + j)
if torch.cuda.is_available():
torch.cuda.manual_seed(args.seed + j)
q0 = r["speech_tokens"]
if not q0 or max(q0) > SEM_MAX or min(q0) < 0:
raise SystemExit(f"row {j}: q0 out of the fish semantic range "
f"— wrong cell file?")
spk = r.get("speaker_id", 0)
vname = voice_for(spk)
ref_text, ref_tokens = get_ref(vname)
emo = r.get("emotion") or r.get("emotion_label")
text = r["prompt"]
if args.emotion_tag and emo and emo != "neutral":
text = f"({EMOTION_TAG.get(emo, emo)}) {text}"
forced.arm(q0, residuals=(r["residual_codes"]
if args.force_residuals else None))
gen_kwargs = dict(
model=model, device=args.device,
decode_one_token=decode_one_token,
text=text, num_samples=1,
max_new_tokens=len(q0) + 8,
top_p=args.top_p, repetition_penalty=1.1,
temperature=args.temp, compile=False,
prompt_text=[ref_text], prompt_tokens=[ref_tokens],
)
if gl_accepts_top_k:
gen_kwargs["top_k"] = args.top_k
parts = []
t_row = time.time()
with contextlib.redirect_stdout(io.StringIO()):
for resp in generate_long(**gen_kwargs):
if resp.action == "sample" and resp.codes is not None:
parts.append(resp.codes.cpu())
if args.device == "cuda":
torch.cuda.synchronize()
render_s = time.time() - t_row
if not parts:
print(f"[render] row {j}: EMPTY generation — skipped")
n_short += 1
continue
codes = torch.cat(parts, dim=1)[:, : len(q0)]
sem = codes[0].tolist()
if sem != q0[: len(sem)]:
bad = next(i for i, (a, b) in enumerate(zip(sem, q0))
if a != b)
print(f"[render] row {j}: FORCING MISMATCH at frame {bad} "
f"(got {sem[bad]}, forced {q0[bad]}) — row dropped")
n_mismatch += 1
continue
if len(sem) < len(q0):
print(f"[render] row {j}: short render {len(sem)}/{len(q0)} "
f"frames — kept, truncated")
n_short += 1
res = codes[1:]
if res.min() < 0 or res.max() > RES_MAX:
raise SystemExit(f"row {j}: residual out of range — "
f"codec/vocab drift, aborting")
out = dict(r)
out["speech_tokens"] = sem
out["residual_codes"] = [res[k].tolist()
for k in range(res.shape[0])]
out["n_frames"] = len(sem)
out["renderer"] = {"model": "fishaudio/s1-mini", "row": j, "seed": args.seed + j,
"voice": vname, "text_tagged": text != r["prompt"],
"temp": args.temp, "top_p": args.top_p,
"seconds": round(render_s, 3),
"rtf": round(render_s / max(len(sem) / 21.533, 1e-6), 4),
"device": (torch.cuda.get_device_name(0) if args.device == "cuda" else args.device)}
fout.write(json.dumps(out, ensure_ascii=False) + "\n")
fout.flush()
n_ok += 1
if (j + 1) % 5 == 0:
print(f" {j + 1}/{len(rows)} rows "
f"({(time.time() - t0) / 60:.1f} min)", flush=True)
print(f"[render] DONE: {n_ok} ok, {n_mismatch} forcing-mismatch, "
f"{n_short} short/empty -> {out_path}")
if n_mismatch:
print("[render] WARNING: forcing mismatches mean the shim missed a "
"sampling path — do NOT use this output until explained.")
print("S1MINI_RENDER_DONE")
if __name__ == "__main__":
main()