Spaces:
Running on Zero
Running on Zero
| """ | |
| SVS data loading utilities for preprocessing. | |
| Provides functions to load raw samples from various dataset formats: | |
| * :func:`load_config` — YAML config loading and validation | |
| * :func:`load_samples_from_folder` — folder-based dataset structure | |
| * :func:`load_samples_from_json_file` — single JSON annotation file | |
| * :func:`load_samples_from_weak_json_file` — weak-label JSON file | |
| * :func:`load_all_datasets` — unified multi-dataset loader | |
| """ | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| from tqdm import tqdm | |
| # Silence / non-lyric markers — a sample containing only these has no actual | |
| # lyric content. Such samples are dropped at load time. | |
| _LYRIC_SKIP = {"AP", "SP", "<AP>", "<SP>", "<sil>", "<pause>", | |
| "", " ", "-", "_", "<unk>"} | |
| def reconstruct_lyric_text(word_list: List[str]) -> str: | |
| """Join lyric syllables, dropping silence/special markers. | |
| Empty result ⇒ the clip is pure breath/silence or has no Chinese word | |
| tokens (e.g. humming, non-Chinese lyrics the annotator refused to | |
| transcribe). Such clips teach nothing useful to text→song SVS training. | |
| """ | |
| kept: List[str] = [] | |
| for w in word_list or []: | |
| if w is None: | |
| continue | |
| stripped = str(w).strip() | |
| if not stripped or stripped in _LYRIC_SKIP: | |
| continue | |
| if stripped.startswith("<") and stripped.endswith(">"): | |
| continue | |
| kept.append(stripped) | |
| return "".join(kept) | |
| def load_config(config_path: str) -> Dict: | |
| """Load configuration from YAML file. | |
| Configuration format:: | |
| datasets: | |
| - name: m4singer | |
| type: json_file | |
| json_path: /path/to/m4singer.json | |
| audio_root: /path/to/wavs | |
| song_id_indices: [0, 1] | |
| - name: cloudmusic | |
| type: folder_based | |
| dataset_root: /path/to/cloudmusic | |
| For validation, splitting is now deferred to training time. | |
| The preprocessing output is a flat directory containing all samples. | |
| Song ID determination: | |
| - For folder_based: song_id is the first-level folder name under dataset_root | |
| - For json_file: song_id is constructed by joining the elements at | |
| song_id_indices with '#' | |
| """ | |
| import yaml | |
| with open(config_path, 'r', encoding='utf-8') as f: | |
| config = yaml.safe_load(f) | |
| if not config: | |
| raise ValueError(f"Empty config file: {config_path}") | |
| # Apply defaults for optional parameters | |
| defaults = { | |
| "sample_rate": 44100, | |
| "max_samples": -1, | |
| "num_workers": 4, | |
| "device": "cuda", | |
| "shard_size": 1000, # Number of samples per Arrow shard for streaming writes | |
| "vae_batch_size": 32, # Max number of samples per VAE encode batch (hard cap) | |
| "vae_max_tokens": 44100 * 100, # Max total audio samples per VAE batch (dynamic sizing) | |
| "num_gpus": -1, # Number of GPUs for parallel VAE encoding (-1 = auto-detect) | |
| "dispatch_chunk_size": 2000, # Multi-GPU: samples per work-queue chunk (dynamic dispatch granularity) | |
| "manifest_dir": None, # Where to cache per-dataset sample manifests (None -> <output_dir>/.manifest_cache) | |
| "refresh_manifest": False, # Force rescan + rewrite of manifests | |
| } | |
| for key, value in defaults.items(): | |
| if key not in config: | |
| config[key] = value | |
| # Validate required parameters | |
| required = ["datasets", "output_dir", "pretrained_path"] | |
| missing = [k for k in required if k not in config] | |
| if missing: | |
| raise ValueError(f"Missing required config parameters: {missing}") | |
| # Validate datasets configuration | |
| if not config["datasets"]: | |
| raise ValueError("'datasets' list cannot be empty. Add at least one dataset configuration.") | |
| # Validate each dataset entry | |
| for i, ds in enumerate(config["datasets"]): | |
| if "name" not in ds: | |
| raise ValueError(f"Dataset {i}: missing 'name' field") | |
| if "type" not in ds: | |
| raise ValueError(f"Dataset {i} ({ds['name']}): missing 'type' field") | |
| ds_type = ds["type"] | |
| if ds_type == "folder_based": | |
| if "dataset_root" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': folder_based type requires 'dataset_root'") | |
| elif ds_type == "json_file": | |
| if "json_path" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': json_file type requires 'json_path'") | |
| if "audio_root" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': json_file type requires 'audio_root'") | |
| if "song_id_indices" not in ds and "song_id_slice" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': json_file type requires 'song_id_indices' or 'song_id_slice'") | |
| elif ds_type == "weak_json_file": | |
| if "json_path" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': weak_json_file type requires 'json_path'") | |
| if "audio_root" not in ds: | |
| raise ValueError(f"Dataset '{ds['name']}': weak_json_file type requires 'audio_root'") | |
| else: | |
| raise ValueError(f"Dataset '{ds['name']}': unknown type '{ds_type}'. Use 'folder_based', 'json_file', or 'weak_json_file'") | |
| return config | |
| def _convert_words_to_syllables(words: List[str]) -> List[Dict]: | |
| """Convert word-only annotation to syllables (no pitch/note info). | |
| For weak-label datasets that only have word annotations. | |
| Each syllable only has a 'char' field — no 'pitch' or 'note'. | |
| """ | |
| return [{"char": w} for w in words] | |
| _SOLO_PRIMARY = {"男歌手": "male", "女歌手": "female"} | |
| def _resolve_bpm(metadata: Dict) -> int: | |
| """Resolve BPM from a metadata dict. | |
| The cloudmusic export carries the tempo under one of two keys depending on | |
| provenance: ``bpm`` (predicted by an estimator) or ``bpmMeta`` (annotated | |
| by the song's author). Training does not distinguish the two, so whichever | |
| is present wins; ``bpm`` is preferred when (rarely) both exist. | |
| """ | |
| for key in ("bpm", "bpmMeta"): | |
| val = metadata.get(key) | |
| if val: | |
| return val | |
| return 120 | |
| def _resolve_song_gender(metadata: Dict) -> Tuple[bool, Optional[str]]: | |
| """Decide whether a song is a single male/female solo and its gender. | |
| Returns ``(is_solo, gender)`` where ``gender`` ∈ {"male", "female", None}. | |
| A song counts as solo only when it has exactly one artist whose ``type`` | |
| is a known male/female singer tag — this drops 组合/乐队/duets so that | |
| prompt/target pairs sampled from one song share a singer. | |
| """ | |
| artists = metadata.get("artists") or [] | |
| # ``artists`` format varies by export: dicts ({"id","name","type"}) in | |
| # cloudmusic, bare strings (just the name) in muchin, and absent/null in | |
| # Muse & songformdb. Only dict entries carry a singer ``type`` to resolve | |
| # gender; a string entry has no type, so it can never qualify as a known | |
| # male/female solo. | |
| if len(artists) == 1 and isinstance(artists[0], dict): | |
| atype = artists[0].get("type") | |
| if atype in _SOLO_PRIMARY: | |
| return True, _SOLO_PRIMARY[atype] | |
| return False, None | |
| def load_samples_from_folder( | |
| dataset_name: str, | |
| dataset_root: str, | |
| max_samples: int = -1, | |
| audio_extensions: Tuple[str, ...] = (".opus", ".flac", ".wav", ".mp3"), | |
| solo_singer_only: bool = False, | |
| num_scan_workers: int = 12, | |
| ) -> List[Dict]: | |
| """Load raw samples from folder-based structure. | |
| Each song lives in its own folder under ``dataset_root``. The current | |
| cloudmusic export keeps *all* per-segment annotations inside a single | |
| song-level ``metadata.json`` (there are no longer per-segment ``.json`` | |
| files); each entry in ``metadata['lyric']`` corresponds to one audio | |
| segment named ``<seg_id>.<ext>``:: | |
| dataset_root/ | |
| <song_folder>/ | |
| metadata.json # id/name/artists/bpm|bpmMeta/lyric[] | |
| 0000.opus | |
| 0001.opus | |
| ... | |
| where each ``lyric`` entry looks like:: | |
| {"seg_id": "0000", "text": "...", | |
| "word": [...], "word_dur": [...], | |
| "pitch": [...], "note": [...], "pitch2word": [...]} | |
| The BPM is read from ``bpm`` or ``bpmMeta`` (see :func:`_resolve_bpm`). | |
| When ``solo_singer_only`` is set, songs that are not a single male/female | |
| solo (exactly one ``artists`` entry with ``type`` ∈ {男歌手, 女歌手}) are | |
| skipped entirely, keeping prompt/target pairs from one song on the same | |
| singer. For a solo song the normalized gender (``"male"``/``"female"``) | |
| is stamped onto every sample under the ``gender`` key so downstream | |
| consumers can pick gender-conditional reference audio without | |
| re-reading metadata. | |
| """ | |
| from vocalrender.training.svs_data import convert_annotation_to_syllables | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| dataset_root = Path(dataset_root) | |
| song_folders = [d for d in dataset_root.iterdir() if d.is_dir()] | |
| print(f"[{dataset_name}] Scanning {len(song_folders)} song folders " | |
| f"with {num_scan_workers} threads...") | |
| # Per-folder worker — opens one metadata.json (the NFS-latency-bound step) | |
| # and assembles its segments. Pure/thread-safe: only reads files and calls | |
| # stateless helpers, so a thread pool just overlaps the open() round-trips. | |
| # The work is metadata-IOPS (a few KB per file), not bandwidth, so a bounded | |
| # pool stays gentle on shared NFS while hiding per-open latency. | |
| def _scan_folder(song_folder: Path) -> Dict: | |
| metadata_path = song_folder / "metadata.json" | |
| if not metadata_path.exists(): | |
| return {"skip_no_meta": True} | |
| try: | |
| with open(metadata_path, "r", encoding="utf-8") as f: | |
| metadata = json.load(f) | |
| except Exception: | |
| return {"skip_no_meta": True} | |
| is_solo, song_gender = _resolve_song_gender(metadata) | |
| if solo_singer_only and not is_solo: | |
| return {"skip_non_solo": True} | |
| bpm = _resolve_bpm(metadata) | |
| # Map each segment id to its audio file (seg_id -> path). Annotations | |
| # reference segments by ``seg_id`` (e.g. "0000" -> "0000.opus"). | |
| audio_by_stem = { | |
| f.stem: f | |
| for f in song_folder.iterdir() | |
| if f.is_file() and f.suffix.lower() in audio_extensions | |
| } | |
| folder_samples: List[Dict] = [] | |
| weak = 0 | |
| for seg in metadata.get("lyric", []): | |
| seg_id = str(seg.get("seg_id", "")).strip() | |
| audio_file = audio_by_stem.get(seg_id) | |
| if audio_file is None: | |
| continue | |
| words = seg.get("word", []) | |
| pitches = seg.get("pitch", []) | |
| notes = seg.get("note", []) | |
| pitch2word = seg.get("pitch2word", []) | |
| pitch_dur = seg.get("pitch_dur", []) | |
| word_dur = seg.get("word_dur", []) | |
| if not words: | |
| continue | |
| # Auto-detect: full score vs weak label (word-only) | |
| has_score = bool(pitches and notes and pitch2word) | |
| if has_score: | |
| syllables = convert_annotation_to_syllables( | |
| words=words, | |
| pitches=pitches, | |
| notes=notes, | |
| pitch2word=pitch2word, | |
| ) | |
| else: | |
| syllables = _convert_words_to_syllables(words) | |
| weak += 1 | |
| folder_samples.append({ | |
| "audio_path": str(audio_file), | |
| "bpm": bpm if has_score else 0, | |
| "syllables": syllables, | |
| "notes": notes, # Raw note list for duration estimation (empty for weak) | |
| "word": words, | |
| "pitch": pitches, | |
| "pitch_dur": pitch_dur, | |
| "pitch2word": pitch2word, | |
| "word_dur": word_dur, | |
| # seg_id is unique within a song; prefix with the folder so it | |
| # is globally unique. | |
| "item_name": f"{song_folder.name}/{seg_id}" if seg_id else audio_file.stem, | |
| "song_name": song_folder.name, # For folder_based, song_name is the folder name | |
| "song_folder": song_folder.name, | |
| "dataset_name": dataset_name, | |
| "has_score": has_score, | |
| "gender": song_gender, | |
| }) | |
| return {"samples": folder_samples, "weak": weak} | |
| samples: List[Dict] = [] | |
| weak_count = 0 | |
| skipped_non_solo = 0 | |
| skipped_no_meta = 0 | |
| with ThreadPoolExecutor(max_workers=max(1, num_scan_workers)) as ex: | |
| futures = [ex.submit(_scan_folder, sf) for sf in song_folders] | |
| for fut in tqdm(as_completed(futures), total=len(futures), | |
| desc=f"Loading [{dataset_name}]"): | |
| res = fut.result() | |
| if res.get("skip_no_meta"): | |
| skipped_no_meta += 1 | |
| continue | |
| if res.get("skip_non_solo"): | |
| skipped_non_solo += 1 | |
| continue | |
| samples.extend(res["samples"]) | |
| weak_count += res["weak"] | |
| # Folder order is non-deterministic under the pool, but ``max_samples`` | |
| # only ever bounds calibration runs (production uses -1), so a slightly | |
| # different subset is acceptable; truncate once the cap is reached. | |
| if max_samples > 0 and len(samples) >= max_samples: | |
| samples = samples[:max_samples] | |
| break | |
| print(f"[{dataset_name}] Loaded {len(samples)} samples from folder structure" | |
| f" ({weak_count} weak-label)") | |
| if skipped_no_meta: | |
| print(f"[{dataset_name}] skipped {skipped_no_meta} folder(s) " | |
| f"without a readable metadata.json") | |
| if solo_singer_only: | |
| print(f"[{dataset_name}] solo_singer_only: skipped {skipped_non_solo} " | |
| f"non-solo / multi-singer / unknown-artist song folder(s)") | |
| return samples | |
| def load_samples_from_json_file( | |
| dataset_name: str, | |
| json_path: str, | |
| audio_root: str, | |
| song_id_indices: Optional[List[int]] = None, | |
| song_id_slice: Optional[List[int]] = None, | |
| song_id_separator: str = "#", | |
| max_samples: int = -1, | |
| ) -> List[Dict]: | |
| """Load raw samples from a single JSON file format (like m4singer.json). | |
| Args: | |
| dataset_name: Name identifier for the dataset | |
| json_path: Path to the JSON annotation file | |
| audio_root: Root directory for audio files | |
| song_id_indices: List of indices in '#'-split item_name for constructing | |
| unique song ID, e.g., [0, 1] for "Alto-1#newboy#0000" -> "Alto-1#newboy" | |
| song_id_slice: [start, end] character positions for extracting song ID | |
| from item_name, e.g., [0, 4] for "2001000001" -> "2001" | |
| max_samples: Maximum number of samples to load | |
| JSON format (array of items):: | |
| [ | |
| { | |
| "item_name": "Alto-1#newboy#0000", | |
| "word": ["好", "的", ...], | |
| "pitch": [59, 62, ...], | |
| "note": ["<NOTE_16>", ...], | |
| "pitch2word": [0, 1, ...], | |
| "bpm": 135, | |
| "wav_fn": "Alto-1#newboy/0000.wav" | |
| }, | |
| ... | |
| ] | |
| """ | |
| from vocalrender.training.svs_data import convert_annotation_to_syllables | |
| json_path = Path(json_path) | |
| audio_root = Path(audio_root) | |
| print(f"[{dataset_name}] Loading from JSON file: {json_path}") | |
| with open(json_path, "r", encoding="utf-8") as f: | |
| items = json.load(f) | |
| print(f"[{dataset_name}] Found {len(items)} items in JSON file") | |
| samples = [] | |
| weak_count = 0 | |
| for item in tqdm(items, desc=f"Processing [{dataset_name}]"): | |
| if max_samples > 0 and len(samples) >= max_samples: | |
| break | |
| words = item.get("word", []) | |
| pitches = item.get("pitch", []) | |
| notes = item.get("note", []) | |
| pitch2word = item.get("pitch2word", []) | |
| pitch_dur = item.get("pitch_dur", []) | |
| word_dur = item.get("word_dur", []) | |
| bpm = item.get("bpm", 120) | |
| wav_fn = item.get("wav_fn", "") | |
| item_name = item.get("item_name", Path(wav_fn).stem) | |
| if not words or not wav_fn: | |
| continue | |
| audio_path = audio_root / wav_fn | |
| if not audio_path.exists(): | |
| continue | |
| # Auto-detect: full score vs weak label (word-only) | |
| has_score = bool(pitches and notes and pitch2word) | |
| if has_score: | |
| syllables = convert_annotation_to_syllables( | |
| words=words, | |
| pitches=pitches, | |
| notes=notes, | |
| pitch2word=pitch2word, | |
| ) | |
| else: | |
| syllables = _convert_words_to_syllables(words) | |
| weak_count += 1 | |
| # Extract song_id from item_name | |
| if song_id_slice is not None: | |
| # Positional extraction: e.g., [0, 4] for "2001000001" -> "2001" | |
| song_name = item_name[song_id_slice[0]:song_id_slice[1]] | |
| elif song_id_indices is not None: | |
| # Split by separator and join selected indices | |
| # e.g., '#'-split [0, 1] for "Alto-1#newboy#0000" -> "Alto-1#newboy" | |
| # e.g., '_'-split [0, 1] for "0_一如年少模样_0" -> "0_一如年少模样" | |
| item_name_parts = item_name.split(song_id_separator) | |
| song_id_parts = [] | |
| for idx in song_id_indices: | |
| if idx < len(item_name_parts): | |
| song_id_parts.append(item_name_parts[idx]) | |
| song_name = song_id_separator.join(song_id_parts) if song_id_parts else item_name | |
| else: | |
| song_name = item_name | |
| # Extract song_folder from wav_fn (e.g., "Alto-1#newboy/0000.wav" -> "Alto-1#newboy") | |
| song_folder = str(Path(wav_fn).parent) if "/" in wav_fn or "\\" in wav_fn else "" | |
| samples.append({ | |
| "audio_path": str(audio_path), | |
| "bpm": bpm if has_score else 0, | |
| "syllables": syllables, | |
| "notes": notes, # Raw note list for duration estimation (empty for weak) | |
| "word": words, | |
| "pitch": pitches, | |
| "pitch_dur": pitch_dur, | |
| "pitch2word": pitch2word, | |
| "word_dur": word_dur, | |
| "item_name": item_name, | |
| "song_name": song_name, # Now contains singer#song format | |
| "song_folder": song_folder, | |
| "dataset_name": dataset_name, | |
| "has_score": has_score, | |
| }) | |
| print(f"[{dataset_name}] Loaded {len(samples)} samples from JSON file" | |
| f" ({weak_count} weak-label)") | |
| return samples | |
| def load_samples_from_weak_json_file( | |
| dataset_name: str, | |
| json_path: str, | |
| audio_root: str, | |
| min_confidence: Optional[str] = None, | |
| max_samples: int = -1, | |
| ) -> List[Dict]: | |
| """Load raw samples from a weak-label JSON file. | |
| Args: | |
| dataset_name: Name identifier for the dataset | |
| json_path: Path to the JSON annotation file | |
| audio_root: Root directory for audio files | |
| min_confidence: Minimum confidence level to include. | |
| "high" = only high; "medium" = medium+high; None = all | |
| max_samples: Maximum number of samples to load | |
| JSON format (array of items):: | |
| [ | |
| { | |
| "audio_path": "song_folder/segment_NNNN/audio.wav", | |
| "transcription": "lyrics text", | |
| "confidence": "high" | "medium" | "low" | |
| }, | |
| ... | |
| ] | |
| """ | |
| json_path = Path(json_path) | |
| audio_root = Path(audio_root) | |
| print(f"[{dataset_name}] Loading from weak JSON file: {json_path}") | |
| with open(json_path, "r", encoding="utf-8") as f: | |
| items = json.load(f) | |
| print(f"[{dataset_name}] Found {len(items)} items in weak JSON file") | |
| # Confidence filtering | |
| confidence_levels = {"high": 3, "medium": 2, "low": 1} | |
| min_conf_val = confidence_levels.get(min_confidence, 0) if min_confidence else 0 | |
| samples = [] | |
| skipped_conf = 0 | |
| skipped_empty = 0 | |
| for item in tqdm(items, desc=f"Processing [{dataset_name}]"): | |
| if max_samples > 0 and len(samples) >= max_samples: | |
| break | |
| transcription = item.get("transcription", "").strip() | |
| if not transcription: | |
| skipped_empty += 1 | |
| continue | |
| confidence = item.get("confidence", "medium") | |
| if confidence_levels.get(confidence, 0) < min_conf_val: | |
| skipped_conf += 1 | |
| continue | |
| wav_fn = item.get("audio_path", "") | |
| if not wav_fn: | |
| continue | |
| audio_path = audio_root / wav_fn | |
| if not audio_path.exists(): | |
| continue | |
| # Each character becomes a word | |
| words = list(transcription) | |
| syllables = _convert_words_to_syllables(words) | |
| # Song name = first path component (e.g. "秦之声 (2024-01-01)-卖妙郎-王楠+") | |
| song_name = Path(wav_fn).parts[0] if Path(wav_fn).parts else "" | |
| # Item name from audio path stem | |
| item_name = str(Path(wav_fn).with_suffix("")) | |
| samples.append({ | |
| "audio_path": str(audio_path), | |
| "bpm": 0, | |
| "syllables": syllables, | |
| "notes": [], | |
| "word": words, | |
| "pitch": [], | |
| "pitch_dur": [], | |
| "pitch2word": [], | |
| "word_dur": [], | |
| "item_name": item_name, | |
| "song_name": song_name, | |
| "song_folder": song_name, | |
| "dataset_name": dataset_name, | |
| "has_score": False, | |
| }) | |
| print(f"[{dataset_name}] Loaded {len(samples)} samples from weak JSON file" | |
| f" (skipped: {skipped_empty} empty, {skipped_conf} low-confidence)") | |
| return samples | |
| def _dataset_signature(ds_config: Dict) -> Dict: | |
| """Config fields that change which samples a dataset yields. | |
| Used as the manifest cache key. Note this captures *config*, not data | |
| content — annotation edits that leave the folder set unchanged are caught | |
| only by the folder-count guard (folder_based) or ``refresh_manifest``. | |
| """ | |
| keys = ("name", "type", "dataset_root", "json_path", "audio_root", | |
| "solo_singer_only", "song_id_indices", "song_id_slice", | |
| "song_id_separator", "min_confidence") | |
| return {k: ds_config.get(k) for k in keys if k in ds_config} | |
| def _manifest_path(manifest_dir: Path, ds_config: Dict) -> Path: | |
| sig = _dataset_signature(ds_config) | |
| h = hashlib.sha1(json.dumps(sig, sort_keys=True, | |
| ensure_ascii=False).encode()).hexdigest()[:10] | |
| name = ds_config.get("name", "unknown") | |
| return manifest_dir / f"{name}__{h}.manifest.json" | |
| def _current_folder_count(ds_config: Dict) -> Optional[int]: | |
| """Cheap directory-count guard for folder_based manifests (one iterdir).""" | |
| if ds_config.get("type") != "folder_based": | |
| return None | |
| root = ds_config.get("dataset_root") | |
| if not root: | |
| return None | |
| try: | |
| return sum(1 for d in Path(root).iterdir() if d.is_dir()) | |
| except Exception: | |
| return None | |
| def load_all_datasets( | |
| datasets_config: List[Dict], | |
| max_samples: int = -1, | |
| manifest_dir: Optional[str] = None, | |
| refresh_manifest: bool = False, | |
| ) -> List[Dict]: | |
| """Load samples from multiple datasets. | |
| Args: | |
| datasets_config: List of dataset configurations, each containing: | |
| - name: Dataset name (will be stored in output) | |
| - type: "folder_based" or "json_file" | |
| - For folder_based: dataset_root | |
| - For json_file: json_path, audio_root | |
| max_samples: Maximum total samples to load (-1 for all) | |
| manifest_dir: If set, cache each dataset's *filtered* sample list to a | |
| JSON manifest here and reuse it on subsequent runs, skipping the | |
| (NFS-latency-bound) folder scan. Only written on a full load | |
| (``max_samples == -1``) so a capped calibration run never poisons | |
| the cache. For folder_based datasets the manifest stores the folder | |
| count and is auto-invalidated if it no longer matches; **annotation | |
| edits that do not change the folder set are NOT detected** — pass | |
| ``refresh_manifest=True`` (or delete the manifest) after editing | |
| metadata. | |
| refresh_manifest: Force a rescan and rewrite of all manifests. | |
| Returns: | |
| Combined list of samples from all datasets | |
| """ | |
| all_samples = [] | |
| mdir = Path(manifest_dir) if manifest_dir else None | |
| if mdir is not None: | |
| mdir.mkdir(parents=True, exist_ok=True) | |
| for ds_config in datasets_config: | |
| ds_name = ds_config.get("name", "unknown") | |
| ds_type = ds_config.get("type", "folder_based") | |
| # Calculate remaining samples if max_samples is set | |
| remaining = max_samples - len(all_samples) if max_samples > 0 else -1 | |
| if max_samples > 0 and remaining <= 0: | |
| print(f"[{ds_name}] Skipping: max_samples reached") | |
| break | |
| # ---- Manifest cache lookup -------------------------------------- | |
| mpath = _manifest_path(mdir, ds_config) if mdir is not None else None | |
| if mpath is not None and mpath.exists() and not refresh_manifest: | |
| try: | |
| with open(mpath, "r", encoding="utf-8") as f: | |
| manifest = json.load(f) | |
| cur_count = _current_folder_count(ds_config) | |
| stale = (cur_count is not None | |
| and manifest.get("folder_count") != cur_count) | |
| if stale: | |
| print(f"[{ds_name}] Manifest stale (folder count " | |
| f"{manifest.get('folder_count')} -> {cur_count}), rescanning") | |
| else: | |
| cached = manifest["samples"] | |
| if max_samples > 0: | |
| cached = cached[:remaining] | |
| print(f"[{ds_name}] Loaded {len(cached)} samples from manifest " | |
| f"cache: {mpath.name} (delete it or set refresh_manifest " | |
| f"if annotations changed)") | |
| all_samples.extend(cached) | |
| continue | |
| except Exception as e: | |
| print(f"[{ds_name}] Failed to read manifest ({e}), rescanning") | |
| if ds_type == "folder_based": | |
| dataset_root = ds_config.get("dataset_root") | |
| if not dataset_root: | |
| print(f"[{ds_name}] Warning: Missing 'dataset_root' for folder_based dataset, skipping") | |
| continue | |
| samples = load_samples_from_folder( | |
| dataset_name=ds_name, | |
| dataset_root=dataset_root, | |
| max_samples=remaining, | |
| solo_singer_only=bool(ds_config.get("solo_singer_only", False)), | |
| ) | |
| elif ds_type == "json_file": | |
| json_path = ds_config.get("json_path") | |
| audio_root = ds_config.get("audio_root") | |
| if not json_path or not audio_root: | |
| print(f"[{ds_name}] Warning: Missing 'json_path' or 'audio_root' for json_file dataset, skipping") | |
| continue | |
| samples = load_samples_from_json_file( | |
| dataset_name=ds_name, | |
| json_path=json_path, | |
| audio_root=audio_root, | |
| song_id_indices=ds_config.get("song_id_indices"), | |
| song_id_slice=ds_config.get("song_id_slice"), | |
| song_id_separator=ds_config.get("song_id_separator", "#"), | |
| max_samples=remaining, | |
| ) | |
| elif ds_type == "weak_json_file": | |
| json_path = ds_config.get("json_path") | |
| audio_root = ds_config.get("audio_root") | |
| if not json_path or not audio_root: | |
| print(f"[{ds_name}] Warning: Missing 'json_path' or 'audio_root' for weak_json_file dataset, skipping") | |
| continue | |
| samples = load_samples_from_weak_json_file( | |
| dataset_name=ds_name, | |
| json_path=json_path, | |
| audio_root=audio_root, | |
| min_confidence=ds_config.get("min_confidence"), | |
| max_samples=remaining, | |
| ) | |
| else: | |
| print(f"[{ds_name}] Warning: Unknown dataset type '{ds_type}', skipping") | |
| continue | |
| # Drop samples with no transcribable lyric content (pure AP/SP clips, | |
| # humming, non-Chinese lyrics left untranscribed). | |
| original_n = len(samples) | |
| samples = [s for s in samples | |
| if reconstruct_lyric_text(s.get("word", []))] | |
| dropped = original_n - len(samples) | |
| if dropped: | |
| print(f"[{ds_name}] Dropped {dropped} empty-lyric sample(s) " | |
| f"(pure AP/SP, humming, or untranscribed non-Chinese)") | |
| # ---- Persist manifest (full loads only) ------------------------- | |
| if mpath is not None and max_samples <= 0: | |
| try: | |
| tmp = mpath.with_suffix(".json.tmp") | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| json.dump({ | |
| "signature": _dataset_signature(ds_config), | |
| "folder_count": _current_folder_count(ds_config), | |
| "num_samples": len(samples), | |
| "samples": samples, | |
| }, f, ensure_ascii=False) | |
| tmp.replace(mpath) # atomic — never leave a half-written manifest | |
| print(f"[{ds_name}] Wrote manifest cache: {mpath.name} " | |
| f"({len(samples)} samples)") | |
| except Exception as e: | |
| print(f"[{ds_name}] Warning: failed to write manifest ({e})") | |
| all_samples.extend(samples) | |
| print(f"\nTotal samples loaded from {len(datasets_config)} dataset(s): {len(all_samples)}") | |
| return all_samples | |