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),
    }