@@ -316,6 +316,160 @@ 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, 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+
319473def 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