@@ -316,6 +316,200 @@ def from_dict(cls, config: dict) -> "ModelDimensions":
316316ModelConfig = 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+
319513def 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