Download modeling_prosody.py from Daporte/prosody2text-code: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/Daporte/prosody2text-code/resolve/main/modeling_prosody.py
- Command line
-
hf download hf://Daporte/prosody2text-code/modeling_prosody.py
-
curl -L -o modeling_prosody.py https://huggingface.co/Daporte/prosody2text-code/resolve/main/modeling_prosody.py
12.8 kB
| """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 | |
| def text(self) -> str: | |
| """The chosen hypothesis, empty if nothing was transcribed.""" | |
| return self.ranked[0][0] if self.ranked else "" | |
| 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] | |
| 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 --------------------------------------------------------- | |
| 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 | |
| ), | |
| ) | |
| 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 | |