voxkit.storage.datasets
Specialized CRUD operations for managing datasets within the VoxKit storage system.
Directory Structure
Each dataset follows a hierarchical structure:
my_dataset/
├── voxkit_dataset.json # Dataset metadata
├── alignments/ # Alignment outputs storage
└── cache/ # Optional cached copy of dataset
├── speaker_001/
│ ├── audio_001.wav
│ ├── audio_001.lab
│ └── ...
└── speaker_002/
└── ...
API
- create_dataset: Create a new dataset with metadata and directories
- get_dataset_metadata: Retrieve metadata for a specific dataset
- list_datasets_metadata: List all existing datasets
- update_dataset_metadata: Update metadata fields for a specific dataset
- delete_dataset: Delete a registered dataset and its metadata
- export_dataset: Export a dataset to a specified output path
- import_dataset: Import an existing dataset into VoxKit storage
- validate_dataset: Validate dataset structure and organization
Notes
- All dataset IDs are unique timestamps with microsecond precision
- Failed operations automatically clean up partial changes
- Dataset validation occurs before creation to prevent invalid data
- Cached datasets are copied for faster access during operations
- The
transcribedflag indicates presence of transcription files - Importing datasets adjusts metadata and validates structure
1"""Specialized CRUD operations for managing datasets within the VoxKit storage system. 2 3Directory Structure 4------------------- 5Each dataset follows a hierarchical structure: 6 7 my_dataset/ 8 ├── voxkit_dataset.json # Dataset metadata 9 ├── alignments/ # Alignment outputs storage 10 └── cache/ # Optional cached copy of dataset 11 ├── speaker_001/ 12 │ ├── audio_001.wav 13 │ ├── audio_001.lab 14 │ └── ... 15 └── speaker_002/ 16 └── ... 17 18API 19--- 20- **create_dataset**: Create a new dataset with metadata and directories 21- **get_dataset_metadata**: Retrieve metadata for a specific dataset 22- **list_datasets_metadata**: List all existing datasets 23- **update_dataset_metadata**: Update metadata fields for a specific dataset 24- **delete_dataset**: Delete a registered dataset and its metadata 25- **export_dataset**: Export a dataset to a specified output path 26- **import_dataset**: Import an existing dataset into VoxKit storage 27- **validate_dataset**: Validate dataset structure and organization 28 29Notes 30----- 31- All dataset IDs are unique timestamps with microsecond precision 32- Failed operations automatically clean up partial changes 33- Dataset validation occurs before creation to prevent invalid data 34- Cached datasets are copied for faster access during operations 35- The `transcribed` flag indicates presence of transcription files 36- Importing datasets adjusts metadata and validates structure 37""" 38 39import csv 40import json 41import logging 42import os 43import shutil 44from pathlib import Path 45from typing import Any, List, Literal, Tuple, TypedDict 46 47from voxkit.storage.constants import ALIGNMENTS_ROOT, DATASETS_ROOT, SUPERSET_AUDIO_EXTENSIONS 48from voxkit.storage.utils import generate_unique_id, get_storage_root, readable_from_unique_id 49 50logger = logging.getLogger(__name__) 51 52 53class DatasetMetadata(TypedDict): 54 """Dataset metadata structure. 55 56 Attributes: 57 name: Human-readable name of the dataset. 58 id: Unique identifier (timestamp with microsecond precision). 59 description: Description of the dataset contents and purpose. 60 original_path: Original file system path to the dataset. 61 cached: Whether the dataset is cached in VoxKit storage. 62 anonymize: Whether speaker identities should be anonymized. 63 transcribed: Whether the dataset includes transcription files. 64 registration_date: Human-readable registration timestamp. 65 hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids. 66 """ 67 68 name: str 69 id: str 70 description: str 71 original_path: str 72 cached: bool 73 anonymize: bool 74 transcribed: bool 75 registration_date: str 76 hand_alignments_path: str | None 77 78 79def _get_datasets_root() -> Path: 80 """Get the root directory for datasets storage. 81 82 Returns: 83 Path to datasets storage root directory 84 """ 85 root = get_storage_root() / DATASETS_ROOT 86 root.mkdir(parents=False, exist_ok=True) 87 return root 88 89 90def _get_dataset_root(dataset_id: str) -> Path | None: 91 """Get the root directory for a specific dataset by ID. 92 93 Args: 94 dataset_id: Identifier of the dataset 95 96 Returns: 97 Path to dataset root directory or None if not found 98 """ 99 datasets_root = _get_datasets_root() 100 if datasets_root and dataset_id: 101 dataset_root = datasets_root / dataset_id 102 if dataset_root.exists(): 103 return dataset_root 104 return None 105 106 107def get_dataset_data_path(meta: DatasetMetadata) -> Path | None: 108 """Return the directory containing the dataset's speaker subdirs. 109 110 For cached datasets this is ``<dataset_root>/cache``; for non-cached 111 datasets it is the original on-disk path recorded in metadata. 112 """ 113 if meta.get("cached"): 114 root = _get_dataset_root(meta["id"]) 115 if root is None: 116 return None 117 return root / "cache" 118 return Path(meta["original_path"]) 119 120 121def _get_dataset_metadata(dataset_root: Path) -> DatasetMetadata | None: 122 """Load dataset metadata from the given dataset root directory. 123 124 Args: 125 dataset_root: Path to the dataset root directory 126 127 Returns: 128 Dataset metadata dictionary or None if not found or invalid 129 """ 130 try: 131 metadata_path = dataset_root / "voxkit_dataset.json" 132 if not metadata_path.exists(): 133 return None 134 with open(metadata_path, "r", encoding="utf-8") as f: 135 result: DatasetMetadata = json.load(f) 136 return result 137 except Exception: 138 return None 139 140 141def create_dataset( 142 name: str, 143 description: str, 144 original_path: str, 145 cached: bool, 146 anonymize: bool, 147 transcribed: bool = False, 148 analysis_data: list[dict[str, Any]] | None = None, 149 analysis_method: str | None = None, 150 hand_alignments_path: str | None = None, 151) -> tuple[Literal[True], DatasetMetadata] | tuple[Literal[False], str]: 152 """Create a dataset metadata dictionary and create necessary directories. 153 154 Validates the dataset structure, creates a unique ID, sets up the directory 155 hierarchy (dataset root and alignments subdirectory), writes metadata to JSON, 156 optionally caches the dataset, and optionally saves analysis results to CSV. 157 158 Args: 159 name: Name of the dataset 160 description: Description of the dataset 161 original_path: Original path to the dataset 162 cached: Whether to copy the dataset into VoxKit storage 163 anonymize: Whether the dataset should be anonymized 164 transcribed: Whether the dataset includes transcription files 165 analysis_data: Optional list of analysis result dictionaries to save as CSV 166 analysis_method: Optional name of the analysis method (used for CSV filename) 167 hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids 168 169 Returns: 170 Tuple of (True, DatasetMetadata) on success or (False, error_message) on failure 171 172 Raises: 173 FileExistsError: If a dataset with the generated ID already exists 174 Exception: If directory creation, metadata writing, or caching fails 175 176 Notes: 177 - Automatically validates dataset structure before creation 178 - Cleans up partially created directories on failure 179 - Cached datasets are copied with shutil.copytree 180 - If analysis_data is provided, saves to {analysis_method}_summary.csv 181 """ 182 # Validate dataset structure 183 valid, msg = validate_dataset(Path(original_path), transcribed=transcribed) 184 if not valid: 185 return False, msg 186 187 now = generate_unique_id() 188 189 try: 190 humannow = readable_from_unique_id(now) 191 metadata = DatasetMetadata( 192 name=name, 193 id=now, 194 description=description, 195 original_path=str(original_path), 196 cached=cached, 197 anonymize=anonymize, 198 transcribed=transcribed, 199 registration_date=humannow, 200 hand_alignments_path=hand_alignments_path, 201 ) 202 203 # Create dataset directory 204 dataset_dir = _get_datasets_root() / metadata["id"] 205 if dataset_dir.exists(): 206 raise FileExistsError(f"Dataset with ID '{metadata['id']}' already exists.") 207 dataset_dir.mkdir(parents=False, exist_ok=False) 208 209 # Create dataset/alignments directory 210 alignments_dir = dataset_dir / ALIGNMENTS_ROOT 211 alignments_dir.mkdir(parents=False, exist_ok=False) 212 metadata_path = dataset_dir / "voxkit_dataset.json" 213 with open(metadata_path, "w", encoding="utf-8") as f: 214 json.dump(metadata, f, indent=2) 215 216 # Cache the dataset if requested 217 if cached: 218 cache_dir = dataset_dir / "cache" 219 cache_dir.mkdir(parents=False, exist_ok=False) 220 shutil.copytree(original_path, cache_dir, dirs_exist_ok=True) 221 222 # Save analysis results if provided 223 if analysis_data is not None and analysis_method is not None: 224 csv_path = dataset_dir / f"{analysis_method.lower()}_summary.csv" 225 _save_analysis_csv(analysis_data, csv_path) 226 227 # Register the hand-annotated alignment entry if a path was provided 228 if hand_alignments_path: 229 from voxkit.storage.alignments import create_hand_alignment 230 231 ok, result = create_hand_alignment(metadata["id"], tg_path=hand_alignments_path) 232 if not ok: 233 raise RuntimeError(f"Failed to register hand alignment: {result}") 234 235 return True, metadata 236 237 except Exception as e: 238 # Clean up on failure 239 dataset_dir = _get_datasets_root() / now 240 if dataset_dir.exists(): 241 shutil.rmtree(dataset_dir, ignore_errors=False) 242 243 logger.exception("Error during dataset creation") 244 return False, f"Failed to create dataset metadata: {str(e)}" 245 246 247def _save_analysis_csv(data: list[dict[str, Any]], path: Path) -> None: 248 """Save analysis data to a CSV file. 249 250 Args: 251 data: List of dictionaries where each dictionary represents a row 252 path: Output path for the CSV file 253 254 Raises: 255 ValueError: If data is empty 256 """ 257 if not data: 258 raise ValueError("No data to write to CSV.") 259 260 fieldnames = data[0].keys() 261 with open(path, "w", newline="", encoding="utf-8") as csvfile: 262 writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 263 writer.writeheader() 264 for row in data: 265 writer.writerow(row) 266 267 268def get_dataset_metadata(dataset_id: str) -> DatasetMetadata | None: 269 """Get the metadata for a specific dataset. 270 271 Retrieves the dataset metadata from the voxkit_dataset.json file in the 272 dataset's directory. 273 274 Args: 275 dataset_id: ID of the dataset to retrieve 276 277 Returns: 278 Dataset metadata dictionary or None if not found 279 280 Raises: 281 Exception: If metadata file exists but cannot be read or parsed 282 """ 283 try: 284 dataset_dir = _get_datasets_root() / dataset_id 285 metadata = _get_dataset_metadata(dataset_dir) 286 if metadata is None: 287 raise FileNotFoundError(f"Metadata for dataset '{dataset_id}' not found.") 288 return metadata 289 290 except Exception: 291 logger.exception("Error retrieving dataset metadata") 292 return None 293 294 295def list_datasets_metadata() -> List[DatasetMetadata]: 296 """List all existing datasets. 297 298 Scans the datasets root directory and collects metadata from all subdirectories 299 containing valid voxkit_dataset.json files. 300 301 Returns: 302 List of dataset metadata dictionaries (empty list if none found) 303 304 Notes: 305 - Silently skips directories without metadata files 306 - Returns empty list on error 307 - Does not guarantee ordering 308 """ 309 datasets = [] 310 datasets_root = _get_datasets_root() 311 312 try: 313 for entry in os.scandir(datasets_root): 314 if entry.is_dir(): 315 metadata_path = os.path.join(entry.path, "voxkit_dataset.json") 316 if os.path.exists(metadata_path): 317 with open(metadata_path, "r", encoding="utf-8") as f: 318 metadata = json.load(f) 319 datasets.append(metadata) 320 return datasets 321 322 except Exception: 323 logger.exception("Error listing datasets") 324 return [] 325 326 327def update_dataset_metadata( 328 dataset_id: str, 329 updates: dict, 330) -> Tuple[bool, str]: 331 """Update the metadata for a specific dataset. 332 333 Updates specific fields in the dataset metadata file. Only updates fields that 334 are present in the updates dictionary and not None. Supported fields: 335 description, cached, anonymize, transcribed. 336 337 Args: 338 dataset_id: ID of the dataset to update 339 updates: Dictionary of metadata fields to update (only non-None values are applied) 340 341 Returns: 342 Tuple of (True, success_message) on success or (False, error_message) on failure 343 344 Raises: 345 FileNotFoundError: If the dataset is not found 346 Exception: If metadata file cannot be written 347 """ 348 try: 349 metadata = get_dataset_metadata(dataset_id) 350 351 if not metadata: 352 return False, f"Dataset {dataset_id} not found" 353 354 for field in ("description", "cached", "anonymize", "transcribed"): 355 if field in updates and updates[field] is not None: 356 metadata[field] = updates[field] 357 358 # Save the updated metadata 359 metadata_path = _get_datasets_root() / dataset_id / "voxkit_dataset.json" 360 with open(metadata_path, "w", encoding="utf-8") as f: 361 json.dump(metadata, f, indent=2) 362 363 return True, "Dataset metadata updated successfully" 364 365 except KeyError as e: 366 return False, f"Invalid metadata key: {str(e)}" 367 except FileNotFoundError as e: 368 return False, str(e) 369 except Exception as e: 370 return False, f"Failed to update dataset metadata: {str(e)}" 371 372 373def delete_dataset(dataset_id: str) -> Tuple[bool, str]: 374 """Delete a registered dataset. 375 376 Permanently removes the dataset directory and all its contents, including 377 metadata, alignments, and cached data. 378 379 Args: 380 dataset_id: ID of the dataset to delete 381 382 Returns: 383 Tuple of (True, success_message) on success or (False, error_message) on failure 384 385 Raises: 386 Exception: If the directory cannot be removed 387 388 Notes: 389 - This operation is irreversible 390 - Removes the entire dataset directory tree 391 - Validates that dataset_id is not empty before proceeding 392 """ 393 if not dataset_id: 394 return False, "Dataset ID cannot be empty." 395 396 dataset_path = _get_datasets_root() / dataset_id 397 398 if dataset_path is None: 399 return False, f"Dataset '{dataset_id}' not found" 400 401 if not dataset_path.exists(): 402 return False, f"Dataset '{dataset_id}' not found" 403 404 try: 405 shutil.rmtree(dataset_path) 406 return True, f"Dataset '{dataset_id}' metadata deleted successfully" 407 408 except Exception as e: 409 return False, f"Failed to delete dataset: {str(e)}" 410 411 412def export_dataset(dataset_id: str, output_root: Path) -> Tuple[bool, str]: 413 """Export an existing dataset to a specified output path. 414 415 Copies the entire dataset directory (including metadata, alignments, and cache) 416 to the specified output location. The exported directory is named using the 417 pattern: {dataset_name}_{dataset_id} 418 419 Args: 420 dataset_id: Identifier of the dataset to export 421 output_root: Path to the output directory where the dataset will be copied 422 423 Returns: 424 Tuple of (True, success_message) on success or (False, error_message) on failure 425 426 Raises: 427 FileExistsError: If destination path already exists 428 """ 429 430 if not output_root.exists(): 431 return False, f"Output path '{output_root}' does not exist." 432 else: 433 dataset_path = _get_datasets_root() / dataset_id 434 435 if not dataset_path.exists(): 436 return False, f"Dataset '{dataset_id}' not found." 437 438 dataset_meta = get_dataset_metadata(dataset_id) 439 if not dataset_meta: 440 return False, f"Metadata for dataset '{dataset_id}' not found." 441 442 dest_path = output_root / (dataset_meta["name"] + "_" + dataset_id) 443 try: 444 shutil.copytree(dataset_path, dest_path, dirs_exist_ok=False) 445 return True, f"Dataset '{dataset_id}' exported successfully to '{dest_path}'." 446 except Exception as e: 447 return False, f"Failed to export dataset: {str(e)}" 448 449 450def _rewrite_imported_alignments(new_dataset_path: Path) -> None: 451 """Rewrite alignment metadata paths after importing a dataset to a new location. 452 453 When a dataset is imported, its directory is copied to a new location under a 454 new dataset id. Any ``local`` alignment has a ``tg_path`` that lives inside 455 the dataset directory and still references the source location. For each such 456 alignment, rewrite ``tg_path`` to ``<new_dataset>/alignments/<alignment_id>/ 457 textgrids``. Non-local alignments (``local == False``) store TextGrids at the 458 dataset's ``original_path``, which is unchanged by import, so they are left 459 alone. 460 """ 461 alignments_dir = new_dataset_path / ALIGNMENTS_ROOT 462 if not alignments_dir.is_dir(): 463 return 464 465 for alignment_dir in alignments_dir.iterdir(): 466 if not alignment_dir.is_dir(): 467 continue 468 metadata_file = alignment_dir / "voxkit_alignment.json" 469 if not metadata_file.exists(): 470 continue 471 try: 472 with open(metadata_file, "r", encoding="utf-8") as f: 473 alignment_metadata = json.load(f) 474 except (OSError, json.JSONDecodeError) as e: 475 logger.warning("Skipping alignment metadata rewrite for '%s': %s", metadata_file, e) 476 continue 477 478 if not alignment_metadata.get("local"): 479 continue 480 481 alignment_metadata["tg_path"] = str(alignment_dir / "textgrids") 482 try: 483 with open(metadata_file, "w", encoding="utf-8") as f: 484 json.dump(alignment_metadata, f, indent=4) 485 except OSError: 486 logger.exception("Failed to rewrite alignment metadata '%s'", metadata_file) 487 488 489def import_dataset(dataset_path: Path) -> Tuple[bool, str]: 490 """Import an existing dataset into VoxKit storage. 491 492 Imports a previously exported dataset or a dataset with valid VoxKit metadata. 493 Generates a new ID, updates metadata with new registration date, validates 494 cached datasets, and verifies original path accessibility for non-cached datasets. 495 496 Args: 497 dataset_path: Path to the dataset to import (must contain voxkit_dataset.json) 498 499 Returns: 500 Tuple of (True, success_message) on success or (False, error_message) on failure 501 502 Raises: 503 Exception: If dataset structure is invalid or copy operations fail 504 505 Notes: 506 - For cached datasets, validates the cache directory structure 507 - For non-cached datasets, verifies the original_path is accessible 508 - Automatically cleans up on failure 509 """ 510 # Validate dataset structure 511 512 if not isinstance(dataset_path, Path): 513 dataset_path = Path(dataset_path) 514 if not dataset_path.exists(): 515 return False, f"Dataset path '{dataset_path}' does not exist." 516 if not dataset_path.is_dir(): 517 return False, f"Dataset path '{dataset_path}' is not a directory." 518 dataset_metadata_typed = _get_dataset_metadata(dataset_path) 519 if dataset_metadata_typed is None: 520 return False, "Dataset metadata file not found in the provided dataset path." 521 522 transcribed_flag: bool = bool(dataset_metadata_typed.get("transcribed", True)) 523 valid, valid_msg = validate_dataset(dataset_path / "cache", transcribed=transcribed_flag) 524 525 now = generate_unique_id() 526 527 dataset_dest = _get_datasets_root() / now 528 try: 529 # Change metadata accordingly 530 dataset_metadata: dict[str, Any] = dict(dataset_metadata_typed) # Make a copy to modify 531 dataset_metadata["id"] = now 532 humannow = readable_from_unique_id(now) 533 dataset_metadata["registration_date"] = humannow 534 535 # Check cache consistency 536 if not dataset_metadata["cached"]: 537 original_location_exists = Path(dataset_metadata["original_path"]).exists() 538 if not original_location_exists: 539 return ( 540 False, 541 f"Original dataset path {dataset_metadata['original_path']} " 542 "does not exist; cannot import non-cached dataset.", 543 ) 544 545 # Validate dataset 546 elif not valid: 547 return False, f"Dataset validation failed: {valid_msg}" 548 549 metadata_path = dataset_dest / "voxkit_dataset.json" 550 551 if not dataset_dest.exists(): 552 dataset_dest.mkdir(parents=False, exist_ok=False) 553 554 shutil.copytree(dataset_path, dataset_dest, dirs_exist_ok=True) 555 556 with open(metadata_path, "w", encoding="utf-8") as f: 557 json.dump(dataset_metadata, f, indent=2) 558 559 _rewrite_imported_alignments(dataset_dest) 560 561 return True, "Dataset imported successfully." 562 563 except Exception as e: 564 # Cleanup on failure 565 if dataset_dest.exists(): 566 shutil.rmtree(dataset_dest, ignore_errors=True) 567 logger.exception("Error during dataset import") 568 return False, f"Failed to import dataset: {str(e)}" 569 570 571def validate_dataset(dataset_path: Path, transcribed: bool = True) -> Tuple[bool, str]: 572 """Validate if a dataset follows the expected organization pattern. 573 574 Validation checks: 575 - Dataset path exists and is a directory 576 - Dataset is not empty 577 - Contains speaker subdirectories (not files at root level) 578 - Each speaker directory is not empty 579 - Each speaker directory contains audio files (.wav, .flac, .mp3, .ogg, .m4a) 580 - If transcribed=True: each audio file has a matching .lab file in the same directory 581 582 Expected structure: 583 584 dataset_path/ 585 ├── speaker_001/ 586 │ ├── audio_001.wav 587 │ ├── audio_001.lab (only required when transcribed=True) 588 │ ├── audio_002.wav 589 │ └── audio_002.lab 590 └── speaker_002/ 591 ├── audio_001.wav 592 └── audio_001.lab 593 594 Args: 595 dataset_path: Path to dataset root directory 596 transcribed: Whether to require a .lab file for every audio file 597 598 Returns: 599 Tuple of (True, validation_message) if valid or (False, error_description) if invalid 600 """ 601 if not isinstance(dataset_path, Path): 602 dataset_path = Path(dataset_path) 603 if not dataset_path.exists(): 604 return False, f"Dataset path '{dataset_path}' does not exist." 605 if not dataset_path.is_dir(): 606 return False, f"Dataset path '{dataset_path}' is not a directory." 607 if not os.listdir(dataset_path): 608 return False, f"Dataset path '{dataset_path}' is empty." 609 for subdir in os.listdir(dataset_path): 610 if subdir.startswith("."): 611 continue # Skip hidden files/directories 612 subdir_path = os.path.join(dataset_path, subdir) 613 if not os.path.isdir(subdir_path): 614 return ( 615 False, 616 f"Expected speaker directories in dataset path '{dataset_path}', " 617 f"found file '{subdir_path}'.", 618 ) 619 if not os.listdir(subdir_path): 620 return False, f"Speaker directory '{subdir_path}' is empty." 621 622 speaker_dirs = [ 623 d for d in os.listdir(dataset_path) if os.path.isdir(os.path.join(dataset_path, d)) 624 ] 625 626 if not speaker_dirs: 627 return False, "No speaker directories found in the dataset path." 628 629 for speaker in speaker_dirs: 630 speaker_path = os.path.join(dataset_path, speaker) 631 audio_files = [ 632 f for f in os.listdir(speaker_path) if f.endswith(tuple(SUPERSET_AUDIO_EXTENSIONS)) 633 ] 634 635 if not audio_files: 636 return False, f"No audio files found in speaker directory '{speaker_path}'." 637 638 lab_files = [f for f in os.listdir(speaker_path) if f.endswith(".lab")] 639 640 if transcribed: 641 audio_stems = {Path(f).stem for f in audio_files} 642 lab_stems = {Path(f).stem for f in lab_files} 643 missing = audio_stems - lab_stems 644 if missing: 645 return ( 646 False, 647 f"Missing .lab files for audio files in speaker directory " 648 f"'{speaker_path}': {', '.join(sorted(missing))}.", 649 ) 650 else: 651 if lab_files: 652 return ( 653 False, 654 f"Dataset is marked as not transcribed but .lab files were found in " 655 f"'{speaker_path}'. Set 'Transcribed' to true or remove the .lab files.", 656 ) 657 658 return True, "Dataset is valid."
54class DatasetMetadata(TypedDict): 55 """Dataset metadata structure. 56 57 Attributes: 58 name: Human-readable name of the dataset. 59 id: Unique identifier (timestamp with microsecond precision). 60 description: Description of the dataset contents and purpose. 61 original_path: Original file system path to the dataset. 62 cached: Whether the dataset is cached in VoxKit storage. 63 anonymize: Whether speaker identities should be anonymized. 64 transcribed: Whether the dataset includes transcription files. 65 registration_date: Human-readable registration timestamp. 66 hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids. 67 """ 68 69 name: str 70 id: str 71 description: str 72 original_path: str 73 cached: bool 74 anonymize: bool 75 transcribed: bool 76 registration_date: str 77 hand_alignments_path: str | None
Dataset metadata structure.
Attributes: name: Human-readable name of the dataset. id: Unique identifier (timestamp with microsecond precision). description: Description of the dataset contents and purpose. original_path: Original file system path to the dataset. cached: Whether the dataset is cached in VoxKit storage. anonymize: Whether speaker identities should be anonymized. transcribed: Whether the dataset includes transcription files. registration_date: Human-readable registration timestamp. hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids.
108def get_dataset_data_path(meta: DatasetMetadata) -> Path | None: 109 """Return the directory containing the dataset's speaker subdirs. 110 111 For cached datasets this is ``<dataset_root>/cache``; for non-cached 112 datasets it is the original on-disk path recorded in metadata. 113 """ 114 if meta.get("cached"): 115 root = _get_dataset_root(meta["id"]) 116 if root is None: 117 return None 118 return root / "cache" 119 return Path(meta["original_path"])
Return the directory containing the dataset's speaker subdirs.
For cached datasets this is <dataset_root>/cache; for non-cached
datasets it is the original on-disk path recorded in metadata.
142def create_dataset( 143 name: str, 144 description: str, 145 original_path: str, 146 cached: bool, 147 anonymize: bool, 148 transcribed: bool = False, 149 analysis_data: list[dict[str, Any]] | None = None, 150 analysis_method: str | None = None, 151 hand_alignments_path: str | None = None, 152) -> tuple[Literal[True], DatasetMetadata] | tuple[Literal[False], str]: 153 """Create a dataset metadata dictionary and create necessary directories. 154 155 Validates the dataset structure, creates a unique ID, sets up the directory 156 hierarchy (dataset root and alignments subdirectory), writes metadata to JSON, 157 optionally caches the dataset, and optionally saves analysis results to CSV. 158 159 Args: 160 name: Name of the dataset 161 description: Description of the dataset 162 original_path: Original path to the dataset 163 cached: Whether to copy the dataset into VoxKit storage 164 anonymize: Whether the dataset should be anonymized 165 transcribed: Whether the dataset includes transcription files 166 analysis_data: Optional list of analysis result dictionaries to save as CSV 167 analysis_method: Optional name of the analysis method (used for CSV filename) 168 hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids 169 170 Returns: 171 Tuple of (True, DatasetMetadata) on success or (False, error_message) on failure 172 173 Raises: 174 FileExistsError: If a dataset with the generated ID already exists 175 Exception: If directory creation, metadata writing, or caching fails 176 177 Notes: 178 - Automatically validates dataset structure before creation 179 - Cleans up partially created directories on failure 180 - Cached datasets are copied with shutil.copytree 181 - If analysis_data is provided, saves to {analysis_method}_summary.csv 182 """ 183 # Validate dataset structure 184 valid, msg = validate_dataset(Path(original_path), transcribed=transcribed) 185 if not valid: 186 return False, msg 187 188 now = generate_unique_id() 189 190 try: 191 humannow = readable_from_unique_id(now) 192 metadata = DatasetMetadata( 193 name=name, 194 id=now, 195 description=description, 196 original_path=str(original_path), 197 cached=cached, 198 anonymize=anonymize, 199 transcribed=transcribed, 200 registration_date=humannow, 201 hand_alignments_path=hand_alignments_path, 202 ) 203 204 # Create dataset directory 205 dataset_dir = _get_datasets_root() / metadata["id"] 206 if dataset_dir.exists(): 207 raise FileExistsError(f"Dataset with ID '{metadata['id']}' already exists.") 208 dataset_dir.mkdir(parents=False, exist_ok=False) 209 210 # Create dataset/alignments directory 211 alignments_dir = dataset_dir / ALIGNMENTS_ROOT 212 alignments_dir.mkdir(parents=False, exist_ok=False) 213 metadata_path = dataset_dir / "voxkit_dataset.json" 214 with open(metadata_path, "w", encoding="utf-8") as f: 215 json.dump(metadata, f, indent=2) 216 217 # Cache the dataset if requested 218 if cached: 219 cache_dir = dataset_dir / "cache" 220 cache_dir.mkdir(parents=False, exist_ok=False) 221 shutil.copytree(original_path, cache_dir, dirs_exist_ok=True) 222 223 # Save analysis results if provided 224 if analysis_data is not None and analysis_method is not None: 225 csv_path = dataset_dir / f"{analysis_method.lower()}_summary.csv" 226 _save_analysis_csv(analysis_data, csv_path) 227 228 # Register the hand-annotated alignment entry if a path was provided 229 if hand_alignments_path: 230 from voxkit.storage.alignments import create_hand_alignment 231 232 ok, result = create_hand_alignment(metadata["id"], tg_path=hand_alignments_path) 233 if not ok: 234 raise RuntimeError(f"Failed to register hand alignment: {result}") 235 236 return True, metadata 237 238 except Exception as e: 239 # Clean up on failure 240 dataset_dir = _get_datasets_root() / now 241 if dataset_dir.exists(): 242 shutil.rmtree(dataset_dir, ignore_errors=False) 243 244 logger.exception("Error during dataset creation") 245 return False, f"Failed to create dataset metadata: {str(e)}"
Create a dataset metadata dictionary and create necessary directories.
Validates the dataset structure, creates a unique ID, sets up the directory hierarchy (dataset root and alignments subdirectory), writes metadata to JSON, optionally caches the dataset, and optionally saves analysis results to CSV.
Args: name: Name of the dataset description: Description of the dataset original_path: Original path to the dataset cached: Whether to copy the dataset into VoxKit storage anonymize: Whether the dataset should be anonymized transcribed: Whether the dataset includes transcription files analysis_data: Optional list of analysis result dictionaries to save as CSV analysis_method: Optional name of the analysis method (used for CSV filename) hand_alignments_path: Optional path to pre-existing hand-annotated TextGrids
Returns: Tuple of (True, DatasetMetadata) on success or (False, error_message) on failure
Raises: FileExistsError: If a dataset with the generated ID already exists Exception: If directory creation, metadata writing, or caching fails
Notes: - Automatically validates dataset structure before creation - Cleans up partially created directories on failure - Cached datasets are copied with shutil.copytree - If analysis_data is provided, saves to {analysis_method}_summary.csv
269def get_dataset_metadata(dataset_id: str) -> DatasetMetadata | None: 270 """Get the metadata for a specific dataset. 271 272 Retrieves the dataset metadata from the voxkit_dataset.json file in the 273 dataset's directory. 274 275 Args: 276 dataset_id: ID of the dataset to retrieve 277 278 Returns: 279 Dataset metadata dictionary or None if not found 280 281 Raises: 282 Exception: If metadata file exists but cannot be read or parsed 283 """ 284 try: 285 dataset_dir = _get_datasets_root() / dataset_id 286 metadata = _get_dataset_metadata(dataset_dir) 287 if metadata is None: 288 raise FileNotFoundError(f"Metadata for dataset '{dataset_id}' not found.") 289 return metadata 290 291 except Exception: 292 logger.exception("Error retrieving dataset metadata") 293 return None
Get the metadata for a specific dataset.
Retrieves the dataset metadata from the voxkit_dataset.json file in the dataset's directory.
Args: dataset_id: ID of the dataset to retrieve
Returns: Dataset metadata dictionary or None if not found
Raises: Exception: If metadata file exists but cannot be read or parsed
296def list_datasets_metadata() -> List[DatasetMetadata]: 297 """List all existing datasets. 298 299 Scans the datasets root directory and collects metadata from all subdirectories 300 containing valid voxkit_dataset.json files. 301 302 Returns: 303 List of dataset metadata dictionaries (empty list if none found) 304 305 Notes: 306 - Silently skips directories without metadata files 307 - Returns empty list on error 308 - Does not guarantee ordering 309 """ 310 datasets = [] 311 datasets_root = _get_datasets_root() 312 313 try: 314 for entry in os.scandir(datasets_root): 315 if entry.is_dir(): 316 metadata_path = os.path.join(entry.path, "voxkit_dataset.json") 317 if os.path.exists(metadata_path): 318 with open(metadata_path, "r", encoding="utf-8") as f: 319 metadata = json.load(f) 320 datasets.append(metadata) 321 return datasets 322 323 except Exception: 324 logger.exception("Error listing datasets") 325 return []
List all existing datasets.
Scans the datasets root directory and collects metadata from all subdirectories containing valid voxkit_dataset.json files.
Returns: List of dataset metadata dictionaries (empty list if none found)
Notes: - Silently skips directories without metadata files - Returns empty list on error - Does not guarantee ordering
328def update_dataset_metadata( 329 dataset_id: str, 330 updates: dict, 331) -> Tuple[bool, str]: 332 """Update the metadata for a specific dataset. 333 334 Updates specific fields in the dataset metadata file. Only updates fields that 335 are present in the updates dictionary and not None. Supported fields: 336 description, cached, anonymize, transcribed. 337 338 Args: 339 dataset_id: ID of the dataset to update 340 updates: Dictionary of metadata fields to update (only non-None values are applied) 341 342 Returns: 343 Tuple of (True, success_message) on success or (False, error_message) on failure 344 345 Raises: 346 FileNotFoundError: If the dataset is not found 347 Exception: If metadata file cannot be written 348 """ 349 try: 350 metadata = get_dataset_metadata(dataset_id) 351 352 if not metadata: 353 return False, f"Dataset {dataset_id} not found" 354 355 for field in ("description", "cached", "anonymize", "transcribed"): 356 if field in updates and updates[field] is not None: 357 metadata[field] = updates[field] 358 359 # Save the updated metadata 360 metadata_path = _get_datasets_root() / dataset_id / "voxkit_dataset.json" 361 with open(metadata_path, "w", encoding="utf-8") as f: 362 json.dump(metadata, f, indent=2) 363 364 return True, "Dataset metadata updated successfully" 365 366 except KeyError as e: 367 return False, f"Invalid metadata key: {str(e)}" 368 except FileNotFoundError as e: 369 return False, str(e) 370 except Exception as e: 371 return False, f"Failed to update dataset metadata: {str(e)}"
Update the metadata for a specific dataset.
Updates specific fields in the dataset metadata file. Only updates fields that are present in the updates dictionary and not None. Supported fields: description, cached, anonymize, transcribed.
Args: dataset_id: ID of the dataset to update updates: Dictionary of metadata fields to update (only non-None values are applied)
Returns: Tuple of (True, success_message) on success or (False, error_message) on failure
Raises: FileNotFoundError: If the dataset is not found Exception: If metadata file cannot be written
374def delete_dataset(dataset_id: str) -> Tuple[bool, str]: 375 """Delete a registered dataset. 376 377 Permanently removes the dataset directory and all its contents, including 378 metadata, alignments, and cached data. 379 380 Args: 381 dataset_id: ID of the dataset to delete 382 383 Returns: 384 Tuple of (True, success_message) on success or (False, error_message) on failure 385 386 Raises: 387 Exception: If the directory cannot be removed 388 389 Notes: 390 - This operation is irreversible 391 - Removes the entire dataset directory tree 392 - Validates that dataset_id is not empty before proceeding 393 """ 394 if not dataset_id: 395 return False, "Dataset ID cannot be empty." 396 397 dataset_path = _get_datasets_root() / dataset_id 398 399 if dataset_path is None: 400 return False, f"Dataset '{dataset_id}' not found" 401 402 if not dataset_path.exists(): 403 return False, f"Dataset '{dataset_id}' not found" 404 405 try: 406 shutil.rmtree(dataset_path) 407 return True, f"Dataset '{dataset_id}' metadata deleted successfully" 408 409 except Exception as e: 410 return False, f"Failed to delete dataset: {str(e)}"
Delete a registered dataset.
Permanently removes the dataset directory and all its contents, including metadata, alignments, and cached data.
Args: dataset_id: ID of the dataset to delete
Returns: Tuple of (True, success_message) on success or (False, error_message) on failure
Raises: Exception: If the directory cannot be removed
Notes: - This operation is irreversible - Removes the entire dataset directory tree - Validates that dataset_id is not empty before proceeding
413def export_dataset(dataset_id: str, output_root: Path) -> Tuple[bool, str]: 414 """Export an existing dataset to a specified output path. 415 416 Copies the entire dataset directory (including metadata, alignments, and cache) 417 to the specified output location. The exported directory is named using the 418 pattern: {dataset_name}_{dataset_id} 419 420 Args: 421 dataset_id: Identifier of the dataset to export 422 output_root: Path to the output directory where the dataset will be copied 423 424 Returns: 425 Tuple of (True, success_message) on success or (False, error_message) on failure 426 427 Raises: 428 FileExistsError: If destination path already exists 429 """ 430 431 if not output_root.exists(): 432 return False, f"Output path '{output_root}' does not exist." 433 else: 434 dataset_path = _get_datasets_root() / dataset_id 435 436 if not dataset_path.exists(): 437 return False, f"Dataset '{dataset_id}' not found." 438 439 dataset_meta = get_dataset_metadata(dataset_id) 440 if not dataset_meta: 441 return False, f"Metadata for dataset '{dataset_id}' not found." 442 443 dest_path = output_root / (dataset_meta["name"] + "_" + dataset_id) 444 try: 445 shutil.copytree(dataset_path, dest_path, dirs_exist_ok=False) 446 return True, f"Dataset '{dataset_id}' exported successfully to '{dest_path}'." 447 except Exception as e: 448 return False, f"Failed to export dataset: {str(e)}"
Export an existing dataset to a specified output path.
Copies the entire dataset directory (including metadata, alignments, and cache) to the specified output location. The exported directory is named using the pattern: {dataset_name}_{dataset_id}
Args: dataset_id: Identifier of the dataset to export output_root: Path to the output directory where the dataset will be copied
Returns: Tuple of (True, success_message) on success or (False, error_message) on failure
Raises: FileExistsError: If destination path already exists
490def import_dataset(dataset_path: Path) -> Tuple[bool, str]: 491 """Import an existing dataset into VoxKit storage. 492 493 Imports a previously exported dataset or a dataset with valid VoxKit metadata. 494 Generates a new ID, updates metadata with new registration date, validates 495 cached datasets, and verifies original path accessibility for non-cached datasets. 496 497 Args: 498 dataset_path: Path to the dataset to import (must contain voxkit_dataset.json) 499 500 Returns: 501 Tuple of (True, success_message) on success or (False, error_message) on failure 502 503 Raises: 504 Exception: If dataset structure is invalid or copy operations fail 505 506 Notes: 507 - For cached datasets, validates the cache directory structure 508 - For non-cached datasets, verifies the original_path is accessible 509 - Automatically cleans up on failure 510 """ 511 # Validate dataset structure 512 513 if not isinstance(dataset_path, Path): 514 dataset_path = Path(dataset_path) 515 if not dataset_path.exists(): 516 return False, f"Dataset path '{dataset_path}' does not exist." 517 if not dataset_path.is_dir(): 518 return False, f"Dataset path '{dataset_path}' is not a directory." 519 dataset_metadata_typed = _get_dataset_metadata(dataset_path) 520 if dataset_metadata_typed is None: 521 return False, "Dataset metadata file not found in the provided dataset path." 522 523 transcribed_flag: bool = bool(dataset_metadata_typed.get("transcribed", True)) 524 valid, valid_msg = validate_dataset(dataset_path / "cache", transcribed=transcribed_flag) 525 526 now = generate_unique_id() 527 528 dataset_dest = _get_datasets_root() / now 529 try: 530 # Change metadata accordingly 531 dataset_metadata: dict[str, Any] = dict(dataset_metadata_typed) # Make a copy to modify 532 dataset_metadata["id"] = now 533 humannow = readable_from_unique_id(now) 534 dataset_metadata["registration_date"] = humannow 535 536 # Check cache consistency 537 if not dataset_metadata["cached"]: 538 original_location_exists = Path(dataset_metadata["original_path"]).exists() 539 if not original_location_exists: 540 return ( 541 False, 542 f"Original dataset path {dataset_metadata['original_path']} " 543 "does not exist; cannot import non-cached dataset.", 544 ) 545 546 # Validate dataset 547 elif not valid: 548 return False, f"Dataset validation failed: {valid_msg}" 549 550 metadata_path = dataset_dest / "voxkit_dataset.json" 551 552 if not dataset_dest.exists(): 553 dataset_dest.mkdir(parents=False, exist_ok=False) 554 555 shutil.copytree(dataset_path, dataset_dest, dirs_exist_ok=True) 556 557 with open(metadata_path, "w", encoding="utf-8") as f: 558 json.dump(dataset_metadata, f, indent=2) 559 560 _rewrite_imported_alignments(dataset_dest) 561 562 return True, "Dataset imported successfully." 563 564 except Exception as e: 565 # Cleanup on failure 566 if dataset_dest.exists(): 567 shutil.rmtree(dataset_dest, ignore_errors=True) 568 logger.exception("Error during dataset import") 569 return False, f"Failed to import dataset: {str(e)}"
Import an existing dataset into VoxKit storage.
Imports a previously exported dataset or a dataset with valid VoxKit metadata. Generates a new ID, updates metadata with new registration date, validates cached datasets, and verifies original path accessibility for non-cached datasets.
Args: dataset_path: Path to the dataset to import (must contain voxkit_dataset.json)
Returns: Tuple of (True, success_message) on success or (False, error_message) on failure
Raises: Exception: If dataset structure is invalid or copy operations fail
Notes: - For cached datasets, validates the cache directory structure - For non-cached datasets, verifies the original_path is accessible - Automatically cleans up on failure
572def validate_dataset(dataset_path: Path, transcribed: bool = True) -> Tuple[bool, str]: 573 """Validate if a dataset follows the expected organization pattern. 574 575 Validation checks: 576 - Dataset path exists and is a directory 577 - Dataset is not empty 578 - Contains speaker subdirectories (not files at root level) 579 - Each speaker directory is not empty 580 - Each speaker directory contains audio files (.wav, .flac, .mp3, .ogg, .m4a) 581 - If transcribed=True: each audio file has a matching .lab file in the same directory 582 583 Expected structure: 584 585 dataset_path/ 586 ├── speaker_001/ 587 │ ├── audio_001.wav 588 │ ├── audio_001.lab (only required when transcribed=True) 589 │ ├── audio_002.wav 590 │ └── audio_002.lab 591 └── speaker_002/ 592 ├── audio_001.wav 593 └── audio_001.lab 594 595 Args: 596 dataset_path: Path to dataset root directory 597 transcribed: Whether to require a .lab file for every audio file 598 599 Returns: 600 Tuple of (True, validation_message) if valid or (False, error_description) if invalid 601 """ 602 if not isinstance(dataset_path, Path): 603 dataset_path = Path(dataset_path) 604 if not dataset_path.exists(): 605 return False, f"Dataset path '{dataset_path}' does not exist." 606 if not dataset_path.is_dir(): 607 return False, f"Dataset path '{dataset_path}' is not a directory." 608 if not os.listdir(dataset_path): 609 return False, f"Dataset path '{dataset_path}' is empty." 610 for subdir in os.listdir(dataset_path): 611 if subdir.startswith("."): 612 continue # Skip hidden files/directories 613 subdir_path = os.path.join(dataset_path, subdir) 614 if not os.path.isdir(subdir_path): 615 return ( 616 False, 617 f"Expected speaker directories in dataset path '{dataset_path}', " 618 f"found file '{subdir_path}'.", 619 ) 620 if not os.listdir(subdir_path): 621 return False, f"Speaker directory '{subdir_path}' is empty." 622 623 speaker_dirs = [ 624 d for d in os.listdir(dataset_path) if os.path.isdir(os.path.join(dataset_path, d)) 625 ] 626 627 if not speaker_dirs: 628 return False, "No speaker directories found in the dataset path." 629 630 for speaker in speaker_dirs: 631 speaker_path = os.path.join(dataset_path, speaker) 632 audio_files = [ 633 f for f in os.listdir(speaker_path) if f.endswith(tuple(SUPERSET_AUDIO_EXTENSIONS)) 634 ] 635 636 if not audio_files: 637 return False, f"No audio files found in speaker directory '{speaker_path}'." 638 639 lab_files = [f for f in os.listdir(speaker_path) if f.endswith(".lab")] 640 641 if transcribed: 642 audio_stems = {Path(f).stem for f in audio_files} 643 lab_stems = {Path(f).stem for f in lab_files} 644 missing = audio_stems - lab_stems 645 if missing: 646 return ( 647 False, 648 f"Missing .lab files for audio files in speaker directory " 649 f"'{speaker_path}': {', '.join(sorted(missing))}.", 650 ) 651 else: 652 if lab_files: 653 return ( 654 False, 655 f"Dataset is marked as not transcribed but .lab files were found in " 656 f"'{speaker_path}'. Set 'Transcribed' to true or remove the .lab files.", 657 ) 658 659 return True, "Dataset is valid."
Validate if a dataset follows the expected organization pattern.
Validation checks:
- Dataset path exists and is a directory
- Dataset is not empty
- Contains speaker subdirectories (not files at root level)
- Each speaker directory is not empty
- Each speaker directory contains audio files (.wav, .flac, .mp3, .ogg, .m4a)
- If transcribed=True: each audio file has a matching .lab file in the same directory
Expected structure:
dataset_path/
├── speaker_001/
│ ├── audio_001.wav
│ ├── audio_001.lab (only required when transcribed=True)
│ ├── audio_002.wav
│ └── audio_002.lab
└── speaker_002/
├── audio_001.wav
└── audio_001.lab
Args: dataset_path: Path to dataset root directory transcribed: Whether to require a .lab file for every audio file
Returns: Tuple of (True, validation_message) if valid or (False, error_description) if invalid