| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """ |
| Extract audio tokens from audio data and pack them into WebDataset shards. |
| |
| Supports two input modes: |
| |
| 1. WebDataset manifest (data.lst): |
| python extract_audio_tokens.py \ |
| --input_manifest data.lst \ |
| --tar_output_pattern output/audios/shard-%06d.tar \ |
| --jsonl_output_pattern output/txts/shard-%06d.jsonl |
| |
| 2. Raw JSONL (each line: {"id": "...", "audio_path": "...", "text": "...", ...}): |
| python extract_audio_tokens.py \ |
| --input_jsonl data.jsonl \ |
| --tar_output_pattern output/audios/shard-%06d.tar \ |
| --jsonl_output_pattern output/txts/shard-%06d.jsonl |
| |
| Output structure: |
| output_dir/ |
| ├── audios/ # WebDataset tar shards (.npy audio tokens + .json metadata) |
| │ ├── shard_000000.tar |
| │ └── ... |
| ├── txts/ # Per-shard JSONL metadata |
| │ ├── shard_000000.jsonl |
| │ └── ... |
| ├── data.lst # Manifest: <tar_path> <jsonl_path> <sample_count> <total_duration> |
| └── errors.jsonl # Failed samples with error details |
| """ |
|
|
| import argparse |
| import io |
| import json |
| import logging |
| import multiprocessing as mp |
| import os |
| import warnings |
| from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import torch |
| import webdataset as wds |
| from torch.utils.data import DataLoader, IterableDataset |
| from tqdm.auto import tqdm |
| from transformers import AutoFeatureExtractor, HiggsAudioV2TokenizerModel |
|
|
| from omnivoice.data.dataset import JsonlDatasetReader, WebDatasetReader |
| from omnivoice.utils.common import str2bool |
|
|
| warnings.filterwarnings( |
| "ignore", category=FutureWarning, module="torch.nn.utils.weight_norm" |
| ) |
|
|
| HIGGS_INPUT_SAMPLE_RATE = 24_000 |
|
|
|
|
| |
| worker_tokenizer = None |
| worker_feature_extractor = None |
|
|
|
|
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "--input_manifest", |
| default=None, |
| help="Path to input dataset manifest (data.lst).", |
| ) |
| parser.add_argument( |
| "--input_jsonl", |
| default=None, |
| help="Path to raw JSONL file (alternative to --input_manifest).", |
| ) |
| parser.add_argument( |
| "--tar_output_pattern", |
| required=True, |
| help="Tar shard pattern passed to WebDataset", |
| ) |
| parser.add_argument( |
| "--jsonl_output_pattern", |
| required=True, |
| help="Jsonl shard pattern passed to WebDataset", |
| ) |
| parser.add_argument( |
| "--samples_per_shard", |
| type=int, |
| default=1000, |
| help="Maximum records per shard", |
| ) |
| parser.add_argument( |
| "--min_num_shards", |
| type=int, |
| default=32, |
| help="Minimum number of output shards (use to ensure " |
| "shard count >= num_gpu * num_workers)", |
| ) |
| parser.add_argument( |
| "--tokenizer_path", |
| type=str, |
| default="eustlb/higgs-audio-v2-tokenizer", |
| help="Path to audio tokenizer.", |
| ) |
| parser.add_argument( |
| "--skip_errors", action="store_true", help="Skip items that fail to process" |
| ) |
| parser.add_argument( |
| "--min_length", |
| type=float, |
| default=0.0, |
| help="Minimum audio duration in seconds (e.g. 2.0)", |
| ) |
| parser.add_argument( |
| "--max_length", |
| type=float, |
| default=float("inf"), |
| help="Maximum audio duration in seconds (e.g. 15.0)", |
| ) |
| parser.add_argument( |
| "--num_machines", |
| type=int, |
| default=1, |
| help="Total number of machines for distributed runs", |
| ) |
| parser.add_argument( |
| "--machine_index", |
| type=int, |
| default=0, |
| help="Zero-based machine index when distributing across multiple " |
| "machines (e.g. 0, 1, ... num_machines-1)", |
| ) |
| parser.add_argument( |
| "--nj_per_gpu", |
| type=int, |
| default=3, |
| help="Number of worker processes to spawn per GPU.", |
| ) |
| parser.add_argument( |
| "--loader_workers", |
| type=int, |
| default=24, |
| help="Number of DataLoader workers for streaming IterableDataset.", |
| ) |
| parser.add_argument( |
| "--shuffle", |
| type=str2bool, |
| default=True, |
| help="Shuffle data by default.", |
| ) |
| parser.add_argument( |
| "--shuffle-seed", |
| type=int, |
| default=42, |
| help="Random seed for shuffle (default: 42).", |
| ) |
| return parser |
|
|
|
|
| def count_lines(path): |
| with open(path, "rb") as f: |
| return sum(buf.count(b"\n") for buf in iter(lambda: f.read(1 << 20), b"")) |
|
|
|
|
| def serialise_numpy(key: str, tokens: np.ndarray) -> dict: |
| buffer = io.BytesIO() |
| np.save(buffer, tokens) |
| return {"__key__": key, "npy": buffer.getvalue()} |
|
|
|
|
| def process_init(rank_queue, tokenizer_path): |
| """ |
| Initialization function for each worker process. |
| Assigns a specific GPU to the process and loads the tokenizer. |
| """ |
| global worker_tokenizer, worker_feature_extractor |
|
|
| |
| formatter = ( |
| "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d]" |
| " [Worker %(process)d] %(message)s" |
| ) |
| logging.basicConfig(format=formatter, level=logging.INFO, force=True) |
|
|
| |
| rank = rank_queue.get() |
| |
| if rank != -1 and torch.cuda.is_available(): |
| worker_device = torch.device(f"cuda:{rank}") |
| else: |
| worker_device = torch.device("cpu") |
|
|
| logging.debug(f"Worker process initialized with device: {worker_device}") |
| |
| worker_feature_extractor = AutoFeatureExtractor.from_pretrained(tokenizer_path) |
| worker_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained( |
| tokenizer_path, device_map=worker_device |
| ) |
| logging.debug(f"Tokenizer loaded successfully on device {worker_device}") |
|
|
|
|
| def process_single_sample(sample: dict[str, Any]) -> dict[str, Any]: |
| """ |
| Single-sample processing function executed in worker processes. |
| Skips invalid samples during streaming processing. |
| """ |
| try: |
| audio_tensor = sample.get("audio", None) |
| if audio_tensor is None: |
| raise ValueError("Sample missing 'audio' field") |
|
|
| with torch.inference_mode(): |
| key = sample["label"]["id"] |
| inputs = worker_feature_extractor( |
| raw_audio=audio_tensor.squeeze(0).numpy(), |
| sampling_rate=HIGGS_INPUT_SAMPLE_RATE, |
| return_tensors="pt", |
| ).to(worker_tokenizer.device) |
| audio_tokens = worker_tokenizer.encode( |
| inputs["input_values"], |
| ).audio_codes.squeeze(0) |
|
|
| assert len(audio_tokens.shape) == 2 |
| assert audio_tokens.size(0) == 8 |
|
|
| num_tokens = audio_tokens.size(1) |
| metadata = sample["label"] |
| metadata["num_tokens"] = num_tokens |
|
|
| |
| audio_tokens_np = audio_tokens.to(torch.int16).cpu().numpy() |
|
|
| return { |
| "status": "success", |
| "key": key, |
| "audio_tokens": audio_tokens_np, |
| "metadata": metadata, |
| "error_msg": None, |
| } |
| except Exception as e: |
| sample_id = sample.get("label", {}).get("id", "unknown") |
| logging.error(f"Failed to process sample {sample_id}: {e}") |
| return { |
| "status": "error", |
| "key": sample_id, |
| "audio_tokens": None, |
| "metadata": None, |
| "error_msg": str(e), |
| } |
|
|
|
|
| def _normalise_value(value: Any) -> Any: |
| """Convert tensors and NumPy scalars to serialisable Python objects.""" |
| if isinstance(value, torch.Tensor): |
| if value.ndim == 0: |
| return value.item() |
| return value.cpu().tolist() |
| if isinstance(value, np.generic): |
| return value.item() |
| if isinstance(value, np.ndarray): |
| return value.tolist() |
| return value |
|
|
|
|
| def _encode_metadata(metadata: dict[str, Any]) -> bytes: |
| cleaned: dict[str, Any] = {} |
| for key, value in metadata.items(): |
| if value is None: |
| continue |
| cleaned[key] = _normalise_value(value) |
| return json.dumps(cleaned, ensure_ascii=False).encode("utf-8") |
|
|
|
|
| class StreamingLengthFilteredDataset(IterableDataset): |
| def __init__( |
| self, |
| base_iterable, |
| min_len: float, |
| max_len: float, |
| sr: int, |
| ): |
| self.base_iterable = base_iterable |
| self.min_len = min_len |
| self.max_len = max_len |
| self.sr = sr |
| self.filtered_count = 0 |
|
|
| def __iter__(self): |
| """Stream samples one by one and filter on the fly.""" |
| for sample in self.base_iterable: |
| try: |
| duration = sample["audio"].size(-1) / self.sr |
| if self.min_len <= duration <= self.max_len: |
| yield sample |
| else: |
| self.filtered_count += 1 |
| logging.warning( |
| f"Filtered sample (duration out of range): " |
| f"{sample['label']['id']} ({duration:.2f}s)" |
| ) |
| except Exception as e: |
| logging.warning(f"Skipped invalid sample during streaming: {e}") |
| continue |
|
|
|
|
| def main() -> None: |
| formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s" |
| logging.basicConfig(format=formatter, level=logging.INFO, force=True) |
| parser = build_parser() |
| args = parser.parse_args() |
| mp.set_start_method("spawn", force=True) |
|
|
| |
| assert bool(args.input_manifest) != bool( |
| args.input_jsonl |
| ), "Exactly one of --input_manifest or --input_jsonl must be provided." |
|
|
| if args.num_machines > 1: |
| assert ( |
| 0 <= args.machine_index < args.num_machines |
| ), f"machine_index {args.machine_index} must be in [0, {args.num_machines})" |
|
|
| |
| if args.input_jsonl: |
| logging.info(f"Input mode: raw JSONL ({args.input_jsonl})") |
| total_samples = count_lines(args.input_jsonl) |
| base_dataset = JsonlDatasetReader( |
| args.input_jsonl, |
| sample_rate=HIGGS_INPUT_SAMPLE_RATE, |
| shuffle=args.shuffle, |
| shuffle_seed=args.shuffle_seed, |
| ) |
| loader_workers = args.loader_workers |
| else: |
| logging.info(f"Input mode: WebDataset manifest ({args.input_manifest})") |
| manifest_num_lines = count_lines(args.input_manifest) |
| loader_workers = min(args.loader_workers, manifest_num_lines) |
| total_samples = 0 |
| manifests = [] |
| with open(args.input_manifest, "r", encoding="utf-8") as f: |
| for line_id, line in tqdm( |
| enumerate(f), |
| total=manifest_num_lines, |
| desc="Calculating dataset length", |
| ): |
| items = line.strip().split(" ") |
| tar_path, jsonl_path, num_items, duration = ( |
| items[0], |
| items[1], |
| int(items[2]), |
| float(items[3]), |
| ) |
| assert os.path.exists(tar_path), f"File {tar_path} does not exist." |
| assert os.path.exists(jsonl_path), f"File {jsonl_path} does not exist." |
| assert jsonl_path.endswith( |
| ".jsonl" |
| ), f"File {jsonl_path} is not a .jsonl file." |
| if ( |
| args.num_machines > 1 |
| and line_id % args.num_machines != args.machine_index |
| ): |
| continue |
| total_samples += num_items |
| manifests.append((tar_path, jsonl_path, num_items, duration)) |
| logging.info( |
| f"Total shards: {manifest_num_lines}, " |
| f"Shards for current index: {len(manifests)}" |
| ) |
| base_dataset = WebDatasetReader( |
| manifests=manifests, |
| sample_rate=HIGGS_INPUT_SAMPLE_RATE, |
| evaluation=True, |
| ) |
|
|
| |
| samples_per_shard = args.samples_per_shard |
| if total_samples > 0: |
| estimated_shards = max( |
| 1, (total_samples + samples_per_shard - 1) // samples_per_shard |
| ) |
| if estimated_shards < args.min_num_shards: |
| samples_per_shard = max(1, total_samples // args.min_num_shards) |
| logging.info( |
| f"Adjusted samples_per_shard from {args.samples_per_shard} to " |
| f"{samples_per_shard} to meet min_num_shards={args.min_num_shards} " |
| f"(total_samples={total_samples})" |
| ) |
|
|
| |
| filtered_dataset = StreamingLengthFilteredDataset( |
| base_iterable=base_dataset, |
| min_len=args.min_length, |
| max_len=args.max_length, |
| sr=HIGGS_INPUT_SAMPLE_RATE, |
| ) |
| dataloader = DataLoader( |
| dataset=filtered_dataset, |
| batch_size=None, |
| num_workers=loader_workers, |
| persistent_workers=loader_workers > 0, |
| pin_memory=False, |
| ) |
|
|
| |
| num_devices = torch.cuda.device_count() |
| if num_devices == 0: |
| logging.warning("No GPUs detected - using CPU for processing") |
| num_processes = args.nj_per_gpu |
| else: |
| num_processes = num_devices * args.nj_per_gpu |
| logging.info( |
| f"GPU count: {num_devices}, Processes per GPU: {args.nj_per_gpu}, " |
| f"Total processes: {num_processes}" |
| ) |
|
|
| |
| manager = mp.Manager() |
| rank_queue = manager.Queue() |
| for rank in list(range(num_devices)) * args.nj_per_gpu: |
| rank_queue.put(rank) |
| if num_devices == 0: |
| for _ in range(num_processes): |
| rank_queue.put(-1) |
|
|
| |
| tar_output_pattern = str(Path(args.tar_output_pattern).expanduser()) |
| jsonl_output_pattern = str(Path(args.jsonl_output_pattern).expanduser()) |
| Path(tar_output_pattern).parent.mkdir(parents=True, exist_ok=True) |
| Path(jsonl_output_pattern).parent.mkdir(parents=True, exist_ok=True) |
|
|
| |
| output_dir = Path(tar_output_pattern).parent.parent |
| error_log_path = str(output_dir / "errors.jsonl") |
| manifest_path = str(output_dir / "data.lst") |
|
|
| |
| error_logger = logging.getLogger("error_log") |
| error_logger.setLevel(logging.ERROR) |
| error_logger.handlers.clear() |
| error_fh = logging.FileHandler(error_log_path, mode="w", encoding="utf-8") |
| error_fh.setFormatter(logging.Formatter("%(message)s")) |
| error_logger.addHandler(error_fh) |
|
|
| |
| processed_count = 0 |
| error_count = 0 |
| write_error_count = 0 |
| failed_ids = [] |
| shard_idx = 0 |
| shard_sample_count = 0 |
| shard_duration = 0.0 |
| shard_manifest = {} |
|
|
| tar_writer = None |
| jsonl_file = None |
|
|
| def open_new_shard(): |
| nonlocal tar_writer, jsonl_file, shard_idx, shard_sample_count, shard_duration |
| if tar_writer is not None: |
| tar_writer.close() |
| if jsonl_file is not None: |
| jsonl_file.close() |
| |
| if shard_idx > 0 and shard_sample_count > 0: |
| prev_idx = shard_idx - 1 |
| shard_manifest[prev_idx] = ( |
| os.path.abspath(tar_output_pattern % prev_idx), |
| os.path.abspath(jsonl_output_pattern % prev_idx), |
| shard_sample_count, |
| shard_duration, |
| ) |
| tar_fname = tar_output_pattern % shard_idx |
| jsonl_fname = jsonl_output_pattern % shard_idx |
| tar_writer = wds.TarWriter(tar_fname) |
| jsonl_file = open(jsonl_fname, "w", encoding="utf-8") |
| shard_idx += 1 |
| shard_sample_count = 0 |
| shard_duration = 0.0 |
|
|
| def write_sample(key, audio_tokens_np, metadata): |
| nonlocal shard_sample_count, write_error_count, shard_duration |
| assert tar_writer is not None and jsonl_file is not None |
| try: |
| token_record = serialise_numpy(key, audio_tokens_np) |
| json_record = _encode_metadata(metadata) |
| tar_writer.write(token_record) |
| jsonl_file.write(json_record.decode("utf-8") + "\n") |
| shard_sample_count += 1 |
| shard_duration += metadata.get("audio_duration", 0.0) |
| except Exception as exc: |
| write_error_count += 1 |
| failed_ids.append(key) |
| error_logger.error( |
| json.dumps({"id": key, "reason": str(exc)}, ensure_ascii=False) |
| ) |
| logging.error(f"Write failed for sample {key}: {exc}") |
|
|
| def handle_result(result): |
| nonlocal processed_count, error_count |
| if result["status"] == "success": |
| |
| if tar_writer is None or shard_sample_count >= samples_per_shard: |
| open_new_shard() |
| write_sample(result["key"], result["audio_tokens"], result["metadata"]) |
| processed_count += 1 |
| else: |
| error_count += 1 |
| failed_ids.append(result["key"]) |
| error_logger.error( |
| json.dumps( |
| {"id": result["key"], "reason": result["error_msg"]}, |
| ensure_ascii=False, |
| ) |
| ) |
| if not args.skip_errors: |
| raise RuntimeError( |
| f"Sample {result['key']} processing failed due " |
| f"to {result['error_msg']} - terminating" |
| ) |
| logging.warning( |
| f"Skipping failed sample {result['key']}: {result['error_msg']}" |
| ) |
|
|
| main_progress = tqdm(total=total_samples, desc="Extracting Audio Tokens") |
|
|
| try: |
| with ProcessPoolExecutor( |
| max_workers=num_processes, |
| initializer=process_init, |
| initargs=(rank_queue, args.tokenizer_path), |
| ) as executor: |
| logging.info(f"Submitting tasks... ({num_processes} workers)") |
| futures = set() |
| max_pending = num_processes * 10 |
|
|
| def drain_completed(): |
| """Wait for at least one future to complete, process all done.""" |
| nonlocal futures |
| done, _ = wait(futures, return_when=FIRST_COMPLETED) |
| for f in done: |
| futures.discard(f) |
| result = f.result() |
| main_progress.update(1) |
| handle_result(result) |
| main_progress.set_postfix( |
| Samples=processed_count, |
| Errors=error_count, |
| ) |
|
|
| |
| for sample in dataloader: |
| if len(futures) >= max_pending: |
| drain_completed() |
|
|
| future = executor.submit(process_single_sample, sample) |
| futures.add(future) |
|
|
| |
| logging.info("Processing remaining pending samples...") |
| while futures: |
| drain_completed() |
|
|
| except Exception: |
| logging.error("Critical error during processing", exc_info=True) |
| raise |
| finally: |
| main_progress.close() |
| if tar_writer is not None: |
| tar_writer.close() |
| if jsonl_file is not None: |
| jsonl_file.close() |
| |
| if shard_idx > 0 and shard_sample_count > 0: |
| last_idx = shard_idx - 1 |
| shard_manifest[last_idx] = ( |
| os.path.abspath(tar_output_pattern % last_idx), |
| os.path.abspath(jsonl_output_pattern % last_idx), |
| shard_sample_count, |
| shard_duration, |
| ) |
|
|
| |
| with open(manifest_path, "w", encoding="utf-8") as mf: |
| for idx in sorted(shard_manifest.keys()): |
| tar_path, jsonl_path, count, duration = shard_manifest[idx] |
| mf.write(f"{tar_path} {jsonl_path} {count} {duration:.3f}\n") |
|
|
| |
| total_failed = error_count + write_error_count |
| filtered_and_skipped = total_samples - processed_count - total_failed |
| logging.info( |
| f"Processing Complete - Successful: {processed_count}, Failed: {total_failed}, " |
| f"Filtered/Skipped: {filtered_and_skipped}, Shards written: {shard_idx}" |
| ) |
| logging.info(f"Manifest written to: {manifest_path} ({len(shard_manifest)} shards)") |
| if total_failed > 0: |
| logging.info(f"Error details: {error_log_path}") |
| if failed_ids and args.skip_errors: |
| logging.warning( |
| f"Failed sample IDs (count: {len(failed_ids)}): {failed_ids[:100]}..." |
| ) |
| if write_error_count > 0 and not args.skip_errors: |
| raise RuntimeError( |
| f"{write_error_count} samples failed to write - check logs for details" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|