Download code/scripts/gen_s1mini_synth.py from maxpicy/mamba-cot-tts: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/maxpicy/mamba-cot-tts/resolve/main/code/scripts/gen_s1mini_synth.py
- Command line
-
hf download hf://maxpicy/mamba-cot-tts/code/scripts/gen_s1mini_synth.py
-
curl -L -o gen_s1mini_synth.py https://huggingface.co/maxpicy/mamba-cot-tts/resolve/main/code/scripts/gen_s1mini_synth.py
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() | |