File size: 12,769 Bytes
cc73002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""Prosody to text: f0 and intensity in, words out.

Two signals -- interpolated f0 and intensity at 200 Hz -- through a conformer
to a mel spectrogram, and the mel through a fine-tuned Whisper. No phonetic
content at any point: the model sees only how the pitch moved and how loud
it was.

Loaded the way any custom architecture on the Hub is:

    from transformers import AutoModel
    model = AutoModel.from_pretrained(
        "Daporte/prosody2text-weights", trust_remote_code=True
    )
    print(model.transcribe("utterance.wav").text)

A note on the shape of this class. It subclasses `PreTrainedModel` so that
`from_pretrained`, the `AutoModel` API and the Hub's download metrics all
work, but it is a pipeline rather than a single network: a conformer and a
`WhisperForConditionalGeneration` held alongside each other, loaded from
their own checkpoints rather than from one state dict. `forward` runs them
in sequence under `no_grad`, so this is not trainable as it stands -- it
exists to run the published pipeline, which is what the paper reports.
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import torch

from transformers import PreTrainedModel

from .configuration_prosody import ProsodyToTextConfig
from .conformer import Conformer
from .features import ProsodyFeatures
from .speech import extract_features
from .whisper_decoder import Whisper, looks_like_fallback


class ProsodyResult:
    """What one utterance produces.

    Carries the intermediate stages as well as the words, because the whole
    point is being able to see them: a transcription that looks wrong is
    usually a contour that looks wrong first.
    """

    def __init__(
        self,
        features: ProsodyFeatures,
        mel: np.ndarray,
        ranked: list[tuple[str, float]],
    ) -> None:
        self.features = features
        self.mel = mel
        self.ranked = ranked

    @property
    def text(self) -> str:
        """The chosen hypothesis, empty if nothing was transcribed."""
        return self.ranked[0][0] if self.ranked else ""

    @property
    def candidates(self) -> list[str]:
        """The whole beam. The top hypothesis is often a narrow winner on an
        input this uncertain, so the runners-up say more about what the model
        recovered than the single best string does."""
        return [text for text, _ in self.ranked]

    @property
    def fallback(self) -> bool:
        """Whether the result is Whisper's no-speech boilerplate rather than
        a transcription. Trained on captions, it answers unintelligible
        input with "Thank you." and shouted auto-caption filler, which reads
        as a result but is not one."""
        return bool(self.text) and looks_like_fallback(self.text)

    def __repr__(self) -> str:
        return (
            f"ProsodyResult(text={self.text!r}, "
            f"frames={self.features.num_frames}, mel={self.mel.shape})"
        )


