mamba-cot-tts / code /scripts /gen_s1mini_synth.py
maxpicy's picture
Add files using upload-large-folder tool
6e802a4 verified
Raw History Blame Contribute Delete
14.8 kB
# gen_s1mini_synth.py: s1-mini helpers used by the Path B renderer (model loading, reference voices).
import argparse
import contextlib
import io
import json
import os
import random
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
SEED_DEFAULT = 20260720
FRAME_HZ = 21.53
SEM_MAX = 4095
RES_MAX = 1023
EMOTION_TAG = {
"whisper": "whispering",
}
_TIKTOKEN_PATTERN = (
r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}|"
r" ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"
)
class S1MiniTiktokenTokenizer:
def __init__(self, ckpt_dir):
import base64
import tiktoken
ckpt_dir = Path(ckpt_dir)
ranks = {}
with open(ckpt_dir / "tokenizer.tiktoken", "rb") as f:
for line in f:
if line.strip():
tok_b64, rank = line.split()
ranks[base64.b64decode(tok_b64)] = int(rank)
with open(ckpt_dir / "special_tokens.json", encoding="utf-8") as f:
self.special_tokens = json.load(f)
self._enc = tiktoken.Encoding(
name="s1mini",
pat_str=_TIKTOKEN_PATTERN,
mergeable_ranks=ranks,
special_tokens=self.special_tokens,
)
self.semantic_begin_id = self.special_tokens["<|semantic:0|>"]
self.semantic_end_id = self.special_tokens["<|semantic:4095|>"]
assert self.semantic_end_id - self.semantic_begin_id == 4095
self.semantic_id_to_token_id = {
i: self.semantic_begin_id + i for i in range(4096)}
import torch
self.semantic_map_tensor = torch.arange(
self.semantic_begin_id, self.semantic_end_id + 1, dtype=torch.long)
def encode(self, text, add_special_tokens=False, **kwargs):
return self._enc.encode(text, allowed_special="all")
def decode(self, tokens, **kwargs):
if isinstance(tokens, int):
tokens = [tokens]
return self._enc.decode(list(tokens))
def get_token_id(self, token):
if token in self.special_tokens:
return self.special_tokens[token]
ids = self._enc.encode(token, allowed_special="all")
assert len(ids) == 1, f"{token!r} is not a single token"
return ids[0]
def load_texts(paths, min_words, max_words, rng):
texts = []
for p in paths:
with open(p, encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
try:
t = json.loads(line).get("prompt", "")
except json.JSONDecodeError:
continue
n = len(t.split())
if min_words <= n <= max_words:
texts.append(t)
rng.shuffle(texts)
if not texts:
raise SystemExit("no usable texts found")
return texts
def load_ref_specs(ref_dir, ref_manifest):
specs = {}
if ref_dir:
d = Path(ref_dir)
for wav in sorted(d.glob("*.wav")):
txt = wav.with_suffix(".txt")
if not txt.exists():
print(f"[refs] skipping {wav.name}: no sibling transcript")
continue
text = txt.read_text(encoding="utf-8").strip()
if text:
specs[wav.stem] = {"name": wav.stem, "wav": str(wav), "text": text}
if ref_manifest:
with open(ref_manifest, encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
r = json.loads(line)
specs[r["name"]] = {"name": r["name"], "wav": r["wav"],
"text": r["text"].strip()}
if not specs:
raise SystemExit("no reference voices (need --ref_dir and/or --ref_manifest)")
return [specs[k] for k in sorted(specs)]
def resolve_checkpoint(arg):
if arg:
return Path(arg)
env = os.environ.get("S1MINI_CKPT")
if env:
return Path(env)
cache = Path.home() / (".cache/huggingface/hub/models--fishaudio--s1-mini/"
"snapshots/f4b445029346701e082b60bb63fcc2d1bb17a0e2")
if cache.exists():
return cache
from huggingface_hub import snapshot_download
return Path(snapshot_download("fishaudio/s1-mini"))
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--output", required=True)
ap.add_argument("--target_rows", type=int, default=1000)
ap.add_argument("--text_jsonl", nargs="+", required=True,
help="JSONL file(s) with a 'prompt' field per row")
ap.add_argument("--ref_dir", default=None,
help="dir of <voice>.wav + <voice>.txt reference pairs")
ap.add_argument("--ref_manifest", default=None,
help="JSONL of {'name','wav','text'} reference rows")
ap.add_argument("--emotions", default="angry,sad,happy,whisper,excited,neutral",
help="comma list cycled across rows; 'neutral' = no tag")
ap.add_argument("--min_words", type=int, default=4)
ap.add_argument("--max_words", type=int, default=28)
ap.add_argument("--max_frames", type=int, default=645,
help="skip rows longer than this (~30 s at 21.53 Hz)")
ap.add_argument("--checkpoint", default=None,
help="s1-mini snapshot dir (default: S1MINI_CKPT env / HF cache)")
ap.add_argument("--device", default="cuda", choices=["cuda", "mps", "cpu"])
ap.add_argument("--precision", default="auto",
choices=["auto", "bf16", "fp16", "fp32"],
help="auto = bf16 on cuda, fp32 elsewhere")
ap.add_argument("--compile", action="store_true",
help="torch.compile the decode step (CUDA only)")
ap.add_argument("--seed", type=int, default=SEED_DEFAULT)
ap.add_argument("--temp", type=float, default=1.0)
ap.add_argument("--top_p", type=float, default=0.9)
ap.add_argument("--top_k", type=int, default=30)
ap.add_argument("--repetition_penalty", type=float, default=1.1,
help="accepted but UNUSED by the PyTorch path (RAS instead)")
ap.add_argument("--max_seq_len", type=int, default=4096,
help="shrink the KV cache below the config's 8192 "
"(must exceed prompt_len + 2048)")
ap.add_argument("--log_every", type=int, default=10)
args = ap.parse_args()
emotions = [e.strip() for e in args.emotions.split(",") if e.strip()]
if not emotions:
raise SystemExit("--emotions parsed to an empty list")
repo = fish_repo_path()
if not Path(repo).exists():
raise SystemExit(f"fish-speech repo not found at {repo} "
f"(set FISH_SPEECH_REPO)")
if repo not in sys.path:
sys.path.insert(0, repo)
import numpy as np
import soundfile as sf
import torch
from loguru import logger
logger.remove()
logger.add(sys.stderr, level="WARNING")
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)
if args.precision == "auto":
precision = torch.bfloat16 if args.device == "cuda" else torch.float32
else:
precision = {"bf16": torch.bfloat16, "fp16": torch.half,
"fp32": torch.float32}[args.precision]
ckpt = resolve_checkpoint(args.checkpoint)
print(f"[s1mini] checkpoint {ckpt} device={args.device} precision={precision}")
t_load0 = time.time()
model, decode_one_token = init_model(
ckpt, args.device, precision,
compile=args.compile and args.device == "cuda")
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(f"[s1mini] tiktoken adapter attached (semantic ids "
f"{tok.semantic_begin_id}..{tok.semantic_end_id})")
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)
print(f"[s1mini] model + codec loaded in {time.time() - t_load0:.1f}s "
f"(max_seq_len {model.config.max_seq_len})")
ref_specs = load_ref_specs(args.ref_dir, args.ref_manifest)
voices = [r["name"] for r in ref_specs]
spec_by_name = {r["name"]: r for r in ref_specs}
print(f"[s1mini] {len(voices)} reference voices: {voices}")
print(f"[s1mini] emotions: {emotions}")
ref_cache = {}
def get_ref(name):
if name not in ref_cache:
spec = spec_by_name[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))
print(f"[refs] encoded {name}: {len(codes[0])} frames "
f"({len(codes[0]) / FRAME_HZ:.1f}s)")
return ref_cache[name]
rng = random.Random(args.seed)
texts = load_texts(args.text_jsonl, args.min_words, args.max_words, rng)
print(f"[s1mini] {len(texts)} candidate texts")
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
done = set()
n_existing = 0
if out_path.exists():
with open(out_path, encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
r = json.loads(line)
done.add((r["prompt"], r["speaker"], r["emotion_label"]))
n_existing += 1
print(f"[s1mini] existing rows: {n_existing} / target {args.target_rows}")
if n_existing >= args.target_rows:
print("target already met; nothing to do")
print("GEN_S1MINI_SYNTH_DONE")
return
written = n_existing
n_skip_len, n_skip_range, frames_hist = 0, 0, []
fout = open(out_path, "a", encoding="utf-8")
t_gen0 = time.time()
rows_at_t0 = written
try:
for i, text in enumerate(texts):
if written >= args.target_rows:
break
voice = voices[i % len(voices)]
emotion = emotions[i % len(emotions)]
key = (text, f"s1mini:{voice}", emotion)
if key in done:
continue
ref_text, ref_tokens = get_ref(voice)
tag = EMOTION_TAG.get(emotion, emotion)
tts_text = text if emotion == "neutral" else f"({tag}) {text}"
gen_kwargs = dict(
model=model,
device=args.device,
decode_one_token=decode_one_token,
text=tts_text,
num_samples=1,
max_new_tokens=args.max_frames + 32,
top_p=args.top_p,
repetition_penalty=args.repetition_penalty,
temperature=args.temp,
compile=args.compile and args.device == "cuda",
prompt_text=[ref_text],
prompt_tokens=[ref_tokens],
)
if _GL_ACCEPTS_TOP_K:
gen_kwargs["top_k"] = args.top_k
gen = generate_long(**gen_kwargs)
parts = []
with contextlib.redirect_stdout(io.StringIO()):
for resp in gen:
if resp.action == "sample" and resp.codes is not None:
parts.append(resp.codes.cpu())
if not parts:
n_skip_len += 1
continue
codes = torch.cat(parts, dim=1)
T = codes.shape[1]
if T < 10 or T > args.max_frames:
n_skip_len += 1
continue
sem, res = codes[0], codes[1:]
if (sem.min() < 0 or sem.max() > SEM_MAX
or res.min() < 0 or res.max() > RES_MAX):
n_skip_range += 1
print(f"[qc] range violation skipped (sem max {int(sem.max())}, "
f"res max {int(res.max())})")
continue
row = {
"prompt": text,
"speech_tokens": sem.tolist(),
"residual_codes": [res[k].tolist() for k in range(res.shape[0])],
"speaker": f"s1mini:{voice}",
"emotion_label": emotion,
"source": "s1mini_synth",
}
fout.write(json.dumps(row) + "\n")
fout.flush()
written += 1
frames_hist.append(T)
if (written - rows_at_t0) % args.log_every == 0:
mins = (time.time() - t_gen0) / 60
rate = (written - rows_at_t0) / mins if mins > 0 else 0
print(f" {written}/{args.target_rows} rows | {rate:.2f} rows/min | "
f"mean {sum(frames_hist)/len(frames_hist):.0f} frames | "
f"skips len={n_skip_len} range={n_skip_range}", flush=True)
finally:
fout.close()
mins = (time.time() - t_gen0) / 60
n_new = written - rows_at_t0
print(f"[s1mini] wrote {n_new} new rows -> {out_path} (total {written}); "
f"{n_new / mins:.2f} rows/min excl. load; "
f"skips len={n_skip_len} range={n_skip_range}")
if frames_hist:
print(f"[s1mini] frames/row: min {min(frames_hist)} "
f"mean {sum(frames_hist)/len(frames_hist):.0f} max {max(frames_hist)}")
if args.device == "cuda" and torch.cuda.is_available():
print(f"[s1mini] peak VRAM reserved: "
f"{torch.cuda.max_memory_reserved() / 1e9:.2f} GB")
print("GEN_S1MINI_SYNTH_DONE")
if __name__ == "__main__":
main()