Automatic Speech Recognition
Transformers
Safetensors
prosody_to_text
feature-extraction
prosody
speech-recognition
custom_code
Instructions to use Daporte/prosody2text-weights with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Daporte/prosody2text-weights with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("automatic-speech-recognition", model="Daporte/prosody2text-weights", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Daporte/prosody2text-weights", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download whisper_decoder.py from Daporte/prosody2text-weights: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/Daporte/prosody2text-weights/resolve/main/whisper_decoder.py
- Command line
-
hf download hf://Daporte/prosody2text-weights/whisper_decoder.py
-
curl -L -o whisper_decoder.py https://huggingface.co/Daporte/prosody2text-weights/resolve/main/whisper_decoder.py
16.9 kB
| """Run the fine-tuned Whisper over a predicted mel. | |
| Uses transformers' own `generate()` rather than a hand-rolled decoder. An | |
| earlier ONNX export with custom beam search produced single punctuation marks | |
| and empty strings where `generate()` produced fluent sentences from the | |
| identical mel -- the model was being silently crippled by the decoding rather | |
| than by the input. | |
| The mel contract is the subtle part. The checkpoint keeps Whisper's standard | |
| **80-channel** input even though it was fine-tuned on 12-bin prosody-predicted | |
| mels: `num_mel_bins` in the training config truncates incoming mels, it never | |
| resizes conv1. The training collator reconciles the two by zero-padding, and | |
| this module reproduces that exactly: | |
| conformer -> [T, 12] -> transpose -> [12, T] -> zero-pad -> [80, 3000] | |
| Bins 12-79 are zeros. Getting this wrong fails at the first conv layer. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import numpy as np | |
| # Whisper's encoder window: 3000 mel frames at 100 Hz = 30 s. | |
| ENCODER_FRAMES = 3000 | |
| ENCODER_MEL_BINS = 80 | |
| # The decoder prefix the checkpoint was fine-tuned behind. | |
| # | |
| # Its own forced_decoder_ids is [[1, None], [2, <|transcribe|>]] -- position 1, | |
| # the language slot, is left unset. Decoding without pinning it does not make | |
| # the model choose a language: it skips the slot and starts from | |
| # <|startoftranscript|> alone, so the prefix is two tokens short of what | |
| # training used. | |
| # | |
| # The published inference runs set language: en, and their recorded | |
| # snapshot_meta.json confirms the full prefix | |
| # [50258, 50259, 50359, 50363] = <|sot|> <|en|> <|transcribe|> <|notimestamps|>. | |
| # Matching it is the point: a different prefix is a different model condition, | |
| # so any comparison against those runs is confounded without it. | |
| DEFAULT_LANGUAGE = "en" | |
| # Generation limits the published inference runs used. | |
| DEFAULT_MAX_NEW_TOKENS = 225 | |
| DEFAULT_NO_REPEAT_NGRAM = 3 | |
| # 10, matching the published inference config for this checkpoint. | |
| # | |
| # Wider is cheap and was tried: 385 ms at 10 beams against 634 ms at 40 on a | |
| # healthy phrase, four times the hypotheses for a quarter more latency. It | |
| # made the transcriptions worse -- a wider beam searches harder for the | |
| # highest-likelihood sequence, and on a signal as weak as a drawn contour | |
| # that is the YouTube-caption boilerplate the checkpoint overlearned. More | |
| # beams find the attractor more reliably rather than finding better words. | |
| DEFAULT_BEAMS = 10 | |
| # Phrases Whisper falls back on when the input carries no usable speech. | |
| # | |
| # Trained on YouTube captions, it learned what tends to accompany | |
| # unintelligible audio: "Thank you." for silence, and shouted boilerplate | |
| # from auto-generated or burned-in captions. Feeding it pure noise returns | |
| # "I'M NOT GOING TO SAY ANYTHING" in three of the top four beams; feeding it | |
| # a real drawing returns it in none. Naming it in the UI keeps it from | |
| # reading as a transcription of the drawing. | |
| HALLUCINATION_MARKERS = ( | |
| "i'm not going to say anything", | |
| "thank you for watching", | |
| "subscribe", | |
| "thanks for watching", | |
| ) | |
| def looks_like_fallback(text: str) -> bool: | |
| """Whether a hypothesis is the model's no-speech boilerplate.""" | |
| lowered = text.strip().lower() | |
| if not lowered: | |
| return False | |
| if len(lowered) > 3 and text.strip().isupper(): | |
| return True | |
| return any(marker in lowered for marker in HALLUCINATION_MARKERS) | |
| # Measured across the templates: real speech runs about 4.3 words/sec, p90 | |
| # 5.6. The drawing is the only evidence about how long the utterance is, so | |
| # the token budget is derived from its duration rather than left at a flat | |
| # 48 -- otherwise a half-second scribble can come back as a paragraph, which | |
| # is the language model talking rather than a transcription. | |
| WORDS_PER_SECOND = 5.6 # p90, so an unusually fast phrase is not truncated | |
| TOKENS_PER_WORD = 2.5 # Whisper splits a word into roughly this many tokens | |
| MIN_TOKENS = 4 | |
| def pad_mel_for_whisper( | |
| mel: np.ndarray, | |
| mel_bins: int = ENCODER_MEL_BINS, | |
| frames: int = ENCODER_FRAMES, | |
| ) -> np.ndarray: | |
| """[T, bins] from the conformer -> [bins_padded, frames] for Whisper. | |
| Transposes to Whisper's [bins, time] layout, then zero-pads both axes. | |
| This mirrors the training collator's `pad_mel_to_expected` branch, so the | |
| model sees the same shape it was fine-tuned on. | |
| """ | |
| mel = np.asarray(mel, dtype=np.float32) | |
| if mel.ndim != 2: | |
| raise ValueError(f"expected a 2-D mel, got {mel.shape}") | |
| # The conformer emits [T, bins]; Whisper wants [bins, T]. | |
| mel = mel.T | |
| out = np.zeros((mel_bins, frames), dtype=np.float32) | |
| bins = min(mel.shape[0], mel_bins) | |
| time = min(mel.shape[1], frames) | |
| out[:bins, :time] = mel[:bins, :time] | |
| return out | |
| class Whisper: | |
| """Transcribes a predicted mel with the fine-tuned checkpoint.""" | |
| def __init__( | |
| self, | |
| model_dir: Path | str, | |
| device: str | None = None, | |
| num_beams: int = DEFAULT_BEAMS, | |
| half: bool = False, | |
| tokenizer_dir: Path | str | None = None, | |
| ) -> None: | |
| import torch | |
| from transformers import (WhisperForConditionalGeneration, | |
| WhisperTokenizerFast) | |
| model_dir = Path(model_dir) | |
| if not (model_dir / "config.json").exists(): | |
| raise FileNotFoundError( | |
| f"{model_dir} is not a Whisper checkpoint directory " | |
| "(no config.json). The weights come from the model repo, so " | |
| "load through it rather than pointing here by hand:\n" | |
| " AutoModel.from_pretrained(" | |
| "'Daporte/prosody2text-weights', trust_remote_code=True)" | |
| ) | |
| if device is None: | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| self.device = torch.device(device) | |
| self.num_beams = num_beams | |
| # fp16 is 1.8x faster (493 ms against 793 ms per phrase at beam 10) | |
| # and halves the VRAM, and it transcribed all twelve templates | |
| # identically -- the acoustic evidence in a drawn contour is far | |
| # coarser than the gap between the precisions. fp32 is the default | |
| # because that is what the published runs used. | |
| # | |
| # CPU stays fp32 regardless: torch has no fast half kernels there, so | |
| # it would be slower, not faster. | |
| self.dtype = ( | |
| torch.float16 if half and self.device.type == "cuda" else torch.float32 | |
| ) | |
| self.model = WhisperForConditionalGeneration.from_pretrained( | |
| model_dir, dtype=self.dtype | |
| ) | |
| self.model.eval().to(self.device) | |
| self.mel_bins: int = self.model.config.num_mel_bins | |
| # Silence one warning, per process, by message. | |
| # | |
| # Every call here passes max_new_tokens, and Whisper's generate() | |
| # fills max_length in from config.max_target_positions (448) on its | |
| # way through, so transformers reports them both set -- once per | |
| # utterance, which over a few thousand buries the running WER. | |
| # | |
| # Clearing generation_config.max_length does not stop it: it is | |
| # already None after load (checked), because the value is | |
| # reconstructed inside generate() rather than read from the | |
| # checkpoint. Nothing on this side can unset it, and max_new_tokens | |
| # takes precedence regardless -- the warning says so itself. So drop | |
| # the record rather than pretend to fix the cause. | |
| import logging | |
| class _NoMaxLengthClash(logging.Filter): | |
| def filter(self, record: logging.LogRecord) -> bool: | |
| return "seem to have been set" not in record.getMessage() | |
| logging.getLogger("transformers.generation.utils").addFilter( | |
| _NoMaxLengthClash() | |
| ) | |
| # The checkpoint carries no tokenizer of its own; it is the stock | |
| # whisper-medium one. Loading it from a local directory keeps first | |
| # run offline -- otherwise this is a HuggingFace Hub fetch, which on | |
| # a fresh Space or an air-gapped machine is a startup failure rather | |
| # than a slow import. | |
| source = str(tokenizer_dir) if tokenizer_dir else "openai/whisper-medium" | |
| self.tokenizer = WhisperTokenizerFast.from_pretrained(source) | |
| def provider(self) -> str: | |
| return str(self.device) | |
| def _digit_token_ids(self) -> list[int]: | |
| """Whisper tokens containing a digit. | |
| Banned under `constrain`: a prosody contour carries no evidence for | |
| "15" over "fifteen", and numerals are a large, acoustically | |
| meaningless slice of the vocabulary. Spelled-out numbers remain | |
| available. | |
| """ | |
| if getattr(self, "_digits", None) is None: | |
| # Decode each id rather than inspecting the raw token: Whisper's | |
| # timestamp markers are spelled "<|24.08|>" and would otherwise | |
| # match on the digits in their name. They decode to nothing and | |
| # are suppressed already. | |
| self._digits = [ | |
| index | |
| for index in range(len(self.tokenizer)) | |
| if any(c.isdigit() for c in self.tokenizer.decode([index])) | |
| ] | |
| return self._digits | |
| def tokens_for(mel: np.ndarray) -> int: | |
| """A token budget proportional to the drawing's length. | |
| The mel runs at 100 Hz, so its frame count is the duration. Letting | |
| generation run to a flat limit regardless lets a short scribble come | |
| back as a long sentence, which is the decoder's prior rather than the | |
| drawing's content. | |
| """ | |
| seconds = len(mel) / 100.0 | |
| words = seconds * WORDS_PER_SECOND | |
| return max(MIN_TOKENS, int(round(words * TOKENS_PER_WORD))) | |
| def token_budget(self, requested: int, language: str | None) -> int: | |
| """`requested`, clipped to what the decoder can actually emit. | |
| Whisper's decoder has max_target_positions=448 and counts the prefix | |
| against it, so a budget derived from the input length has to leave | |
| room for the prefix or generate() raises -- which it did on a 4.8 s | |
| clip, where the duration-derived budget came to 478 and the | |
| four-token prefix pushed the total to 482. | |
| """ | |
| limit = int(getattr(self.model.config, "max_target_positions", 448)) | |
| prefix = len(self.decoder_prefix(language)) | |
| # One spare for EOS, which also occupies a position. | |
| return max(1, min(int(requested), limit - prefix - 1)) | |
| def decoder_prefix(self, language: str | None = DEFAULT_LANGUAGE) -> list[int]: | |
| """The tokens the decoder is primed with before any content. | |
| Built from the tokenizer with the language and task set, which is how | |
| the published runs got theirs: their snapshot_meta.json records | |
| [50258, 50259, 50359, 50363], and this reproduces exactly that. | |
| """ | |
| if language: | |
| # prefix_tokens is a property derived from these two, so set them | |
| # on the tokenizer rather than hand-assembling the sequence. | |
| self.tokenizer.set_prefix_tokens(language=language, task="transcribe") | |
| return [int(t) for t in self.tokenizer.prefix_tokens] | |
| prefix = [self.model.config.decoder_start_token_id] | |
| forced = getattr(self.model.generation_config, "forced_decoder_ids", None) | |
| for _position, token in forced or []: | |
| if token is not None: | |
| prefix.append(int(token)) | |
| return prefix | |
| def hypotheses( | |
| self, | |
| mel: np.ndarray, | |
| max_tokens: int | None = None, | |
| num_beams: int | None = None, | |
| top_k: int | None = None, | |
| language: str | None = DEFAULT_LANGUAGE, | |
| no_repeat_ngram_size: int | None = None, | |
| constrain: bool = True, | |
| ) -> list[tuple[str, float]]: | |
| """The beam's ranked candidates as (text, score), best first. | |
| `language` pins the decoder prefix; see DEFAULT_LANGUAGE. Pass None | |
| to leave the checkpoint's own forced_decoder_ids alone, which gives a | |
| two-token-shorter prefix than training used. | |
| On input this uncertain the top hypothesis is often a narrow winner, | |
| so the runners-up say more about what the model heard than the single | |
| best string does. | |
| Scores are HuggingFace's length-normalised sequence log-probabilities | |
| where available (higher is better), and NaN where the capture below | |
| cannot recover them -- the ordering is always meaningful even when the | |
| numbers are not. | |
| """ | |
| import torch | |
| from transformers import GenerationMixin | |
| beams = num_beams or self.num_beams | |
| limits = {} | |
| if language: | |
| limits["language"] = language | |
| # The published runs set no_repeat_ngram_size: 3 and nothing else. | |
| ngram = (DEFAULT_NO_REPEAT_NGRAM if no_repeat_ngram_size is None | |
| else no_repeat_ngram_size) | |
| if ngram: | |
| limits["no_repeat_ngram_size"] = ngram | |
| # `constrain` is what the published dataset runs used: ban numerals, | |
| # and bound the length by the utterance rather than by a flat limit. | |
| # Letting generation run to 225 regardless lets a half-second clip | |
| # come back as a paragraph, which is the decoder's prior talking | |
| # rather than the input. | |
| if constrain: | |
| limits["bad_words_ids"] = [[i] for i in self._digit_token_ids()] | |
| max_tokens = max_tokens or self.tokens_for(mel) | |
| else: | |
| max_tokens = max_tokens or DEFAULT_MAX_NEW_TOKENS | |
| max_tokens = self.token_budget(max_tokens, language) | |
| features = pad_mel_for_whisper(mel, self.mel_bins, ENCODER_FRAMES) | |
| batch = torch.from_numpy(features).unsqueeze(0).to(self.device, self.dtype) | |
| if beams <= 1: | |
| with torch.no_grad(): | |
| ids = self.model.generate( | |
| batch, num_beams=1, max_new_tokens=max_tokens, **limits | |
| ) | |
| return [(self.tokenizer.batch_decode( | |
| ids, skip_special_tokens=True)[0].strip(), float("nan"))] | |
| # Whisper's generate() wrapper keeps only the top beam and does not | |
| # honour num_return_sequences; asking it for all of them inflates the | |
| # decoder cache until a 24 GB GPU runs out. The approach here is the | |
| # one the reference inference script uses: patch | |
| # GenerationMixin.generate, set num_return_sequences on the inner | |
| # call, capture every beam, and hand Whisper back the single sequence | |
| # it expects. | |
| captured: list = [] | |
| original = GenerationMixin.generate | |
| def capturing(model_self, input_features, **kwargs): | |
| config = kwargs.get("generation_config") | |
| width = config.num_beams if config else 1 | |
| if width <= 1: | |
| return original(model_self, input_features, **kwargs) | |
| config.num_return_sequences = width | |
| # sequences_scores only comes back when the inner call is asked | |
| # for a dict; Whisper's wrapper does not always pass this down. | |
| kwargs["return_dict_in_generate"] = True | |
| # ...and, since transformers 5, only when scores are explicitly | |
| # requested. Without this the attribute is present but None, so | |
| # every hypothesis came back scored NaN -- the ranking survived, | |
| # being the beam's own order, but the numbers beside it were | |
| # gone and nothing said so. | |
| kwargs["output_scores"] = True | |
| out = original(model_self, input_features, **kwargs) | |
| config.num_return_sequences = 1 | |
| seqs = out.sequences if hasattr(out, "sequences") else out | |
| captured.append((seqs, getattr(out, "sequences_scores", None))) | |
| top1 = seqs[::width] | |
| if hasattr(out, "sequences"): | |
| out.sequences = top1 | |
| else: | |
| out = top1 | |
| return out | |
| GenerationMixin.generate = capturing | |
| try: | |
| with torch.no_grad(): | |
| self.model.generate( | |
| batch, | |
| num_beams=beams, | |
| max_new_tokens=max_tokens, | |
| return_dict_in_generate=True, | |
| **limits, | |
| ) | |
| finally: | |
| GenerationMixin.generate = original | |
| if not captured: | |
| return [] | |
| seqs, scores = captured[0] | |
| texts = self.tokenizer.batch_decode(seqs, skip_special_tokens=True) | |
| values = ( | |
| scores.tolist() if scores is not None | |
| else [float("nan")] * len(texts) | |
| ) | |
| ranked = [(t.strip(), s) for t, s in zip(texts, values)] | |
| return ranked[:top_k] if top_k else ranked | |