File size: 7,456 Bytes
c0bce59 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | """Phase 1 model workers (docs/03-data-pipeline.md, research note R8).
Each module here is one model family run in its own virtual environment
(:mod:`nida.data.envs`) as ``python -m nida.data.workers.<name> ...``. They reuse the
Phase 0 JSONL protocol and generic loop (:mod:`nida.bench.protocol`,
:mod:`nida.bench.workers.runner`): one ``WorkRequest`` in, one ``WorkResult`` out, heavy
imports only inside ``Adapter.load``.
Array outputs (frame-level probabilities, CTC emissions, ...) never travel inside the JSON
result. Every request names its output file in ``params["out_path"]`` (a ``.npy`` path); the
worker writes a float32 array there atomically and returns the path plus the facts a
consumer needs to interpret it (``num_frames``, ``frame_hop_ms``, ...) in the payload.
This ``__init__`` holds the small helpers shared by all four workers: request/audio
validation, the frame-count check that guards against a wrong hop assumption, and the atomic
``.npy`` writer. It imports only the standard library (numpy and soundfile are imported
lazily inside the functions that need them, which only run in worker environments).
"""
from __future__ import annotations
import importlib
import io
import math
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from nida.bench.protocol import WorkRequest
from nida.utils.io import atomic_write_bytes
if TYPE_CHECKING:
import numpy.typing as npt
__all__ = [
"REQUIRED_SAMPLE_RATE_HZ",
"AudioInfo",
"FrameCountError",
"audio_info",
"finish_frame_output",
"out_path_param",
"require_16k_mono",
"verify_frame_count",
"write_float32_npy",
]
#: Input rate the orchestrator guarantees for the 16 kHz workers (diarization, CTC, emotion).
REQUIRED_SAMPLE_RATE_HZ = 16000
class FrameCountError(ValueError):
"""A model returned a number of frames that does not match the audio duration."""
@dataclass(frozen=True)
class AudioInfo:
"""Header facts about one audio file.
Attributes:
sample_rate_hz: Sampling rate of the file.
num_channels: Channel count.
num_samples: Samples per channel.
duration_ms: ``1000 * num_samples / sample_rate_hz``.
"""
sample_rate_hz: int
num_channels: int
num_samples: int
duration_ms: float
def audio_info(path: str) -> AudioInfo:
"""Read the header of ``path`` with soundfile (no decoding).
Raises:
ValueError: if the file reports a non-positive rate, channel count or length.
RuntimeError: if soundfile cannot open the file (``soundfile.LibsndfileError``).
"""
soundfile = importlib.import_module("soundfile")
info = soundfile.info(path)
rate_hz = int(info.samplerate)
channels = int(info.channels)
samples = int(info.frames)
if rate_hz <= 0 or channels <= 0 or samples <= 0:
raise ValueError(f"{path}: empty or invalid audio ({rate_hz} Hz, {channels} ch, {samples})")
return AudioInfo(rate_hz, channels, samples, 1000.0 * samples / rate_hz)
def require_16k_mono(request: WorkRequest) -> AudioInfo:
"""Return the audio header of ``request`` after checking it is 16 kHz mono.
The orchestrator is responsible for producing 16 kHz mono files; anything else is an
error here rather than a silent resample (a wrong rate shifts every timestamp).
Raises:
ValueError: if the file is not 16 kHz mono.
"""
info = audio_info(request.audio_path)
if info.sample_rate_hz != REQUIRED_SAMPLE_RATE_HZ or info.num_channels != 1:
raise ValueError(
f"{request.clip_id}: expected {REQUIRED_SAMPLE_RATE_HZ} Hz mono, got "
f"{info.sample_rate_hz} Hz, {info.num_channels} channel(s)"
)
return info
def out_path_param(request: WorkRequest) -> Path:
"""The ``.npy`` output path named by ``request.params["out_path"]``.
Raises:
ValueError: if the parameter is missing or does not end in ``.npy``.
"""
raw = request.params.get("out_path")
if not raw:
raise ValueError(f"{request.clip_id}: missing params.out_path")
path = Path(raw)
if path.suffix != ".npy":
raise ValueError(f"{request.clip_id}: params.out_path must end in .npy, got {raw!r}")
return path
def verify_frame_count(
*,
num_frames: int,
frame_hop_ms: float,
duration_ms: float,
tolerance_ms: float,
what: str,
) -> None:
"""Check that ``num_frames`` frames of ``frame_hop_ms`` cover ``duration_ms``.
A wrong hop assumption (say 20 ms instead of 10 ms) would silently shift every
downstream timestamp, so each worker verifies the model output against the audio length.
Raises:
FrameCountError: if there are no frames, or ``|num_frames * hop - duration|`` exceeds
``tolerance_ms``.
ValueError: on a non-positive hop or negative tolerance.
"""
if frame_hop_ms <= 0 or tolerance_ms < 0:
raise ValueError(f"invalid hop/tolerance: {frame_hop_ms} ms / {tolerance_ms} ms")
if num_frames < 1:
raise FrameCountError(f"{what}: model returned no frames for {duration_ms:.1f} ms of audio")
covered_ms = num_frames * frame_hop_ms
if not math.isfinite(covered_ms) or abs(covered_ms - duration_ms) > tolerance_ms:
raise FrameCountError(
f"{what}: {num_frames} frames x {frame_hop_ms} ms = {covered_ms:.1f} ms does not "
f"match the audio duration {duration_ms:.1f} ms (tolerance {tolerance_ms} ms)"
)
def write_float32_npy(path: Path, array: npt.ArrayLike) -> None:
"""Atomically write ``array`` as a float32 ``.npy`` file (no pickle).
Raises:
ValueError: if the array contains NaN or infinity (nothing is written).
"""
import numpy as np
data = np.ascontiguousarray(array, dtype=np.float32)
if not np.isfinite(data).all():
raise ValueError(f"{path}: refusing to write non-finite values")
buffer = io.BytesIO()
np.save(buffer, data, allow_pickle=False)
atomic_write_bytes(path, buffer.getvalue())
def finish_frame_output(
*,
out_path: Path,
array: npt.ArrayLike,
frame_hop_ms: float,
duration_ms: float,
tolerance_ms: float,
what: str,
) -> dict[str, object]:
"""Verify the frame count of a ``[num_frames, num_columns]`` array, write it, describe it.
The check runs before the write, so a result with a wrong frame count never produces an
output file.
Returns:
Payload fragment: ``out_path``, ``num_frames``, ``num_columns``, ``frame_hop_ms`` and
``duration_ms``.
Raises:
FrameCountError: on a frame count that does not fit the duration.
ValueError: if the array is not 2-D or contains non-finite values.
"""
import numpy as np
data = np.asarray(array)
if data.ndim != 2:
raise ValueError(
f"{what}: expected a 2-D [num_frames, num_columns] array, got {data.shape}"
)
num_frames, num_columns = int(data.shape[0]), int(data.shape[1])
verify_frame_count(
num_frames=num_frames,
frame_hop_ms=frame_hop_ms,
duration_ms=duration_ms,
tolerance_ms=tolerance_ms,
what=what,
)
write_float32_npy(out_path, data)
return {
"out_path": str(out_path),
"num_frames": num_frames,
"num_columns": num_columns,
"frame_hop_ms": frame_hop_ms,
"duration_ms": round(duration_ms, 3),
}
|