From b9dbed7f33a7950f8d059fdffd6cc5302296fb92 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 20 Jul 2026 19:29:32 +0300 Subject: [PATCH 1/3] Add ASR paired glob --- src/datacollective/schema.py | 9 +++ .../schema_loaders/tasks/asr.py | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/datacollective/schema.py b/src/datacollective/schema.py index 3df5685..fa1d892 100644 --- a/src/datacollective/schema.py +++ b/src/datacollective/schema.py @@ -125,6 +125,13 @@ class DatasetSchema(BaseModel): content_mapping: ContentMapping | None = Field( default=None, description="Mapping for glob-based content extraction" ) + record_path: str | None = Field( + default=None, + description=( + "for JSON sidecar files: top-level key holding the list of records " + '(e.g. "transcriptions"); one DataFrame row per record' + ), + ) # --- Multi-split strategy (e.g. Common Voice) --- splits: list[str] | None = Field( @@ -267,6 +274,7 @@ def _parse_schema(raw: str | dict[str, Any] | Path) -> DatasetSchema: "file_pattern", "audio_extension", "content_mapping", + "record_path", "splits", "splits_file_pattern", "sections", @@ -290,6 +298,7 @@ def _parse_schema(raw: str | dict[str, Any] | Path) -> DatasetSchema: file_pattern=data.get("file_pattern"), audio_extension=data.get("audio_extension"), content_mapping=content_mapping, + record_path=data.get("record_path"), splits=data.get("splits"), splits_file_pattern=data.get("splits_file_pattern"), sections=data.get("sections"), diff --git a/src/datacollective/schema_loaders/tasks/asr.py b/src/datacollective/schema_loaders/tasks/asr.py index 6eefb81..642977a 100644 --- a/src/datacollective/schema_loaders/tasks/asr.py +++ b/src/datacollective/schema_loaders/tasks/asr.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pandas as pd @@ -21,6 +22,16 @@ def __init__(self, schema: DatasetSchema, extract_dir: Path) -> None: raise ValueError( "ASR multi_split schema must specify 'splits' (list of split names)" ) + elif schema.root_strategy == Strategy.PAIRED_GLOB: + if (schema.format or "").casefold() != "json": + raise ValueError("ASR paired_glob schema only supports 'format: json'") + if not schema.file_pattern: + raise ValueError("ASR paired_glob schema must specify 'file_pattern'") + if not schema.columns: + raise ValueError( + "ASR paired_glob schema must specify at least two column mappings " + "for audio and transcription" + ) else: if not schema.index_file: raise ValueError("ASR schema must specify 'index_file'") @@ -32,9 +43,62 @@ def __init__(self, schema: DatasetSchema, extract_dir: Path) -> None: def load(self) -> pd.DataFrame: if self.schema.root_strategy == Strategy.MULTI_SPLIT: return self._load_multi_split() + if self.schema.root_strategy == Strategy.PAIRED_GLOB: + return self._load_paired_glob_json() raw_df = self._load_index_file() return self._apply_column_mappings(raw_df) + def _load_paired_glob_json(self) -> pd.DataFrame: + """ + Load an ASR dataset where each audio file is paired with a JSON sidecar + (matched via ``file_pattern``) instead of a central index file. + + When ``record_path`` is set, the JSON key it names must hold a list of + records (e.g. time-aligned utterances) and each record becomes one row; + the remaining top-level keys are flattened with dot notation + (audio.filename, ...) and repeated on + every row of that file. Without ``record_path`` each JSON file yields + a single row. Column mappings are then applied as for index files, so + ``file_path`` columns (typically sourced from a filename field inside + the JSON) resolve through the usual audio-path machinery. + """ + assert self.schema.file_pattern is not None + + json_files = sorted(self.extract_dir.rglob(self.schema.file_pattern)) + json_files = [p for p in json_files if not p.name.startswith("._")] + if not json_files: + raise FileNotFoundError( + f"No files matching '{self.schema.file_pattern}' " + f"found under '{self.extract_dir}'" + ) + + logger.debug( + f"Found {len(json_files)} JSON files matching '{self.schema.file_pattern}'" + ) + + record_path = self.schema.record_path + frames: list[pd.DataFrame] = [] + for path in json_files: + data = json.loads(path.read_text(encoding=self.schema.encoding)) + if record_path: + if record_path not in data: + raise KeyError( + f"record_path '{record_path}' not found in '{path}'. " + f"Available keys: {list(data)}" + ) + frame = pd.json_normalize(data, record_path=record_path) + meta = pd.json_normalize( + {key: value for key, value in data.items() if key != record_path} + ) + for column in meta.columns: + frame[column] = meta[column].iloc[0] + else: + frame = pd.json_normalize(data) + frames.append(frame) + + raw_df = pd.concat(frames, ignore_index=True) + return self._apply_column_mappings(raw_df) + def _load_multi_split(self) -> pd.DataFrame: """ Load all split TSV/CSV files whose stems match the ``splits`` list, From 1d88f61dacd5aafa77ecd041cd6023d186bf3a53 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 20 Jul 2026 19:29:42 +0300 Subject: [PATCH 2/3] Add test --- tests/schema_loaders/tasks/test_asr_loader.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/tests/schema_loaders/tasks/test_asr_loader.py b/tests/schema_loaders/tasks/test_asr_loader.py index 608f89b..efe350a 100644 --- a/tests/schema_loaders/tasks/test_asr_loader.py +++ b/tests/schema_loaders/tasks/test_asr_loader.py @@ -585,3 +585,139 @@ def test_multi_split_no_matching_files_raises(self, tmp_path: Path) -> None: ) with pytest.raises(RuntimeError, match="No split files"): ASRLoader(schema, tmp_path).load() + + +def _write_json_sidecar(path: Path, filename: str, n_utts: int = 2) -> None: + import json + + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "audio": {"filename": filename, "duration_sec": 12.5, "sample_rate_hz": 44100}, + "metadata": {"gender": "male", "id": "abc123"}, + "transcriptions": [ + { + "utt_id": f"{Path(filename).stem}_{i:04d}", + "speaker": f"SPEAKER{i % 2 + 1}", + "start_time": i * 2.0, + "end_time": i * 2.0 + 1.5, + "text": f"utterance {i}", + } + for i in range(1, n_utts + 1) + ], + } + path.write_text(json.dumps(data), encoding="utf-8") + + +def _paired_glob_json_schema(**overrides) -> DatasetSchema: + fields = { + "dataset_id": "ds", + "task": "ASR", + "root_strategy": "paired_glob", + "format": "json", + "file_pattern": "**/*.merged.json", + "record_path": "transcriptions", + "columns": { + "audio_path": ColumnMapping( + source_column="audio.filename", + dtype="file_path", + path_match_strategy="exact", + ), + "transcription": ColumnMapping(source_column="text"), + "speaker_id": ColumnMapping( + source_column="speaker", dtype="category", optional=True + ), + "start_time": ColumnMapping( + source_column="start_time", dtype="float", optional=True + ), + "gender": ColumnMapping( + source_column="metadata.gender", + dtype="category", + optional=True, + ), + }, + } + fields.update(overrides) + return DatasetSchema(**fields) + + +class TestASRPairedGlobJSONValidation: + def test_requires_json_format(self, tmp_path: Path) -> None: + schema = _paired_glob_json_schema(format="tsv") + with pytest.raises(ValueError, match="format: json"): + ASRLoader(schema, tmp_path) + + def test_requires_file_pattern(self, tmp_path: Path) -> None: + schema = _paired_glob_json_schema(file_pattern=None) + with pytest.raises(ValueError, match="file_pattern"): + ASRLoader(schema, tmp_path) + + def test_requires_columns(self, tmp_path: Path) -> None: + schema = _paired_glob_json_schema(columns={}) + with pytest.raises(ValueError, match="column mapping"): + ASRLoader(schema, tmp_path) + + +class TestASRPairedGlobJSON: + def test_one_row_per_record_with_flattened_meta(self, tmp_path: Path) -> None: + _write_json_sidecar(tmp_path / "rec1.merged.json", "rec1.wav", n_utts=3) + _write_json_sidecar(tmp_path / "rec2.merged.json", "rec2.wav", n_utts=2) + (tmp_path / "rec1.wav").touch() + (tmp_path / "rec2.wav").touch() + + df = ASRLoader(_paired_glob_json_schema(), tmp_path).load() + + assert len(df) == 5 + assert list(df.columns) == [ + "audio_path", + "transcription", + "speaker_id", + "start_time", + "gender", + ] + # Per-recording fields repeat on every utterance row + assert set(Path(p).name for p in df["audio_path"]) == {"rec1.wav", "rec2.wav"} + assert (df["gender"] == "male").all() + assert df["start_time"].dtype == "float64" + assert df["transcription"].iloc[0] == "utterance 1" + + def test_audio_resolved_via_exact_search(self, tmp_path: Path) -> None: + """Audio referenced by bare filename resolves even in nested layouts.""" + _write_json_sidecar(tmp_path / "inner" / "rec1.merged.json", "rec1.wav") + (tmp_path / "inner" / "rec1.wav").touch() + + df = ASRLoader(_paired_glob_json_schema(), tmp_path).load() + assert Path(df["audio_path"].iloc[0]).exists() + + def test_without_record_path_one_row_per_file(self, tmp_path: Path) -> None: + _write_json_sidecar(tmp_path / "rec1.merged.json", "rec1.wav") + (tmp_path / "rec1.wav").touch() + + schema = _paired_glob_json_schema( + record_path=None, + columns={ + "audio_path": ColumnMapping( + source_column="audio.filename", + dtype="file_path", + path_match_strategy="exact", + ), + "gender": ColumnMapping( + source_column="metadata.gender", dtype="category" + ), + }, + ) + df = ASRLoader(schema, tmp_path).load() + assert len(df) == 1 + + def test_missing_record_path_key_raises(self, tmp_path: Path) -> None: + import json + + (tmp_path / "rec1.merged.json").write_text( + json.dumps({"audio": {"filename": "rec1.wav"}}), encoding="utf-8" + ) + + with pytest.raises(KeyError, match="record_path 'transcriptions'"): + ASRLoader(_paired_glob_json_schema(), tmp_path).load() + + def test_no_matching_files_raises(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="No files matching"): + ASRLoader(_paired_glob_json_schema(), tmp_path).load() From c0c2e1c8f4cd2dbaa1ebf1ef27c05acfa1d950f8 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 21 Jul 2026 09:42:50 +0300 Subject: [PATCH 3/3] Update docs --- docs/loaders/asr.md | 94 +++++++++++++++++++++++++++++++++++- docs/schema_documentation.md | 20 +++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/loaders/asr.md b/docs/loaders/asr.md index 3a62217..64cefbc 100644 --- a/docs/loaders/asr.md +++ b/docs/loaders/asr.md @@ -2,7 +2,7 @@ Loader for **ASR** (Automatic Speech Recognition) datasets. -There are two parsing strategies for ASR datasets, controlled by the `root_strategy` field in the schema. +There are three parsing strategies for ASR datasets, controlled by the `root_strategy` field in the schema. ## Strategies @@ -32,6 +32,33 @@ Each split (e.g. `train`, `dev`, `test`) is stored in a separate file. The loade | `columns` | ✗ | *(optional)* Column mappings applied to every split frame. | | `base_audio_path` | ✗ | *(optional)* Directory prefix or list of directories used to resolve `file_path` dtype columns. | +### Paired-glob JSON strategy (`root_strategy: "paired_glob"`) + +For datasets with **no central index file**, where each audio file is paired +with a JSON sidecar (e.g. `recording.merged.json` + `recording.wav`). The +loader globs for the JSON files, flattens each one into rows, and applies the +regular column mappings. + +When `record_path` is set, the named top-level JSON key must hold a **list of +records** (e.g. time-aligned utterances) and each record becomes one DataFrame +row. The remaining top-level keys are flattened with dot notation +(`audio.filename`, `metadata.speaker2_gender`, …) and repeated on every row of +that file. Without `record_path`, each JSON file yields a single row. + +Audio pairing does not rely on filename-stem matching: source the `audio_path` +column from a filename field inside the JSON and resolve it with +`path_match_strategy: "exact"`. + +**Controlled by:** + +| Field | Required | Description | +|---|---|---| +| `format` | ✓ | Must be `"json"`. | +| `file_pattern` | ✓ | Glob pattern to find the JSON sidecars (e.g. `"**/*.merged.json"`). | +| `columns` | ✓ | Mapping of logical column names to (dot-notation) source columns and dtypes. | +| `record_path` | ✗ | *(optional)* Top-level JSON key holding the list of records; one row per record. | +| `audio_extension` | ✗ | *(optional)* Extension of the paired audio files (e.g. `".wav"`), documentation / fallback. | + --- ## Examples @@ -280,3 +307,68 @@ columns: optional: true ``` + +### Paired-glob JSON schema + +Each `*.merged.json` sidecar describes one WAV recording: an `audio` block +(with the exact WAV filename), a flat `metadata` block, and a `transcriptions` +array of time-aligned utterances. The schema below yields one row per +utterance, with the per-recording `audio.*` / `metadata.*` fields repeated on +every row: + +```yaml +dataset_id: "xxx" +task: "ASR" +root_strategy: "paired_glob" +format: "json" + +file_pattern: "**/*.merged.json" +audio_extension: ".wav" + +record_path: "transcriptions" + +columns: + audio_path: + source_column: "audio.filename" + dtype: "file_path" + path_match_strategy: "exact" + transcription: + source_column: "text" + dtype: "string" + utterance_id: + source_column: "utt_id" + dtype: "string" + optional: true + speaker_id: + source_column: "speaker" + dtype: "category" + optional: true + start_time: + source_column: "start_time" + dtype: "float" + optional: true + end_time: + source_column: "end_time" + dtype: "float" + optional: true + audio_duration_sec: + source_column: "audio.duration_sec" + dtype: "float" + optional: true + sample_rate_hz: + source_column: "audio.sample_rate_hz" + dtype: "int" + optional: true + gender: + source_column: "metadata.speaker2_gender" + dtype: "category" + optional: true + topic: + source_column: "metadata.user_topic" + dtype: "string" + optional: true + validation_id: + source_column: "metadata.val_id" + dtype: "string" + optional: true +``` diff --git a/docs/schema_documentation.md b/docs/schema_documentation.md index 0c4a4af..ac4d13c 100644 --- a/docs/schema_documentation.md +++ b/docs/schema_documentation.md @@ -67,7 +67,7 @@ strategy is inferred from the fields present in the schema: |---|---------------------------------------------------------------------|---| | **Index-based** (default) | A metadata file (CSV / TSV / pipe-delimited) lists each sample. | `index_file`, `columns` | | **Multi-split** | Multiple split files (train, dev, test, …) each containing samples. | `root_strategy: "multi_split"`, `splits` | -| **Paired-glob** | Each audio file has a matching `.txt` file, no index file at all. | `root_strategy: "paired_glob"`, `file_pattern`, `audio_extension` | +| **Paired-glob** | Each audio file has a matching sidecar file (`.txt` for TTS, JSON for ASR), no index file at all. | `root_strategy: "paired_glob"`, `file_pattern`, `audio_extension` (TTS) / `format: "json"`, `record_path`, `columns` (ASR) | | **Glob** | Directory-structured dataset with metadata encoded in the path hierarchy. | `root_strategy: "glob"`, `file_pattern` | ### Index-based fields @@ -94,12 +94,30 @@ strategy is inferred from the fields present in the schema: ### Paired-glob fields +**TTS (text sidecars)** — each audio file has a matching `.txt` file with the +transcription; pairing is done on the filename stem: + | Field | Default | Required | Description | |---|---|---|---| | `root_strategy` | — | ✓ | Must be `"paired_glob"`. | | `file_pattern` | — | ✓ | Glob pattern to find text files (e.g. `"**/*.txt"`). | | `audio_extension` | — | ✓ | Extension of the matching audio files (e.g. `".webm"`). | +**ASR (JSON sidecars)** — each audio file has a matching JSON file holding the +audio filename, metadata, and (optionally) a list of time-aligned utterance +records: + +| Field | Default | Required | Description | +|---|---|---|---| +| `root_strategy` | — | ✓ | Must be `"paired_glob"`. | +| `format` | — | ✓ | Must be `"json"`. | +| `file_pattern` | — | ✓ | Glob pattern to find the JSON sidecars (e.g. `"**/*.merged.json"`). | +| `columns` | — | ✓ | Column mappings over the flattened JSON; nested keys use dot notation (e.g. `audio.filename`, `metadata.speaker2_gender`). | +| `record_path` | — | ✗ | Top-level JSON key holding a list of records (e.g. `"transcriptions"`); each record becomes one row and the remaining top-level keys are repeated per row. When omitted, each JSON file yields one row. | +| `audio_extension` | — | ✗ | Extension of the paired audio files (e.g. `".wav"`). Pairing normally comes from a filename field inside the JSON, mapped as a `file_path` column with `path_match_strategy: "exact"`. | + +See [ASR loader](./loaders/asr.md) for a complete example. + ### Glob fields | Field | Default | Required | Description |