safetensors checkpoint; inference code only
Browse files- README.md +2 -2
- __pycache__/detective.cpython-311.pyc +0 -0
- detective.py +1 -1
- model.pt → model.safetensors +2 -2
- requirements.txt +1 -0
- twjudge/accent/__pycache__/compact.cpython-311.pyc +0 -0
- twjudge/accent/compact.py +47 -1
- twjudge/data/__init__.py +0 -0
- twjudge/data/build_corpus.py +0 -142
- twjudge/data/build_manifest.py +0 -185
- twjudge/data/fetch_audio.py +0 -98
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.
|
| 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.
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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 =
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|