Instructions to use Splintir/mms-tts-ceb-pld with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Splintir/mms-tts-ceb-pld with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-to-audio", model="Splintir/mms-tts-ceb-pld")# Load model directly from transformers import AutoTokenizer, AutoModelForTextToWaveform tokenizer = AutoTokenizer.from_pretrained("Splintir/mms-tts-ceb-pld") model = AutoModelForTextToWaveform.from_pretrained("Splintir/mms-tts-ceb-pld", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """Turn one PLD speaker into a VITS training set, cached on the Hub. | |
| python scripts/vits_data.py --language ceb --survey | |
| python scripts/vits_data.py --language ceb --speaker-id top --push-to Splintir/pld-ceb-vits | |
| Why this exists separately from `tts_data.py`: that script computes log-mel | |
| spectrograms and x-vectors, because SpeechT5 consumes both. VITS consumes raw | |
| waveform and text and nothing else -- it learns its own alignment and carries | |
| one baked-in voice, so there is no speaker embedding to compute. | |
| **Single speaker, deliberately.** MMS/VITS checkpoints hold exactly one voice. | |
| Finetuning a one-voice model on PLD's many speakers averages them into mush, | |
| which is the most likely reason the single-speaker SpeechT5 `-solo` run beat the | |
| full-corpus `-v2` run on every statistic. Applying that lesson before the run | |
| this time rather than after it. | |
| `--survey` prints the speaker distribution and exits, so the choice of speaker | |
| is made against clip counts and total duration rather than assumed. A VITS | |
| finetune wants tens of minutes at minimum; if the top speaker is thin, the | |
| survey says so before any GPU time is spent. | |
| The scan reuses `tts_data.py`'s shard iterator: PLD's train split is 201 shards | |
| of every language interleaved (~24 GB), so one language means touching all of | |
| them, one shard on disk at a time. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import sys | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| from tts_data import PLD_REPO, iter_shard_rows # noqa: E402 | |
| DIGIT_RE = re.compile(r"\d") | |
| # MMS tokenizers are character-level and lowercase, with a per-language vocab | |
| # that excludes digits. Rejecting a row is honest; keeping it would teach the | |
| # model that "1990" is silence. | |
| KEEP_RE = re.compile(r"[^a-zñáéíóú' -]") | |
| def clean_text(text: str) -> str | None: | |
| text = text.replace("’", "'").replace("‘", "'") | |
| text = text.lower().strip() | |
| if not text or DIGIT_RE.search(text): | |
| return None | |
| text = KEEP_RE.sub("", text) | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return text or None | |
| def survey(language: str, token: str | None, shards: int) -> None: | |
| """Print who speaks this language and for how long, then stop.""" | |
| clips: Counter[str] = Counter() | |
| secs: defaultdict[str, float] = defaultdict(float) | |
| # gender/age are not in tts_data.COLUMNS and the iterator only reads those, | |
| # so the survey reports clips and duration -- the two numbers that decide | |
| # whether a speaker can carry a finetune. | |
| for n, (_, row) in enumerate(iter_shard_rows(language, token, range(shards)), 1): | |
| sid = row["speaker_id"] | |
| clips[sid] += 1 | |
| secs[sid] += float(row.get("duration") or 0.0) | |
| if n % 2000 == 0: | |
| print(f" ... {n} rows, {len(clips)} speakers", flush=True) | |
| total = sum(clips.values()) | |
| print(f"\n{language}: {total} usable clips, {len(clips)} speakers, " | |
| f"{sum(secs.values()) / 3600:.1f} h total\n") | |
| print(f"{'speaker_id':<26}{'clips':>8}{'minutes':>10}") | |
| for sid, n in clips.most_common(20): | |
| print(f"{sid:<26}{n:>8}{secs[sid] / 60:>10.1f}") | |
| print("\nA VITS finetune wants >= ~30 min from one speaker. Pick from the " | |
| "top rows and rerun with --speaker-id.") | |
| def cache_dir(language: str, speaker: str) -> Path: | |
| return Path("vits_cache") / language / speaker | |
| def load_cached(language: str, speaker: str): | |
| """Reuse a completed scan. Scanning 201 shards to find ~15 minutes of audio | |
| costs ~24 GB of transfer, so it must never be repeated because a later step | |
| failed.""" | |
| manifest = cache_dir(language, speaker) / "manifest.jsonl" | |
| if not manifest.exists(): | |
| return None | |
| rows = [json.loads(line) for line in | |
| manifest.read_text(encoding="utf-8").splitlines() if line.strip()] | |
| rows = [r for r in rows if (cache_dir(language, speaker) / r["file"]).exists()] | |
| if not rows: | |
| return None | |
| print(f"reusing {len(rows)} cached clips from " | |
| f"{cache_dir(language, speaker)} (delete it to force a rescan)", | |
| flush=True) | |
| return rows | |
| def collect(language: str, speaker: str, token: str | None, shards: int, | |
| max_seconds: float): | |
| """Gather one speaker's clips. Returns (records, resolved_speaker_id).""" | |
| import io | |
| import soundfile as sf | |
| # `top` cannot be resolved until the corpus has been scanned once, so the | |
| # first pass counts and the second keeps. Two passes over 24 GB is slow; | |
| # buffering every language's audio in RAM instead is worse. | |
| if speaker == "top": | |
| counts: Counter[str] = Counter() | |
| for _, row in iter_shard_rows(language, token, range(shards)): | |
| counts[row["speaker_id"]] += 1 | |
| if not counts: | |
| raise SystemExit(f"no usable {language} rows -- check the filters") | |
| speaker, n = counts.most_common(1)[0] | |
| print(f"resolved `top` -> {speaker} ({n} clips)", flush=True) | |
| out = cache_dir(language, speaker) | |
| out.mkdir(parents=True, exist_ok=True) | |
| manifest = (out / "manifest.jsonl").open("w", encoding="utf-8") | |
| records, total = [], 0.0 | |
| seen_shard = -1 | |
| for shard, row in iter_shard_rows(language, token, range(shards)): | |
| # One speaker is a handful of clips scattered over 201 shards, so a | |
| # clip-count progress line can stay silent for hours. Report the scan | |
| # itself instead -- otherwise a live run is indistinguishable from a | |
| # hung one. | |
| if shard != seen_shard: | |
| seen_shard = shard | |
| print(f" shard {shard + 1}/{shards} kept {len(records)} clips, " | |
| f"{total / 60:.1f} min", flush=True) | |
| if row["speaker_id"] != speaker: | |
| continue | |
| text = clean_text(row["sentence"]) | |
| if not text: | |
| continue | |
| raw = row["audio"]["bytes"] | |
| # Decode once here rather than trusting the shard's declared duration: | |
| # the trainer segments on real sample counts, and a mismatch shows up as | |
| # a silent crash deep in the collator. | |
| try: | |
| wav, rate = sf.read(io.BytesIO(raw), dtype="float32", always_2d=False) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f" skipped unreadable clip: {exc}", flush=True) | |
| continue | |
| if wav.ndim > 1: | |
| wav = wav.mean(axis=1) | |
| secs = len(wav) / rate | |
| if not 1.0 <= secs <= 15.0: | |
| continue | |
| # Write PLD's own encoded bytes straight through rather than re-encoding | |
| # the decoded array: no quality loss, and `datasets` can build an Audio | |
| # column from encoded bytes without torchcodec, which it needs for raw | |
| # arrays and file paths alike. | |
| ext = Path(row["audio"].get("path") or "clip.wav").suffix or ".wav" | |
| name = f"{len(records):04d}{ext}" | |
| (out / name).write_bytes(raw) | |
| manifest.write(json.dumps({"file": name, "text": text, | |
| "seconds": round(secs, 3)}) + "\n") | |
| manifest.flush() | |
| records.append({"file": name, "text": text, "seconds": round(secs, 3)}) | |
| total += secs | |
| if max_seconds and total >= max_seconds: | |
| break | |
| manifest.close() | |
| print(f"\n{speaker}: {len(records)} clips, {total / 60:.1f} min " | |
| f"-> cached in {out}", flush=True) | |
| if total < 900: | |
| print("WARNING: under 15 minutes. Expect a weak finetune -- consider " | |
| "pooling a second speaker of the same gender and dialect.", | |
| flush=True) | |
| return records, speaker | |
| def main() -> None: | |
| ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) | |
| ap.add_argument("--language", default="ceb", help="PLD ISO 639-3 code") | |
| ap.add_argument("--speaker-id", default="top", | |
| help="`top` picks the speaker with the most clips") | |
| ap.add_argument("--survey", action="store_true", | |
| help="print the speaker distribution and exit") | |
| ap.add_argument("--shards", type=int, default=201) | |
| ap.add_argument("--max-seconds", type=float, default=0, | |
| help="stop after this much audio (0 = no cap)") | |
| ap.add_argument("--push-to", default="", | |
| help="Hub dataset repo, e.g. Splintir/pld-ceb-vits") | |
| args = ap.parse_args() | |
| token = os.environ.get("HF_TOKEN") | |
| if not token: | |
| env = Path(__file__).resolve().parent.parent / ".env" | |
| if env.exists(): | |
| for line in env.read_text(encoding="utf-8").splitlines(): | |
| key, _, value = line.strip().partition("=") | |
| if key == "HF_TOKEN" and value: | |
| token = value.strip() | |
| print(f"scanning {args.shards} {PLD_REPO} train shards for {args.language} ...", | |
| flush=True) | |
| if args.survey: | |
| survey(args.language, token, args.shards) | |
| return | |
| speaker = args.speaker_id | |
| records = None if speaker == "top" else load_cached(args.language, speaker) | |
| if records is None: | |
| records, speaker = collect(args.language, args.speaker_id, token, | |
| args.shards, args.max_seconds) | |
| if not records: | |
| raise SystemExit(f"no clips for speaker {speaker}") | |
| from datasets import Audio, Dataset | |
| src = cache_dir(args.language, speaker) | |
| rows = [{"audio": {"bytes": (src / r["file"]).read_bytes(), | |
| "path": r["file"]}, | |
| "text": r["text"]} | |
| for r in records] | |
| ds = Dataset.from_list(rows).cast_column("audio", Audio(sampling_rate=16000)) | |
| # A held-out slice the trainer can score against, kept small: VITS eval is | |
| # slow (it renders audio) and the number that decides anything is the | |
| # 50-line bench, not this. Proportional with a floor and a ceiling -- a | |
| # flat floor alone puts more clips in eval than train on a small set. | |
| n_eval = max(4, min(16, len(ds) // 10)) | |
| ds = ds.train_test_split(test_size=n_eval, seed=0) | |
| print(ds) | |
| if args.push_to: | |
| ds.push_to_hub(args.push_to, token=token, private=True) | |
| print(f"pushed -> {args.push_to} (speaker {speaker})") | |
| else: | |
| out = Path("vits_data") / args.language | |
| ds.save_to_disk(str(out)) | |
| print(f"saved -> {out} (pass --push-to to upload)") | |
| if __name__ == "__main__": | |
| main() | |