JacobLinCool commited on
Commit
0decc58
·
verified ·
1 Parent(s): 0486cc8

safetensors checkpoint; inference code only

Browse files
README.md CHANGED
@@ -41,7 +41,7 @@ It was built to evaluate text-to-speech systems: comparing different renditions
41
  ## Usage
42
 
43
  ```python
44
- # pip install -r requirements.txt (torch, torchaudio, librosa, soundfile, numpy, pypinyin, jieba, opencc)
45
  from huggingface_hub import snapshot_download
46
  import sys
47
  path = snapshot_download("JacobLinCool/accent-detective")
@@ -58,7 +58,7 @@ det.score_recording("talk.wav", "第一句話。第二句話。") # sent
58
 
59
  ## Model
60
 
61
- Everything is one checkpoint, `model.pt`, of 18.6 M parameters:
62
 
63
  - **Encoder:** a Conformer student encoder (4 layers, width 384, log-mel input). It is distilled to predict seven hidden layers of TencentGameMate/chinese-wav2vec2-base. The judges read the predicted layers 4 and 8, with each utterance's mean removed.
64
  - **Alignment head:** a convolutional CTC head over toneless pinyin letters, trained with a frame-level KL to the MMS forced-alignment model. It replaces a separate 315 M-parameter aligner.
 
41
  ## Usage
42
 
43
  ```python
44
+ # pip install -r requirements.txt (torch, torchaudio, librosa, soundfile, numpy, pypinyin, jieba, opencc, safetensors)
45
  from huggingface_hub import snapshot_download
46
  import sys
47
  path = snapshot_download("JacobLinCool/accent-detective")
 
58
 
59
  ## Model
60
 
61
+ Everything is one checkpoint, `model.safetensors`, of 18.6 M parameters:
62
 
63
  - **Encoder:** a Conformer student encoder (4 layers, width 384, log-mel input). It is distilled to predict seven hidden layers of TencentGameMate/chinese-wav2vec2-base. The judges read the predicted layers 4 and 8, with each utterance's mean removed.
64
  - **Alignment head:** a convolutional CTC head over toneless pinyin letters, trained with a frame-level KL to the MMS forced-alignment model. It replaces a separate 315 M-parameter aligner.
__pycache__/detective.cpython-311.pyc CHANGED
Binary files a/__pycache__/detective.cpython-311.pyc and b/__pycache__/detective.cpython-311.pyc differ
 
detective.py CHANGED
@@ -14,5 +14,5 @@ from twjudge.detective import Detective, index, sentence_ids # noqa: E402,F401
14
 
15
 
16
  def load(device=None, cuda_graphs=True) -> Detective:
17
- return Detective(HERE / "model.pt", lexicon=HERE / "lexicon/concised.json.xz",
18
  cedict=HERE / "lexicon/cedict.txt.gz", device=device, cuda_graphs=cuda_graphs)
 
14
 
15
 
16
  def load(device=None, cuda_graphs=True) -> Detective:
17
+ return Detective(HERE / "model.safetensors", lexicon=HERE / "lexicon/concised.json.xz",
18
  cedict=HERE / "lexicon/cedict.txt.gz", device=device, cuda_graphs=cuda_graphs)
model.pt → model.safetensors RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:33c2eef92b752fe0886ebbfd8e55ab048dc8f8d9e5ea23be434105afce2d23dd
3
- size 74379026
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ab6d6189ac1053154f05fd9ea373f323a5f5da1f0e7ed06d90e98efaa9018408
3
+ size 74303292
requirements.txt CHANGED
@@ -6,3 +6,4 @@ numpy
6
  pypinyin
7
  jieba
8
  opencc
 
 
6
  pypinyin
7
  jieba
8
  opencc
9
+ safetensors
twjudge/accent/__pycache__/compact.cpython-311.pyc CHANGED
Binary files a/twjudge/accent/__pycache__/compact.cpython-311.pyc and b/twjudge/accent/__pycache__/compact.cpython-311.pyc differ
 
twjudge/accent/compact.py CHANGED
@@ -119,6 +119,52 @@ def context(chars):
119
  for k, (t, s, c, f) in enumerate(zip(tones, sandhi_tones(tones), chars, fin))]
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  class CompactJudge:
123
  def __init__(self, path: str | Path, readings=None, device: str | None = None):
