CoolFace
Datasetpublic

laubonghaudoi/legco-speech

香港立法會會議語音數據集 本數據集係由香港立法會會議製成嘅大規模語音數據集。原始錄音總時長 22,196 個鐘,切分語音後總時長 20,471 個鐘。數據集分兩個子集,raw同segmented,分別為原始錄音同VAD識別切分後嘅語音。 數據集製作流程 先去香港特別行政區立法會 YouTube下載所有會議紀錄並轉為 16kHz 採樣率嘅 OPUS音頻 用 fsmn-vad 切分所有語音,並用 Qwen3-ASR-1.7B 轉寫成粵文 srt 字幕 轉寫後用正則表達式修正字幕中常見轉寫錯誤 將數據集分成 raw、 segmented 兩個子集傳到HF 子集 subset raw segment 總行數 Row number 14,036 9,557,109 總時長 Total duration 22,195.55 hr (79,903,980.00 s) 20471.21 hr (73,696,365.27 s) 平均時長 Average duration 1.58 hr (5692.79… See the full description on the dataset page: https://huggingface.co/datasets/laubonghaudoi/legco-speech.

sourceHugging Facecc0-1.0updated 7mo agoView on Hugging Face
3likes5.2kdownloads
segment_audio.py286 linesDownload Raw Back to scripts
1"""Segment full meeting audio into sentence-level clips and write Parquet shards.2 3For each meeting in metadata.csv, this script:4  1. Parses the SRT file to get (start, end, text) segments.5  2. Uses ffmpeg to extract each segment from the opus file (stream copy, no re-encoding).6  3. Batches segments and writes Parquet shards with embedded audio bytes.7 8Usage:9    python -m scripts.segment_audio [--workers N] [--shard-size N] [--out-dir DIR]10"""11 12import argparse13import collections14import csv15import itertools16import re17import subprocess18from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait19from dataclasses import dataclass20from pathlib import Path21 22from rich.console import Console23from rich.progress import (24    BarColumn,25    MofNCompleteColumn,26    Progress,27    SpinnerColumn,28    TextColumn,29    TimeElapsedColumn,30    TimeRemainingColumn,31)32 33REPO_ROOT = Path(__file__).resolve().parent.parent34DEFAULT_SHARD_SIZE = 500035DEFAULT_WORKERS = 436 37console = Console()38 39 40@dataclass41class SrtSegment:42    index: int43    start_seconds: float44    end_seconds: float45    text: str46 47 48def _ts_to_seconds(ts: str) -> float:49    """Convert SRT timestamp (HH:MM:SS,mmm) to seconds."""50    h, m, rest = ts.split(":")51    s, ms = rest.split(",")52    return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 100053 54 55def parse_srt(srt_path: Path) -> list[SrtSegment]:56    """Parse an SRT file into a list of timed segments."""57    try:58        content = srt_path.read_text(encoding="utf-8")59    except FileNotFoundError:60        return []61 62    segments: list[SrtSegment] = []63    blocks = re.split(r"\n\s*\n", content.strip())64 65    for block in blocks:66        lines = block.strip().split("\n")67        if len(lines) < 3:68            continue69 70        try:71            idx = int(lines[0].strip())72        except ValueError:73            continue74 75        ts_match = re.match(76            r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",77            lines[1].strip(),78        )79        if not ts_match:80            continue81 82        start = _ts_to_seconds(ts_match.group(1))83        end = _ts_to_seconds(ts_match.group(2))84        text = " ".join(l.strip() for l in lines[2:] if l.strip())85 86        if not text or end <= start:87            continue88 89        segments.append(SrtSegment(index=idx, start_seconds=start, end_seconds=end, text=text))90 91    return segments92 93 94def extract_segment_audio(opus_path: Path, start: float, duration: float) -> bytes | None:95    """Extract a segment from an opus file using ffmpeg stream copy.96 97    Returns the raw OGG/Opus bytes, or None on failure.98    """99    cmd = [100        "ffmpeg",101        "-v", "error",102        "-ss", f"{start:.3f}",103        "-i", str(opus_path),104        "-t", f"{duration:.3f}",105        "-c", "copy",106        "-f", "ogg",107        "pipe:1",108    ]109    try:110        result = subprocess.run(cmd, capture_output=True, timeout=30)111        if result.returncode != 0:112            return None113        if len(result.stdout) < 100:114            return None115        return result.stdout116    except (subprocess.TimeoutExpired, OSError):117        return None118 119 120def process_meeting(row: dict) -> list[dict]:121    """Process a single meeting: parse SRT, extract all audio segments."""122    video_id = row["id"]123    opus_path = REPO_ROOT / row["audio"]124    srt_path = REPO_ROOT / row["subtitles"]125 126    if not opus_path.exists():127        return []128 129    segments = parse_srt(srt_path)130    if not segments:131        return []132 133    results = []134    for seg in segments:135        duration = seg.end_seconds - seg.start_seconds136        audio_bytes = extract_segment_audio(opus_path, seg.start_seconds, duration)137        if audio_bytes is None:138            continue139 140        results.append({141            "video_id": video_id,142            "segment_id": seg.index,143            "audio": {"bytes": audio_bytes, "path": f"{video_id}_{seg.index:05d}.opus"},144            "text": seg.text,145            "start_time": round(seg.start_seconds, 3),146            "end_time": round(seg.end_seconds, 3),147            "duration": round(duration, 3),148        })149 150    return results151 152 153def write_shard(segments: list[dict], shard_idx: int, out_dir: Path) -> Path:154    """Write a list of segment dicts as a Parquet shard with Audio feature."""155    from datasets import Audio, Dataset, Features, Value156 157    features = Features({158        "video_id": Value("string"),159        "segment_id": Value("int32"),160        "audio": Audio(),161        "text": Value("string"),162        "start_time": Value("float64"),163        "end_time": Value("float64"),164        "duration": Value("float64"),165    })166 167    ds = Dataset.from_dict(168        {k: [s[k] for s in segments] for k in segments[0]},169        features=features,170    )171 172    path = out_dir / f"train-{shard_idx:05d}.parquet"173    ds.to_parquet(path)174    return path175 176 177def _flush_buffer(buffer: collections.deque, shard_size: int, shard_idx: int,178                   out_dir: Path, *, force: bool = False) -> int:179    """Write complete shards from buffer. Returns updated shard_idx."""180    while len(buffer) >= shard_size or (force and buffer):181        n = min(shard_size, len(buffer))182        batch = [buffer.popleft() for _ in range(n)]183        shard_path = write_shard(batch, shard_idx, out_dir)184        console.print(185            f"  Wrote shard {shard_idx} ({n} segments) -> {shard_path.name}"186        )187        del batch188        shard_idx += 1189    return shard_idx190 191 192def main() -> None:193    parser = argparse.ArgumentParser(description="Segment audio and build Parquet shards")194    parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS,195                        help="Number of parallel workers (default: %(default)s)")196    parser.add_argument("--shard-size", type=int, default=DEFAULT_SHARD_SIZE,197                        help="Segments per Parquet shard (default: %(default)s)")198    parser.add_argument("--out-dir", type=Path, default=REPO_ROOT / "segmented",199                        help="Output directory for Parquet shards")200    args = parser.parse_args()201 202    args.out_dir.mkdir(parents=True, exist_ok=True)203 204    src = REPO_ROOT / "metadata.csv"205    with open(src, encoding="utf-8", newline="") as f:206        reader = csv.DictReader(f)207        rows = list(reader)208 209    console.print(f"Processing {len(rows)} meetings with {args.workers} workers")210    console.print(f"Shard size: {args.shard_size} segments")211    console.print(f"Output: {args.out_dir}")212 213    buffer: collections.deque[dict] = collections.deque()214    shard_idx = 0215    total_segments = 0216    errors = 0217    meetings_done = 0218    max_in_flight = args.workers * 2219 220    progress = Progress(221        SpinnerColumn(),222        TextColumn("[progress.description]{task.description}"),223        BarColumn(),224        MofNCompleteColumn(),225        TimeElapsedColumn(),226        TimeRemainingColumn(),227        console=console,228    )229 230    with progress:231        task = progress.add_task("Meetings processed", total=len(rows))232        rows_iter = iter(rows)233 234        with ProcessPoolExecutor(max_workers=args.workers) as pool:235            active: dict = {}236 237            # Seed the pool with an initial batch of work238            for row in itertools.islice(rows_iter, max_in_flight):239                f = pool.submit(process_meeting, row)240                active[f] = row["id"]241 242            while active:243                done, _ = wait(active, return_when=FIRST_COMPLETED)244 245                for future in done:246                    video_id = active.pop(future)247                    try:248                        segments = future.result()249                        buffer.extend(segments)250                        total_segments += len(segments)251                        del segments252                    except Exception as e:253                        errors += 1254                        console.print(f"[red]Error processing {video_id}: {e}[/red]")255 256                    # Release the Future's internal result reference257                    future._result = None258 259                    meetings_done += 1260                    progress.advance(task)261 262                    # Submit next meeting to keep the pool fed263                    row = next(rows_iter, None)264                    if row is not None:265                        f = pool.submit(process_meeting, row)266                        active[f] = row["id"]267 268                # Flush complete shards after processing each batch of done futures269                shard_idx = _flush_buffer(270                    buffer, args.shard_size, shard_idx, args.out_dir271                )272 273    # Flush remaining segments274    shard_idx = _flush_buffer(275        buffer, args.shard_size, shard_idx, args.out_dir, force=True276    )277 278    console.print(f"\n[bold green]Done![/bold green]")279    console.print(f"  Total segments: {total_segments}")280    console.print(f"  Total shards:   {shard_idx}")281    console.print(f"  Errors:         {errors}")282 283 284if __name__ == "__main__":285    main()286 
laubonghaudoi/legco-speech · CoolFace