class ProsodyToTextModel(PreTrainedModel):
    """The published pipeline, as a Hub model."""

    config_class = ProsodyToTextConfig
    # Nothing here is trained by this class, and the weights are loaded by
    # the two components rather than from a single state dict.
    base_model_prefix = "prosody"
    supports_gradient_checkpointing = False
    _supports_sdpa = False

    def __init__(self, config: ProsodyToTextConfig) -> None:
        super().__init__(config)
        self.config = config
        self._conformer: Conformer | None = None
        self._whisper: Whisper | None = None
        self._root: Path | None = None

    # ---- loading ---------------------------------------------------------

    @classmethod
    def from_pretrained(cls, model_id, *args, **kwargs):  # noqa: ANN001
        """Build the pipeline from a repo or a local directory.

        Overridden rather than relying on the base implementation: there is
        no single state dict to load. Each half is its own checkpoint in its
        own subfolder, so both are resolved from the snapshot and loaded by
        their own code.
        """
        from transformers import AutoConfig

        half = kwargs.pop("half", False)
        cpu = kwargs.pop("cpu", False)
        load_whisper = kwargs.pop("whisper", True)
        config = kwargs.pop("config", None)
        # Consumed so a caller can pass the usual from_pretrained arguments
        # without them reaching code that has no use for them.
        kwargs.pop("torch_dtype", None)
        kwargs.pop("device_map", None)

        root = Path(model_id)
        if not root.is_dir():
            from huggingface_hub import snapshot_download

            patterns = ["*.py", "*.json", config_file(model_id)]
            patterns += ["conformer/*", "tokenizer/*"]
            if load_whisper:
                patterns.append("whisper/*")
            root = Path(snapshot_download(
                repo_id=str(model_id),
                allow_patterns=[p for p in patterns if p],
                revision=kwargs.pop("revision", None),
                token=kwargs.pop("token", None),
            ))

        if config is None:
            config = AutoConfig.from_pretrained(
                str(root), trust_remote_code=True
            )

        model = cls(config)
        model._root = root
        model._load(root, half=half, cpu=cpu, whisper=load_whisper)
        return model

    def _load(
        self, root: Path, half: bool, cpu: bool, whisper: bool
    ) -> None:
        warn_if_driver_hidden(whisper)

        self._conformer = Conformer(
            root / self.config.conformer_subfolder, cpu=cpu
        )
        if not whisper:
            return

        tokenizer = root / self.config.tokenizer_subfolder
        self._whisper = Whisper(
            root / self.config.whisper_subfolder,
            half=half,
            num_beams=self.config.num_beams,
            tokenizer_dir=(
                tokenizer if (tokenizer / "tokenizer.json").exists() else None
            ),
        )

    @property
    def device_summary(self) -> str:
        """Where each half is running, which is worth printing: the whole
        difference between a minute and an hour is whether Whisper found the
        GPU."""
        parts = []
        if self._conformer is not None:
            parts.append(f"conformer: {self._conformer.provider}")
        if self._whisper is not None:
            parts.append(f"whisper: {self._whisper.provider}")
        return ", ".join(parts) or "nothing loaded"

    # ---- running ---------------------------------------------------------

    def forward(
        self,
        f0_hz: np.ndarray | torch.Tensor | None = None,
        intensity_db: np.ndarray | torch.Tensor | None = None,
        transcribe: bool = True,
        **kwargs,
    ) -> ProsodyResult:
        """The two contours in, the mel and the ranked beam out.

        Physical units -- Hz and dB on the 200 Hz grid -- not normalised. The
        normalisation to the model's own scale happens here so a caller
        cannot apply it twice, which produces a mel that looks plausible and
        is wrong.
        """
        if f0_hz is None or intensity_db is None:
            raise ValueError("f0_hz and intensity_db are both required")

        features = ProsodyFeatures(
            f0_hz=as_array(f0_hz), intensity_db=as_array(intensity_db)
        )
        return self.run(features, transcribe=transcribe)

    def run(
        self, features: ProsodyFeatures, transcribe: bool = True
    ) -> ProsodyResult:
        """Features to words, with the mel kept on the way through."""
        if self._conformer is None:
            raise RuntimeError(
                "the conformer is not loaded; build this with "
                "from_pretrained()"
            )
        if features.num_frames == 0:
            return ProsodyResult(
                features, np.zeros((0, self.config.mel_bins), np.float32), []
            )

        mel = self._conformer.predict(features.normalised())
        ranked: list[tuple[str, float]] = []
        if transcribe and self._whisper is not None and len(mel):
            ranked = self._whisper.hypotheses(
                mel,
                language=self.config.language,
                # None under `constrain`, so the budget comes from the
                # utterance's duration rather than the flat ceiling.
                max_tokens=(
                    None if self.config.constrain
                    else self.config.max_new_tokens
                ),
                no_repeat_ngram_size=self.config.no_repeat_ngram_size,
                constrain=self.config.constrain,
            )
        return ProsodyResult(features, mel, ranked)

    def transcribe(
        self,
        audio,
        sampling_rate: int | None = None,
        trim: bool = True,
    ) -> ProsodyResult:
        """Audio to words, for the common case.

        `audio` is a path, or samples with `sampling_rate` given. `trim` cuts
        the quiet lead-in and tail a live recording carries -- yaapt finds a
        pitch in room noise, so without it several hundred milliseconds of
        nothing reach the model. Dataset clips usually arrive trimmed
        already; pass False to match the published runs exactly.
        """
        if isinstance(audio, (str, Path)):
            # In a try block on purpose. trust_remote_code scans a modeling
            # file's imports statically and refuses to load unless every one
            # is installed -- and it skips Try nodes precisely so a module
            # can mark a dependency optional. Reading a path is the only
            # thing here that needs soundfile; a caller passing arrays, as
            # the drawing demo does, should not be made to install it.
            try:
                import soundfile
            except ImportError as error:  # pragma: no cover
                raise ImportError(
                    "reading audio from a path needs soundfile "
                    "(pip install soundfile); pass samples and "
                    "sampling_rate instead to avoid it"
                ) from error

            samples, sampling_rate = soundfile.read(
                str(audio), dtype="float64", always_2d=False
            )
            if getattr(samples, "ndim", 1) > 1:
                samples = samples.mean(axis=1)
        else:
            if sampling_rate is None:
                raise ValueError(
                    "sampling_rate is required when passing samples"
                )
            samples = as_array(audio)

        return self.run(extract_features(samples, sampling_rate, trim=trim))


# ---- helpers -------------------------------------------------------------


def as_array(value) -> np.ndarray:  # noqa: ANN001
    if isinstance(value, torch.Tensor):
        value = value.detach().cpu().numpy()
    array = np.asarray(value, dtype=np.float64)
    return array.squeeze() if array.ndim > 1 else array


def config_file(model_id) -> str:  # noqa: ANN001
    """`config.json`, named separately so the download pattern reads clearly."""
    return "config.json"


# Directories where a GPU driver may sit without being on the loader path.
#
# A pip-installed torch finds libcuda.so.1 through the dynamic loader, and on
# most systems the driver package puts it where the loader already looks. Not
# everywhere: NixOS and some container images keep it outside that path, so
# torch reports "Found no NVIDIA driver on your system" on a machine whose
# nvidia-smi works perfectly.
DRIVER_DIRS = (
    "/run/opengl-driver/lib",          # NixOS
    "/usr/lib/x86_64-linux-gnu",       # Debian, Ubuntu
    "/usr/lib64",                      # Fedora, RHEL
    "/usr/local/nvidia/lib64",         # some container images
)


def warn_if_driver_hidden(whisper: bool) -> None:
    """Say when a GPU is present but unreachable.

    Falling back to CPU silently is the wrong default for something twenty
    times slower: across a few thousand utterances that is the difference
    between an hour and a day, and the cause is one environment variable.
    """
    if not whisper:
        return
    if torch.cuda.is_available():
        return

    import ctypes

    try:
        ctypes.CDLL("libcuda.so.1")
        return          # loadable, so CUDA is unavailable for another reason
    except OSError:
        pass

    for directory in DRIVER_DIRS:
        if (Path(directory) / "libcuda.so.1").exists():
            print(
                f"warning: a GPU driver is installed but not on the library "
                f"path, so this will run on CPU. Re-run with:\n"
                f"    LD_LIBRARY_PATH={directory}:$LD_LIBRARY_PATH",
                flush=True,
            )
            return