124
  import torch
@@ -126,7 +172,7 @@ class CompactJudge:
126
  from twjudge.accent.neural import SyllableNet
127
  from twjudge.align.ctc import CTCAligner
128
 
129
- ck = torch.load(path, map_location="cpu")
130
  self.ck = ck
131
  if ck["ssl"] == "student": # self-contained checkpoint (compact_export.py)
132
  from twjudge.accent.ssl_features import device as default_device
 
119
  for k, (t, s, c, f) in enumerate(zip(tones, sandhi_tones(tones), chars, fin))]
120
 
121
 
122
+ META_KEYS = ("ssl", "student_config", "n_layers", "ctc_layer", "ctc_arch", "judge_layers", "d_in", "teacher")
123
+
124
+
125
+ def save_checkpoint(ck: dict, path) -> None:
126
+ """A self-contained compact checkpoint (compact_export.py) as safetensors: tensors under the prefixes
127
+ student. / ctc. / judge.<k>., everything else as JSON in the metadata."""
128
+ import json
129
+
130
+ from safetensors.torch import save_file
131
+
132
+ states = ck.get("judge_states") or [ck["judge_state"]]
133
+ tensors = {f"student.{k}": v for k, v in ck["student_state"].items()}
134
+ tensors.update({f"ctc.{k}": v for k, v in ck["ctc_state"].items()})
135
+ for i, st in enumerate(states):
136
+ tensors.update({f"judge.{i}.{k}": v for k, v in st.items()})
137
+ meta = {k: ck[k] for k in META_KEYS if k in ck}
138
+ meta["n_judges"] = len(states)
139
+ save_file({k: v.contiguous() for k, v in tensors.items()}, str(path),
140
+ metadata={"accent_detective": json.dumps(meta), "reports": json.dumps(ck.get("reports", {}), default=str)})
141
+
142
+
143
+ def load_checkpoint(path) -> dict:
144
+ """The checkpoint dict of a .pt file, or rebuilt from a .safetensors file written by save_checkpoint."""
145
+ import torch
146
+
147
+ if not str(path).endswith(".safetensors"):
148
+ return torch.load(path, map_location="cpu")
149
+ import json
150
+
151
+ from safetensors import safe_open
152
+
153
+ with safe_open(str(path), framework="pt") as f:
154
+ meta = json.loads(f.metadata()["accent_detective"])
155
+ tensors = {k: f.get_tensor(k) for k in f.keys()}
156
+ ck = dict(meta)
157
+
158
+ def part(prefix):
159
+ return {k[len(prefix):]: v for k, v in tensors.items() if k.startswith(prefix)}
160
+
161
+ ck["student_state"] = part("student.")
162
+ ck["ctc_state"] = part("ctc.")
163
+ ck["judge_states"] = [part(f"judge.{i}.") for i in range(meta["n_judges"])]
164
+ ck["judge_state"] = ck["judge_states"][0]
165
+ return ck
166
+
167
+
168
  class CompactJudge:
169
  def __init__(self, path: str | Path, readings=None, device: str | None = None):
170
  import torch
 
172
  from twjudge.accent.neural import SyllableNet
173
  from twjudge.align.ctc import CTCAligner
174
 
175
+ ck = load_checkpoint(path)
176
  self.ck = ck
177
  if ck["ssl"] == "student": # self-contained checkpoint (compact_export.py)
178
  from twjudge.accent.ssl_features import device as default_device
