giangndm commited on
Commit
b8a90de
·
verified ·
1 Parent(s): 6b85415

Add temporary GigaSpeech2 conversion tab

Browse files
features/__init__.py CHANGED
@@ -18,6 +18,6 @@ and submit it from the tab with `core.jobs.submit_job(...)`, returning
18
  To add a feature: create the module and append it to `FEATURES` (tab order).
19
  """
20
 
21
- from . import audio_compress, text_extract
22
 
23
- FEATURES = [text_extract, audio_compress]
 
18
  To add a feature: create the module and append it to `FEATURES` (tab order).
19
  """
20
 
21
+ from . import audio_compress, gigaspeech2_convert, text_extract
22
 
23
+ FEATURES = [text_extract, audio_compress, gigaspeech2_convert]
features/gigaspeech2_convert.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temporary GigaSpeech2 converter: disk-indexed transcripts and sequential archives."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sqlite3
10
+ import tarfile
11
+ import tempfile
12
+ import traceback
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Optional
16
+
17
+ import gradio as gr
18
+ import pyarrow as pa
19
+ import pyarrow.parquet as pq
20
+ import soundfile as sf
21
+ from datasets import Audio, Features, Value
22
+ from huggingface_hub import CommitOperationAdd, DatasetCard, HfApi, hf_hub_download
23
+
24
+ from core.common import AppContext, SPLIT_RE
25
+ from core.jobs import submit_job
26
+ from features.audio_compress import AudioEncoder, KEEP_SR, _upsert_config
27
+
28
+ TITLE = "Convert GigaSpeech2 (temporary)"
29
+ SOURCE = "speechcolab/gigaspeech2"
30
+
31
+
32
+ def source_plan(api, subset, source_split, revision):
33
+ info = api.dataset_info(SOURCE, revision=revision or None)
34
+ files = {s.rfilename for s in info.siblings}
35
+ base = f"data/{subset}"
36
+ transcript = f"{base}/{source_split}.tsv"
37
+ if source_split == "train_refined":
38
+ archives = sorted((f for f in files if re.fullmatch(rf"{base}/train/\d+\.tar\.gz", f)),
39
+ key=lambda f: int(PurePosixPath(f).name.split('.')[0]))
40
+ else:
41
+ archives = [f"{base}/{source_split}.tar.gz"]
42
+ if transcript not in files or not archives or any(f not in files for f in archives):
43
+ raise ValueError("Source transcript or archives are missing.")
44
+ return info.sha, transcript, archives
45
+
46
+
47
+ def index_transcript(path, db):
48
+ db.execute("CREATE TABLE transcript (segment_id TEXT PRIMARY KEY, text TEXT NOT NULL)")
49
+ count = 0
50
+ with open(path, encoding="utf-8-sig") as stream:
51
+ for line_number, line in enumerate(stream, 1):
52
+ line = line.rstrip("\r\n")
53
+ if not line:
54
+ continue
55
+ parts = line.split("\t", 1)
56
+ if len(parts) != 2 or not parts[0]:
57
+ raise ValueError(f"Invalid transcript at line {line_number}")
58
+ db.execute("INSERT INTO transcript VALUES (?, ?)", parts)
59
+ count += 1
60
+ if count % 10000 == 0:
61
+ db.commit()
62
+ db.commit()
63
+ return count
64
+
65
+
66
+ def convert_archive(archive, destination, db, subset, fmt, level, workers):
67
+ features = Features({"segment_id": Value("string"), "text": Value("string"),
68
+ "audio": Audio(sampling_rate=16000), "duration": Value("float64"),
69
+ "language": Value("string")})
70
+ schema = features.arrow_schema
71
+ matched = skipped = 0
72
+ batch = []
73
+
74
+ def encode(item):
75
+ segment_id, text, raw, path = item
76
+ info = sf.info(io.BytesIO(raw))
77
+ if info.samplerate != 16000 or info.channels != 1:
78
+ raise ValueError(f"Unexpected audio format for {segment_id}: {info.samplerate}Hz/{info.channels}ch")
79
+ audio = {"bytes": raw, "path": path}
80
+ if fmt != "Original WAV":
81
+ # Independent encoders: their statistics are mutable and must not be shared.
82
+ audio = AudioEncoder(fmt, level, None, "PCM_16", KEEP_SR, False)(audio)
83
+ return {"segment_id": segment_id, "text": text, "audio": audio,
84
+ "duration": info.duration, "language": subset}
85
+
86
+ db.execute("CREATE TEMP TABLE seen (segment_id TEXT PRIMARY KEY)")
87
+ try:
88
+ with pq.ParquetWriter(destination, schema, compression="zstd") as writer, \
89
+ ThreadPoolExecutor(max_workers=workers) as pool, tarfile.open(archive, "r|gz") as tar:
90
+ def flush():
91
+ writer.write_table(pa.Table.from_pylist(list(pool.map(encode, batch)), schema=schema))
92
+ batch.clear()
93
+
94
+ for member in tar:
95
+ if not member.isfile() or not member.name.lower().endswith(".wav"):
96
+ continue
97
+ path = PurePosixPath(member.name)
98
+ segment_id = path.stem
99
+ result = db.execute("SELECT text FROM transcript WHERE segment_id=?", (segment_id,)).fetchone()
100
+ if result is None:
101
+ skipped += 1
102
+ continue
103
+ db.execute("INSERT INTO seen VALUES (?)", (segment_id,))
104
+ if member.size > 64 * 1024 * 1024:
105
+ raise ValueError(f"Audio member too large: {member.name}")
106
+ with tar.extractfile(member) as audio_file:
107
+ raw = audio_file.read()
108
+ batch.append((segment_id, result[0], raw, path.name))
109
+ matched += 1
110
+ if len(batch) >= 32:
111
+ flush()
112
+ if batch:
113
+ flush()
114
+ return matched, skipped
115
+ finally:
116
+ db.execute("DROP TABLE seen")
117
+ db.commit()
118
+
119
+
120
+ def convert_and_push(token, subset, source_split, revision, out_repo, out_subset, out_split,
121
+ private, fmt, level, num_workers, files_per_commit, max_archives,
122
+ progress=lambda *a, **k: None):
123
+ logs = []
124
+ workdir = tempfile.mkdtemp(prefix="gigaspeech2-")
125
+
126
+ def log(message, status="⏳ Converting GigaSpeech2…"):
127
+ logs.append(message)
128
+ print(message, flush=True)
129
+ return status, "\n".join(logs)
130
+
131
+ try:
132
+ out_repo, out_subset, out_split = out_repo.strip(), out_subset.strip(), out_split.strip()
133
+ if out_repo == SOURCE or not re.fullmatch(r"[\w.-]+/[\w.-]+", out_repo):
134
+ raise ValueError("Choose a destination namespace/repo different from the source.")
135
+ if not re.fullmatch(r"[\w.-]+", out_subset) or out_subset in (".", "..") or not SPLIT_RE.fullmatch(out_split):
136
+ raise ValueError("Invalid destination subset or split.")
137
+ if subset not in ("vi", "th", "id") or source_split not in ("train_refined", "dev", "test"):
138
+ raise ValueError("Invalid source subset/split.")
139
+ workers, per_commit, limit = int(num_workers), int(files_per_commit), int(max_archives)
140
+ if workers < 1 or per_commit < 1 or limit < 0 or fmt not in ("Original WAV", "FLAC", "Opus") or not 0 <= float(level) <= 1:
141
+ raise ValueError("Workers/files per upload must be positive; archive limit must be nonnegative.")
142
+ api = HfApi(token=token)
143
+ sha, transcript, archives = source_plan(api, subset, source_split, revision.strip())
144
+ prefix = f"{out_subset}/{out_split}"
145
+ manifest_path = f".gigaspeech2/{out_subset}/{out_split}.json"
146
+ settings = dict(source=SOURCE, revision=sha, subset=subset, source_split=source_split,
147
+ format=fmt, level=float(level) if fmt != "Original WAV" else None,
148
+ schema_version=1, archives=archives)
149
+ api.create_repo(out_repo, repo_type="dataset", private=bool(private), exist_ok=True)
150
+ existing_files = set(api.list_repo_files(out_repo, repo_type="dataset"))
151
+ expected = {f"{prefix}-{i:05d}-of-{len(archives):05d}.parquet" for i in range(len(archives))}
152
+ split_files = {f for f in existing_files if f.startswith(prefix + "-") and f.endswith(".parquet")}
153
+ completed = {}
154
+ if manifest_path in existing_files:
155
+ manifest_local = hf_hub_download(out_repo, manifest_path, repo_type="dataset", token=token,
156
+ local_dir=f"{workdir}/manifest")
157
+ manifest = json.loads(Path(manifest_local).read_text())
158
+ if manifest["settings"] != settings:
159
+ raise ValueError("Destination conversion settings differ. Use another destination subset/split.")
160
+ completed = manifest["completed"]
161
+ elif split_files:
162
+ raise ValueError("Destination split contains shards without a conversion manifest; choose another split.")
163
+ if split_files - expected or split_files != set(completed):
164
+ raise ValueError("Destination shards and conversion manifest disagree.")
165
+ todo = [(i, archive) for i, archive in enumerate(archives)
166
+ if f"{prefix}-{i:05d}-of-{len(archives):05d}.parquet" not in completed]
167
+ if limit:
168
+ todo = todo[:limit]
169
+ yield log(f"Source {subset}/{source_split} @ {sha}; {len(archives)} archives, {len(completed)} already uploaded.")
170
+ if not todo:
171
+ yield log("No remaining archives.", "### ✅ Conversion already complete")
172
+ return
173
+ yield log(f"Downloading transcript: {transcript}")
174
+ local_tsv = hf_hub_download(SOURCE, transcript, revision=sha, repo_type="dataset", token=token,
175
+ local_dir=f"{workdir}/transcript")
176
+ with sqlite3.connect(f"{workdir}/transcripts.sqlite") as db:
177
+ count = index_transcript(local_tsv, db)
178
+ shutil.rmtree(f"{workdir}/transcript")
179
+ yield log(f"Indexed {count:,} transcript rows on disk.")
180
+ pending = []
181
+ for k, (i, archive) in enumerate(todo, 1):
182
+ progress((k - 1) / len(todo), desc=f"Archive {i + 1}/{len(archives)}: download")
183
+ yield log(f"Downloading [{i + 1}/{len(archives)}] {archive}")
184
+ local_tar = hf_hub_download(SOURCE, archive, revision=sha, repo_type="dataset", token=token,
185
+ local_dir=f"{workdir}/archive")
186
+ destination = f"{prefix}-{i:05d}-of-{len(archives):05d}.parquet"
187
+ local_parquet = f"{workdir}/{i:05d}.parquet"
188
+ yield log(f"Converting {archive} with {workers} workers…")
189
+ rows, skipped = convert_archive(local_tar, local_parquet, db, subset, fmt, float(level), workers)
190
+ shutil.rmtree(f"{workdir}/archive")
191
+ completed[destination] = dict(archive=archive, rows=rows, skipped=skipped)
192
+ pending.append((local_parquet, destination))
193
+ # No yield between conversion and upload when the batch is ready.
194
+ if len(pending) >= per_commit or k == len(todo):
195
+ card = DatasetCard.load(out_repo, token=token) if "README.md" in existing_files else DatasetCard(
196
+ "---\nlicense: apache-2.0\ntask_categories:\n- automatic-speech-recognition\n---\n"
197
+ "# GigaSpeech2 converted to Parquet\n\n"
198
+ f"Source: [{SOURCE}](https://huggingface.co/datasets/{SOURCE}). "
199
+ "The original dataset's terms of access apply. "
200
+ "Only audio with a matching transcript is included.\n")
201
+ card.data.configs = _upsert_config(card.data.get("configs", []), out_subset, out_split, prefix + "-*.parquet")
202
+ manifest_file = f"{workdir}/conversion.json"
203
+ Path(manifest_file).write_text(json.dumps(dict(settings=settings, completed=completed), ensure_ascii=False))
204
+ api.create_commit(out_repo, repo_type="dataset", operations=[
205
+ *[CommitOperationAdd(path_in_repo=dst, path_or_fileobj=local) for local, dst in pending],
206
+ CommitOperationAdd(path_in_repo=manifest_path, path_or_fileobj=manifest_file),
207
+ CommitOperationAdd(path_in_repo="README.md", path_or_fileobj=str(card).encode()),
208
+ ], commit_message=f"Convert GigaSpeech2 {subset}/{source_split}: through archive {i}")
209
+ existing_files.add("README.md")
210
+ for local, _ in pending:
211
+ os.remove(local)
212
+ pending.clear()
213
+ yield log(f"Uploaded through archive {i}: {rows:,} rows; {skipped:,} audio excluded by transcript.")
214
+ else:
215
+ yield log(f"Prepared archive {i}: {rows:,} rows; awaiting upload batch ({len(pending)}/{per_commit}).")
216
+ if len(completed) == len(archives):
217
+ converted_rows = sum(item["rows"] for item in completed.values())
218
+ if converted_rows != count:
219
+ raise ValueError(f"Transcript coverage mismatch: {converted_rows:,} audio rows / {count:,} transcript rows. Inspect source archives before using this split.")
220
+ progress(1, desc="Finished")
221
+ yield log(f"Uploaded {len(completed)}/{len(archives)} archives.",
222
+ f"### ✅ Conversion finished\n\n[{out_repo}](https://huggingface.co/datasets/{out_repo}) · "
223
+ f"`{out_subset}/{out_split}` · {len(completed)}/{len(archives)} archives. "
224
+ "Submit again to resume remaining archives.")
225
+ except Exception as exc:
226
+ logs.append(traceback.format_exc())
227
+ yield f"### ❌ Failed\n\n{exc}\n\nUploaded batches are kept; submit again to resume.", "\n".join(logs)
228
+ finally:
229
+ shutil.rmtree(workdir, ignore_errors=True)
230
+
231
+
232
+ def submit_convert(oauth_token: Optional[gr.OAuthToken], session_token: Optional[str], subset, source_split,
233
+ revision, out_repo, out_subset, out_split, private, fmt, level, num_workers,
234
+ files_per_commit, max_archives):
235
+ params = {k: v for k, v in locals().items() if k not in ("oauth_token", "session_token")}
236
+ return submit_job(oauth_token, session_token, feature=TITLE,
237
+ title=f"GigaSpeech2 {subset}/{source_split} → {out_repo}", fn=convert_and_push, params=params)
238
+
239
+
240
+ def build(ctx: AppContext):
241
+ gr.Markdown("Convert **speechcolab/gigaspeech2** on this Space: transcript → one tar.gz at a time → Parquet → Hub. "
242
+ "Columns: `segment_id`, `text`, `audio` (embedded bytes), `duration`, `language`.")
243
+ with gr.Accordion("📥 Source", open=True):
244
+ with gr.Row():
245
+ subset = gr.Dropdown(["vi", "th", "id"], value="vi", label="Source subset (language)")
246
+ source_split = gr.Dropdown(["train_refined", "dev", "test"], value="train_refined", label="Source transcript / split")
247
+ revision = gr.Textbox(value="main", label="Source revision")
248
+ with gr.Accordion("📤 Destination", open=True):
249
+ out_repo = gr.Textbox(label="Target dataset repo", placeholder="your-name/gigaspeech2-parquet")
250
+ with gr.Row():
251
+ out_subset = gr.Textbox(value="vi", label="Target subset (config)")
252
+ out_split = gr.Textbox(value="train", label="Target split")
253
+ private = gr.Checkbox(value=True, label="Create private repo")
254
+ with gr.Accordion("⚙️ Conversion", open=True):
255
+ with gr.Row():
256
+ fmt = gr.Dropdown(["Original WAV", "FLAC", "Opus"], value="FLAC", label="Audio format")
257
+ level = gr.Slider(0, 1, value=0.6, step=0.01, label="Compression level")
258
+ with gr.Row():
259
+ num_workers = gr.Number(value=os.cpu_count() or 2, precision=0, label="Conversion workers")
260
+ files_per_commit = gr.Number(value=1, precision=0, label="Parquet files per upload", info="One Parquet per archive; pending files use temporary disk.")
261
+ max_archives = gr.Number(value=0, precision=0, label="Max archives in this run (0 = all)")
262
+ gr.Markdown("Resume skips uploaded archives and checks source revision / conversion settings. "
263
+ "Temporary files are removed after upload. Follow or cancel in **📋 Jobs**; cancellation takes effect between stages. "
264
+ "Access to the gated source dataset must already be granted to your signed-in account.")
265
+ subset.input(lambda value: value, inputs=[subset], outputs=[out_subset])
266
+ run = gr.Button("🚀 Submit job: convert GigaSpeech2", variant="primary", elem_classes=["run-btn"])
267
+ status = gr.Markdown()
268
+ run.click(submit_convert, inputs=[ctx.session_token, subset, source_split, revision, out_repo,
269
+ out_subset, out_split, private, fmt, level, num_workers, files_per_commit, max_archives],
270
+ outputs=[status, ctx.submitted_job], concurrency_limit=None)