nida-tts / src /nida /data /pilot.py
Bilal7Dev's picture
Nida v0.5 (research preview): weights, 16 voices, code, model card
c0bce59 verified
Raw History Blame Contribute Delete
137 kB
"""Phase 1 pilot runner behind ``nida data-pilot`` (YODAS, ~10 h, end to end on Colab). Stages (each idempotent and resumable; ``all`` runs them in order in one session): check → ingest → sed → diar → asr → ctc → emotion → annotate → report → enhance → latents → dataset → panel * ``ingest`` (CPU): YODAS listing → deterministic selection up to ``max_hours`` → streaming extraction of the selected wavs into the local cache → S1/S2 checks (:mod:`nida.data.ingest`) → ``work/ingest`` manifest + quarantine. * ``sed`` / ``diar`` / ``ctc`` / ``emotion`` / ``asr`` (GPU): model workers in isolated environments over the ingested segments. Array outputs live in the *local* cache; a result whose array is gone (new Colab session) is dropped and recomputed. * ``annotate`` (CPU): S2 music, S3 speakers, S4 dual ASR, S5 alignment, S6 events/styles/ emotion, S7 fusion → ``work/annotate-<config hash>`` manifest + quarantine. Changing any threshold file gives a new directory, so tuning never overwrites earlier outputs. * ``report``: S8 statistics, S10 video-disjoint splits, ``reports/pilot.md``. * ``enhance`` (GPU, optional): speech enhancement of the accepted segments by the arm in ``configs/data/enhance.yaml`` (``uvr`` | ``sidon`` | ``dfn``, :mod:`nida.data.enhance`); a no-op returning ``{"arm": "none"}`` for ``none`` (the baseline). Reads the original wav, writes a 16 kHz mono PCM16 wav of the *same number of samples* to ``<cache>/audio-enh-<arm>/<same relpath>`` and records, per segment, the pre-fix length difference, the peak and the loudness of the written samples. The worker only refuses absurd length differences (``max_length_diff_ms``); the pilot quarantines by ``length_tolerance_ms`` and quarantines worker errors, into ``work/enhance-<arm>-<annotate hash>/quarantine.jsonl``, excluding them from ``latents``, ``dataset``, ``timings`` and ``panel``. Only segments a later stage still reads are re-enhanced in a new session (the wavs are local-cache only), and nothing at all when none is needed. Enhancement changes neither labels nor splits, so ``annotate`` and ``report`` (and their hash) are shared with the baseline and always see the original audio (tags were decided on it); ``report`` gains an enhance section. * ``latents`` (GPU, S9): VAE latents of the accepted segments (frozen AudioVAE V2). With an enhancement arm the *enhanced* wavs are encoded, into their own results folder and array cache (``voxcpm2_audiovae_v2-enh-<arm>``), so baseline and enhanced latents never mix. * ``dataset`` (CPU): the Phase 2 training dataset ``dataset-<config hash>/``: float16 latent shards, ``index.jsonl`` (text, emotion, intensity, rate/loudness buckets, group, split) and ``meta.json`` (written last; its presence marks the stage done). With an enhancement arm the directory is ``dataset-<config hash>-enh-<arm>/``, the loudness buckets come from the enhanced audio, and ``meta.json`` gains an ``enhance`` block (arm, model id, revision, limits, excluded segments and their digest, clipping summary, ``loudness_source``, worker packages and runtime); without an arm nothing about the dataset differs from the pre-enhancement pipeline. * ``panel``: per-tag precision listening panel (:mod:`nida.data.tag_panel`); with an enhancement arm it plays the enhanced audio and its id carries ``-enh-<arm>``. * ``timings`` (extra stage, GPU: CTC only): the unit timeline of every dataset segment for the aligned text path (E11, ADR-0008) -> ``dataset-<hash>/timings.jsonl`` (:mod:`nida.data.timings`). For an enhanced dataset the CTC runs on the enhanced audio. ``all`` normally runs the stages one after another. With ``parallel_workers > 1`` the independent worker stages (``sed``, ``diar``, both ``asr`` systems, ``ctc``, ``emotion``) run concurrently as separate subprocesses (see :meth:`Pilot._run_worker_group`). Durable outputs (manifests, worker results, reports, panels) are on Drive under ``root``; audio and arrays are in ``cache`` (local disk) and are rebuilt when missing. """ from __future__ import annotations import functools import hashlib import io import json import logging import math import shutil from collections import Counter, defaultdict from collections.abc import Callable, Iterable, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict, dataclass, fields from itertools import pairwise from pathlib import Path from typing import TYPE_CHECKING, Any from nida.bench import envs as bench_envs from nida.bench import fetch from nida.bench.b1 import LOG_TAG, MAX_ERROR_FRACTION, _stream_runner from nida.bench.clips import YodasUtt, yodas_video_id from nida.bench.prepare import parse_yodas_listing from nida.bench.protocol import ( WorkRequest, WorkResult, drop_results, load_results, read_summary, write_requests, ) from nida.bench.registry import ASR_SYSTEMS, DATA_PINS, System, load_sources, require_approved from nida.bench.textmetrics import normalize_for_scoring from nida.data import acoustic, enhance, envs, ingest, manifest, stats, tag_panel from nida.data.align import align_words from nida.data.asr_filter import AsrFilterConfig, check_alignment, check_transcripts from nida.data.enhance import NO_ENHANCE, EnhanceConfig from nida.data.events import EventConfig, detect_events from nida.data.fillers_ctc import ( FillerCandidate, FillerConfig, gap_filler_candidates, insert_fillers, ) from nida.data.fusion import FusionConfig, emotion_condition, fuse_segment from nida.data.quarantine import QuarantineLog from nida.data.registry import DATA_MODELS, DataModel, enhance_model from nida.data.speakers import SpeakerConfig, check_speakers from nida.data.splits import assign_splits from nida.data.timings import ( TIMINGS_FILENAME, TimelineResult, build_timings, load_timings, read_index, write_timings, ) from nida.data.types import QuarantineRecord, TimedWord, WordStyleScore from nida.text.fillers import canonical_filler from nida.text.numbers import verbalize_digits from nida.text.turkish import nfc from nida.utils.io import atomic_write_json, atomic_write_text if TYPE_CHECKING: import pyarrow as pa # type: ignore[import-untyped] # no stubs __all__ = [ "ALL_STAGES", "EXTRA_STAGES", "Pilot", "PilotPlan", "QualityConfig", "WorkerJob", "enhanced_audio_root_for", "load_enhance_config", "run_worker_job", ] log = logging.getLogger(__name__) #: Stages outside ``all`` (measurements that never change the dataset). EXTRA_STAGES = ("fillers", "fillers_whisper", "timings") #: Most filler candidates put on the listening panel. FILLER_PANEL_ITEMS = 40 #: ``timings``: suffix of the CTC re-run's results folder / array cache. Distinct from the plain #: ``mpoyraz_ctc_tr`` results, which cover every ingested segment and which ``_run_worker`` #: would prune down to the dataset's segments. TIMINGS_CTC_SUFFIX = "-timings" #: ``fillers_whisper``: accepted segments sent to the prompted Whisper run (seeded rank order). FILLER_WHISPER_SAMPLE = 300 #: ``fillers_whisper``: ASR system that is prompted (approved in configs/sources.yaml). FILLER_WHISPER_SYSTEM = "whisper_large_v3" #: ``fillers_whisper``: results folder; distinct from the plain ``asr-whisper_large_v3`` so #: prompted and plain transcripts can never mix. FILLER_WHISPER_WORKER = f"asr-{FILLER_WHISPER_SYSTEM}-fillerprompt" #: Whisper decoder prompt for the ``fillers_whisper`` experiment. Whisper imitates the style of #: its prompt, so a prompt that *contains* Turkish hesitation sounds (spelled the way the #: canonical set spells them, with elongations) makes it more willing to write such sounds #: instead of dropping them (docs/research/R10-dolgu-farkindalikli-asr.md). Changing this #: string requires a new results folder (the stage refuses to reuse results of another prompt). FILLER_PROMPT = "Iıı, şey, eee... hmm, yani, ımm, aaa." #: A Whisper transcript holding this many consecutive prompt tokens is a prompt echo #: (hallucinated copy of the prompt), not evidence of spoken fillers. PROMPT_ECHO_MIN_TOKENS = 4 #: ``all`` with ``parallel_workers > 1``: the worker stages that depend only on the ingest #: manifest and the extracted audio (not on each other), in their sequential order. PARALLEL_GROUP = ("sed", "diar", "asr", "ctc", "emotion") #: ASR roles, in run order (primary first). ASR_ROLES = ("primary", "secondary") ALL_STAGES = ( "check", "ingest", "sed", "diar", "asr", "ctc", "emotion", "annotate", "report", "enhance", "latents", "dataset", "panel", ) #: Threshold files whose bytes (plus the model pins) name the ``annotate-<digest>`` directory. #: ``enhance.yaml`` is deliberately not one of them (:func:`load_enhance_config`). CONFIG_FILES = ( "acoustic.yaml", "asr_filter.yaml", "dataset.yaml", "events.yaml", "fillers.yaml", "fusion.yaml", "ingest.yaml", "quality.yaml", "speakers.yaml", ) EXTRA_SED_CLASSES = ("Music", "Speech") INGEST_CHUNK = 1000 # rows per manifest part / quarantine flush #: tr000 shards reserved for the B1 layer-C pool (nida.bench.prepare). tr100 has no such #: reservation: the benchmark never reads it. BENCH_SHARDS = frozenset({"00000000", "00000001"}) #: YODAS Turkish subsets the pilot reads (``data/<subset>/{audio,text,duration}/<shard>``): #: ``tr000`` has manual subtitles, ``tr100`` automatic ones. Both use the same file layout. YODAS_SUBSETS = ("tr000", "tr100") #: The subset of every plan that does not name one (and of ``pilot_plan.json`` files written #: before the ``subset`` key existed). DEFAULT_SUBSET = "tr000" #: Number of tr000 shards: ``data/tr000/text/00000000.txt`` .. ``00000063.txt``. Counted from #: the espnet/yodas Hugging Face tree on 2026-10-12 (64 shards of each of audio/text/duration); #: ``00000064.txt`` answers 404 at the pinned revision. The de-duplication against tr000 #: downloads all of them, so a wrong count is not tolerated silently: a missing listing raises. TR000_NUM_SHARDS = 64 TR000_SHARDS = tuple(f"{index:08d}" for index in range(TR000_NUM_SHARDS)) #: Counts key of the utterances dropped because their video is in tr000 (other subsets only). DUPLICATE_VIDEO_KEY = "duplicate_video_in_tr000" FLOAT16_MAX_ABS = 6.0e4 # latents beyond this would overflow the float16 dataset shards DATASET_SHARD_FRAMES = 200_000 # ~2.2 h of 25 Hz latents per shard DURATION_BIN_EDGES_MS = (1000, 2000, 4000, 6000, 8000, 10000, 15000, 20000) LANGUAGE = "tr" # forced language of ASR systems run without language identification #: ``enhance`` stage: the marker (written last) in ``work/enhance-<arm>-<annotate hash>/`` that #: says the stage finished for exactly this accepted set; ``latents``/``dataset``/``timings`` #: of an enhanced dataset refuse to run without it. ENHANCE_MARKER = "enhance.json" #: ``enhance`` stage: quarantine ``stage`` field. ENHANCE_STAGE = "enhance" #: ``meta.json`` ``enhance.loudness_source``: the dataset's loudness buckets are measured on the #: enhanced audio (the worker's ``loudness_dbfs``), not on the ingest audio. ENHANCED_LOUDNESS_SOURCE = "enhanced" #: Sentinels of the enhance section in ``reports/pilot.md``. ENHANCE_REPORT_BEGIN = "<!-- enhance:begin -->" ENHANCE_REPORT_END = "<!-- enhance:end -->" @dataclass(frozen=True) class PilotPlan: """Everything that determines *which* segments the pilot processes.""" seed: int = 20261008 shards: str = "00000002" max_hours: float = 10.0 asr_primary: str = "omniasr_llm_7b" asr_secondary: str = "qwen3_asr_1_7b" # different model family than the primary yodas_revision: str = DATA_PINS["yodas_revision"] panel_per_tag: int = 15 #: YODAS subset (``tr000`` or ``tr100``). Recorded in ``pilot_plan.json``; a plan file #: without the key was written for ``tr000``. subset: str = DEFAULT_SUBSET def __post_init__(self) -> None: if self.subset not in YODAS_SUBSETS: raise ValueError(f"subset must be one of {YODAS_SUBSETS}, got {self.subset!r}") self.shard_list() if not (math.isfinite(self.max_hours) and self.max_hours > 0): raise ValueError(f"max_hours must be a positive finite number, got {self.max_hours!r}") if self.panel_per_tag < 1: raise ValueError("panel_per_tag must be >= 1") reserved = set(self.shard_list()) & BENCH_SHARDS if self.subset == "tr000" else set() if reserved: raise ValueError(f"shards {sorted(reserved)} hold the B1 layer-C pool (faz-1.md)") def shard_list(self) -> list[str]: shards = [s.strip() for s in self.shards.split(",")] if not shards or any(len(s) != 8 or not s.isdigit() for s in shards): raise ValueError(f"invalid shards {self.shards!r}") if len(set(shards)) != len(shards): raise ValueError(f"repeated shard in {self.shards!r}") return shards @dataclass(frozen=True) class QualityConfig: """configs/data/quality.yaml.""" music_prob: float max_music_ratio: float def __post_init__(self) -> None: for name in ("music_prob", "max_music_ratio"): value = getattr(self, name) if isinstance(value, bool) or not isinstance(value, int | float): raise ValueError(f"{name} must be a number") if not (math.isfinite(value) and 0.0 <= value <= 1.0): raise ValueError(f"{name} must be in [0, 1], got {value!r}") @classmethod def from_mapping(cls, raw: Mapping[str, object]) -> QualityConfig: expected = {f.name for f in fields(cls)} if set(raw) != expected: raise ValueError(f"quality config keys must be exactly {sorted(expected)}") return cls(**raw) # type: ignore[arg-type] # validated in __post_init__ @dataclass(frozen=True) class DatasetConfig: """``configs/data/dataset.yaml``: fixed condition-bucket edges (shared by all roots).""" rate_bucket_edges_chars_per_s: tuple[float, ...] loudness_bucket_edges_dbfs: tuple[float, ...] @classmethod def from_mapping(cls, raw: Mapping[str, object]) -> DatasetConfig: from nida.train.data import N_LOUDNESS_BUCKETS, N_RATE_BUCKETS expected = {"rate_bucket_edges_chars_per_s", "loudness_bucket_edges_dbfs"} if set(raw) != expected: raise ValueError( f"dataset config keys: missing={sorted(expected - set(raw))} " f"unknown={sorted(set(raw) - expected)}" ) edges: dict[str, tuple[float, ...]] = {} for key, n_buckets in ( ("rate_bucket_edges_chars_per_s", N_RATE_BUCKETS), ("loudness_bucket_edges_dbfs", N_LOUDNESS_BUCKETS), ): value = raw[key] if not isinstance(value, list) or not all( isinstance(x, int | float) and not isinstance(x, bool) and math.isfinite(x) for x in value ): raise ValueError(f"{key} must be a list of finite numbers") if len(value) != n_buckets - 1 or any(b <= a for a, b in pairwise(value)): raise ValueError(f"{key} must be {n_buckets - 1} strictly increasing edges") edges[key] = tuple(float(x) for x in value) return cls(edges["rate_bucket_edges_chars_per_s"], edges["loudness_bucket_edges_dbfs"]) @dataclass(frozen=True) class _Configs: ingest: ingest.IngestConfig acoustic: acoustic.AcousticConfig asr: AsrFilterConfig dataset: DatasetConfig events: EventConfig fillers: FillerConfig fusion: FusionConfig quality: QualityConfig speakers: SpeakerConfig digest: str #: Not part of ``digest`` (enhancement changes neither labels nor splits). enhance: EnhanceConfig = NO_ENHANCE def _load_yaml(path: Path) -> Mapping[str, object]: import yaml # type: ignore[import-untyped] # PyYAML: bench extra, no stubs installed obj = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(obj, dict): raise ValueError(f"{path}: expected a mapping") return obj def load_enhance_config(config_dir: Path) -> EnhanceConfig: """``enhance.yaml`` of ``config_dir``; a directory without the file means no enhancement. The file is read separately from :data:`CONFIG_FILES` and never enters the annotate digest: the arm must not rename ``annotate-<digest>`` (labels and splits do not depend on it). Raises: ValueError: if the file exists but is not a valid enhance config (the message names it). """ path = config_dir / enhance.CONFIG_FILENAME if not path.exists(): return NO_ENHANCE try: return EnhanceConfig.from_mapping(_load_yaml(path)) except ValueError as err: raise ValueError(f"{path}: {err}") from err def load_configs(config_dir: Path) -> _Configs: """Load and cross-check every threshold file; ``digest`` names the annotate output.""" digest = hashlib.sha1() for name in CONFIG_FILES: digest.update(name.encode("utf-8") + b"\x00" + (config_dir / name).read_bytes()) # Model identity is part of what the outputs depend on (a new pin = new outputs). refs = {k: [m.ref.model_id, m.ref.revision] for k, m in sorted(DATA_MODELS.items())} digest.update(json.dumps(refs, sort_keys=True).encode("utf-8")) fusion_cfg = FusionConfig.from_mapping(_load_yaml(config_dir / "fusion.yaml")) event_cfg = EventConfig.from_mapping(_load_yaml(config_dir / "events.yaml")) event_cfg.check_onset_against({k: b.low for k, b in fusion_cfg.bands.items()}) return _Configs( ingest=ingest.IngestConfig.from_mapping(_load_yaml(config_dir / "ingest.yaml")), acoustic=acoustic.load_acoustic_config(config_dir / "acoustic.yaml"), asr=AsrFilterConfig.from_mapping(_load_yaml(config_dir / "asr_filter.yaml")), dataset=DatasetConfig.from_mapping(_load_yaml(config_dir / "dataset.yaml")), events=event_cfg, fillers=FillerConfig.from_mapping(_load_yaml(config_dir / "fillers.yaml")), fusion=fusion_cfg, quality=QualityConfig.from_mapping(_load_yaml(config_dir / "quality.yaml")), speakers=SpeakerConfig.from_mapping(_load_yaml(config_dir / "speakers.yaml")), digest=digest.hexdigest()[:12], enhance=load_enhance_config(config_dir), ) #: Qwen3-ASR language names -> codes used in asr_filter.yaml ``allowed_languages``. _QWEN_LANGUAGE_CODES = {"Turkish": "tr", "English": "en"} def language_code(name: str) -> str: """Map a detected language name to a code; unknown names are kept (and so rejected).""" return _QWEN_LANGUAGE_CODES.get(name.strip(), name.strip() or "unknown") def clean_asr_text(text: str) -> str: """NFC + undo the ``i`` + U+0307 that some ASR models emit for a lower-cased ``İ``. omniASR writes "İtalya" as "i̇talya" (i followed by COMBINING DOT ABOVE); NFC cannot compose it, so the alignment vocabulary rejects it (pilot smoke run 2026-10-08). """ return nfc(text).replace("i\u0307", "i") def normalise_transcript(text: str) -> str | None: """:func:`clean_asr_text`, then digits to Turkish words (tag-spec §4: text is verbalized). Both ASR transcripts go through this before the agreement check, so "3" and "üç" agree, and the alignment and the stored text never contain digits. ``None`` when a digit token is ambiguous or unsupported (``14:30``, ``5km``); the caller quarantines the segment. """ return verbalize_digits(clean_asr_text(text)) @dataclass(frozen=True) class WhisperFiller: """One filler token in a Whisper transcript, located by its neighbouring words. Attributes: index: Position in the normalised token list of the transcript. filler: Canonical filler (a member of :data:`nida.text.fillers.FILLERS`). before: Nearest preceding non-filler word, ``None`` at the start of the text. after: Nearest following non-filler word, ``None`` at the end of the text. """ index: int filler: str before: str | None after: str | None def filler_tokens(text: str) -> list[str]: """Tokens of an ASR transcript, fillers in canonical spelling. NFC (:func:`clean_asr_text`), Turkish lower-case, punctuation split, and every filler spelling variant mapped through ``canonical_filler`` (``"Ee..."`` becomes ``"ee"``). """ return normalize_for_scoring(clean_asr_text(text)) def find_whisper_fillers(tokens: Sequence[str]) -> list[WhisperFiller]: """Canonical fillers among ``tokens`` with their non-filler neighbours. ``tokens`` must come from :func:`filler_tokens`. Consecutive fillers share the same neighbouring words. """ flags = [canonical_filler(t) for t in tokens] found: list[WhisperFiller] = [] for i, canonical in enumerate(flags): if canonical is None: continue before = next((tokens[j] for j in range(i - 1, -1, -1) if flags[j] is None), None) after = next((tokens[j] for j in range(i + 1, len(tokens)) if flags[j] is None), None) found.append(WhisperFiller(i, canonical, before, after)) return found def mark_fillers(tokens: Sequence[str]) -> str: """``tokens`` joined by spaces with every filler shown as ``⟦ıı?⟧`` (listening panel).""" return " ".join(f"⟦{t}?⟧" if canonical_filler(t) is not None else t for t in tokens) #: Hesitation fillers (the ones the experiment is about); ``of``, ``ah``, ``vay`` ... are #: interjections Whisper writes without any prompt and are reported separately. HESITATION_FILLERS = frozenset({"ıı", "ee", "hmm", "ımm", "aa"}) def echoes_prompt(tokens: Sequence[str], prompt_tokens: Sequence[str]) -> bool: """True if ``tokens`` contain :data:`PROMPT_ECHO_MIN_TOKENS` consecutive prompt tokens, or consist only of prompt words / fillers (a partial echo or a filler loop). Whisper sometimes repeats its prompt instead of transcribing; the fillers of such a transcript come from the prompt, not from the audio. """ vocabulary = set(prompt_tokens) if tokens and all(t in vocabulary or canonical_filler(t) is not None for t in tokens): return True n = PROMPT_ECHO_MIN_TOKENS windows = {tuple(prompt_tokens[i : i + n]) for i in range(len(prompt_tokens) - n + 1)} return any(tuple(tokens[i : i + n]) in windows for i in range(len(tokens) - n + 1)) def _basename_in(member: str, *, names: frozenset[str]) -> bool: """Archive member selector: the member's file name (any folder) is one of ``names``.""" return member.rsplit("/", 1)[-1] in names def _rank(seed: int, utt_id: str) -> str: return hashlib.sha1(f"{seed}\x1f{utt_id}".encode()).hexdigest() def select_utterances( utts: Sequence[YodasUtt], plan: PilotPlan, cfg: ingest.IngestConfig ) -> list[YodasUtt]: """Deterministic pick: listing duration in range, hash order, up to ``max_hours``.""" budget_ms = plan.max_hours * 3_600_000 eligible = [u for u in utts if cfg.min_duration_ms <= u.duration_ms <= cfg.max_duration_ms] eligible.sort(key=lambda u: _rank(plan.seed, u.utt_id)) chosen: list[YodasUtt] = [] total = 0 for utt in eligible: if total + utt.duration_ms > budget_ms: break chosen.append(utt) total += utt.duration_ms return sorted(chosen, key=lambda u: u.utt_id) def video_ids_in_text_listing(text_file: str, shard: str) -> set[str]: """Video ids of a YODAS ``text/<shard>.txt`` listing (lines ``<utt_id> <caption>``). Raises: ValueError: if a line's utterance id has the wrong shape, or the listing has no utterance at all (a real shard is never empty). An empty time span is not an error here: the line still names its video. """ videos: set[str] = set() for number, line in enumerate(text_file.splitlines(), start=1): if not line.strip(): continue utt_id = line.split(" ", 1)[0] try: videos.add(yodas_video_id(utt_id)) except ValueError as err: raise ValueError(f"YODAS text listing {shard} line {number}: {err}") from err if not videos: raise ValueError(f"YODAS text listing {shard} holds no utterance") return videos def drop_videos( utts: Sequence[YodasUtt], blocked_videos: frozenset[str] ) -> tuple[list[YodasUtt], int]: """Split off the utterances whose video is in ``blocked_videos``. Returns the kept utterances (order preserved) and the number dropped. An utterance whose id has no parseable video part is kept: the ingest stage quarantines it as ``bad_utt_id``, so it is still accounted for. """ kept: list[YodasUtt] = [] for utt in utts: try: video_id = yodas_video_id(utt.utt_id) except ValueError: kept.append(utt) continue if video_id not in blocked_videos: kept.append(utt) return kept, len(utts) - len(kept) def audio_root_for(cache: Path, subset: str) -> Path: """Folder holding ``<shard>/<utt_id>.wav`` of ``subset`` in the local cache. tr000 keeps the original ``<cache>/audio`` (existing caches stay valid); every other subset has its own ``<cache>/audio/<subset>`` because shard numbers repeat across subsets. """ return cache / "audio" if subset == DEFAULT_SUBSET else cache / "audio" / subset def enhanced_audio_root_for(cache: Path, subset: str, arm: str) -> Path: """Folder holding ``<shard>/<utt_id>.wav`` of ``arm``'s enhanced audio of ``subset``. Same layout as :func:`audio_root_for` with ``audio`` replaced by ``audio-enh-<arm>``, so ``<root>/<audio_relpath>`` is the enhanced twin of ``<audio_root>/<audio_relpath>``. """ base = cache / enhance.audio_dir_name(arm) return base if subset == DEFAULT_SUBSET else base / subset def _untagged_event_counts(rows: Sequence[Mapping[str, object]]) -> dict[str, int]: """Per-kind number of untagged (below-band) detections in accepted annotate rows. Raises: ValueError: a row without ``untagged_events_json`` (written before ADR-0010). """ counts: Counter[str] = Counter() for row in rows: raw = row.get("untagged_events_json") if not isinstance(raw, str): raise ValueError(f"annotate row {row.get('segment_id')!r} has no untagged_events_json") counts.update(str(e["kind"]) for e in json.loads(raw)) return dict(sorted(counts.items())) def _annotate_schema() -> pa.Schema: import pyarrow as pa return manifest.schema_from_fields( [ ("segment_id", pa.string()), ("utt_id", pa.string()), ("video_id", pa.string()), ("audio_relpath", pa.string()), ("duration_ms", pa.int64()), ("text", pa.string()), ("plain_transcript", pa.string()), ("tags_json", pa.string()), ("tag_ids_json", pa.string()), ("emotion", pa.string(), True), ("intensity", pa.float64(), True), ("asr_cer", pa.float64()), ("align_score", pa.float64()), ("shout_measured", pa.bool_()), # ADR-0010: below-band event detections left untagged in an accepted segment # (mostly uncertain breaths); [{kind, start_ms, end_ms, conf, source}]. ("untagged_events_json", pa.string()), ] ) @dataclass(frozen=True) class WorkerJob: """One prepared worker invocation: everything :meth:`Pilot._run_worker` needs. Attributes: name: Results folder name under ``<root>/workers/`` (also the log tag). python: Interpreter of the worker's isolated environment. module: Worker module run with ``python -m``. model_id: Pinned model id. revision: Pinned model revision. batch_size: Worker batch size. requests: One request per segment. array_outputs: True if results point to arrays in the local cache. """ name: str python: Path module: str model_id: str revision: str batch_size: int requests: tuple[WorkRequest, ...] array_outputs: bool def run_worker_job( out_dir: Path, name: str, python: Path | Callable[[], Path], module: str, model_id: str, revision: str, batch_size: int, requests: Sequence[WorkRequest], *, array_outputs: bool, runner: Callable[[Sequence[str]], int], retry_failed: bool = False, skip_if_complete: bool = False, ) -> dict[str, WorkResult]: """Run one model worker over ``requests`` (resumable) and return its results. ``out_dir`` holds ``requests.jsonl``, ``results.jsonl`` and ``results.summary.json``. A results file written by another model/revision is refused; results for clips that are no longer requested, and results whose array output vanished from the local cache, are dropped (and so recomputed) before the worker is started. Args: out_dir: Results folder of this worker (created on demand). name: Log tag / error prefix. python: Interpreter of the worker's environment, or a function returning it that is called only if the worker is actually started (building an environment is expensive and pointless when every result already exists). module: Worker module (``python -m``). model_id: Pinned model id. revision: Pinned model revision. batch_size: Worker batch size. requests: One request per clip (must not be empty). array_outputs: ``True`` if results point to ``.npy`` files in the local cache. runner: Runs the command line, returns the exit code. retry_failed: Pass ``--retry-failed`` to the worker. skip_if_complete: Do not start the worker when every requested clip already has a result (and, with ``retry_failed``, none of them failed). The failure-fraction check still applies to the stored results. Raises: RuntimeError: no requests, results of another model, worker failure, a result count that differs from the request count, or more than ``MAX_ERROR_FRACTION`` failures. """ if not requests: raise RuntimeError(f"{name}: no requests; did the previous stage produce any segment?") results_path = out_dir / "results.jsonl" wanted = {r.clip_id for r in requests} summary_path = out_dir / "results.summary.json" if results_path.exists() and summary_path.exists(): previous = read_summary(summary_path) if (previous.model_id, previous.revision) != (model_id, revision): raise RuntimeError( f"{name}: existing results come from {previous.model_id}@" f"{previous.revision}, not {model_id}@{revision}; move them away first" ) if results_path.exists(): existing = load_results(results_path, repair=True) stray = [cid for cid in existing if cid not in wanted] lost: list[str] = [] if array_outputs: by_id = {r.clip_id: r for r in requests} lost = [ cid for cid, res in existing.items() if cid in by_id and res.output is not None and not Path(str(res.output.get("out_path", ""))).exists() ] if stray or lost: log.warning("%s: dropping %d stray and %d lost results", name, len(stray), len(lost)) drop_results(results_path, [*stray, *lost]) if skip_if_complete: kept = load_results(results_path) failed = sum(1 for cid in wanted if cid in kept and kept[cid].output is None) if wanted <= set(kept) and not (retry_failed and failed): if failed > MAX_ERROR_FRACTION * len(wanted): raise RuntimeError( f"{name}: {failed}/{len(wanted)} failed; fix, then --retry-failed" ) return kept write_requests(out_dir / "requests.jsonl", requests) interpreter = python() if callable(python) else python cmd = [ str(interpreter), "-m", module, "--requests", str(out_dir / "requests.jsonl"), "--results", str(results_path), "--model-id", model_id, "--revision", revision, "--batch-size", str(batch_size), "--device", "cuda", *(["--retry-failed"] if retry_failed else []), ] code = runner(cmd) if code != 0: raise RuntimeError(f"{name}: worker exited with code {code}") summary = read_summary(out_dir / "results.summary.json") total = summary.n_ok + summary.n_error if total != len(requests): raise RuntimeError(f"{name}: {total} results for {len(requests)} requests") if summary.n_error > MAX_ERROR_FRACTION * total: raise RuntimeError(f"{name}: {summary.n_error}/{total} failed; fix, then --retry-failed") return load_results(results_path) @dataclass(frozen=True) class _Enhanced: """What the pilot keeps of one successful enhancement result.""" length_diff_samples: int peak: float loudness_dbfs: float output: Mapping[str, object] def _ids_digest(ids: Iterable[str]) -> str: """SHA-256 of the sorted ids (identity of the excluded set, recorded in marker and meta).""" return hashlib.sha256("\n".join(sorted(ids)).encode("utf-8")).hexdigest() def _number(segment_id: str, output: Mapping[str, object], key: str) -> float: value = output.get(key) if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value): raise RuntimeError(f"{segment_id}: enhance result has no finite number {key!r}") return float(value) def _enhance_outcome( segment_id: str, result: WorkResult, cfg: EnhanceConfig, clipping_threshold: float, wav_path: Path | None, ) -> _Enhanced | QuarantineRecord: """Judge one enhancement result: what to keep of it, or the quarantine record. The worker only refuses absurd length differences; the tolerance and the clipping limit are applied here, to the recorded ``length_diff_samples`` and ``clipping_ratio`` (so the config governs the outcome of every run). A length difference is judged before clipping. Args: clipping_threshold: ``acoustic.yaml`` threshold the current run counts clipping with; a result made with another one is an error (its ratio means something else). wav_path: Where the segment's wav must be (checked when a later stage will read it); ``None`` = nobody needs it in this session, its absence is expected. Raises: RuntimeError: if a successful result lacks ``length_diff_samples``, ``peak``, ``loudness_dbfs`` or ``clipping_ratio``, was made with another clipping threshold, or its wav is missing from ``wav_path`` (a worker that lies is a bug, not a bad segment). """ if result.output is None: reason = ( enhance.REASON_LENGTH if enhance.is_length_error(result.error) else enhance.REASON_FAILED ) return QuarantineRecord(segment_id, ENHANCE_STAGE, reason, {"error": result.error}) diff = result.output.get("length_diff_samples") if isinstance(diff, bool) or not isinstance(diff, int): raise RuntimeError(f"{segment_id}: enhance result has no integer length_diff_samples") peak = _number(segment_id, result.output, "peak") loudness = _number(segment_id, result.output, "loudness_dbfs") ratio = _number(segment_id, result.output, "clipping_ratio") if _number(segment_id, result.output, "clipping_threshold") != clipping_threshold: raise RuntimeError( f"{segment_id}: the enhance result counts clipping at " f"{result.output['clipping_threshold']}, acoustic.yaml says {clipping_threshold}; " "move the enhance worker results away and rerun" ) if wav_path is not None and not wav_path.is_file(): raise RuntimeError(f"{segment_id}: enhance result without a wav at {wav_path}") if abs(diff) > cfg.length_tolerance_samples: return QuarantineRecord( segment_id, ENHANCE_STAGE, enhance.REASON_LENGTH, {"length_diff_samples": diff, "tolerance_samples": cfg.length_tolerance_samples}, ) if ratio > cfg.max_enhanced_clipping_ratio: return QuarantineRecord( segment_id, ENHANCE_STAGE, enhance.REASON_CLIPPED, { "clipping_ratio": ratio, "max_enhanced_clipping_ratio": cfg.max_enhanced_clipping_ratio, "clipping_threshold": clipping_threshold, "peak": peak, }, ) return _Enhanced(diff, peak, loudness, result.output) def _worker_runtime(outputs: Iterable[Mapping[str, object]]) -> dict[str, object]: """Facts the worker added to its payloads (:data:`nida.data.enhance.PAYLOAD_KEYS` aside). One value per key; if the items disagree (a model file changed between sessions) the key maps to the list of distinct values and a warning is logged. """ seen: dict[str, dict[str, object]] = {} for output in outputs: for key, value in output.items(): if key not in enhance.PAYLOAD_KEYS: seen.setdefault(key, {})[json.dumps(value, sort_keys=True)] = value runtime: dict[str, object] = {} for key, values in sorted(seen.items()): if len(values) > 1: log.warning("enhance: the worker reported %d different values of %s", len(values), key) runtime[key] = list(values.values()) else: runtime[key] = next(iter(values.values())) return runtime def _enhance_section(marker: Mapping[str, Any]) -> str: """The ``reports/pilot.md`` section of an enhanced run (between sentinel comments).""" length, clipping = marker["length_diff_samples"], marker["clipping"] lines = [ ENHANCE_REPORT_BEGIN, f"## Konuşma iyileştirme ({marker['arm']})", "", f"- Model: `{marker['model_id']}` @ `{marker['revision']}`", f"- Kabul edilen segment: {marker['accepted']}; iyileştirilen: {marker['enhanced']}", f"- Karantina: {json.dumps(marker['quarantined'], ensure_ascii=False)}", f"- Uzunluk farkı: en çok {length['max_abs']} örnek; sıfırdan farklı " f"{length['nonzero']} segment (tolerans {marker['length_tolerance_ms']} ms, işçi " f"sınırı {marker['max_length_diff_ms']} ms)", f"- Tam ölçeği aşan (tepe > 1,0; PCM16'ya kırpılır): " f"{clipping['segments_over_full_scale']} segment; en yüksek tepe {clipping['max_peak']}", f"- Kırpma oranı sınırı ({marker['max_enhanced_clipping_ratio']}, eşik " f"{marker['clipping_threshold']}) nedeniyle karantinaya alınan: " f"{clipping['quarantined']} segment; en yüksek oran {clipping['max_ratio']}", "- Etiketler özgün ses üzerinde belirlendi (annotate); iyileştirilmiş sesle yeniden " "doğrulanmadı.", ENHANCE_REPORT_END, ] return "\n".join(lines) def _with_enhance_section(text: str, marker: Mapping[str, Any] | None) -> str: """``text`` with its enhance section replaced by (or, for ``None``, stripped of) the marker's. Idempotent, and ``report`` and ``enhance`` produce the same bytes in either order. """ begin = text.find(ENHANCE_REPORT_BEGIN) if begin >= 0: end = text.index(ENHANCE_REPORT_END, begin) + len(ENHANCE_REPORT_END) text = text[:begin].rstrip("\n") + "\n" + text[end:].lstrip("\n") if marker is None: return text return text.rstrip("\n") + "\n\n" + _enhance_section(marker) + "\n" @dataclass class Pilot: root: Path cache: Path envs_root: Path repo_dir: Path sources_path: Path config_dir: Path plan: PilotPlan = PilotPlan() uv: str | None = None retry_failed: bool = False #: ASR batch size override (operational: the bench sizes are for a 22 GB L4; a 96 GB G4 #: takes far larger batches). Not part of the plan: it does not change the outputs. asr_batch_size: int | None = None #: Worker subprocesses ``run("all")`` may run at the same time (``sed``, ``diar``, both #: ASR systems, ``ctc``, ``emotion``). 1 = the plain sequential order. Operational like #: ``asr_batch_size``: it does not change the outputs. parallel_workers: int = 1 runner: Callable[[Sequence[str]], int] = _stream_runner opener: fetch.Opener = fetch.default_opener def __post_init__(self) -> None: if isinstance(self.parallel_workers, bool) or self.parallel_workers < 1: raise ValueError( f"parallel_workers must be an integer >= 1, got {self.parallel_workers!r}" ) # ---------------------------------------------------------------- paths/helpers @property def work(self) -> Path: return self.root / "work" @property def audio_root(self) -> Path: return audio_root_for(self.cache, self.plan.subset) def _enhanced_root(self, cfg: EnhanceConfig) -> Path: return enhanced_audio_root_for(self.cache, self.plan.subset, cfg.arm) def _dataset_dir(self, out_dir: Path, cfg: EnhanceConfig) -> Path: """``dataset-<annotate hash>``, plus ``-enh-<arm>`` for an enhancement arm.""" suffix = enhance.arm_suffix(cfg.arm) if cfg.enabled else "" return self.root / f"dataset-{out_dir.name.removeprefix('annotate-')}{suffix}" def _primary(self) -> System: return ASR_SYSTEMS[self.plan.asr_primary] def _secondary(self) -> System: return ASR_SYSTEMS[self.plan.asr_secondary] def _require(self) -> None: if self.plan.asr_primary == self.plan.asr_secondary: raise ValueError("primary and secondary ASR must differ") if self._primary().worker == self._secondary().worker: # the agreement filter needs uncorrelated errors (ADR-0001 rule 2, ADR-0011); "family" # is approximated by the worker module (e.g. Whisper and BuzzASR share asr_hf) raise ValueError( "primary and secondary ASR must come from different model families, got " f"{self.plan.asr_primary} and {self.plan.asr_secondary} " f"(both {self._primary().worker})" ) if self._language_role() is None: raise ValueError( "no ASR system of the plan identifies the language: the S4 language filter " "would be silently disabled (docs/03; use a Qwen3-ASR system in either role)" ) needed = {"yodas_tr", self._primary().source_id, self._secondary().source_id} needed |= {m.source_id for m in DATA_MODELS.values()} arm = load_enhance_config(self.config_dir) if arm.enabled: needed.add(enhance_model(arm.arm).source_id) require_approved(needed, load_sources(self.sources_path)) def _uv(self) -> str: if self.uv is None: self.uv = bench_envs.find_uv(bench_envs.subprocess_runner) return self.uv def _plan_check(self) -> None: path = self.root / "manifests" / "pilot_plan.json" if path.exists(): existing = json.loads(path.read_text(encoding="utf-8")) if isinstance(existing, dict) and "subset" not in existing: existing = {**existing, "subset": DEFAULT_SUBSET} # written before the key existed if existing != asdict(self.plan): raise ValueError( f"{path} was written for a different plan: {existing} != {asdict(self.plan)}" ) else: atomic_write_json(path, asdict(self.plan)) def _listing_file(self, subset: str, kind: str, shard: str) -> Path: """Download (cached) ``data/<subset>/<kind>/<shard>.txt``; ``kind``: text | duration. tr000 keeps the original cache folder ``<cache>/yodas/<kind>``; other subsets use ``<cache>/yodas/<subset>/<kind>`` (shard numbers repeat across subsets). """ name = f"data/{subset}/{kind}/{shard}.txt" url = fetch.hf_resolve_url("espnet/yodas", self.plan.yodas_revision, name, "dataset") folder = self.cache / "yodas" dest = (folder if subset == DEFAULT_SUBSET else folder / subset) / kind / f"{shard}.txt" return fetch.download(url, dest, opener=self.opener).path def _listing(self, shard: str) -> list[YodasUtt]: subset = self.plan.subset return parse_yodas_listing( self._listing_file(subset, "text", shard).read_text(encoding="utf-8"), self._listing_file(subset, "duration", shard).read_text(encoding="utf-8"), shard, ) def _tr000_video_ids(self) -> frozenset[str]: """Video ids of all tr000 shards (text listings only), to de-duplicate other subsets. Raises: nida.bench.fetch.FetchError: if a tr000 text listing cannot be downloaded. """ videos: set[str] = set() for shard in TR000_SHARDS: try: path = self._listing_file(DEFAULT_SUBSET, "text", shard) except fetch.FetchError as err: raise fetch.FetchError( f"tr000 text listing {shard} is unavailable at revision " f"{self.plan.yodas_revision}; the de-duplication needs all " f"{TR000_NUM_SHARDS} listings (TR000_SHARDS): {err}" ) from err videos |= video_ids_in_text_listing(path.read_text(encoding="utf-8"), shard) log.info("tr000 holds %d videos in %d shards", len(videos), TR000_NUM_SHARDS) return frozenset(videos) def _selection(self, cfgs: _Configs) -> tuple[list[YodasUtt], dict[str, int]]: """Selected utterances and listing counts (nothing leaves the listing uncounted). ``listed`` = ``listed_duration_out_of_range`` + ``beyond_hour_budget`` + ``selected`` (+ ``duplicate_video_in_tr000`` for a subset other than tr000). The duplicates are removed first, so the duration counts describe the utterances that remain. """ listed = [u for shard in self.plan.shard_list() for u in self._listing(shard)] ids = [u.utt_id for u in listed] if len(set(ids)) != len(ids): raise ValueError("YODAS utterance ids repeat across the pilot shards") utts, duplicates = listed, 0 if self.plan.subset != DEFAULT_SUBSET: utts, duplicates = drop_videos(listed, self._tr000_video_ids()) log.info( "%s: dropped %d of %d listed utterances whose video is in tr000", self.plan.subset, duplicates, len(listed), ) chosen = select_utterances(utts, self.plan, cfgs.ingest) in_range = sum( 1 for u in utts if cfgs.ingest.min_duration_ms <= u.duration_ms <= cfgs.ingest.max_duration_ms ) counts = { "listed": len(listed), "listed_duration_out_of_range": len(utts) - in_range, "beyond_hour_budget": in_range - len(chosen), "selected": len(chosen), } if self.plan.subset != DEFAULT_SUBSET: counts[DUPLICATE_VIDEO_KEY] = duplicates return chosen, counts def _extract(self, utts: Sequence[YodasUtt]) -> None: """Make sure every given utterance's wav is in the cache (streams each shard once). Members are matched by file name, not by their folder inside the archive: some YODAS tr000 archives (00000008 and later) keep every wav under ``00000000/`` instead of their own shard number. Extracted files go to a staging folder first and are then moved to ``<audio_root>/<shard>/<utt_id>.wav``; a file name that occurs in two archive folders is an error (the utterance would be ambiguous). """ by_shard: dict[str, set[str]] = defaultdict(set) for utt in utts: if not (self.audio_root / utt.shard / f"{utt.utt_id}.wav").exists(): by_shard[utt.shard].add(f"{utt.utt_id}.wav") for shard, wanted in sorted(by_shard.items()): url = fetch.hf_resolve_url( "espnet/yodas", self.plan.yodas_revision, f"data/{self.plan.subset}/audio/{shard}.tar.gz", "dataset", ) log.info("extracting %d wavs from shard %s", len(wanted), shard) staging = self.audio_root / ".staging" / shard extracted = fetch.stream_extract( url, staging, functools.partial(_basename_in, names=frozenset(wanted)), opener=self.opener, ) by_name: dict[str, Path] = {} for path in extracted: if path.name in by_name: raise RuntimeError( f"shard {shard}: {path.name} occurs twice in the archive " f"({by_name[path.name].parent.name}/ and {path.parent.name}/)" ) by_name[path.name] = path elsewhere = sum(1 for p in by_name.values() if p.parent.name != shard) if elsewhere: log.warning("shard %s: %d wavs sit in another archive folder", shard, elsewhere) target_dir = self.audio_root / shard target_dir.mkdir(parents=True, exist_ok=True) for name, path in by_name.items(): path.replace(target_dir / name) shutil.rmtree(staging) def _ingested(self) -> list[dict[str, object]]: stage_dir = self.work / "ingest" return manifest.read_manifest(stage_dir) if stage_dir.exists() else [] def _run_worker( self, name: str, python: Path, module: str, model_id: str, revision: str, batch_size: int, requests: Sequence[WorkRequest], *, array_outputs: bool, ) -> dict[str, WorkResult]: """Run one worker over ``requests`` (resumable) and return its results.""" return run_worker_job( self.root / "workers" / name, name, python, module, model_id, revision, batch_size, requests, array_outputs=array_outputs, runner=self.runner, retry_failed=self.retry_failed, ) def _pending( self, name: str, rows: Sequence[Mapping[str, object]], *, array_outputs: bool, include_failed: bool = True, ) -> list[Mapping[str, object]]: """Rows whose worker result is missing, failed or lost its array (need audio). ``include_failed=False`` leaves out rows with a recorded failure (a worker only retries those under ``--retry-failed``, so their audio is not needed otherwise). """ existing = load_results(self.root / "workers" / name / "results.jsonl", repair=True) pending: list[Mapping[str, object]] = [] for row in rows: result = existing.get(str(row["segment_id"])) lost = ( result is not None and result.output is not None and array_outputs and not Path(str(result.output.get("out_path", ""))).is_file() ) if result is None or (result.output is None and include_failed) or lost: pending.append(row) return pending def _prepare_data_job( self, model: DataModel, params: Callable[[str], dict[str, str]], *, name: str | None = None, array_outputs: bool = True, rows: Sequence[Mapping[str, object]] | None = None, extract: bool = True, audio_root: Path | None = None, extract_failed: bool = True, ) -> WorkerJob: """Prepare a data-model worker: audio, environment and requests (main thread only). ``extract_failed=False`` does not extract the audio of rows whose result is a recorded failure (the enhancement stage: the worker skips them unless ``--retry-failed``). ``extract=False`` leaves the audio to the caller (the parallel group extracts the union of its jobs' pending rows once, before any thread starts). ``audio_root`` reads another audio root than the original one (the enhanced wavs of an enhancement arm). Nothing is extracted for it: a pending row whose wav is missing is an error naming the stage that makes it. """ rows = self._ingested() if rows is None else rows key = name or model.key if audio_root is not None: self._require_audio( audio_root, self._pending(key, rows, array_outputs=array_outputs), key ) elif extract: pending = self._pending( key, rows, array_outputs=array_outputs, include_failed=extract_failed ) self._extract(self._utts_of(pending)) spec = envs.data_env_for_worker(model.worker) python = bench_envs.ensure_env(spec, self.envs_root, self.repo_dir, uv=self._uv()) root = self.audio_root if audio_root is None else audio_root requests = tuple( WorkRequest( str(row["segment_id"]), str(root / str(row["audio_relpath"])), params(str(row["segment_id"])), ) for row in rows ) return WorkerJob( key, python, envs.worker_module_name(model.worker), model.ref.model_id, model.ref.revision, model.batch_size, requests, array_outputs, ) @staticmethod def _require_audio(root: Path, rows: Sequence[Mapping[str, object]], what: str) -> None: """Raise if a wav of ``rows`` is missing under ``root`` (never extracted).""" missing = [ str(row["audio_relpath"]) for row in rows if not (root / str(row["audio_relpath"])).is_file() ] if missing: raise RuntimeError( f"{what}: {len(missing)} enhanced wavs are missing from {root} (e.g. " f"{missing[:3]}); run the enhance stage first (a new Colab session loses them)" ) def _execute_job(self, job: WorkerJob, *, tag_logs: bool = False) -> dict[str, WorkResult]: """Run a prepared job (blocks until its subprocess ends; safe to call from a thread). With ``tag_logs`` every streamed worker line is prefixed with the job name. """ token = LOG_TAG.set(job.name) if tag_logs else None try: return self._run_worker( job.name, job.python, job.module, job.model_id, job.revision, job.batch_size, job.requests, array_outputs=job.array_outputs, ) finally: if token is not None: LOG_TAG.reset(token) def _data_worker( self, model: DataModel, params: Callable[[str], dict[str, str]], *, name: str | None = None, array_outputs: bool = True, rows: Sequence[Mapping[str, object]] | None = None, audio_root: Path | None = None, ) -> None: self._execute_job( self._prepare_data_job( model, params, name=name, array_outputs=array_outputs, rows=rows, audio_root=audio_root, ) ) def _npy(self, model: DataModel, segment: str) -> str: return str(self.cache / "arrays" / model.key / f"{segment}.npy") @staticmethod def _utts_of(rows: Sequence[Mapping[str, object]]) -> list[YodasUtt]: """Extraction keys from ingest *or* annotate rows (both carry utt_id, duration_ms and audio_relpath = ``<shard>/<utt_id>.wav``; captions are not needed).""" utts: list[YodasUtt] = [] for r in rows: shard, _, name = str(r["audio_relpath"]).partition("/") if name != f"{r['utt_id']}.wav" or not shard: raise ValueError(f"unexpected audio_relpath {r['audio_relpath']!r}") utts.append(YodasUtt(str(r["utt_id"]), "", int(str(r["duration_ms"])), shard)) return utts # ------------------------------------------------------------------- stages def run(self, stage: str) -> dict[str, object]: if stage == "all": out: dict[str, object] = {} for name in ALL_STAGES: if self.parallel_workers > 1 and name in PARALLEL_GROUP: if name == PARALLEL_GROUP[0]: out.update(self._run_worker_group()) continue out[name] = self.run(name) return out if stage not in ALL_STAGES + EXTRA_STAGES: known = ", ".join(ALL_STAGES + EXTRA_STAGES) raise ValueError(f"unknown pilot stage {stage!r}; known: {known}") log.info("=== pilot stage %s", stage) self._require() self._plan_check() handler: Callable[[], dict[str, object]] = getattr(self, "_stage_" + stage) return handler() def _stage_check(self) -> dict[str, object]: cfgs = load_configs(self.config_dir) return {"configs": cfgs.digest, "shards": self.plan.shard_list()} def _stage_ingest(self) -> dict[str, object]: cfgs = load_configs(self.config_dir) selection, listing = self._selection(cfgs) stage_dir = self.work / "ingest" qlog = QuarantineLog(stage_dir) qlog.recover() done = qlog.quarantined_ids() if stage_dir.exists(): done |= {str(r["utt_id"]) for r in manifest.read_manifest(stage_dir, ["utt_id"])} todo = [u for u in selection if u.utt_id not in done] self._extract(todo) rows: list[dict[str, object]] = [] drops: list[QuarantineRecord] = [] for index, utt in enumerate(todo, start=1): result: dict[str, object] | QuarantineRecord if not (self.audio_root / utt.shard / f"{utt.utt_id}.wav").exists(): result = QuarantineRecord( utt.utt_id, ingest.STAGE, "missing_from_archive", {"shard": utt.shard} ) else: result = ingest.ingest_utterance(utt, self.audio_root, cfgs.ingest, cfgs.acoustic) if isinstance(result, QuarantineRecord): drops.append(result) else: rows.append(result) if len(rows) >= INGEST_CHUNK or index == len(todo): if drops: qlog.append(drops) drops = [] if rows: manifest.write_part(stage_dir, rows, ingest.ingest_schema()) rows = [] log.info("ingest: %d/%d", index, len(todo)) kept = self._ingested() hours = sum(int(str(r["duration_ms"])) for r in kept) / 3_600_000 return { "listing": listing, "selected": len(selection), "kept": len(kept), "kept_hours": round(hours, 3), "quarantined": qlog.counts_by_reason(), } @staticmethod def _sed_classes(cfgs: _Configs) -> list[str]: classes = sorted({n for names in cfgs.events.classes.values() for n in names}) return classes + [c for c in EXTRA_SED_CLASSES if c not in classes] def _sed_name(self, cfgs: _Configs) -> str: """SED results are keyed by the class list: a changed events.yaml recomputes them.""" digest = hashlib.sha1(json.dumps(self._sed_classes(cfgs)).encode("utf-8")).hexdigest() return f"panns_cnn14_dlm-{digest[:8]}" def _sed_job(self, cfgs: _Configs, *, extract: bool = True) -> WorkerJob: model = DATA_MODELS["panns_cnn14_dlm"] classes = self._sed_classes(cfgs) name = self._sed_name(cfgs) return self._prepare_data_job( model, lambda sid: { "out_path": str(self.cache / "arrays" / name / f"{sid}.npy"), "classes": json.dumps(classes), }, name=name, extract=extract, ) def _sed_out(self, cfgs: _Configs) -> dict[str, object]: return {"classes": self._sed_classes(cfgs), "results": self._sed_name(cfgs)} def _diar_job(self, *, extract: bool = True) -> WorkerJob: model = DATA_MODELS["sortformer_v2"] return self._prepare_data_job( model, lambda sid: {"out_path": self._npy(model, sid)}, extract=extract ) def _ctc_job(self, *, extract: bool = True) -> WorkerJob: model = DATA_MODELS["mpoyraz_ctc_tr"] return self._prepare_data_job( model, lambda sid: {"out_path": self._npy(model, sid)}, extract=extract ) def _timings_ctc_job( self, rows: Sequence[Mapping[str, object]], cfg: EnhanceConfig = NO_ENHANCE ) -> WorkerJob: """CTC over ``rows`` only, in its own results folder (never prunes the ``ctc`` stage's). With an enhancement arm the CTC runs on the enhanced wavs, in a folder of its own (``...-timings-enh-<arm>``) so original-audio and enhanced-audio emissions never mix. """ model = DATA_MODELS["mpoyraz_ctc_tr"] name = model.key + TIMINGS_CTC_SUFFIX + (enhance.arm_suffix(cfg.arm) if cfg.enabled else "") return self._prepare_data_job( model, lambda sid: {"out_path": str(self.cache / "arrays" / name / f"{sid}.npy")}, name=name, rows=rows, audio_root=self._enhanced_root(cfg) if cfg.enabled else None, ) def _emotion_job(self, *, extract: bool = True) -> WorkerJob: model = DATA_MODELS["emotion2vec_plus_large"] return self._prepare_data_job(model, lambda sid: {}, array_outputs=False, extract=extract) def _stage_sed(self) -> dict[str, object]: cfgs = load_configs(self.config_dir) self._execute_job(self._sed_job(cfgs)) return self._sed_out(cfgs) def _stage_diar(self) -> dict[str, object]: self._execute_job(self._diar_job()) return {} def _stage_ctc(self) -> dict[str, object]: self._execute_job(self._ctc_job()) return {} def _stage_emotion(self) -> dict[str, object]: self._execute_job(self._emotion_job()) return {} @staticmethod def _asr_language(system: System) -> str: """A Qwen system identifies the language itself, in either role (S4 language filter, docs/03; ADR-0011 makes Qwen the primary): its detected language is checked in annotate. Every other system is forced to Turkish.""" if system.worker == "asr_qwen": return "auto" return LANGUAGE def _language_role(self) -> str | None: """The ASR role whose detected language drives the S4 language filter: the role run with automatic language identification (the secondary when both are), else ``None``.""" for role in ("secondary", "primary"): if self._asr_language(self._asr_system(role)) == "auto": return role return None def _asr_system(self, role: str) -> System: if role not in ASR_ROLES: raise ValueError(f"unknown ASR role {role!r}; known: {ASR_ROLES}") return self._primary() if role == "primary" else self._secondary() def _asr_job( self, role: str, rows: Sequence[Mapping[str, object]], *, extract: bool = True ) -> WorkerJob: """Prepare the ASR worker of ``role`` ("primary" or "secondary") over ``rows``.""" system = self._asr_system(role) name = f"asr-{system.system_id}" if extract: self._extract(self._utts_of(self._pending(name, rows, array_outputs=False))) spec = bench_envs.env_for_worker(system.worker) python = bench_envs.ensure_env(spec, self.envs_root, self.repo_dir, uv=self._uv()) requests = tuple( WorkRequest( str(r["segment_id"]), str(self.audio_root / str(r["audio_relpath"])), {"language": self._asr_language(system)}, ) for r in rows ) return WorkerJob( name, python, f"nida.bench.workers.{system.worker}", system.model_id, system.revision, self.asr_batch_size or system.batch_size, requests, array_outputs=False, ) def _asr_out(self, role: str, results: Mapping[str, WorkResult]) -> dict[str, object]: return { "system": self._asr_system(role).system_id, "ok": sum(r.ok for r in results.values()), } def _stage_asr(self) -> dict[str, object]: rows = self._ingested() out: dict[str, object] = {} for role in ASR_ROLES: out[role] = self._asr_out(role, self._execute_job(self._asr_job(role, rows))) return out # ------------------------------------------------------------ parallel worker group def _run_worker_group(self) -> dict[str, object]: """Run ``sed``, ``diar``, ``asr`` (both systems), ``ctc`` and ``emotion`` concurrently. Prepare (main thread, sequential): build every job (environments are created one at a time; uv builds must not overlap), then extract the audio of the *union* of the jobs' pending rows once, so no job touches the shared audio cache while the workers run. Execute: each job's subprocess in a pool of ``parallel_workers`` threads. All jobs are waited for (a failing job does not cancel the others, so their results are saved); if any failed, one ``RuntimeError`` names every failed job. Thread safety: a job only reads and writes ``<root>/workers/<job name>/`` and its own array folder under ``<cache>/arrays/``; job names are distinct (checked below). ``self.runner`` is stateless (one subprocess per call) and the other ``Pilot`` fields are read-only while the threads run. Returns: The values the sequential stages return, keyed by stage, in ``ALL_STAGES`` order. Raises: RuntimeError: if one or more jobs failed (after all jobs have finished). """ log.info( "=== pilot stages %s (parallel_workers=%d)", ", ".join(PARALLEL_GROUP), self.parallel_workers, ) self._require() self._plan_check() cfgs = load_configs(self.config_dir) rows = self._ingested() jobs = [ self._sed_job(cfgs, extract=False), self._diar_job(extract=False), self._asr_job("primary", rows, extract=False), self._asr_job("secondary", rows, extract=False), self._ctc_job(extract=False), self._emotion_job(extract=False), ] names = [job.name for job in jobs] if len(set(names)) != len(names): raise RuntimeError(f"parallel worker jobs share a results folder: {sorted(names)}") pending: dict[str, Mapping[str, object]] = {} for job in jobs: for row in self._pending(job.name, rows, array_outputs=job.array_outputs): pending[str(row["segment_id"])] = row self._extract(self._utts_of(list(pending.values()))) results: dict[str, dict[str, WorkResult]] = {} errors: dict[str, Exception] = {} with ThreadPoolExecutor( max_workers=self.parallel_workers, thread_name_prefix="pilot-worker" ) as pool: futures = {job.name: pool.submit(self._execute_job, job, tag_logs=True) for job in jobs} for name, future in futures.items(): # the pool has shut down: every future is done try: results[name] = future.result() except Exception as err: # noqa: BLE001 - collected and raised together below log.error("%s: worker job failed: %s: %s", name, type(err).__name__, err) errors[name] = err if errors: detail = "; ".join(f"{n}: {type(e).__name__}: {e}" for n, e in errors.items()) raise RuntimeError( f"{len(errors)} of {len(jobs)} parallel worker jobs failed: {detail}" ) from next(iter(errors.values())) return { "sed": self._sed_out(cfgs), "diar": {}, "asr": { role: self._asr_out(role, results[f"asr-{self._asr_system(role).system_id}"]) for role in ASR_ROLES }, "ctc": {}, "emotion": {}, } # ---------------------------------------------------------------- annotate def _results(self, name: str) -> dict[str, WorkResult]: return load_results(self.root / "workers" / name / "results.jsonl") def _stage_annotate(self) -> dict[str, object]: import numpy as np import soundfile # type: ignore[import-untyped] # untyped cfgs = load_configs(self.config_dir) rows = self._ingested() self._extract(self._utts_of(rows)) out_dir = self.work / f"annotate-{cfgs.digest}" qlog = QuarantineLog(out_dir) qlog.recover() done = qlog.quarantined_ids() if out_dir.exists(): done |= manifest.done_ids(out_dir, "segment_id") res = { "sed": self._results(self._sed_name(cfgs)), "diar": self._results("sortformer_v2"), "ctc": self._results("mpoyraz_ctc_tr"), "emotion": self._results("emotion2vec_plus_large"), "primary": self._results(f"asr-{self.plan.asr_primary}"), "secondary": self._results(f"asr-{self.plan.asr_secondary}"), } todo_ids = {str(r["segment_id"]) for r in rows} - done array_keys = ("sed", "diar", "ctc") for key, results in res.items(): missing = [str(r["segment_id"]) for r in rows if str(r["segment_id"]) not in results] if missing: raise RuntimeError( f"annotate: {key} has no result for {len(missing)} segments " f"(e.g. {missing[:3]}); run its stage first" ) if key in array_keys: lost = [ cid for cid, result in results.items() if result.output is not None and not Path(str(result.output["out_path"])).is_file() ] if lost: raise RuntimeError( f"annotate: {len(lost)} {key} arrays are not in the local cache " "(new Colab session?); rerun the sed/diar/ctc stages first" ) deferred = sorted( sid for sid in todo_ids if any(res[key][sid].output is None for key in res) ) if deferred: log.warning( "annotate: %d segments have failed worker items; they are skipped (not " "quarantined) until the worker is rerun with --retry-failed", len(deferred), ) todo_ids -= set(deferred) # Pass 1 over ALL ingested segments (not only the ones left to do): speaker # baselines must not depend on how far an interrupted run got. Only the # segments still to do are staged / quarantined. staged: list[tuple[dict[str, object], Any]] = [] drops: list[QuarantineRecord] = [] features_by_video: dict[str, list[acoustic.WordFeatures]] = defaultdict(list) for row in rows: sid = str(row["segment_id"]) if any(res[key][sid].output is None for key in res): continue # deferred (worker error), see above outcome = self._annotate_one(row, res, cfgs, np, soundfile) if isinstance(outcome, QuarantineRecord): if sid in todo_ids: drops.append(outcome) continue features_by_video[str(row["video_id"])].extend(outcome["features"]) if sid in todo_ids: staged.append((row, outcome)) if len(drops) >= INGEST_CHUNK: qlog.append(drops) drops = [] # Pass 2: speaker-relative shout scores (per video), fusion, emotion. baselines: dict[str, acoustic.SpeakerBaseline | None] = {} for video, feats in features_by_video.items(): try: baselines[video] = acoustic.speaker_baseline(feats, cfgs.acoustic) except acoustic.BaselineError: baselines[video] = None # too few voiced words: shout not measurable out_rows: list[dict[str, object]] = [] fillers_inserted: Counter[str] = Counter() # in accepted segments of this pass fillers_skipped_event = 0 # candidates an event detector also heard (accepted segs) for row, outcome in staged: sid = str(row["segment_id"]) styles: list[WordStyleScore] = [acoustic.phrase_support(outcome["whisper"])] baseline = baselines[str(row["video_id"])] if baseline is not None: shout = acoustic.shout_scores(outcome["features"], baseline, cfgs.acoustic) styles.append(acoustic.phrase_support(shout)) fused = fuse_segment( sid, outcome["words"], int(str(row["duration_ms"])), outcome["events"], styles, cfgs.fusion, ) if fused.quarantine is not None: drops.append(fused.quarantine) continue emotion = emotion_condition(outcome["emotion_scores"], cfgs.fusion) assert fused.text is not None out_rows.append( { "segment_id": sid, "utt_id": row["utt_id"], "video_id": row["video_id"], "audio_relpath": row["audio_relpath"], "duration_ms": row["duration_ms"], "text": fused.text, "plain_transcript": nfc(str(outcome["transcript"])), "tags_json": json.dumps([asdict(t) for t in fused.tags], ensure_ascii=False), "tag_ids_json": json.dumps(sorted({t.tag for t in fused.tags})), "emotion": emotion.emotion, "intensity": emotion.intensity, "asr_cer": outcome["asr_cer"], "align_score": outcome["align_score"], "shout_measured": baseline is not None, "untagged_events_json": json.dumps( [asdict(e) for e in fused.ignored_events], ensure_ascii=False ), } ) fillers_inserted.update(c.text for c in outcome["fillers"]) fillers_skipped_event += int(outcome["fillers_skipped_event"]) if len(out_rows) >= INGEST_CHUNK: manifest.write_part(out_dir, out_rows, _annotate_schema()) out_rows = [] if out_rows: manifest.write_part(out_dir, out_rows, _annotate_schema()) if drops: qlog.append(drops) accepted = manifest.read_manifest(out_dir) if out_dir.exists() else [] return { "config_digest": cfgs.digest, "accepted": len(accepted), "accepted_hours": round( sum(int(str(r["duration_ms"])) for r in accepted) / 3_600_000, 3 ), "quarantined": qlog.counts_by_reason(), "videos_without_shout_baseline": sum(1 for b in baselines.values() if b is None), "untagged_events": _untagged_event_counts(accepted), "accepted_without_shout_measurement": sum( 1 for r in accepted if not r["shout_measured"] ), "deferred_worker_errors": len(deferred), "fillers_enabled": cfgs.fillers.enabled, "fillers_inserted_this_pass": dict(sorted(fillers_inserted.items())), "fillers_skipped_event_this_pass": fillers_skipped_event, } def _annotate_one( self, row: Mapping[str, object], res: Mapping[str, Mapping[str, WorkResult]], cfgs: _Configs, np: Any, # Any: numpy module passed in (imported lazily by the caller) soundfile: Any, # Any: untyped soundfile module ) -> dict[str, Any] | QuarantineRecord: """S2–S6 for one segment; returns the inputs of fusion or a quarantine record.""" from nida.data.asr_filter import asr_agreement sid = str(row["segment_id"]) duration_ms = int(str(row["duration_ms"])) def drop(reason: str, **details: object) -> QuarantineRecord: return QuarantineRecord(sid, "annotate", reason, dict(details)) outputs: dict[str, Mapping[str, object]] = {} for key, results in res.items(): output = results[sid].output if output is None: # excluded by the caller (deferred), never quarantined raise AssertionError(f"{sid}: {key} result has no output") outputs[key] = output # S2 music sed = outputs["sed"] sed_probs = np.load(str(sed["out_path"]), allow_pickle=False) classes = [str(c) for c in sed["classes"]] # type: ignore[attr-defined] music = sed_probs[:, classes.index("Music")] music_ratio = float(np.mean(music >= cfgs.quality.music_prob)) if music_ratio > cfgs.quality.max_music_ratio: return drop("music", music_ratio=music_ratio) # S3 speakers diar = outputs["diar"] activity = np.load(str(diar["out_path"]), allow_pickle=False) speakers = check_speakers( activity, frame_hop_ms=float(str(diar["frame_hop_ms"])), duration_ms=duration_ms, cfg=cfgs.speakers, ) if speakers.reason is not None: return drop( speakers.reason, n_speakers=speakers.n_speakers, overlap_ms=speakers.overlap_ms ) # S4 dual ASR primary_raw = str(outputs["primary"]["text"]) secondary_raw = str(outputs["secondary"]["text"]) primary = normalise_transcript(primary_raw) secondary = normalise_transcript(secondary_raw) if primary is None or secondary is None: return drop("unverbalizable_number", primary=primary_raw, secondary=secondary_raw) language_role = self._language_role() if language_role is None: # _require refuses such plans; kept for type narrowing raise ValueError("no ASR system of the plan identifies the language") # strict: a language-identifying worker always reports it (like "text" above) language = language_code(str(outputs[language_role]["language"])) reason = check_transcripts(primary, secondary, language, cfgs.asr) if reason is not None: return drop(reason, primary=primary, secondary=secondary) # S5 alignment ctc = outputs["ctc"] vocab_list = [str(t) for t in ctc["vocab"]] # type: ignore[attr-defined] vocab = {tok: i for i, tok in enumerate(vocab_list)} emissions = np.load(str(ctc["out_path"]), allow_pickle=False) aligned = align_words( primary.split(), emissions, vocab, blank_token=vocab_list[int(str(ctc["blank_id"]))], word_delimiter="|", frame_hop_ms=float(str(ctc["frame_hop_ms"])), duration_ms=duration_ms, ) if aligned.words is None: return drop(f"align_{aligned.failure}", **dict(aligned.details)) words: tuple[TimedWord, ...] = aligned.words reason2 = check_alignment(words, duration_ms, cfgs.asr) if reason2 is not None: # The measured values, so the provisional thresholds can be calibrated later. measured = [w.align_score for w in words if w.align_score is not None] return drop( reason2, mean_align_score=sum(measured) / len(measured) if measured else None, n_words=len(words), speech_ms=words[-1].end_ms - words[0].start_ms if words else 0, ) scores = [w.align_score for w in words if w.align_score is not None] # S6 events frames = detect_events( sed_probs, classes, frame_hop_ms=float(str(sed["frame_hop_ms"])), duration_ms=duration_ms, cfg=cfgs.events, source="panns_cnn14_dlm", ) for kind, conf in frames.too_short.items(): if conf >= cfgs.fusion.bands[kind].low: return drop("short_event", kind=kind, conf=conf) # Fillers (ADR-0001: the ASRs never write them; CTC gap scan, option A). A gap # sound that an event detector also heard (a sigh read as "hmm") is the event's: # the candidate is skipped, so it cannot turn the event into speech overlap. fillers: list[FillerCandidate] = [] fillers_skipped_event = 0 if cfgs.fillers.enabled: candidates = gap_filler_candidates( words, emissions, vocab, blank_token=vocab_list[int(str(ctc["blank_id"]))], word_delimiter="|", frame_hop_ms=float(str(ctc["frame_hop_ms"])), duration_ms=duration_ms, min_gap_ms=cfgs.fillers.min_gap_ms, ) heard = [ d for d in frames.detections if d.kind in cfgs.fusion.bands and d.conf >= cfgs.fusion.bands[d.kind].low ] free = [ c for c in candidates if not any( d.start_ms < c.speech_end_ms and c.speech_start_ms < d.end_ms for d in heard ) ] fillers_skipped_event = len(candidates) - len(free) words, fillers = insert_fillers(words, free, cfgs.fillers) # S6 acoustic styles audio, rate = soundfile.read( str(self.audio_root / str(row["audio_relpath"])), dtype="float64" ) features = acoustic.word_features(audio, int(rate), words, cfgs.acoustic) whisper = acoustic.whisper_scores(features, cfgs.acoustic) emotion_scores = { str(k): float(str(v)) for k, v in outputs["emotion"]["probabilities"].items() # type: ignore[attr-defined] } return { "words": words, "fillers": fillers, "fillers_skipped_event": fillers_skipped_event, "events": frames.detections, "features": features, "whisper": whisper, "emotion_scores": emotion_scores, "transcript": primary, "asr_cer": asr_agreement(primary, secondary), "align_score": sum(scores) / len(scores), } # ------------------------------------------------------------------ report def _accepted(self) -> tuple[Path, list[dict[str, object]]]: cfgs = load_configs(self.config_dir) out_dir = self.work / f"annotate-{cfgs.digest}" if not out_dir.exists(): raise RuntimeError(f"{out_dir} missing: run the annotate stage first") return out_dir, manifest.read_manifest(out_dir) def _stage_report(self) -> dict[str, object]: out_dir, rows = self._accepted() if not rows: raise RuntimeError("annotate accepted no segment; see its quarantine counts") groups = {str(r["segment_id"]): str(r["video_id"]) for r in rows} splits = assign_splits( groups, {"train": 0.9, "val": 0.05, "test": 0.05}, str(self.plan.seed) ) atomic_write_json(out_dir / "splits.json", splits) seg_stats = [ stats.SegmentStat( duration_ms=int(str(r["duration_ms"])), num_chars=sum(ch.isalnum() for ch in str(r["plain_transcript"])), tags=tuple(str(t["tag"]) for t in json.loads(str(r["tags_json"]))), emotion=None if r["emotion"] is None else str(r["emotion"]), ) for r in rows ] summary = stats.summarize(seg_stats, duration_bin_edges_ms=DURATION_BIN_EDGES_MS) quarantine = { "ingest": QuarantineLog(self.work / "ingest").counts_by_reason(), "annotate": QuarantineLog(out_dir).counts_by_reason(), } report = [ "# Faz 1 pilot raporu", "", f"- Plan: `{json.dumps(asdict(self.plan), ensure_ascii=False)}`", f"- Eşik dosyaları özeti: `{out_dir.name}`", f"- Kabul edilen segment: {len(rows)}", f"- Bölmeler (video-ayrık): {dict(Counter(splits.values()))}", "", "## Karantina nedenleri", "", *(f"- {stage}: {json.dumps(c, ensure_ascii=False)}" for stage, c in quarantine.items()), "", stats.render_stats_markdown(summary), ] text = "\n".join(report) + "\n" enh = load_enhance_config(self.config_dir) if enh.enabled: marker, _ = self._read_marker(enh, out_dir, rows) if marker is not None: # the enhance stage ran before: its section belongs here text = _with_enhance_section(text, marker) atomic_write_text(self.root / "reports" / "pilot.md", text) return { "accepted": len(rows), "splits": dict(Counter(splits.values())), "quarantine": quarantine, } # ---------------------------------------------------------------- enhance @staticmethod def _enhance_worker_name(cfg: EnhanceConfig) -> str: """Results folder (and log tag) of the enhancement worker of ``cfg.arm``.""" return f"enhance-{cfg.arm}" def _enhance_dir(self, out_dir: Path, cfg: EnhanceConfig) -> Path: """Durable folder of one arm over one accepted set: marker and quarantine.""" return self.work / f"enhance-{cfg.arm}-{out_dir.name.removeprefix('annotate-')}" def _latents_name(self, cfg: EnhanceConfig) -> str: """Results folder / array cache name of the VAE latents (arm-specific when enhanced).""" key = DATA_MODELS["voxcpm2_audiovae_v2"].key return key + enhance.arm_suffix(cfg.arm) if cfg.enabled else key def _enhance_needed( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]] ) -> set[str]: """Segments whose enhanced wav a later stage still has to read. Enhanced wavs live in the local cache only, so a new session must not enhance what nobody will read: while the enhanced dataset is not built, the segments whose latents are still to compute (no result, a failed one, or a lost array); once it is built, the dataset segments that have no timeline yet (``timings`` is the only later reader). Segments the current config excludes anyway (a recorded length difference above the tolerance, too much clipping, a recorded worker failure) are not needed: nothing reads their wav. They are judged from the durable worker results with the *current* limits, not from an earlier quarantine file, so loosening a limit brings them back into ``need``. """ dataset_dir = self._dataset_dir(out_dir, cfg) if not (dataset_dir / "meta.json").exists(): rows = self._pending(self._latents_name(cfg), accepted, array_outputs=True) need = {str(r["segment_id"]) for r in rows} else: done = ( set(load_timings(dataset_dir)) if (dataset_dir / TIMINGS_FILENAME).exists() else set() ) need = set(read_index(dataset_dir)) - done results_path = self.root / "workers" / self._enhance_worker_name(cfg) / "results.jsonl" if not need or not results_path.exists(): return need results = load_results(results_path, repair=True) threshold = self._clipping_threshold() for sid in sorted(need): result = results.get(sid) if result is None or (result.output is None and self.retry_failed): continue # not enhanced yet, or about to be retried outcome = _enhance_outcome(sid, result, cfg, threshold, None) if isinstance(outcome, QuarantineRecord): need.discard(sid) return need def _clipping_threshold(self) -> float: """``acoustic.yaml`` ``clipping_threshold``: the S2 definition of a clipped sample.""" return load_configs(self.config_dir).acoustic.clipping_threshold def _stage_enhance(self) -> dict[str, object]: """Enhance the accepted segments with the configured arm (``enhance.yaml``). ``arm: none`` is a no-op returning ``{"arm": "none"}``. Otherwise the arm's worker (own isolated environment, results in ``workers/enhance-<arm>``) turns the original wav of every accepted segment into ``<cache>/audio-enh-<arm>/<audio_relpath>`` (16 kHz mono PCM16, the same number of samples); see :meth:`_enhance_run` for what is kept, recomputed and quarantined. Segments that no later stage needs are not re-enhanced in a new session (:meth:`_enhance_needed`), and when nothing is needed and the marker is current the stage does nothing at all (``"reused": true``). """ cfg = load_enhance_config(self.config_dir) if not cfg.enabled: return {"arm": enhance.ARM_NONE} out_dir, accepted = self._accepted() if not accepted: raise RuntimeError("annotate accepted no segment; nothing to enhance") return self._enhance_run( cfg, out_dir, accepted, self._enhance_needed(cfg, out_dir, accepted) ) def _enhance_run( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]], need: set[str], ) -> dict[str, object]: """Run the enhancement worker so that every segment of ``need`` has its wav on disk. The worker is asked about *all* accepted segments, so its durable results (length difference, peak, loudness: they are read when the dataset is built) are never pruned; it only does work for segments without a result, and a finished result whose wav is missing from the cache is dropped, and so recomputed, only if the segment is in ``need``. The worker writes every wav whose length is within ``max_length_diff_ms``; the pilot quarantines by ``length_tolerance_ms`` from the recorded difference (also re-checked on finished results, so changing it needs no recomputation) and quarantines worker errors (``enhance_failed``) and clipping beyond ``max_enhanced_clipping_ratio`` (``enhance_clipped``). Nothing is dropped silently: the quarantine file is derived from the results on every run and ``enhanced + quarantined == accepted`` is checked. The marker ``enhance.json`` is removed first and written last; ``latents``, ``dataset`` and ``timings`` refuse to run without one that matches the current accepted set, arm, model pin, limits and excluded segments. """ model = enhance_model(cfg.arm) name = self._enhance_worker_name(cfg) root = self._enhanced_root(cfg) qdir = self._enhance_dir(out_dir, cfg) if not need and not self.retry_failed: marker, _ = self._read_marker(cfg, out_dir, accepted) if marker is not None: return {**marker, "reused": True} out_paths = {str(r["segment_id"]): str(root / str(r["audio_relpath"])) for r in accepted} results_path = self.root / "workers" / name / "results.jsonl" if results_path.exists(): existing = load_results(results_path, repair=True) lost = sorted( sid for sid in need if sid in existing and existing[sid].output is not None and not Path(out_paths[sid]).is_file() ) if lost: log.info( "%s: %d enhanced wavs are gone from the cache; recomputing", name, len(lost) ) drop_results(results_path, lost) # Not "done" from here until the very end: a failed or interrupted run must not leave # a marker that vouches for results it did not see. (qdir / ENHANCE_MARKER).unlink(missing_ok=True) max_diff_ms = str(cfg.max_length_diff_ms) threshold = self._clipping_threshold() if self._pending(name, accepted, array_outputs=False): results = self._execute_job( self._prepare_data_job( model, lambda sid: { "out_path": out_paths[sid], "max_length_diff_ms": max_diff_ms, "clipping_threshold": repr(threshold), }, name=name, array_outputs=False, rows=accepted, # originals only for segments without a result (and recorded failures when # they are being retried): the worker skips the other failures extract_failed=self.retry_failed, ) ) else: results = self._results(name) quarantined: list[QuarantineRecord] = [] kept: list[_Enhanced] = [] for row in sorted(accepted, key=lambda r: str(r["segment_id"])): sid = str(row["segment_id"]) if sid not in results: raise RuntimeError(f"{name}: no result for segment {sid}") outcome = _enhance_outcome( sid, results[sid], cfg, threshold, Path(out_paths[sid]) if sid in need else None, ) if isinstance(outcome, QuarantineRecord): quarantined.append(outcome) else: kept.append(outcome) if len(kept) + len(quarantined) != len(accepted): raise RuntimeError( f"{name}: {len(kept)} enhanced + {len(quarantined)} quarantined != " f"{len(accepted)} accepted segments" ) qlog = QuarantineLog(qdir) qlog.path.unlink(missing_ok=True) # derived from this run's results qlog.append(quarantined) counts = qlog.counts_by_reason() if quarantined: log.warning("%s: %d segments quarantined: %s", name, len(quarantined), counts) peaks = [e.peak for e in kept] outputs = [results[str(row["segment_id"])].output for row in accepted] ratios = [float(str(o["clipping_ratio"])) for o in outputs if o is not None] report: dict[str, object] = { "arm": cfg.arm, "source_id": model.source_id, "model_id": model.ref.model_id, "revision": model.ref.revision, "annotate": out_dir.name, "accepted": len(accepted), "enhanced": len(kept), "quarantined": counts, "excluded_sha256": _ids_digest(q.item_id for q in quarantined), "length_tolerance_ms": cfg.length_tolerance_ms, "max_length_diff_ms": cfg.max_length_diff_ms, "max_enhanced_clipping_ratio": cfg.max_enhanced_clipping_ratio, "clipping_threshold": threshold, "length_diff_samples": { "max_abs": max((abs(e.length_diff_samples) for e in kept), default=0), "nonzero": sum(1 for e in kept if e.length_diff_samples), }, "clipping": { "segments_over_full_scale": sum(1 for p in peaks if p > 1.0), "max_peak": max(peaks, default=0.0), "quarantined": counts.get(enhance.REASON_CLIPPED, 0), "max_ratio": max(ratios, default=0.0), }, "worker_runtime": _worker_runtime(e.output for e in kept), "results": name, "audio_root": str(root), } atomic_write_json(qdir / ENHANCE_MARKER, report) self._write_enhance_report(report) return {**report, "reused": False} def _read_marker( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]] ) -> tuple[dict[str, Any] | None, str]: """The ``enhance.json`` marker if it vouches for the current run, else why not.""" qdir = self._enhance_dir(out_dir, cfg) marker_path = qdir / ENHANCE_MARKER if not marker_path.is_file(): return None, f"{marker_path} missing: run the enhance stage first" marker = json.loads(marker_path.read_text(encoding="utf-8")) if not isinstance(marker, dict): return None, f"{marker_path} is not a JSON object; rerun the enhance stage" model = enhance_model(cfg.arm) expected: dict[str, object] = { "arm": cfg.arm, "model_id": model.ref.model_id, "revision": model.ref.revision, "annotate": out_dir.name, "accepted": len(accepted), "length_tolerance_ms": cfg.length_tolerance_ms, "max_length_diff_ms": cfg.max_length_diff_ms, "max_enhanced_clipping_ratio": cfg.max_enhanced_clipping_ratio, "clipping_threshold": self._clipping_threshold(), } stale = {k: [marker.get(k), v] for k, v in expected.items() if marker.get(k) != v} if stale: return None, ( f"{marker_path} does not match the current run (marker, expected): {stale}; " "rerun the enhance stage" ) excluded = QuarantineLog(qdir).quarantined_ids() if _ids_digest(excluded) != marker.get("excluded_sha256"): return None, ( f"{qdir}: the quarantine file ({len(excluded)} segments) is not the one the " f"marker recorded ({marker.get('quarantined')}); rerun the enhance stage" ) return marker, "" def _enhanced_view( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]] ) -> tuple[dict[str, Any], list[dict[str, object]]]: """``(marker, rows)``: the current marker and the accepted rows the enhance stage kept. Raises: RuntimeError: if the stage has not finished for this accepted set, arm, model pin and limits, or the quarantine file is not the one the marker recorded. """ marker, problem = self._read_marker(cfg, out_dir, accepted) if marker is None: raise RuntimeError(problem) excluded = QuarantineLog(self._enhance_dir(out_dir, cfg)).quarantined_ids() return marker, [r for r in accepted if str(r["segment_id"]) not in excluded] def _enhanced_rows( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]] ) -> list[dict[str, object]]: """The accepted rows the finished ``enhance`` stage kept (accepted minus quarantined).""" return self._enhanced_view(cfg, out_dir, accepted)[1] def _ensure_enhanced_audio( self, cfg: EnhanceConfig, out_dir: Path, accepted: Sequence[dict[str, object]], ids: Sequence[str], ) -> None: """Make sure the enhanced wavs of ``ids`` are in the cache (a stage that reads a few).""" by_id = {str(r["segment_id"]): str(r["audio_relpath"]) for r in accepted} root = self._enhanced_root(cfg) missing = {sid for sid in ids if not (root / by_id[sid]).is_file()} if missing: self._enhance_run(cfg, out_dir, accepted, missing) def _enhance_meta( self, cfg: EnhanceConfig, marker: Mapping[str, Any], num_segments: int ) -> dict[str, object]: """The ``enhance`` block of an enhanced dataset's ``meta.json``.""" summary_path = ( self.root / "workers" / self._enhance_worker_name(cfg) / "results.summary.json" ) return { "arm": cfg.arm, "source_id": marker["source_id"], "model_id": marker["model_id"], "revision": marker["revision"], "length_tolerance_ms": marker["length_tolerance_ms"], "max_length_diff_ms": marker["max_length_diff_ms"], "max_enhanced_clipping_ratio": marker["max_enhanced_clipping_ratio"], "clipping_threshold": marker["clipping_threshold"], "length_diff_samples": marker["length_diff_samples"], "clipping": marker["clipping"], "excluded": marker["quarantined"], "excluded_sha256": marker["excluded_sha256"], "loudness_source": ENHANCED_LOUDNESS_SOURCE, "segments": num_segments, "worker_packages": dict(read_summary(summary_path).packages), "worker_runtime": marker["worker_runtime"], } def _write_enhance_report(self, marker: Mapping[str, Any]) -> None: """Put the enhance section into an existing ``reports/pilot.md`` (it is written by ``report``, which runs before ``enhance``; ``report`` adds the section itself when it runs later).""" path = self.root / "reports" / "pilot.md" if path.exists(): text = _with_enhance_section(path.read_text(encoding="utf-8"), marker) atomic_write_text(path, text) def _stage_latents(self) -> dict[str, object]: model = DATA_MODELS["voxcpm2_audiovae_v2"] cfg = load_enhance_config(self.config_dir) out_dir, accepted = self._accepted() if not accepted: raise RuntimeError("annotate accepted no segment; nothing to encode") if not cfg.enabled: self._data_worker(model, lambda sid: {"out_path": self._npy(model, sid)}, rows=accepted) return {"encoded": len(accepted)} rows = self._enhanced_rows(cfg, out_dir, accepted) if not rows: raise RuntimeError("the enhance stage quarantined every accepted segment") name = self._latents_name(cfg) if (self._dataset_dir(out_dir, cfg) / "meta.json").exists(): # The enhanced wavs and latent arrays live in the local cache only; once the dataset # is built nothing reads them, so a new session must not enhance and encode again. return { "encoded": 0, "arm": cfg.arm, "results": name, "skipped": "dataset already built", } self._data_worker( model, lambda sid: {"out_path": str(self.cache / "arrays" / name / f"{sid}.npy")}, name=name, rows=rows, audio_root=self._enhanced_root(cfg), ) return {"encoded": len(rows), "arm": cfg.arm, "results": name} def _stage_dataset(self) -> dict[str, object]: """Write the training dataset (format: nida.train.data docstring).""" import numpy as np from nida.utils.io import atomic_write_bytes enh = load_enhance_config(self.config_dir) out_dir, accepted = self._accepted() dataset_dir = self._dataset_dir(out_dir, enh) meta_path = dataset_dir / "meta.json" if meta_path.exists(): meta = json.loads(meta_path.read_text(encoding="utf-8")) if enh.enabled: current, kept = self._enhanced_view(enh, out_dir, accepted) recorded = meta.get("enhance", {}).get("excluded_sha256") if meta["segments"] != len(kept) or recorded != current["excluded_sha256"]: raise RuntimeError( f"{dataset_dir} holds {meta['segments']} segments built from other " "enhance results than the enhance stage keeps now; move the dataset " "away and rerun the dataset stage" ) return {"dataset": str(dataset_dir), "segments": meta["segments"], "reused": True} splits_path = out_dir / "splits.json" if not splits_path.exists(): raise RuntimeError("splits.json missing: run the report stage first") splits = json.loads(splits_path.read_text(encoding="utf-8")) marker: dict[str, Any] | None = None if enh.enabled: marker, accepted = self._enhanced_view(enh, out_dir, accepted) latents = self._results(self._latents_name(enh)) if marker is None: loudness = { str(r["segment_id"]): float(str(r["loudness_dbfs"])) for r in self._ingested() } else: # the level of the audio the latents were made from, measured by the worker enhanced_results = self._results(self._enhance_worker_name(enh)) loudness = { str(r["segment_id"]): _number( str(r["segment_id"]), enhanced_results[str(r["segment_id"])].output or {}, "loudness_dbfs", ) for r in accepted } def rate(row: Mapping[str, object]) -> float: chars = sum(ch.isalnum() for ch in str(row["plain_transcript"])) return chars / (int(str(row["duration_ms"])) / 1000) train = [r for r in accepted if splits[str(r["segment_id"])] == "train"] if not train: raise RuntimeError("no training segment") # Fixed edges (configs/data/dataset.yaml), not this root's quantiles: datasets of # different roots are merged for training and must mean the same by a bucket. cfgs = load_configs(self.config_dir) rate_edges = list(cfgs.dataset.rate_bucket_edges_chars_per_s) loud_edges = list(cfgs.dataset.loudness_bucket_edges_dbfs) observed_rate = [ float(x) for x in np.quantile([rate(r) for r in train], [0.2, 0.4, 0.6, 0.8]) ] observed_loud = [ float(x) for x in np.quantile([loudness[str(r["segment_id"])] for r in train], [1 / 3, 2 / 3]) ] index: list[dict[str, object]] = [] missing: list[str] = [] shard: list[Any] = [] shard_frames = 0 shard_no = 0 def flush() -> None: nonlocal shard, shard_frames, shard_no if not shard: return buffer = io.BytesIO() np.save(buffer, np.concatenate(shard).astype(np.float16), allow_pickle=False) atomic_write_bytes(dataset_dir / f"shard-{shard_no:05d}.npy", buffer.getvalue()) shard, shard_frames = [], 0 shard_no += 1 latent_dim: int | None = None frame_hop_ms: float | None = None for row in sorted(accepted, key=lambda r: str(r["segment_id"])): sid = str(row["segment_id"]) result = latents.get(sid) if result is None or result.output is None: missing.append(sid) continue array = np.load(str(result.output["out_path"]), allow_pickle=False) hop = float(str(result.output["frame_hop_ms"])) if frame_hop_ms is None: frame_hop_ms = hop elif hop != frame_hop_ms: raise ValueError(f"{sid}: frame hop {hop} != {frame_hop_ms}") if latent_dim is None: latent_dim = int(array.shape[1]) elif int(array.shape[1]) != latent_dim: raise ValueError(f"{sid}: latent_dim {array.shape[1]} != {latent_dim}") if not np.all(np.isfinite(array)) or float(np.max(np.abs(array))) > FLOAT16_MAX_ABS: raise ValueError(f"{sid}: latents not representable in float16") index.append( { "segment_id": sid, "shard": f"shard-{shard_no:05d}.npy", "offset": shard_frames, "num_frames": int(array.shape[0]), "text": str(row["text"]), "emotion": row["emotion"], "intensity": row["intensity"], "rate_bucket": int(np.searchsorted(rate_edges, rate(row), side="right")), "loudness_bucket": int( np.searchsorted(loud_edges, loudness[sid], side="right") ), "group": str(row["video_id"]), "split": splits[sid], } ) shard.append(array) shard_frames += int(array.shape[0]) if shard_frames >= DATASET_SHARD_FRAMES: flush() flush() if missing: raise RuntimeError( f"{len(missing)} accepted segments have no latents (e.g. {missing[:3]}); " "rerun the latents stage" ) lines = "".join(json.dumps(item, ensure_ascii=False) + "\n" for item in index) atomic_write_text(dataset_dir / "index.jsonl", lines) meta = { "segments": len(index), "frames": sum(int(str(i["num_frames"])) for i in index), "latent_dim": latent_dim, "frame_hop_ms": frame_hop_ms, "rate_bucket_edges_chars_per_s": rate_edges, "loudness_bucket_edges_dbfs": loud_edges, "train_quantile_rate_edges": observed_rate, "train_quantile_loudness_edges": observed_loud, "annotate": out_dir.name, "plan": asdict(self.plan), } if marker is not None: meta["enhance"] = self._enhance_meta(enh, marker, len(accepted)) atomic_write_json(meta_path, meta) return {"dataset": str(dataset_dir), "segments": len(index), "reused": False} def _stage_timings(self) -> dict[str, object]: """Unit timelines of the dataset's segments (E11, ADR-0008) -> ``timings.jsonl``. For every segment of ``dataset-<hash>/index.jsonl`` that has no valid line in ``timings.jsonl`` yet: re-run the CTC worker over those segments (own results folder ``workers/mpoyraz_ctc_tr-timings``; lost arrays are recomputed by the usual worker logic, audio is extracted first), re-align the stored ``plain_transcript`` with :func:`align_words` exactly as ``annotate`` did, re-find the CTC-gap fillers when ``fillers.yaml`` has them enabled, and build the timeline (:func:`nida.data.timings.build_timings`). Word times are not stored by annotate, so this is where they come from; the ASR results are not needed because ``plain_transcript`` is the aligned transcript. Resumable: segments already in a valid ``timings.jsonl`` are kept and not touched (an invalid file raises); the file is rewritten atomically with old and new lines. Segments that get no timeline are written to ``<dataset>/timings_quarantine/ quarantine.jsonl`` (replaced on every run, so it always describes the segments missing from ``timings.jsonl``). To recompute segments, delete their lines (or the file); ``written + quarantined == segments`` is checked before returning. """ import numpy as np cfgs = load_configs(self.config_dir) out_dir, accepted = self._accepted() dataset_dir = self._dataset_dir(out_dir, cfgs.enhance) meta_path = dataset_dir / "meta.json" if not meta_path.exists(): raise RuntimeError(f"{meta_path} missing: run the dataset stage first") meta = json.loads(meta_path.read_text(encoding="utf-8")) frame_hop_ms = float(meta["frame_hop_ms"]) index = read_index(dataset_dir) timings_path = dataset_dir / TIMINGS_FILENAME existing = load_timings(dataset_dir) if timings_path.exists() else {} todo = sorted(set(index) - set(existing)) if cfgs.enhance.enabled: # An enhanced dataset: the CTC reads the enhanced wavs of the todo segments, which a # new session no longer has: make them here (like the panel does), then require # that the timelines come from the very enhance results the dataset was built from # and from segments the enhance stage kept. known = {str(r["segment_id"]) for r in accepted} self._ensure_enhanced_audio( cfgs.enhance, out_dir, accepted, [sid for sid in todo if sid in known] ) marker, accepted = self._enhanced_view(cfgs.enhance, out_dir, accepted) if meta.get("enhance", {}).get("excluded_sha256") != marker["excluded_sha256"]: raise RuntimeError( f"{dataset_dir} was built from other enhance results than the enhance " "stage keeps now; move the dataset away and rerun the dataset stage" ) by_id = {str(r["segment_id"]): r for r in accepted} unknown = sorted(set(index) - set(by_id)) if unknown: raise RuntimeError( f"{len(unknown)} dataset segments are not among the accepted (and enhanced) " f"segments of {out_dir.name} (e.g. {unknown[:3]})" ) log.info( "timings: %d dataset segments, %d done, %d to do", len(index), len(existing), len(todo) ) built = dict(existing) quarantined: list[QuarantineRecord] = [] clamped_units = 0 if todo: results = self._execute_job( self._timings_ctc_job([by_id[sid] for sid in todo], cfgs.enhance) ) for sid in todo: output = results[sid].output if output is None: quarantined.append( QuarantineRecord( sid, "timings", "timings_ctc_failed", {"error": results[sid].error} ) ) continue outcome = self._timings_one( by_id[sid], index[sid].text, index[sid].num_frames, frame_hop_ms, output, cfgs.fillers, np, ) if outcome.timings is not None: built[sid] = outcome.timings clamped_units += outcome.clamped_units else: assert outcome.quarantine is not None quarantined.append(outcome.quarantine) if len(built) + len(quarantined) != len(index): raise RuntimeError( f"timings: {len(built)} timelines + {len(quarantined)} quarantined != " f"{len(index)} dataset segments" ) if todo: write_timings(timings_path, built.values()) qlog = QuarantineLog(dataset_dir / "timings_quarantine") qlog.path.unlink(missing_ok=True) # derived from this run: every missing segment is in it qlog.append(quarantined) counts = qlog.counts_by_reason() if quarantined: log.warning("timings: %d segments quarantined: %s", len(quarantined), counts) return { "dataset": str(dataset_dir), "segments": len(index), "written": len(built), "written_this_run": len(built) - len(existing), "reused": len(existing), "quarantined": counts, "units": sum(len(t.units) for t in built.values()), "zero_length_units": sum(t.zero_length_units for t in built.values()), "clamped_units_this_run": clamped_units, "frame_hop_ms": frame_hop_ms, } @staticmethod def _timings_one( row: Mapping[str, object], text: str, num_frames: int, frame_hop_ms: float, ctc: Mapping[str, object], fillers: FillerConfig, np: Any, # Any: numpy module passed in (imported lazily by the caller) ) -> TimelineResult: """Re-align one dataset segment's transcript and build its timeline.""" sid = str(row["segment_id"]) vocab_list = [str(t) for t in ctc["vocab"]] # type: ignore[attr-defined] vocab = {tok: i for i, tok in enumerate(vocab_list)} blank = vocab_list[int(str(ctc["blank_id"]))] ctc_hop_ms = float(str(ctc["frame_hop_ms"])) duration_ms = int(str(row["duration_ms"])) emissions = np.load(str(ctc["out_path"]), allow_pickle=False) try: aligned = align_words( str(row["plain_transcript"]).split(), emissions, vocab, blank_token=blank, word_delimiter="|", frame_hop_ms=ctc_hop_ms, duration_ms=duration_ms, ) if aligned.words is None: record = QuarantineRecord( sid, "timings", f"timings_align_{aligned.failure}", dict(aligned.details) ) return TimelineResult(None, record) words: tuple[TimedWord, ...] = aligned.words if fillers.enabled: # As annotate did, minus the SED filter (the SED arrays are not re-run): the # candidates are optional words for the matcher, see timings.spoken_words. candidates = gap_filler_candidates( words, emissions, vocab, blank_token=blank, word_delimiter="|", frame_hop_ms=ctc_hop_ms, duration_ms=duration_ms, min_gap_ms=fillers.min_gap_ms, ) words, _ = insert_fillers(words, candidates, fillers) except ValueError as err: record = QuarantineRecord(sid, "timings", "timings_align_error", {"error": str(err)}) return TimelineResult(None, record) return build_timings( sid, text, str(row["tags_json"]), words, num_frames=num_frames, frame_hop_ms=frame_hop_ms, ) def _stage_panel(self) -> dict[str, object]: import soundfile # untyped, see above enh = load_enhance_config(self.config_dir) out_dir, accepted = self._accepted() # With an enhancement arm the panel plays what the dataset was trained on (the enhanced # wavs of the segments the enhance stage kept) and carries the arm in its id; the tags # themselves were decided on the original audio (annotate). rows = self._enhanced_rows(enh, out_dir, accepted) if enh.enabled else accepted audio_root = self._enhanced_root(enh) if enh.enabled else self.audio_root candidates = [ tag_panel.PanelCandidate( segment_id=str(r["segment_id"]), text=str(r["text"]), tags=tuple(json.loads(str(r["tag_ids_json"]))), audio_path=audio_root / str(r["audio_relpath"]), info="" if r["emotion"] is None else f"duygu: {r['emotion']}", ) for r in rows ] chosen = tag_panel.select_items(candidates, self.plan.panel_per_tag, str(self.plan.seed)) if not chosen: log.warning("panel: no accepted segment carries a tag; no panel written") return {"panel": None, "items": 0, "reason": "no accepted segment carries a tag"} wanted = {cand.segment_id for _, cand in chosen} if enh.enabled: self._ensure_enhanced_audio(enh, out_dir, accepted, sorted(wanted)) else: self._extract(self._utts_of([r for r in self._ingested() if r["segment_id"] in wanted])) suffix = enhance.arm_suffix(enh.arm) if enh.enabled else "" panel_id = f"pilot-tags-{out_dir.name.removeprefix('annotate-')}{suffix}" flac_dir = self.cache / "panel_audio" / panel_id def to_flac(cand: tag_panel.PanelCandidate) -> Path: dest = flac_dir / f"{cand.segment_id}.flac" if not dest.exists(): audio, rate = soundfile.read(str(cand.audio_path), dtype="float64") dest.parent.mkdir(parents=True, exist_ok=True) soundfile.write(str(dest), audio, int(rate), subtype="PCM_16") return dest spec, key = tag_panel.build_tag_panel(panel_id, chosen, to_flac) html = tag_panel_write(spec, self.root / "panels" / panel_id) atomic_write_json(self.root / "manifests" / f"{panel_id}-key.json", key) return {"panel": str(html), "items": len(key)} def _stage_fillers(self) -> dict[str, object]: """Filler candidates in alignment gaps (docs/research/dolgu-tespiti-oneri.md, A). Measurement only: re-aligns each accepted segment's transcript, proposes CTC gap fillers, counts them and writes a listening panel of up to :data:`FILLER_PANEL_ITEMS` candidates. The dataset is not changed. """ import numpy as np import soundfile # untyped, see above from nida.bench.panel import AudioRef, PanelItem, PanelSpec, Question from nida.data.fillers_ctc import gap_filler_candidates out_dir, rows = self._accepted() ctc_results = self._results(DATA_MODELS["mpoyraz_ctc_tr"].key) skipped: Counter[str] = Counter() found: list[tuple[Mapping[str, object], Any, list[str], list[Any]]] = [] for row in rows: sid = str(row["segment_id"]) result = ctc_results.get(sid) if result is None or result.output is None: skipped["no_ctc_result"] += 1 continue ctc = result.output if not Path(str(ctc["out_path"])).is_file(): skipped["ctc_array_not_cached"] += 1 continue vocab_list = [str(t) for t in ctc["vocab"]] # type: ignore[attr-defined] vocab = {tok: i for i, tok in enumerate(vocab_list)} emissions = np.load(str(ctc["out_path"]), allow_pickle=False) blank = vocab_list[int(str(ctc["blank_id"]))] hop = float(str(ctc["frame_hop_ms"])) duration_ms = int(str(row["duration_ms"])) tokens = str(row["plain_transcript"]).split() aligned = align_words( tokens, emissions, vocab, blank_token=blank, word_delimiter="|", frame_hop_ms=hop, duration_ms=duration_ms, ) if aligned.words is None: skipped[f"realign_{aligned.failure}"] += 1 continue candidates = gap_filler_candidates( aligned.words, emissions, vocab, blank_token=blank, word_delimiter="|", frame_hop_ms=hop, duration_ms=duration_ms, ) if candidates: found.append((row, aligned.words, tokens, candidates)) by_text = Counter(c.text for _, _, _, cands in found for c in cands) stats: dict[str, object] = { "segments": len(rows), "segments_with_candidates": len(found), "candidates": sum(by_text.values()), "by_filler": dict(sorted(by_text.items())), "skipped": dict(sorted(skipped.items())), } flat = [(row, words, cand) for row, words, _, cands in found for cand in cands] flat.sort(key=lambda x: _rank(self.plan.seed, f"{x[0]['segment_id']}:{x[2].start_ms}")) chosen = flat[:FILLER_PANEL_ITEMS] panel_id = f"pilot-fillers-{out_dir.name.removeprefix('annotate-')}" atomic_write_json(self.root / "manifests" / f"{panel_id}-stats.json", stats) if not chosen: return {**stats, "panel": None} wanted = {str(row["segment_id"]) for row, _, _ in chosen} self._extract(self._utts_of([r for r in self._ingested() if r["segment_id"] in wanted])) flac_dir = self.cache / "panel_audio" / panel_id items: list[PanelItem] = [] key: dict[str, dict[str, object]] = {} for index, (chosen_row, words, cand) in enumerate(chosen): sid = str(chosen_row["segment_id"]) dest = flac_dir / f"{sid}.flac" if not dest.exists(): audio, rate = soundfile.read( str(self.audio_root / str(chosen_row["audio_relpath"])), dtype="float64" ) dest.parent.mkdir(parents=True, exist_ok=True) soundfile.write(str(dest), audio, int(rate), subtype="PCM_16") before = [w.text for w in words if w.end_ms <= cand.start_ms] after = [w.text for w in words if w.start_ms >= cand.end_ms] shown = " ".join([*before, f"⟦{cand.text}?⟧", *after]) item_id = f"f{index:04d}" key[item_id] = { "segment_id": sid, "filler": cand.text, "start_ms": cand.start_ms, "end_ms": cand.end_ms, "speech_start_ms": cand.speech_start_ms, "speech_end_ms": cand.speech_end_ms, "decoded": cand.decoded, "conf": cand.conf, } items.append( PanelItem( item_id=item_id, audios=(AudioRef("Ses", dest),), text=shown, info=( f"Aday dolgu: {cand.text} · {cand.speech_start_ms / 1000:.2f}–" f"{cand.speech_end_ms / 1000:.2f} s · CTC: {cand.decoded} " f"({cand.conf:.2f})" ), ) ) spec = PanelSpec( panel_id=panel_id, title=f"Nida dolgu adayları — {panel_id}", instructions=( "Her kartta bir ses ve metin var; ⟦…?⟧ işareti modelin dolgu sesi " "(ıı, ee, hmm…) bulduğunu düşündüğü yeri ve saniye aralığını gösteriyor. " "O aralıkta gerçekten böyle bir dolgu sesi varsa 'evet', yoksa (sessizlik, " "nefes, kelimenin parçası…) 'hayır', karar veremiyorsan 'emin değilim'. " "Dolgu var ama türü farklıysa (ör. ıı yerine ee) 'evet' de ve nota yaz. " "Bitince 'CSV indir'." ), questions=( Question( "correct", "İşaretli yerde gerçekten bir dolgu sesi var mı?", "choice", (tag_panel.ANSWER_YES, tag_panel.ANSWER_NO, tag_panel.ANSWER_UNSURE), ), ), items=tuple(items), ) html = tag_panel_write(spec, self.root / "panels" / panel_id) atomic_write_json(self.root / "manifests" / f"{panel_id}-key.json", key) return {**stats, "panel": str(html), "items": len(items)} def _stage_fillers_whisper(self) -> dict[str, object]: """Does prompted Whisper write real fillers? (experiment, dataset unchanged) Runs Whisper large-v3 with the decoder prompt :data:`FILLER_PROMPT` over :data:`FILLER_WHISPER_SAMPLE` accepted segments (seeded ``_rank`` order), reads the fillers it wrote, counts them and writes a listening panel of up to :data:`FILLER_PANEL_ITEMS` segments for the user to confirm them by ear. Results live in ``workers/asr-whisper_large_v3-fillerprompt``. Segments whose transcript repeats the prompt (:func:`echoes_prompt`) are counted as ``prompt_echo`` and neither counted as filler finds nor put on the panel. """ import soundfile # untyped, see above from nida.bench.panel import AudioRef, PanelItem, PanelSpec, Question system = ASR_SYSTEMS[FILLER_WHISPER_SYSTEM] require_approved({system.source_id}, load_sources(self.sources_path)) out_dir, rows = self._accepted() if not rows: raise RuntimeError("annotate accepted no segment; nothing to transcribe") sample = sorted(rows, key=lambda r: _rank(self.plan.seed, str(r["utt_id"])))[ :FILLER_WHISPER_SAMPLE ] name = FILLER_WHISPER_WORKER prompt_file = self.root / "workers" / name / "prompt.txt" if prompt_file.exists(): used = prompt_file.read_text(encoding="utf-8") if used != FILLER_PROMPT: raise RuntimeError( f"{name}: existing results were made with prompt {used!r}, not " f"{FILLER_PROMPT!r}; move them away first" ) self._extract(self._utts_of(self._pending(name, sample, array_outputs=False))) python = bench_envs.ensure_env( bench_envs.env_for_worker(system.worker), self.envs_root, self.repo_dir, uv=self._uv() ) requests = [ WorkRequest( str(r["segment_id"]), str(self.audio_root / str(r["audio_relpath"])), {"language": LANGUAGE, "prompt": FILLER_PROMPT}, ) for r in sample ] atomic_write_text(prompt_file, FILLER_PROMPT) results = self._run_worker( name, python, f"nida.bench.workers.{system.worker}", system.model_id, system.revision, self.asr_batch_size or system.batch_size, requests, array_outputs=False, ) prompt_tokens = filler_tokens(FILLER_PROMPT) examined = failed = echoes = prefix_stripped = primary_with_fillers = 0 by_filler: Counter[str] = Counter() hits: list[tuple[Mapping[str, object], list[str], list[WhisperFiller]]] = [] for row in sample: sid = str(row["segment_id"]) result = results.get(sid) if result is None or result.output is None: failed += 1 continue if "text" not in result.output: raise RuntimeError(f"{name}: payload of {sid} has no 'text'") examined += 1 prefix_stripped += result.output.get("prompt_stripped") is True primary_tokens = filler_tokens(str(row["plain_transcript"])) primary_with_fillers += any(canonical_filler(t) is not None for t in primary_tokens) tokens = filler_tokens(str(result.output["text"])) if echoes_prompt(tokens, prompt_tokens): echoes += 1 continue fillers = find_whisper_fillers(tokens) by_filler.update(f.filler for f in fillers) if any(f.filler in HESITATION_FILLERS for f in fillers): hits.append((row, tokens, fillers)) summary: dict[str, object] = { "prompt": FILLER_PROMPT, "accepted_segments": len(rows), "sampled": len(sample), "examined": examined, "failed": failed, "prompt_echo": echoes, "prompt_prefix_stripped": prefix_stripped, "segments_with_hesitation_fillers": len(hits), "hesitation_fillers": sum(n for f, n in by_filler.items() if f in HESITATION_FILLERS), "note": ( "sample = accepted segments only (ASR disagreement drops, a typical place " "for fillers, are excluded): a lower bound; no control items" ), "whisper_fillers": sum(by_filler.values()), "by_filler": dict(sorted(by_filler.items())), "primary_transcripts_with_fillers": primary_with_fillers, } panel_id = f"pilot-whisperfillers-{out_dir.name.removeprefix('annotate-')}" atomic_write_json(self.root / "manifests" / f"{panel_id}-stats.json", summary) chosen = hits[:FILLER_PANEL_ITEMS] if not chosen: return {**summary, "panel": None} wanted = {str(row["segment_id"]) for row, _, _ in chosen} self._extract(self._utts_of([r for r in self._ingested() if r["segment_id"] in wanted])) flac_dir = self.cache / "panel_audio" / panel_id items: list[PanelItem] = [] key: dict[str, dict[str, object]] = {} for index, (hit_row, tokens, fillers) in enumerate(chosen): sid = str(hit_row["segment_id"]) dest = flac_dir / f"{sid}.flac" if not dest.exists(): audio, rate = soundfile.read( str(self.audio_root / str(hit_row["audio_relpath"])), dtype="float64" ) dest.parent.mkdir(parents=True, exist_ok=True) soundfile.write(str(dest), audio, int(rate), subtype="PCM_16") item_id = f"w{index:04d}" key[item_id] = { "segment_id": sid, "whisper_text": " ".join(tokens), "primary_transcript": str(hit_row["plain_transcript"]), "fillers": [asdict(f) for f in fillers], } items.append( PanelItem( item_id=item_id, audios=(AudioRef("Ses", dest),), text=mark_fillers(tokens), info=( f"Ana ASR: {hit_row['plain_transcript']} · " f"süre {int(str(hit_row['duration_ms'])) / 1000:.1f} s" ), ) ) spec = PanelSpec( panel_id=panel_id, title=f"Nida Whisper dolguları — {panel_id}", instructions=( "Her kartta bir ses ve Whisper'ın yazdığı metin var; ⟦…?⟧ işaretli sözcükler " "Whisper'ın yazdığı dolgu sesleri (ıı, ee, hmm…). Altta karşılaştırma için " "ana ASR'nin metni görünüyor. İşaretli dolgular seste yaklaşık o " "yerde gerçekten duyuluyorsa 'evet', duyulmuyorsa 'hayır', karar " "veremiyorsan 'emin değilim'. Bazıları var bazıları yoksa 'hayır' de ve " "nota yaz. Bitince 'CSV indir'." ), questions=( Question( "present", "İşaretli dolgu sesleri gerçekten var mı (yaklaşık o yerde)?", "choice", (tag_panel.ANSWER_YES, tag_panel.ANSWER_NO, tag_panel.ANSWER_UNSURE), ), ), items=tuple(items), ) html = tag_panel_write(spec, self.root / "panels" / panel_id) atomic_write_json(self.root / "manifests" / f"{panel_id}-key.json", key) return {**summary, "panel": str(html), "items": len(items)} def tag_panel_write(spec: Any, out_dir: Path) -> Path: # Any: PanelSpec (kept import-light) from nida.bench.panel import write_panel return write_panel(spec, out_dir)
"""Phase 1 pilot runner behind ``nida data-pilot`` (YODAS, ~10 h, end to end on Colab).
Stages (each idempotent and resumable; ``all`` runs them in order in one session):
check → ingest → sed → diar → asr → ctc → emotion → annotate → report → enhance →
latents → dataset → panel
* ``ingest`` (CPU): YODAS listing → deterministic selection up to ``max_hours`` →
streaming extraction of the selected wavs into the local cache → S1/S2 checks
(:mod:`nida.data.ingest`) → ``work/ingest`` manifest + quarantine.
* ``sed`` / ``diar`` / ``ctc`` / ``emotion`` / ``asr`` (GPU): model workers in isolated
environments over the ingested segments. Array outputs live in the *local* cache; a
result whose array is gone (new Colab session) is dropped and recomputed.
* ``annotate`` (CPU): S2 music, S3 speakers, S4 dual ASR, S5 alignment, S6 events/styles/
emotion, S7 fusion → ``work/annotate-<config hash>`` manifest + quarantine. Changing any
threshold file gives a new directory, so tuning never overwrites earlier outputs.
* ``report``: S8 statistics, S10 video-disjoint splits, ``reports/pilot.md``.
* ``enhance`` (GPU, optional): speech enhancement of the accepted segments by the arm in
``configs/data/enhance.yaml`` (``uvr`` | ``sidon`` | ``dfn``, :mod:`nida.data.enhance`);
a no-op returning ``{"arm": "none"}`` for ``none`` (the baseline). Reads the original wav,
writes a 16 kHz mono PCM16 wav of the *same number of samples* to
``<cache>/audio-enh-<arm>/<same relpath>`` and records, per segment, the pre-fix length
difference, the peak and the loudness of the written samples. The worker only refuses absurd
length differences (``max_length_diff_ms``); the pilot quarantines by ``length_tolerance_ms``
and quarantines worker errors, into ``work/enhance-<arm>-<annotate hash>/quarantine.jsonl``,
excluding them from ``latents``, ``dataset``, ``timings`` and ``panel``. Only segments a later
stage still reads are re-enhanced in a new session (the wavs are local-cache only), and nothing
at all when none is needed. Enhancement changes neither labels nor splits, so ``annotate`` and
``report`` (and their hash) are shared with the baseline and always see the original audio
(tags were decided on it); ``report`` gains an enhance section.
* ``latents`` (GPU, S9): VAE latents of the accepted segments (frozen AudioVAE V2). With an
enhancement arm the *enhanced* wavs are encoded, into their own results folder and array
cache (``voxcpm2_audiovae_v2-enh-<arm>``), so baseline and enhanced latents never mix.
* ``dataset`` (CPU): the Phase 2 training dataset ``dataset-<config hash>/``: float16 latent
shards, ``index.jsonl`` (text, emotion, intensity, rate/loudness buckets, group, split)
and ``meta.json`` (written last; its presence marks the stage done). With an enhancement
arm the directory is ``dataset-<config hash>-enh-<arm>/``, the loudness buckets come from the
enhanced audio, and ``meta.json`` gains an ``enhance`` block (arm, model id, revision, limits,
excluded segments and their digest, clipping summary, ``loudness_source``, worker packages and
runtime); without an arm nothing about the dataset differs from the pre-enhancement pipeline.
* ``panel``: per-tag precision listening panel (:mod:`nida.data.tag_panel`); with an enhancement
arm it plays the enhanced audio and its id carries ``-enh-<arm>``.
* ``timings`` (extra stage, GPU: CTC only): the unit timeline of every dataset segment for the
aligned text path (E11, ADR-0008) -> ``dataset-<hash>/timings.jsonl``
(:mod:`nida.data.timings`). For an enhanced dataset the CTC runs on the enhanced audio.
``all`` normally runs the stages one after another. With ``parallel_workers > 1`` the
independent worker stages (``sed``, ``diar``, both ``asr`` systems, ``ctc``, ``emotion``)
run concurrently as separate subprocesses (see :meth:`Pilot._run_worker_group`).
Durable outputs (manifests, worker results, reports, panels) are on Drive under
``root``; audio and arrays are in ``cache`` (local disk) and are rebuilt when missing.
"""
from __future__ import annotations
import functools
import hashlib
import io
import json
import logging
import math
import shutil
from collections import Counter, defaultdict
from collections.abc import Callable, Iterable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, fields
from itertools import pairwise
from pathlib import Path
from typing import TYPE_CHECKING, Any
from nida.bench import envs as bench_envs
from nida.bench import fetch
from nida.bench.b1 import LOG_TAG, MAX_ERROR_FRACTION, _stream_runner
from nida.bench.clips import YodasUtt, yodas_video_id
from nida.bench.prepare import parse_yodas_listing
from nida.bench.protocol import (
WorkRequest,
WorkResult,
drop_results,
load_results,
read_summary,
write_requests,
)
from nida.bench.registry import ASR_SYSTEMS, DATA_PINS, System, load_sources, require_approved
from nida.bench.textmetrics import normalize_for_scoring
from nida.data import acoustic, enhance, envs, ingest, manifest, stats, tag_panel
from nida.data.align import align_words
from nida.data.asr_filter import AsrFilterConfig, check_alignment, check_transcripts
from nida.data.enhance import NO_ENHANCE, EnhanceConfig
from nida.data.events import EventConfig, detect_events
from nida.data.fillers_ctc import (
FillerCandidate,
FillerConfig,
gap_filler_candidates,
insert_fillers,
)
from nida.data.fusion import FusionConfig, emotion_condition, fuse_segment
from nida.data.quarantine import QuarantineLog
from nida.data.registry import DATA_MODELS, DataModel, enhance_model