Skip to content

Commit 0ca47ee

Browse files
committed
Whisper: identify canonical openai/whisper-* by config.json dims (#645)
mlx-community whisper conversions ship weights only — no preprocessor_config.json or tokenizer files — so WhisperProcessor.from_pretrained silently fails on load and the model crashes with `ValueError: Processor not found.` on first generate(). Recover by reading the architecture signature from config.json — the 5-tuple (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab) uniquely identifies each canonical openai/whisper variant, including all .en English-only models and large-v3-turbo (distinguished by 4 decoder layers vs 32). Map the signature to the corresponding openai/whisper-* repo and retry the processor load there. Identifying by dims rather than directory name handles the real mlx-community landscape — ~50+ repos with arbitrary suffixes (whisper-large-v3-mlx-4bit, whisper-base-mlx-q4, whisper-base.en-mlx-fp32, whisper-large-v3-asr-4bit, etc.) and user-renamed local directories. Also tightens error handling: * Catch OSError specifically on the local load (transformers' signal for missing files) rather than bare Exception. Other failures — corrupt JSON, permission errors — propagate so a fine-tuned local checkpoint can't be silently masked by the canonical OpenAI processor (a vocab mismatch would generate garbage transcription with no error signal). * Catch ImportError specifically on the transformers import. Documents HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE in the load-helper docstring so users in air-gapped environments know how to suppress the fallback's network round-trip. Handles both openai/mlx config keys (n_audio_state, n_mels, …) and HF Transformers keys (d_model, num_mel_bins, …). Fixes #645
1 parent 60a223e commit 0ca47ee

2 files changed

Lines changed: 478 additions & 8 deletions

File tree

mlx_audio/stt/models/whisper/whisper.py

Lines changed: 155 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,160 @@ def from_dict(cls, config: dict) -> "ModelDimensions":
316316
ModelConfig = ModelDimensions
317317

318318

319+
# Each canonical openai/whisper-* variant has a unique architecture signature.
320+
# Identifying the variant from config.json dims (rather than the directory
321+
# name) makes the fallback robust to arbitrary repo naming
322+
# (whisper-large-v3-mlx-4bit, whisper-base.en-mlx, user-renamed local dirs).
323+
# Signature: (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab).
324+
# large-v1 and large-v2 share architecture; v2 is the more common upstream
325+
# choice and its processor files are interchangeable with v1's. Issue #645.
326+
_WHISPER_ARCH_TO_OPENAI = {
327+
# Multilingual variants (vocab 51865)
328+
(384, 80, 4, 4, 51865): "openai/whisper-tiny",
329+
(512, 80, 6, 6, 51865): "openai/whisper-base",
330+
(768, 80, 12, 12, 51865): "openai/whisper-small",
331+
(1024, 80, 24, 24, 51865): "openai/whisper-medium",
332+
(1280, 80, 32, 32, 51865): "openai/whisper-large-v2",
333+
# English-only variants (vocab 51864 — no language tokens)
334+
(384, 80, 4, 4, 51864): "openai/whisper-tiny.en",
335+
(512, 80, 6, 6, 51864): "openai/whisper-base.en",
336+
(768, 80, 12, 12, 51864): "openai/whisper-small.en",
337+
(1024, 80, 24, 24, 51864): "openai/whisper-medium.en",
338+
# large-v3 (added Cantonese token + 128 mel bins)
339+
(1280, 128, 32, 32, 51866): "openai/whisper-large-v3",
340+
# large-v3-turbo (4 decoder layers vs 32)
341+
(1280, 128, 32, 4, 51866): "openai/whisper-large-v3-turbo",
342+
# distil-whisper variants (Hugging Face, not openai) — out of scope for
343+
# this fallback. They have their own canonical repos (e.g.
344+
# ``distil-whisper/distil-large-v3``) and would need a separate mapping
345+
# if the project decides to support them.
346+
}
347+
348+
349+
def _whisper_arch_signature(
350+
model_path: Path,
351+
) -> Optional[Tuple[int, int, int, int, int]]:
352+
"""Read config.json from ``model_path`` and return its 5-tuple of dims.
353+
354+
Returns None if config.json is missing, unreadable, or doesn't expose
355+
the expected whisper dimensions. Handles both openai/mlx-style keys
356+
(``n_audio_state``, ``n_mels``, …) and HF Transformers-style keys
357+
(``d_model``, ``num_mel_bins``, …).
358+
"""
359+
cfg_path = model_path / "config.json"
360+
if not cfg_path.is_file():
361+
return None
362+
# PermissionError / other OSError from read_text() propagates — a real
363+
# filesystem issue should not be masked by silently returning None.
364+
# Only swallow JSONDecodeError so a malformed local config.json still
365+
# falls through to the existing "warn and return None" path rather than
366+
# crashing the load.
367+
try:
368+
cfg = json.loads(cfg_path.read_text())
369+
except json.JSONDecodeError:
370+
return None
371+
n_audio_state = cfg.get("n_audio_state") or cfg.get("d_model")
372+
n_mels = cfg.get("n_mels") or cfg.get("num_mel_bins")
373+
n_audio_layer = cfg.get("n_audio_layer") or cfg.get("encoder_layers")
374+
n_text_layer = cfg.get("n_text_layer") or cfg.get("decoder_layers")
375+
n_vocab = cfg.get("n_vocab") or cfg.get("vocab_size")
376+
if None in (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab):
377+
return None
378+
return (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab)
379+
380+
381+
def _canonical_openai_whisper_repo(model_path: Path) -> Optional[str]:
382+
"""Return the canonical openai/whisper-* repo for ``model_path``.
383+
384+
Identifies the architecture from config.json dims rather than parsing
385+
the directory name, so quantization suffixes and renames don't matter.
386+
Returns None when the architecture isn't a recognized canonical variant
387+
so the caller preserves the prior "warn and set _processor=None" path.
388+
"""
389+
sig = _whisper_arch_signature(model_path)
390+
if sig is None:
391+
return None
392+
return _WHISPER_ARCH_TO_OPENAI.get(sig)
393+
394+
395+
def _has_local_processor_files(model_path: Path) -> bool:
396+
"""Return True if ``model_path`` ships its own WhisperProcessor files.
397+
398+
The canonical-openai fallback should only fire when the local directory
399+
is missing these files — not when the local load fails for other reasons
400+
(cache/auth/path issues that would be masked by an unexpected HF Hub
401+
download). ``preprocessor_config.json`` is the feature extractor; either
402+
``tokenizer.json`` or ``tokenizer_config.json`` covers the tokenizer
403+
side.
404+
"""
405+
return (model_path / "preprocessor_config.json").is_file() and (
406+
(model_path / "tokenizer.json").is_file()
407+
or (model_path / "tokenizer_config.json").is_file()
408+
)
409+
410+
411+
def _load_whisper_processor(model_path: Path):
412+
"""Load WhisperProcessor for ``model_path``, with canonical-openai fallback.
413+
414+
The canonical-openai fallback only fires when (a) the local load raises
415+
``OSError`` AND (b) ``model_path`` is a local directory that lacks the
416+
expected processor files. Other ``OSError`` cases (cache permission,
417+
auth, network on a remote-resolved path) propagate so they aren't
418+
masked by an unexpected HF Hub download.
419+
420+
Within the fallback, only ``OSError`` and ``ImportError`` from the
421+
canonical load are converted to a warning + ``None``. Unexpected
422+
exceptions (ValueError, AttributeError, etc.) propagate so silent
423+
masking of upstream regressions stays loud.
424+
425+
Network: when the fallback fires, the canonical processor is fetched
426+
from the HF Hub (~4 MB). Set ``HF_HUB_OFFLINE=1`` or
427+
``TRANSFORMERS_OFFLINE=1`` in air-gapped environments — transformers
428+
will raise a clear offline-mode error instead of waiting on a network
429+
timeout, and that error is surfaced via the warning path below.
430+
431+
Returns the processor or ``None`` if neither the local load nor the
432+
canonical fallback succeeds.
433+
"""
434+
try:
435+
from transformers import WhisperProcessor
436+
except ImportError as e:
437+
warnings.warn(f"Could not load WhisperProcessor: {e}.")
438+
return None
439+
440+
try:
441+
return WhisperProcessor.from_pretrained(str(model_path))
442+
except OSError as local_err:
443+
# Only fall back when this looks like the missing-processor-files
444+
# scenario the PR targets. If the local dir does ship processor
445+
# files, the OSError is something else (auth, cache, env) and
446+
# propagating it surfaces the real cause.
447+
if not (model_path.is_dir() and not _has_local_processor_files(model_path)):
448+
raise
449+
canonical = _canonical_openai_whisper_repo(model_path)
450+
if canonical is None:
451+
warnings.warn(f"Could not load WhisperProcessor: {local_err}.")
452+
return None
453+
try:
454+
processor = WhisperProcessor.from_pretrained(canonical)
455+
except (OSError, ImportError) as fallback_err:
456+
# OSError covers HF Hub network/auth/offline-mode errors and
457+
# any local-cache write failures during download. ImportError
458+
# would be a missing huggingface-hub. Anything else
459+
# (ValueError on a malformed canonical repo, etc.) propagates.
460+
warnings.warn(
461+
f"Could not load WhisperProcessor locally or from {canonical}: "
462+
f"local={local_err} fallback={fallback_err}"
463+
)
464+
return None
465+
warnings.warn(
466+
f"Loaded WhisperProcessor from {canonical} as fallback because "
467+
f"{model_path} is missing processor files: {local_err}",
468+
stacklevel=2,
469+
)
470+
return processor
471+
472+
319473
def sinusoids(length, channels, max_timescale=10000):
320474
"""Returns sinusoids for positional embedding"""
321475
assert channels % 2 == 0
@@ -692,14 +846,7 @@ def post_load_hook(model: "Model", model_path: Path) -> "Model":
692846
Returns:
693847
The model with processor initialized
694848
"""
695-
try:
696-
from transformers import WhisperProcessor
697-
698-
processor = WhisperProcessor.from_pretrained(str(model_path))
699-
model._processor = processor
700-
except Exception as e:
701-
model._processor = None
702-
warnings.warn(f"Could not load WhisperProcessor: {e}.")
849+
model._processor = _load_whisper_processor(Path(model_path))
703850

704851
# Load alignment heads from generation_config.json for word-level timestamps
705852
gen_config_path = Path(model_path) / "generation_config.json"

0 commit comments

Comments
 (0)