Skip to content

Commit 0db2af0

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 0db2af0

2 files changed

Lines changed: 518 additions & 8 deletions

File tree

mlx_audio/stt/models/whisper/whisper.py

Lines changed: 195 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,200 @@ 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, malformed (``JSONDecodeError``),
355+
or doesn't expose the expected whisper dimensions. Read errors that
356+
indicate a real filesystem problem (``PermissionError`` and other
357+
``OSError``) propagate rather than being masked as "no signature".
358+
Handles both openai/mlx-style keys (``n_audio_state``, ``n_mels``, …)
359+
and HF Transformers-style keys (``d_model``, ``num_mel_bins``, …).
360+
"""
361+
cfg_path = model_path / "config.json"
362+
if not cfg_path.is_file():
363+
return None
364+
# PermissionError / other OSError from read_text() propagates — a real
365+
# filesystem issue should not be masked by silently returning None.
366+
# Only swallow JSONDecodeError so a malformed local config.json still
367+
# falls through to the existing "warn and return None" path rather than
368+
# crashing the load.
369+
try:
370+
cfg = json.loads(cfg_path.read_text())
371+
except json.JSONDecodeError:
372+
return None
373+
n_audio_state = cfg.get("n_audio_state") or cfg.get("d_model")
374+
n_mels = cfg.get("n_mels") or cfg.get("num_mel_bins")
375+
n_audio_layer = cfg.get("n_audio_layer") or cfg.get("encoder_layers")
376+
n_text_layer = cfg.get("n_text_layer") or cfg.get("decoder_layers")
377+
n_vocab = cfg.get("n_vocab") or cfg.get("vocab_size")
378+
if None in (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab):
379+
return None
380+
return (n_audio_state, n_mels, n_audio_layer, n_text_layer, n_vocab)
381+
382+
383+
def _canonical_openai_whisper_repo(model_path: Path) -> Optional[str]:
384+
"""Return the canonical openai/whisper-* repo for ``model_path``.
385+
386+
Identifies the architecture from config.json dims rather than parsing
387+
the directory name, so quantization suffixes and renames don't matter.
388+
Returns None when the architecture isn't a recognized canonical variant
389+
so the caller preserves the prior "warn and set _processor=None" path.
390+
"""
391+
sig = _whisper_arch_signature(model_path)
392+
if sig is None:
393+
return None
394+
return _WHISPER_ARCH_TO_OPENAI.get(sig)
395+
396+
397+
def _has_local_processor_files(model_path: Path) -> bool:
398+
"""Return True if ``model_path`` ships its own WhisperProcessor files.
399+
400+
The canonical-openai fallback should only fire when the local directory
401+
is missing these files — not when the local load fails for other reasons
402+
(cache/auth/path issues that would be masked by an unexpected HF Hub
403+
download). ``preprocessor_config.json`` is the feature extractor; either
404+
``tokenizer.json`` or ``tokenizer_config.json`` covers the tokenizer
405+
side.
406+
407+
The ``is True`` comparisons coerce the result to a strict boolean so a
408+
mocked ``Path`` (whose ``is_file()`` returns a truthy ``MagicMock``)
409+
reads as "files absent" — the caller's gate then falls through to the
410+
existing fallback behavior instead of misclassifying a mock as a
411+
complete local directory.
412+
"""
413+
return (model_path / "preprocessor_config.json").is_file() is True and (
414+
(model_path / "tokenizer.json").is_file() is True
415+
or (model_path / "tokenizer_config.json").is_file() is True
416+
)
417+
418+
419+
def _load_whisper_processor(model_path: Path):
420+
"""Load WhisperProcessor for ``model_path``, with canonical-openai fallback.
421+
422+
The local-load catch is narrow: only the "missing processor files"
423+
scenario (the local directory lacks ``preprocessor_config.json`` /
424+
tokenizer files) triggers the canonical fallback. A local directory
425+
that *does* ship processor files but still raised propagates the error
426+
so genuinely-broken local checkpoints (auth, cache, corrupt files)
427+
surface their real cause instead of being masked by a network download.
428+
429+
The canonical fetch itself is best-effort graceful degradation: any
430+
failure (offline mode, 401/404, network error, or a malformed canonical
431+
config) is converted to a warning + ``None``, matching the pre-fix
432+
"warn and set _processor=None" behavior. The user then gets the clear
433+
"Processor not found" error at transcribe time.
434+
435+
Network: when the fallback fires, the canonical processor is fetched
436+
from the HF Hub (~4 MB). Set ``HF_HUB_OFFLINE=1`` or
437+
``TRANSFORMERS_OFFLINE=1`` in air-gapped environments — transformers
438+
raises an offline-mode error that is caught here and surfaced via the
439+
warning path (preferable to a long network timeout).
440+
441+
Returns the processor or ``None`` if neither the local load nor the
442+
canonical fallback succeeds.
443+
"""
444+
try:
445+
from transformers import WhisperProcessor
446+
except ImportError as e:
447+
warnings.warn(f"Could not load WhisperProcessor: {e}.")
448+
return None
449+
450+
# Same expected-exception set as the fallback path below: OSError plus
451+
# the HF Hub / network errors transformers raises when files aren't
452+
# available (RepositoryNotFoundError, EntryNotFoundError, OfflineMode,
453+
# RequestException). Imported lazily to keep the optional deps optional.
454+
_expected_local_errors = [OSError]
455+
# Import each HF error class separately so a missing one doesn't take
456+
# out the whole catch list. Older huggingface_hub versions don't ship
457+
# OfflineModeError; HfHubHTTPError has been stable since 0.16.
458+
try:
459+
from huggingface_hub.errors import HfHubHTTPError
460+
461+
_expected_local_errors.append(HfHubHTTPError)
462+
except ImportError:
463+
pass
464+
try:
465+
from huggingface_hub.errors import OfflineModeError
466+
467+
_expected_local_errors.append(OfflineModeError)
468+
except ImportError:
469+
pass
470+
try:
471+
from requests.exceptions import RequestException
472+
473+
_expected_local_errors.append(RequestException)
474+
except ImportError:
475+
pass
476+
477+
try:
478+
return WhisperProcessor.from_pretrained(str(model_path))
479+
except tuple(_expected_local_errors) as local_err:
480+
# Only the "missing processor files" scenario should trigger the
481+
# canonical fallback. A local directory that ships its own
482+
# processor files but still raised indicates something else
483+
# (auth, cache, env) — propagate so the user sees the real cause.
484+
if _has_local_processor_files(model_path):
485+
raise
486+
canonical = _canonical_openai_whisper_repo(model_path)
487+
if canonical is None:
488+
warnings.warn(f"Could not load WhisperProcessor: {local_err}.")
489+
return None
490+
# The canonical fetch is best-effort graceful degradation: any
491+
# failure (offline mode, 401/404, network error, or a malformed
492+
# canonical config) degrades to a warning + ``None``, exactly as
493+
# the pre-fix "warn and set _processor=None" path did. The user
494+
# then sees the clear "Processor not found" error at transcribe
495+
# time. The local-load catch above is the one that's narrowed, so
496+
# genuinely-broken *local* checkpoints still surface their cause.
497+
try:
498+
processor = WhisperProcessor.from_pretrained(canonical)
499+
except Exception as fallback_err:
500+
warnings.warn(
501+
f"Could not load WhisperProcessor locally or from {canonical}: "
502+
f"local={local_err} fallback={fallback_err}"
503+
)
504+
return None
505+
warnings.warn(
506+
f"Loaded WhisperProcessor from {canonical} as fallback because "
507+
f"{model_path} is missing processor files: {local_err}",
508+
stacklevel=2,
509+
)
510+
return processor
511+
512+
319513
def sinusoids(length, channels, max_timescale=10000):
320514
"""Returns sinusoids for positional embedding"""
321515
assert channels % 2 == 0
@@ -692,14 +886,7 @@ def post_load_hook(model: "Model", model_path: Path) -> "Model":
692886
Returns:
693887
The model with processor initialized
694888
"""
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}.")
889+
model._processor = _load_whisper_processor(Path(model_path))
703890

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

0 commit comments

Comments
 (0)