twjudge/data/__init__.py DELETED
File without changes
twjudge/data/build_corpus.py DELETED
@@ -1,142 +0,0 @@
1
- """Speaker-disjoint Taiwan / Mainland corpus for the Taiwan-accent judge.
2
-
3
- Both sides come from Common Voice, recorded by volunteers on their own devices through the same
4
- web client, so the recording channel is similar and a classifier cannot win on channel alone.
5
-
6
- Taiwan: Common Voice 25 zh-TW, self-reported birthplace,
7
- when given, in Taiwan.
8
- Mainland: Common Voice 21 zh-CN (keeve101/common-voice-21.0-2025-03-14-zh-CN-split), birthplace,
9
- when given, in a Mainland province (Taiwan, Hong Kong, Macau and overseas excluded).
10
-
11
- Splits are by speaker. The Taiwan speakers of the project's TTS evaluation splits (dev, test, qc)
12
- all go to the judge's test split so the judge never trains on them; Mainland speakers follow the
13
- CV split they come from. Each speaker contributes at most PER_SPEAKER clips.
14
-
15
- python -m twjudge.data.build_corpus --out data/judge_corpus
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import argparse
21
- import collections
22
- import csv
23
- import hashlib
24
- import json
25
- import re
26
- import unicodedata
27
- from pathlib import Path
28
-
29
- import pyarrow.parquet as pq
30
-
31
- from twjudge.data.build_manifest import ALLOWED, h, norm_text
32
-
33
- PER_SPEAKER = 25
34
- MIN_CHARS, MAX_CHARS = 6, 40
35
- TW_TARGET = {"train": 9000, "dev": 900}
36
- TW_FOREIGN = re.compile(r"中國|中国|香港|澳門|澳门|馬來西亞|马来西亚|新加坡|美國|美国|日本|大陸|大陆")
37
- CN_EXCLUDE = re.compile(r"台湾|臺灣|台灣|香港|澳门|澳門|马来西亚|馬來西亞|新加坡|海外")
38
-
39
-
40
- def _rank(key: str) -> str:
41
- return hashlib.sha256(f"judge-v1|{key}".encode()).hexdigest()
42
-
43
-
44
- def _ok_text(text: str) -> bool:
45
- n = len(norm_text(text))
46
- return bool(ALLOWED.match(text)) and MIN_CHARS <= n <= MAX_CHARS
47
-
48
-
49
- def taiwan(cv_dir: Path, project_manifests: Path) -> dict[str, list[dict]]:
50
- held = set()
51
- for s in ("dev", "test", "qc"):
52
- held |= {json.loads(l)["speaker"] for l in (project_manifests / f"{s}.jsonl").open()}
53
- by_spk = collections.defaultdict(list)
54
- for r in csv.DictReader((cv_dir / "validated.tsv").open(), delimiter="\t", quoting=csv.QUOTE_NONE):
55
- if int(r["up_votes"]) < 2 or int(r["down_votes"]) > 0 or TW_FOREIGN.search(r["accents"]):
56
- continue
57
- if not _ok_text(r["sentence"].strip()):
58
- continue
59
- by_spk[h(r["client_id"])[:16]].append({"path": r["path"], "text": r["sentence"].strip()})
60
- splits = {"train": [], "dev": [], "test": []}
61
- order = sorted(set(by_spk) - held, key=_rank)
62
- for spk in sorted(held & set(by_spk), key=_rank):
63
- splits["test"] += _take(by_spk[spk], spk)
64
- for spk in order:
65
- name = "dev" if len(splits["dev"]) < TW_TARGET["dev"] else "train"
66
- if name == "train" and len(splits["train"]) >= TW_TARGET["train"]:
67
- break
68
- splits[name] += _take(by_spk[spk], spk)
69
- return splits
70
-
71
-
72
- def mainland(cn_dir: Path) -> dict[str, list[dict]]:
73
- splits = {}
74
- for name in ("train", "dev", "test"):
75
- by_spk = collections.defaultdict(list)
76
- for f in sorted((cn_dir / "data").glob(f"{name}-*.parquet")):
77
- t = pq.read_table(f, columns=["client_id", "path", "sentence", "accents", "up_votes", "down_votes"])
78
- for r in t.to_pylist():
79
- acc = r["accents"] or ""
80
- if r["down_votes"] > 0 or CN_EXCLUDE.search(acc) or (acc and "出生地" not in acc):
81
- continue
82
- text = unicodedata.normalize("NFKC", r["sentence"]).strip()
83
- if not _ok_text(text):
84
- continue
85
- by_spk[hashlib.sha256(r["client_id"].encode()).hexdigest()[:16]].append(
86
- {"path": r["path"], "text": text})
87
- splits[name] = [c for spk in sorted(by_spk, key=_rank) for c in _take(by_spk[spk], spk)]
88
- return splits
89
-
90
-
91
- def _take(clips: list[dict], spk: str) -> list[dict]:
92
- return [{**c, "speaker": spk} for c in sorted(clips, key=lambda c: _rank(c["path"]))[:PER_SPEAKER]]
93
-
94
-
95
- def extract(parquets: list[Path], wanted: set[str], dest: Path) -> set[str]:
96
- dest.mkdir(parents=True, exist_ok=True)
97
- found = set()
98
- for f in parquets:
99
- for batch in pq.ParquetFile(f).iter_batches(columns=["path", "audio"], batch_size=256):
100
- for path, audio in zip(batch.column("path").to_pylist(), batch.column("audio").to_pylist()):
101
- name = Path(path).name
102
- if name in wanted and name not in found:
103
- (dest / name).write_bytes(audio["bytes"])
104
- found.add(name)
105
- return found
106
-
107
-
108
- def main() -> None:
109
- ap = argparse.ArgumentParser(description=__doc__)
110
- ap.add_argument("--tw-dir", default="data/cv25")
111
- ap.add_argument("--cn-dir", default="data/cv21cn")
112
- ap.add_argument("--project-manifests", default="data/manifests_local/cv25")
113
- ap.add_argument("--out", default="data/judge_corpus")
114
- a = ap.parse_args()
115
- out = Path(a.out)
116
- sets = {"tw": taiwan(Path(a.tw_dir), Path(a.project_manifests)), "cn": mainland(Path(a.cn_dir))}
117
- # the Mainland test split is far larger than needed; keep it about the size of the Taiwan one
118
- sets["cn"]["test"] = sets["cn"]["test"][:len(sets["tw"]["test"]) * 2]
119
- sets["cn"]["dev"] = sets["cn"]["dev"][:TW_TARGET["dev"]]
120
- sets["cn"]["train"] = sets["cn"]["train"][:TW_TARGET["train"]]
121
- found = {"tw": extract(sorted(Path(a.tw_dir, "parquet").glob("validated-*.parquet")),
122
- {Path(c["path"]).name for s in sets["tw"].values() for c in s}, out / "clips/tw"),
123
- "cn": extract(sorted(Path(a.cn_dir, "data").glob("*.parquet")),
124
- {Path(c["path"]).name for s in sets["cn"].values() for c in s}, out / "clips/cn")}
125
- report = {}
126
- for split in ("train", "dev", "test"):
127
- with (out / f"{split}.jsonl").open("w") as f:
128
- for lab in ("tw", "cn"):
129
- rows = [c for c in sets[lab][split] if Path(c["path"]).name in found[lab]]
130
- for c in rows:
131
- name = Path(c["path"]).name
132
- f.write(json.dumps({"item_id": f"{lab}_{Path(name).stem}", "label": lab, "split": split,
133
- "speaker": f"{lab}{c['speaker']}", "text": c["text"],
134
- "wav": str((out / "clips" / lab / name).resolve())},
135
- ensure_ascii=False) + "\n")
136
- report[f"{split}/{lab}"] = {"clips": len(rows), "speakers": len({c["speaker"] for c in rows})}
137
- (out / "report.json").write_text(json.dumps(report, indent=1))
138
- print(json.dumps(report))
139
-
140
-
141
- if __name__ == "__main__":
142
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
twjudge/data/build_manifest.py DELETED
@@ -1,185 +0,0 @@
1
- """Leak-free target splits from Common Voice 25 zh-TW ``validated`` (PLAN §4).
2
-
3
- Speaker-disjoint, normalized-text-disjoint splits filled in a fixed order:
4
- test → dev → qc → train. A speaker belongs to exactly one split and a
5
- normalized sentence to at most one split, so an earlier split can never leak
6
- into a later one. Train clips are ordered round-robin over speakers, so every
7
- prefix is speaker-balanced and the nested subsets 10 ⊂ 60 ⊂ 180 minutes are
8
- prefixes of one list.
9
-
10
- Selection uses only metadata. Waveform-hash de-duplication runs after the
11
- audio is fetched (``fetch_audio``).
12
-
13
- python -m twjudge.data.build_manifest --cv-dir data/cv25 --out judge/manifests/cv25
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import argparse
19
- import csv
20
- import hashlib
21
- import json
22
- import re
23
- import unicodedata
24
- from collections import defaultdict
25
- from pathlib import Path
26
-
27
- SOURCE_REPO = "OpenFormosa/common_voice_25_zh-TW"
28
- SOURCE_REVISION = "9e969df60ad63f812b68a755581c961bc967673d"
29
- SALT = "accentbridge-cv25-v1"
30
-
31
- # (split, target minutes, per-speaker cap minutes; further capped at 10% of the split)
32
- PLAN = [("test", 30.0, 2.5), ("dev", 20.0, 2.5), ("qc", 10.0, 2.5), ("train", 180.0, 3.0)]
33
- TRAIN_SUBSETS = (10.0, 60.0, 180.0)
34
-
35
- MIN_S, MAX_S = 2.0, 10.0
36
- MIN_CHARS = 6
37
- EXCLUDED_ACCENTS = {"香港"}
38
- ALLOWED = re.compile(r"^[㐀-鿿豈-﫿,。、!?;:「」『』()—…·.,.!?;:\s]+$")
39
- STRIP = re.compile(r"[^㐀-鿿豈-﫿]")
40
-
41
-
42
- def h(text: str) -> str:
43
- return hashlib.sha256(f"{SALT}|{text}".encode()).hexdigest()
44
-
45
-
46
- def norm_text(s: str) -> str:
47
- return STRIP.sub("", unicodedata.normalize("NFKC", s))
48
-
49
-
50
- def load(cv_dir: Path) -> list[dict]:
51
- dur = {r["clip"]: int(r["duration[ms]"]) for r in
52
- csv.DictReader((cv_dir / "clip_durations.tsv").open(), delimiter="\t")}
53
- rows, reasons = [], defaultdict(int)
54
- for r in csv.DictReader((cv_dir / "validated.tsv").open(), delimiter="\t", quoting=csv.QUOTE_NONE):
55
- d = dur.get(r["path"], 0) / 1000
56
- text = r["sentence"].strip()
57
- accents = set(r["accents"].split("|")) - {""}
58
- if not MIN_S <= d <= MAX_S:
59
- reasons["duration"] += 1
60
- elif int(r["up_votes"]) < 2 or int(r["down_votes"]) > 0:
61
- reasons["votes"] += 1
62
- elif accents & EXCLUDED_ACCENTS:
63
- reasons["accent_label"] += 1
64
- elif not ALLOWED.match(text):
65
- reasons["non_cjk_text"] += 1 # digits/latin would go through text normalization
66
- elif len(norm_text(text)) < MIN_CHARS:
67
- reasons["too_few_chars"] += 1
68
- else:
69
- rows.append({"clip_id": r["path"], "speaker": h(r["client_id"])[:16],
70
- "sentence_id": r["sentence_id"], "text": text,
71
- "text_key": norm_text(text), "duration_s": d})
72
- return rows, dict(reasons)
73
-
74
-
75
- def build(rows: list[dict]) -> dict[str, list[dict]]:
76
- by_speaker: dict[str, list[dict]] = defaultdict(list)
77
- for r in rows:
78
- by_speaker[r["speaker"]].append(r)
79
- for clips in by_speaker.values():
80
- clips.sort(key=lambda r: h(r["clip_id"]))
81
- speakers = sorted(by_speaker, key=h)
82
-
83
- used_text: set[str] = set()
84
- taken: set[str] = set()
85
- splits: dict[str, list[dict]] = {}
86
- cursor = 0
87
- for split, target_min, cap_min in PLAN:
88
- cap_min = min(cap_min, 0.1 * target_min) # no speaker above 10% of a split
89
- per_speaker: list[list[dict]] = []
90
- total = 0.0
91
- while total < target_min * 60 and cursor < len(speakers):
92
- spk = speakers[cursor]
93
- cursor += 1
94
- mine, secs, seen = [], 0.0, set()
95
- for r in by_speaker[spk]:
96
- if r["text_key"] in used_text or r["text_key"] in seen:
97
- continue
98
- if secs + r["duration_s"] > cap_min * 60:
99
- break
100
- mine.append(r)
101
- seen.add(r["text_key"])
102
- secs += r["duration_s"]
103
- if mine:
104
- per_speaker.append(mine)
105
- used_text |= seen
106
- taken.add(spk)
107
- total += secs
108
- # round-robin interleave so every prefix is speaker-balanced
109
- ordered, k = [], 0
110
- while any(k < len(c) for c in per_speaker):
111
- ordered += [c[k] for c in per_speaker if k < len(c)]
112
- k += 1
113
- # trim to the target duration, never exceeding it
114
- out, secs = [], 0.0
115
- for r in ordered:
116
- if secs + r["duration_s"] > target_min * 60:
117
- break
118
- out.append({**r, "split": split})
119
- secs += r["duration_s"]
120
- splits[split] = out
121
- return splits
122
-
123
-
124
- def summarize(clips: list[dict]) -> dict:
125
- spk = defaultdict(float)
126
- for r in clips:
127
- spk[r["speaker"]] += r["duration_s"]
128
- total = sum(spk.values())
129
- return {"clips": len(clips), "minutes": round(total / 60, 2), "speakers": len(spk),
130
- "max_speaker_share": round(max(spk.values()) / total, 3) if total else None}
131
-
132
-
133
- def main() -> None:
134
- ap = argparse.ArgumentParser(description=__doc__)
135
- ap.add_argument("--cv-dir", required=True)
136
- ap.add_argument("--out", required=True)
137
- args = ap.parse_args()
138
- out = Path(args.out)
139
- out.mkdir(parents=True, exist_ok=True)
140
-
141
- rows, reasons = load(Path(args.cv_dir))
142
- splits = build(rows)
143
- train = splits.pop("train")
144
- report = {"source_repo": SOURCE_REPO, "source_revision": SOURCE_REVISION, "salt": SALT,
145
- "eligible_clips": len(rows), "filtered_out": reasons, "splits": {}}
146
- for name, clips in splits.items():
147
- report["splits"][name] = summarize(clips)
148
- _write(out / f"{name}.jsonl", clips)
149
- prev = 0
150
- for minutes in TRAIN_SUBSETS:
151
- prefix, secs = [], 0.0
152
- for r in train:
153
- if secs + r["duration_s"] > minutes * 60:
154
- break
155
- prefix.append(r)
156
- secs += r["duration_s"]
157
- assert len(prefix) >= prev
158
- prev = len(prefix)
159
- name = f"train_{int(minutes)}m"
160
- report["splits"][name] = summarize(prefix)
161
- _write(out / f"{name}.jsonl", [{**r, "split": name} for r in prefix])
162
-
163
- # leak checks across the disjoint splits (train_180m contains the smaller train subsets)
164
- names = ["test", "dev", "qc", "train_180m"]
165
- sets = {n: [json.loads(l) for l in (out / f"{n}.jsonl").open()] for n in names}
166
- for i, a in enumerate(names):
167
- for b in names[i + 1:]:
168
- for key in ("speaker", "text_key", "clip_id"):
169
- overlap = {r[key] for r in sets[a]} & {r[key] for r in sets[b]}
170
- assert not overlap, f"{key} overlap between {a} and {b}"
171
- report["leak_checks"] = "passed: speaker, normalized text and clip disjoint across test/dev/qc/train"
172
- report["target_audio_core_minutes"] = round(sum(
173
- report["splits"][n]["minutes"] for n in ("test", "dev", "qc", "train_60m")), 2)
174
- (out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
175
- print(json.dumps(report, ensure_ascii=False, indent=2))
176
-
177
-
178
- def _write(path: Path, rows: list[dict]) -> None:
179
- with path.open("w") as f:
180
- for r in rows:
181
- f.write(json.dumps(r, ensure_ascii=False) + "\n")
182
-
183
-
184
- if __name__ == "__main__":
185
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
twjudge/data/fetch_audio.py DELETED
@@ -1,98 +0,0 @@
1
- """Extract the selected clips from the Common Voice parquet shards and finish the manifests.
2
-
3
- Writes each selected clip's original MP3 bytes to ``<clips>/<clip_id>``, adds
4
- its SHA-256, drops clips whose waveform hash duplicates a clip in an earlier
5
- split (PLAN §4.3), and writes two manifest sets:
6
-
7
- local/ full rows including transcripts (git-ignored, under data/)
8
- published/ shareable rows: IDs, hashes, durations and split only, no audio
9
- and no transcript text (PLAN §11)
10
-
11
- python -m twjudge.data.fetch_audio --parquet data/cv25/parquet \
12
- --manifests data/manifests_local/cv25 --clips data/cv25/clips --published judge/manifests/cv25
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import argparse
18
- import hashlib
19
- import json
20
- from pathlib import Path
21
-
22
- import pyarrow.parquet as pq
23
-
24
- ORDER = ["test", "dev", "qc", "train_180m"] # earlier split wins a duplicate
25
- SUBSETS = ["train_10m", "train_60m"]
26
- PUBLIC_KEYS = ["clip_id", "speaker", "sentence_id", "sentence_hash", "duration_s", "split",
27
- "audio_sha256", "license_review_status"]
28
-
29
-
30
- def main() -> None:
31
- ap = argparse.ArgumentParser(description=__doc__)
32
- ap.add_argument("--parquet", required=True)
33
- ap.add_argument("--manifests", required=True)
34
- ap.add_argument("--clips", required=True)
35
- ap.add_argument("--published", required=True)
36
- args = ap.parse_args()
37
- man, clips, pub = Path(args.manifests), Path(args.clips), Path(args.published)
38
- clips.mkdir(parents=True, exist_ok=True)
39
- pub.mkdir(parents=True, exist_ok=True)
40
-
41
- splits = {n: [json.loads(l) for l in (man / f"{n}.jsonl").open()] for n in ORDER + SUBSETS}
42
- wanted = {r["clip_id"] for n in ORDER for r in splits[n]}
43
- found: dict[str, str] = {}
44
- for shard in sorted(Path(args.parquet).glob("validated-*.parquet")):
45
- pf = pq.ParquetFile(shard)
46
- for batch in pf.iter_batches(columns=["path", "audio"], batch_size=512):
47
- for path, audio in zip(batch.column("path").to_pylist(), batch.column("audio").to_pylist()):
48
- if path in wanted and path not in found:
49
- data = audio["bytes"]
50
- (clips / path).write_bytes(data)
51
- found[path] = hashlib.sha256(data).hexdigest()
52
- missing = wanted - set(found)
53
-
54
- seen_hash: dict[str, str] = {}
55
- dropped = []
56
- keep: dict[str, set[str]] = {}
57
- for n in ORDER:
58
- keep[n] = set()
59
- for r in splits[n]:
60
- hsh = found.get(r["clip_id"])
61
- if hsh is None:
62
- dropped.append({"clip_id": r["clip_id"], "split": n, "reason": "missing_audio"})
63
- elif hsh in seen_hash:
64
- dropped.append({"clip_id": r["clip_id"], "split": n, "reason": f"duplicate_of_{seen_hash[hsh]}"})
65
- else:
66
- seen_hash[hsh] = n
67
- keep[n].add(r["clip_id"])
68
- keep_train = keep["train_180m"]
69
- report = {"wanted": len(wanted), "extracted": len(found), "missing": len(missing),
70
- "dropped": dropped, "splits": {}}
71
- for n in ORDER + SUBSETS:
72
- allowed = keep_train if n.startswith("train") else keep[n]
73
- rows = []
74
- for r in splits[n]:
75
- if r["clip_id"] not in allowed:
76
- continue
77
- rows.append({**r, "audio_sha256": found[r["clip_id"]],
78
- "sentence_hash": hashlib.sha256(r["text_key"].encode()).hexdigest()[:16],
79
- "license_review_status": "approved_2026-10-24"})
80
- with (man / f"{n}.jsonl").open("w") as f:
81
- for r in rows:
82
- f.write(json.dumps(r, ensure_ascii=False) + "\n")
83
- with (pub / f"{n}.jsonl").open("w") as f:
84
- for r in rows:
85
- f.write(json.dumps({k: r[k] for k in PUBLIC_KEYS}) + "\n")
86
- report["splits"][n] = {"clips": len(rows), "minutes": round(sum(r["duration_s"] for r in rows) / 60, 2)}
87
- build_report = json.loads((man / "report.json").read_text())
88
- build_report["audio"] = report
89
- (man / "report.json").write_text(json.dumps(build_report, ensure_ascii=False, indent=2))
90
- public_report = {k: v for k, v in build_report.items() if k != "audio"} | {"audio": {
91
- k: v for k, v in report.items() if k != "dropped"} | {"dropped_count": len(dropped)}}
92
- (pub / "report.json").write_text(json.dumps(public_report, ensure_ascii=False, indent=2))
93
- print(json.dumps({k: v for k, v in report.items() if k != "dropped"} | {"dropped": len(dropped)},
94
- ensure_ascii=False, indent=2))
95
-
96
-
97
- if __name__ == "__main__":
98
- main()