Daporte commited on
Commit
cc73002
·
verified ·
1 Parent(s): 73a450d

Upload modeling_prosody.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. modeling_prosody.py +341 -0
modeling_prosody.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prosody to text: f0 and intensity in, words out.
2
+
3
+ Two signals -- interpolated f0 and intensity at 200 Hz -- through a conformer
4
+ to a mel spectrogram, and the mel through a fine-tuned Whisper. No phonetic
5
+ content at any point: the model sees only how the pitch moved and how loud
6
+ it was.
7
+
8
+ Loaded the way any custom architecture on the Hub is:
9
+
10
+ from transformers import AutoModel
11
+ model = AutoModel.from_pretrained(
12
+ "Daporte/prosody2text-weights", trust_remote_code=True
13
+ )
14
+ print(model.transcribe("utterance.wav").text)
15
+
16
+ A note on the shape of this class. It subclasses `PreTrainedModel` so that
17
+ `from_pretrained`, the `AutoModel` API and the Hub's download metrics all
18
+ work, but it is a pipeline rather than a single network: a conformer and a
19
+ `WhisperForConditionalGeneration` held alongside each other, loaded from
20
+ their own checkpoints rather than from one state dict. `forward` runs them
21
+ in sequence under `no_grad`, so this is not trainable as it stands -- it
22
+ exists to run the published pipeline, which is what the paper reports.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+ import torch
31
+
32
+ from transformers import PreTrainedModel
33
+
34
+ from .configuration_prosody import ProsodyToTextConfig
35
+ from .conformer import Conformer
36
+ from .features import ProsodyFeatures
37
+ from .speech import extract_features
38
+ from .whisper_decoder import Whisper, looks_like_fallback
39
+
40
+
41
+ class ProsodyResult:
42
+ """What one utterance produces.
43
+
44
+ Carries the intermediate stages as well as the words, because the whole
45
+ point is being able to see them: a transcription that looks wrong is
46
+ usually a contour that looks wrong first.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ features: ProsodyFeatures,
52
+ mel: np.ndarray,
53
+ ranked: list[tuple[str, float]],
54
+ ) -> None:
55
+ self.features = features
56
+ self.mel = mel
57
+ self.ranked = ranked
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ """The chosen hypothesis, empty if nothing was transcribed."""
62
+ return self.ranked[0][0] if self.ranked else ""
63
+
64
+ @property
65
+ def candidates(self) -> list[str]:
66
+ """The whole beam. The top hypothesis is often a narrow winner on an
67
+ input this uncertain, so the runners-up say more about what the model
68
+ recovered than the single best string does."""
69
+ return [text for text, _ in self.ranked]
70
+
71
+ @property
72
+ def fallback(self) -> bool:
73
+ """Whether the result is Whisper's no-speech boilerplate rather than
74
+ a transcription. Trained on captions, it answers unintelligible
75
+ input with "Thank you." and shouted auto-caption filler, which reads
76
+ as a result but is not one."""
77
+ return bool(self.text) and looks_like_fallback(self.text)
78
+
79
+ def __repr__(self) -> str:
80
+ return (
81
+ f"ProsodyResult(text={self.text!r}, "
82
+ f"frames={self.features.num_frames}, mel={self.mel.shape})"
83
+ )
84
+
85
+
86
+ class ProsodyToTextModel(PreTrainedModel):
87
+ """The published pipeline, as a Hub model."""
88
+
89
+ config_class = ProsodyToTextConfig
90
+ # Nothing here is trained by this class, and the weights are loaded by
91
+ # the two components rather than from a single state dict.
92
+ base_model_prefix = "prosody"
93
+ supports_gradient_checkpointing = False
94
+ _supports_sdpa = False
95
+
96
+ def __init__(self, config: ProsodyToTextConfig) -> None:
97
+ super().__init__(config)
98
+ self.config = config
99
+ self._conformer: Conformer | None = None
100
+ self._whisper: Whisper | None = None
101
+ self._root: Path | None = None
102
+
103
+ # ---- loading ---------------------------------------------------------
104
+
105
+ @classmethod
106
+ def from_pretrained(cls, model_id, *args, **kwargs): # noqa: ANN001
107
+ """Build the pipeline from a repo or a local directory.
108
+
109
+ Overridden rather than relying on the base implementation: there is
110
+ no single state dict to load. Each half is its own checkpoint in its
111
+ own subfolder, so both are resolved from the snapshot and loaded by
112
+ their own code.
113
+ """
114
+ from transformers import AutoConfig
115
+
116
+ half = kwargs.pop("half", False)
117
+ cpu = kwargs.pop("cpu", False)
118
+ load_whisper = kwargs.pop("whisper", True)
119
+ config = kwargs.pop("config", None)
120
+ # Consumed so a caller can pass the usual from_pretrained arguments
121
+ # without them reaching code that has no use for them.
122
+ kwargs.pop("torch_dtype", None)
123
+ kwargs.pop("device_map", None)
124
+
125
+ root = Path(model_id)
126
+ if not root.is_dir():
127
+ from huggingface_hub import snapshot_download
128
+
129
+ patterns = ["*.py", "*.json", config_file(model_id)]
130
+ patterns += ["conformer/*", "tokenizer/*"]
131
+ if load_whisper:
132
+ patterns.append("whisper/*")
133
+ root = Path(snapshot_download(
134
+ repo_id=str(model_id),
135
+ allow_patterns=[p for p in patterns if p],
136
+ revision=kwargs.pop("revision", None),
137
+ token=kwargs.pop("token", None),
138
+ ))
139
+
140
+ if config is None:
141
+ config = AutoConfig.from_pretrained(
142
+ str(root), trust_remote_code=True
143
+ )
144
+
145
+ model = cls(config)
146
+ model._root = root
147
+ model._load(root, half=half, cpu=cpu, whisper=load_whisper)
148
+ return model
149
+
150
+ def _load(
151
+ self, root: Path, half: bool, cpu: bool, whisper: bool
152
+ ) -> None:
153
+ warn_if_driver_hidden(whisper)
154
+
155
+ self._conformer = Conformer(
156
+ root / self.config.conformer_subfolder, cpu=cpu
157
+ )
158
+ if not whisper:
159
+ return
160
+
161
+ tokenizer = root / self.config.tokenizer_subfolder
162
+ self._whisper = Whisper(
163
+ root / self.config.whisper_subfolder,
164
+ half=half,
165
+ num_beams=self.config.num_beams,
166
+ tokenizer_dir=(
167
+ tokenizer if (tokenizer / "tokenizer.json").exists() else None
168
+ ),
169
+ )
170
+
171
+ @property
172
+ def device_summary(self) -> str:
173
+ """Where each half is running, which is worth printing: the whole
174
+ difference between a minute and an hour is whether Whisper found the
175
+ GPU."""
176
+ parts = []
177
+ if self._conformer is not None:
178
+ parts.append(f"conformer: {self._conformer.provider}")
179
+ if self._whisper is not None:
180
+ parts.append(f"whisper: {self._whisper.provider}")
181
+ return ", ".join(parts) or "nothing loaded"
182
+
183
+ # ---- running ---------------------------------------------------------
184
+
185
+ def forward(
186
+ self,
187
+ f0_hz: np.ndarray | torch.Tensor | None = None,
188
+ intensity_db: np.ndarray | torch.Tensor | None = None,
189
+ transcribe: bool = True,
190
+ **kwargs,
191
+ ) -> ProsodyResult:
192
+ """The two contours in, the mel and the ranked beam out.
193
+
194
+ Physical units -- Hz and dB on the 200 Hz grid -- not normalised. The
195
+ normalisation to the model's own scale happens here so a caller
196
+ cannot apply it twice, which produces a mel that looks plausible and
197
+ is wrong.
198
+ """
199
+ if f0_hz is None or intensity_db is None:
200
+ raise ValueError("f0_hz and intensity_db are both required")
201
+
202
+ features = ProsodyFeatures(
203
+ f0_hz=as_array(f0_hz), intensity_db=as_array(intensity_db)
204
+ )
205
+ return self.run(features, transcribe=transcribe)
206
+
207
+ def run(
208
+ self, features: ProsodyFeatures, transcribe: bool = True
209
+ ) -> ProsodyResult:
210
+ """Features to words, with the mel kept on the way through."""
211
+ if self._conformer is None:
212
+ raise RuntimeError(
213
+ "the conformer is not loaded; build this with "
214
+ "from_pretrained()"
215
+ )
216
+ if features.num_frames == 0:
217
+ return ProsodyResult(
218
+ features, np.zeros((0, self.config.mel_bins), np.float32), []
219
+ )
220
+
221
+ mel = self._conformer.predict(features.normalised())
222
+ ranked: list[tuple[str, float]] = []
223
+ if transcribe and self._whisper is not None and len(mel):
224
+ ranked = self._whisper.hypotheses(
225
+ mel,
226
+ language=self.config.language,
227
+ # None under `constrain`, so the budget comes from the
228
+ # utterance's duration rather than the flat ceiling.
229
+ max_tokens=(
230
+ None if self.config.constrain
231
+ else self.config.max_new_tokens
232
+ ),
233
+ no_repeat_ngram_size=self.config.no_repeat_ngram_size,
234
+ constrain=self.config.constrain,
235
+ )
236
+ return ProsodyResult(features, mel, ranked)
237
+
238
+ def transcribe(
239
+ self,
240
+ audio,
241
+ sampling_rate: int | None = None,
242
+ trim: bool = True,
243
+ ) -> ProsodyResult:
244
+ """Audio to words, for the common case.
245
+
246
+ `audio` is a path, or samples with `sampling_rate` given. `trim` cuts
247
+ the quiet lead-in and tail a live recording carries -- yaapt finds a
248
+ pitch in room noise, so without it several hundred milliseconds of
249
+ nothing reach the model. Dataset clips usually arrive trimmed
250
+ already; pass False to match the published runs exactly.
251
+ """
252
+ if isinstance(audio, (str, Path)):
253
+ # In a try block on purpose. trust_remote_code scans a modeling
254
+ # file's imports statically and refuses to load unless every one
255
+ # is installed -- and it skips Try nodes precisely so a module
256
+ # can mark a dependency optional. Reading a path is the only
257
+ # thing here that needs soundfile; a caller passing arrays, as
258
+ # the drawing demo does, should not be made to install it.
259
+ try:
260
+ import soundfile
261
+ except ImportError as error: # pragma: no cover
262
+ raise ImportError(
263
+ "reading audio from a path needs soundfile "
264
+ "(pip install soundfile); pass samples and "
265
+ "sampling_rate instead to avoid it"
266
+ ) from error
267
+
268
+ samples, sampling_rate = soundfile.read(
269
+ str(audio), dtype="float64", always_2d=False
270
+ )
271
+ if getattr(samples, "ndim", 1) > 1:
272
+ samples = samples.mean(axis=1)
273
+ else:
274
+ if sampling_rate is None:
275
+ raise ValueError(
276
+ "sampling_rate is required when passing samples"
277
+ )
278
+ samples = as_array(audio)
279
+
280
+ return self.run(extract_features(samples, sampling_rate, trim=trim))
281
+
282
+
283
+ # ---- helpers -------------------------------------------------------------
284
+
285
+
286
+ def as_array(value) -> np.ndarray: # noqa: ANN001
287
+ if isinstance(value, torch.Tensor):
288
+ value = value.detach().cpu().numpy()
289
+ array = np.asarray(value, dtype=np.float64)
290
+ return array.squeeze() if array.ndim > 1 else array
291
+
292
+
293
+ def config_file(model_id) -> str: # noqa: ANN001
294
+ """`config.json`, named separately so the download pattern reads clearly."""
295
+ return "config.json"
296
+
297
+
298
+ # Directories where a GPU driver may sit without being on the loader path.
299
+ #
300
+ # A pip-installed torch finds libcuda.so.1 through the dynamic loader, and on
301
+ # most systems the driver package puts it where the loader already looks. Not
302
+ # everywhere: NixOS and some container images keep it outside that path, so
303
+ # torch reports "Found no NVIDIA driver on your system" on a machine whose
304
+ # nvidia-smi works perfectly.
305
+ DRIVER_DIRS = (
306
+ "/run/opengl-driver/lib", # NixOS
307
+ "/usr/lib/x86_64-linux-gnu", # Debian, Ubuntu
308
+ "/usr/lib64", # Fedora, RHEL
309
+ "/usr/local/nvidia/lib64", # some container images
310
+ )
311
+
312
+
313
+ def warn_if_driver_hidden(whisper: bool) -> None:
314
+ """Say when a GPU is present but unreachable.
315
+
316
+ Falling back to CPU silently is the wrong default for something twenty
317
+ times slower: across a few thousand utterances that is the difference
318
+ between an hour and a day, and the cause is one environment variable.
319
+ """
320
+ if not whisper:
321
+ return
322
+ if torch.cuda.is_available():
323
+ return
324
+
325
+ import ctypes
326
+
327
+ try:
328
+ ctypes.CDLL("libcuda.so.1")
329
+ return # loadable, so CUDA is unavailable for another reason
330
+ except OSError:
331
+ pass
332
+
333
+ for directory in DRIVER_DIRS:
334
+ if (Path(directory) / "libcuda.so.1").exists():
335
+ print(
336
+ f"warning: a GPU driver is installed but not on the library "
337
+ f"path, so this will run on CPU. Re-run with:\n"
338
+ f" LD_LIBRARY_PATH={directory}:$LD_LIBRARY_PATH",
339
+ flush=True,
340
+ )
341
+ return