Splintir commited on
Commit
c120609
·
verified ·
1 Parent(s): d39b7ef

Ship the data scripts with the checkpoint

Browse files
Files changed (1) hide show
  1. vits_data.py +250 -0
vits_data.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn one PLD speaker into a VITS training set, cached on the Hub.
2
+
3
+ python scripts/vits_data.py --language ceb --survey
4
+ python scripts/vits_data.py --language ceb --speaker-id top --push-to Splintir/pld-ceb-vits
5
+
6
+ Why this exists separately from `tts_data.py`: that script computes log-mel
7
+ spectrograms and x-vectors, because SpeechT5 consumes both. VITS consumes raw
8
+ waveform and text and nothing else -- it learns its own alignment and carries
9
+ one baked-in voice, so there is no speaker embedding to compute.
10
+
11
+ **Single speaker, deliberately.** MMS/VITS checkpoints hold exactly one voice.
12
+ Finetuning a one-voice model on PLD's many speakers averages them into mush,
13
+ which is the most likely reason the single-speaker SpeechT5 `-solo` run beat the
14
+ full-corpus `-v2` run on every statistic. Applying that lesson before the run
15
+ this time rather than after it.
16
+
17
+ `--survey` prints the speaker distribution and exits, so the choice of speaker
18
+ is made against clip counts and total duration rather than assumed. A VITS
19
+ finetune wants tens of minutes at minimum; if the top speaker is thin, the
20
+ survey says so before any GPU time is spent.
21
+
22
+ The scan reuses `tts_data.py`'s shard iterator: PLD's train split is 201 shards
23
+ of every language interleaved (~24 GB), so one language means touching all of
24
+ them, one shard on disk at a time.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import sys
34
+ from collections import Counter, defaultdict
35
+ from pathlib import Path
36
+
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
38
+
39
+ from tts_data import PLD_REPO, iter_shard_rows # noqa: E402
40
+
41
+ DIGIT_RE = re.compile(r"\d")
42
+
43
+ # MMS tokenizers are character-level and lowercase, with a per-language vocab
44
+ # that excludes digits. Rejecting a row is honest; keeping it would teach the
45
+ # model that "1990" is silence.
46
+ KEEP_RE = re.compile(r"[^a-zñáéíóú' -]")
47
+
48
+
49
+ def clean_text(text: str) -> str | None:
50
+ text = text.replace("’", "'").replace("‘", "'")
51
+ text = text.lower().strip()
52
+ if not text or DIGIT_RE.search(text):
53
+ return None
54
+ text = KEEP_RE.sub("", text)
55
+ text = re.sub(r"\s+", " ", text).strip()
56
+ return text or None
57
+
58
+
59
+ def survey(language: str, token: str | None, shards: int) -> None:
60
+ """Print who speaks this language and for how long, then stop."""
61
+ clips: Counter[str] = Counter()
62
+ secs: defaultdict[str, float] = defaultdict(float)
63
+
64
+ # gender/age are not in tts_data.COLUMNS and the iterator only reads those,
65
+ # so the survey reports clips and duration -- the two numbers that decide
66
+ # whether a speaker can carry a finetune.
67
+ for n, (_, row) in enumerate(iter_shard_rows(language, token, range(shards)), 1):
68
+ sid = row["speaker_id"]
69
+ clips[sid] += 1
70
+ secs[sid] += float(row.get("duration") or 0.0)
71
+ if n % 2000 == 0:
72
+ print(f" ... {n} rows, {len(clips)} speakers", flush=True)
73
+
74
+ total = sum(clips.values())
75
+ print(f"\n{language}: {total} usable clips, {len(clips)} speakers, "
76
+ f"{sum(secs.values()) / 3600:.1f} h total\n")
77
+ print(f"{'speaker_id':<26}{'clips':>8}{'minutes':>10}")
78
+ for sid, n in clips.most_common(20):
79
+ print(f"{sid:<26}{n:>8}{secs[sid] / 60:>10.1f}")
80
+ print("\nA VITS finetune wants >= ~30 min from one speaker. Pick from the "
81
+ "top rows and rerun with --speaker-id.")
82
+
83
+
84
+ def cache_dir(language: str, speaker: str) -> Path:
85
+ return Path("vits_cache") / language / speaker
86
+
87
+
88
+ def load_cached(language: str, speaker: str):
89
+ """Reuse a completed scan. Scanning 201 shards to find ~15 minutes of audio
90
+ costs ~24 GB of transfer, so it must never be repeated because a later step
91
+ failed."""
92
+ manifest = cache_dir(language, speaker) / "manifest.jsonl"
93
+ if not manifest.exists():
94
+ return None
95
+ rows = [json.loads(line) for line in
96
+ manifest.read_text(encoding="utf-8").splitlines() if line.strip()]
97
+ rows = [r for r in rows if (cache_dir(language, speaker) / r["file"]).exists()]
98
+ if not rows:
99
+ return None
100
+ print(f"reusing {len(rows)} cached clips from "
101
+ f"{cache_dir(language, speaker)} (delete it to force a rescan)",
102
+ flush=True)
103
+ return rows
104
+
105
+
106
+ def collect(language: str, speaker: str, token: str | None, shards: int,
107
+ max_seconds: float):
108
+ """Gather one speaker's clips. Returns (records, resolved_speaker_id)."""
109
+ import io
110
+
111
+ import soundfile as sf
112
+
113
+ # `top` cannot be resolved until the corpus has been scanned once, so the
114
+ # first pass counts and the second keeps. Two passes over 24 GB is slow;
115
+ # buffering every language's audio in RAM instead is worse.
116
+ if speaker == "top":
117
+ counts: Counter[str] = Counter()
118
+ for _, row in iter_shard_rows(language, token, range(shards)):
119
+ counts[row["speaker_id"]] += 1
120
+ if not counts:
121
+ raise SystemExit(f"no usable {language} rows -- check the filters")
122
+ speaker, n = counts.most_common(1)[0]
123
+ print(f"resolved `top` -> {speaker} ({n} clips)", flush=True)
124
+
125
+ out = cache_dir(language, speaker)
126
+ out.mkdir(parents=True, exist_ok=True)
127
+ manifest = (out / "manifest.jsonl").open("w", encoding="utf-8")
128
+
129
+ records, total = [], 0.0
130
+ seen_shard = -1
131
+ for shard, row in iter_shard_rows(language, token, range(shards)):
132
+ # One speaker is a handful of clips scattered over 201 shards, so a
133
+ # clip-count progress line can stay silent for hours. Report the scan
134
+ # itself instead -- otherwise a live run is indistinguishable from a
135
+ # hung one.
136
+ if shard != seen_shard:
137
+ seen_shard = shard
138
+ print(f" shard {shard + 1}/{shards} kept {len(records)} clips, "
139
+ f"{total / 60:.1f} min", flush=True)
140
+ if row["speaker_id"] != speaker:
141
+ continue
142
+ text = clean_text(row["sentence"])
143
+ if not text:
144
+ continue
145
+ raw = row["audio"]["bytes"]
146
+ # Decode once here rather than trusting the shard's declared duration:
147
+ # the trainer segments on real sample counts, and a mismatch shows up as
148
+ # a silent crash deep in the collator.
149
+ try:
150
+ wav, rate = sf.read(io.BytesIO(raw), dtype="float32", always_2d=False)
151
+ except Exception as exc: # noqa: BLE001
152
+ print(f" skipped unreadable clip: {exc}", flush=True)
153
+ continue
154
+ if wav.ndim > 1:
155
+ wav = wav.mean(axis=1)
156
+ secs = len(wav) / rate
157
+ if not 1.0 <= secs <= 15.0:
158
+ continue
159
+
160
+ # Write PLD's own encoded bytes straight through rather than re-encoding
161
+ # the decoded array: no quality loss, and `datasets` can build an Audio
162
+ # column from encoded bytes without torchcodec, which it needs for raw
163
+ # arrays and file paths alike.
164
+ ext = Path(row["audio"].get("path") or "clip.wav").suffix or ".wav"
165
+ name = f"{len(records):04d}{ext}"
166
+ (out / name).write_bytes(raw)
167
+ manifest.write(json.dumps({"file": name, "text": text,
168
+ "seconds": round(secs, 3)}) + "\n")
169
+ manifest.flush()
170
+
171
+ records.append({"file": name, "text": text, "seconds": round(secs, 3)})
172
+ total += secs
173
+ if max_seconds and total >= max_seconds:
174
+ break
175
+
176
+ manifest.close()
177
+ print(f"\n{speaker}: {len(records)} clips, {total / 60:.1f} min "
178
+ f"-> cached in {out}", flush=True)
179
+ if total < 900:
180
+ print("WARNING: under 15 minutes. Expect a weak finetune -- consider "
181
+ "pooling a second speaker of the same gender and dialect.",
182
+ flush=True)
183
+ return records, speaker
184
+
185
+
186
+ def main() -> None:
187
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
188
+ ap.add_argument("--language", default="ceb", help="PLD ISO 639-3 code")
189
+ ap.add_argument("--speaker-id", default="top",
190
+ help="`top` picks the speaker with the most clips")
191
+ ap.add_argument("--survey", action="store_true",
192
+ help="print the speaker distribution and exit")
193
+ ap.add_argument("--shards", type=int, default=201)
194
+ ap.add_argument("--max-seconds", type=float, default=0,
195
+ help="stop after this much audio (0 = no cap)")
196
+ ap.add_argument("--push-to", default="",
197
+ help="Hub dataset repo, e.g. Splintir/pld-ceb-vits")
198
+ args = ap.parse_args()
199
+
200
+ token = os.environ.get("HF_TOKEN")
201
+ if not token:
202
+ env = Path(__file__).resolve().parent.parent / ".env"
203
+ if env.exists():
204
+ for line in env.read_text(encoding="utf-8").splitlines():
205
+ key, _, value = line.strip().partition("=")
206
+ if key == "HF_TOKEN" and value:
207
+ token = value.strip()
208
+
209
+ print(f"scanning {args.shards} {PLD_REPO} train shards for {args.language} ...",
210
+ flush=True)
211
+
212
+ if args.survey:
213
+ survey(args.language, token, args.shards)
214
+ return
215
+
216
+ speaker = args.speaker_id
217
+ records = None if speaker == "top" else load_cached(args.language, speaker)
218
+ if records is None:
219
+ records, speaker = collect(args.language, args.speaker_id, token,
220
+ args.shards, args.max_seconds)
221
+ if not records:
222
+ raise SystemExit(f"no clips for speaker {speaker}")
223
+
224
+ from datasets import Audio, Dataset
225
+
226
+ src = cache_dir(args.language, speaker)
227
+ rows = [{"audio": {"bytes": (src / r["file"]).read_bytes(),
228
+ "path": r["file"]},
229
+ "text": r["text"]}
230
+ for r in records]
231
+ ds = Dataset.from_list(rows).cast_column("audio", Audio(sampling_rate=16000))
232
+ # A held-out slice the trainer can score against, kept small: VITS eval is
233
+ # slow (it renders audio) and the number that decides anything is the
234
+ # 50-line bench, not this. Proportional with a floor and a ceiling -- a
235
+ # flat floor alone puts more clips in eval than train on a small set.
236
+ n_eval = max(4, min(16, len(ds) // 10))
237
+ ds = ds.train_test_split(test_size=n_eval, seed=0)
238
+ print(ds)
239
+
240
+ if args.push_to:
241
+ ds.push_to_hub(args.push_to, token=token, private=True)
242
+ print(f"pushed -> {args.push_to} (speaker {speaker})")
243
+ else:
244
+ out = Path("vits_data") / args.language
245
+ ds.save_to_disk(str(out))
246
+ print(f"saved -> {out} (pass --push-to to upload)")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()