ffasr / backends /universal.py
whojavumusic's picture
custom eval update
a65ea12
Raw
History Blame Contribute Delete
15.2 kB
"""
One ASR backend for ~every speech-seq2seq model: built-in (Whisper, CohereAsr, ...) and
remote-code (efficient-speech, custom Whisper variants, etc.).
Flow:
processor(audio, sr, return_tensors="pt", [language="en"])
model.generate(**inputs, max_new_tokens=_safe_cap(model))
processor.decode(outputs, ...) (falls back to processor.batch_decode)
Loading order:
1. `AutoModelForSpeechSeq2Seq.from_pretrained(..., trust_remote_code=True)`
Covers standard HF classes (Whisper, CohereAsrForConditionalGeneration, ...).
2. `AutoModel.from_pretrained(..., trust_remote_code=True)`
Covers repos that expose their generator class via `auto_map["AutoModel"]`
(e.g. `efficient-speech/lite-whisper-*`).
Environment overrides (all optional):
FFASR_PROCESSOR_ID — force a fallback processor (e.g. `openai/whisper-large-v3`).
FFASR_LANGUAGE — pass language=... to processor/generate when supported. Default: `en`.
FFASR_MAX_NEW_TOKENS — override generate() length budget. Default: auto (safe cap).
"""
from __future__ import annotations
import os
from collections.abc import Callable
import json
import time
import numpy as np
import torch
from ._audio_utils import safe_pad_audio
from ._model_utils import attach_params
_DEBUG_LOG_PATH = "/home/user/app/.cursor/debug-3654e7.log"
_DEBUG_SESSION_ID = "3654e7"
_DEBUG_RUN_ID = os.environ.get("FFASR_DEBUG_RUN_ID", "initial")
def _debug_log(hypothesis_id: str, location: str, message: str, data: dict) -> None:
payload = {
"sessionId": _DEBUG_SESSION_ID,
"runId": _DEBUG_RUN_ID,
"hypothesisId": hypothesis_id,
"location": location,
"message": message,
"data": data,
"timestamp": int(time.time() * 1000),
}
try:
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=True) + "\n")
except Exception:
pass
def _safe_max_new_tokens(model, env_override: int | None, default_cap: int = 256) -> int:
"""
Whisper-style models enforce `len(decoder_prefix) + max_new_tokens ≤ max_target_positions`.
Derive a cap from the config so we never trip that validation.
"""
if env_override is not None and env_override > 0:
return int(env_override)
cfg = getattr(model, "config", None)
if cfg is None:
return default_cap
mtp = (
getattr(cfg, "max_target_positions", None)
or getattr(cfg, "max_length", None)
or getattr(cfg, "max_position_embeddings", None)
)
try:
mtp = int(mtp) if mtp else 0
except Exception:
mtp = 0
if mtp and mtp <= 2048:
# Leave headroom for task/language/timestamp prefix tokens.
return max(32, min(448, mtp - 16))
return default_cap
def _pick_dtype(device_str: str) -> torch.dtype:
use_cuda = device_str == "cuda" and torch.cuda.is_available()
if use_cuda and torch.cuda.is_bf16_supported():
return torch.bfloat16
if use_cuda:
return torch.float16
return torch.float32
def _load_processor(model_id: str):
from transformers import AutoProcessor
override = os.environ.get("FFASR_PROCESSOR_ID", "").strip()
if override:
return AutoProcessor.from_pretrained(override, trust_remote_code=True)
try:
return AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
except Exception:
return AutoProcessor.from_pretrained(
"openai/whisper-large-v3", trust_remote_code=True
)
def _load_model(model_id: str, dtype: torch.dtype, device_str: str):
from transformers import AutoModel, AutoModelForSpeechSeq2Seq
errors: list[str] = []
for cls in (AutoModelForSpeechSeq2Seq, AutoModel):
try:
model = cls.from_pretrained(
model_id,
trust_remote_code=True,
torch_dtype=dtype,
).to(device_str)
return model, cls.__name__
except Exception as e:
errors.append(f"{cls.__name__}: {type(e).__name__}: {e}")
raise RuntimeError("; ".join(errors))
def _call_processor(processor, audio: np.ndarray, sampling_rate: int, language: str):
"""Most ASR processors accept `sampling_rate` and either ignore or consume `language`."""
tries = (
dict(sampling_rate=sampling_rate, return_tensors="pt", language=language),
dict(sampling_rate=sampling_rate, return_tensors="pt"),
)
last_exc: Exception | None = None
for kwargs in tries:
try:
return processor(audio, **kwargs)
except (TypeError, ValueError) as e:
last_exc = e
continue
if last_exc is not None:
raise last_exc
return processor(audio, sampling_rate=sampling_rate, return_tensors="pt")
def _move_to_device(inputs, device_str: str, dtype: torch.dtype):
if hasattr(inputs, "to"):
inputs = inputs.to(device_str)
try:
inputs = inputs.to(dtype=dtype)
except Exception:
pass
batch = dict(inputs)
meta: dict = {}
for key in ("audio_chunk_index",):
if key in batch:
meta[key] = batch.pop(key)
# Some processors include optional keys with explicit None values. Passing
# those through `generate(**batch)` can trigger model-specific failures
# (e.g. Cohere ASR expecting a tensor decoder_attention_mask once the key exists).
batch = {k: v for k, v in batch.items() if v is not None}
# Cohere ASR path: when decoder_input_ids is present but decoder_attention_mask
# is absent, HF generation internals may try to extend a None mask and crash.
dec_ids = batch.get("decoder_input_ids")
dec_attn = batch.get("decoder_attention_mask")
if dec_ids is not None and dec_attn is None:
try:
batch["decoder_attention_mask"] = torch.ones_like(dec_ids, dtype=torch.long)
except Exception:
pass
return batch, meta
def _find_expected_input_feature_dim(model) -> int | None:
"""
Return the expected final-dimension size for `input_features` when discoverable.
"""
candidates = (
("encoder", "input_linear"),
("model", "encoder", "input_linear"),
("speech_encoder", "input_linear"),
)
for path in candidates:
node = model
ok = True
for name in path:
if not hasattr(node, name):
ok = False
break
node = getattr(node, name)
if not ok:
continue
in_features = getattr(node, "in_features", None)
try:
if in_features is not None:
return int(in_features)
except Exception:
continue
return None
def _normalize_input_features_layout(model, batch: dict) -> dict:
"""
Some processors return `input_features` as `[B, F, T]` while models expect `[B, T, F]`.
If we can infer the expected feature dim and it matches axis 1 (not axis 2),
transpose to avoid shape mismatches in the encoder projection.
"""
feats = batch.get("input_features")
if not hasattr(feats, "shape") or getattr(feats, "dim", lambda: 0)() != 3:
return batch
expected = _find_expected_input_feature_dim(model)
if expected is None:
return batch
b, d1, d2 = int(feats.shape[0]), int(feats.shape[1]), int(feats.shape[2])
if d2 == expected:
return batch
if d1 == expected:
fixed = dict(batch)
fixed["input_features"] = feats.transpose(1, 2).contiguous()
_debug_log(
"H6",
"backends/universal.py:_normalize_input_features_layout:transpose",
"Transposed input_features to match model expected feature dimension",
{
"batch_size": b,
"before_shape": [b, d1, d2],
"after_shape": [b, d2, d1],
"expected_feature_dim": expected,
},
)
return fixed
return batch
def _encoder_tensor_from_batch(batch: dict):
"""Return encoder inputs without using ``or`` on tensors (ambiguous bool)."""
if batch.get("input_features") is not None:
return batch["input_features"]
if batch.get("input_values") is not None:
return batch["input_values"]
return None
def _generate(model, batch: dict, max_new_tokens: int, language: str):
"""Try language-aware generate first (Whisper path); fall back to plain generate."""
batch = _normalize_input_features_layout(model, batch)
base = dict(max_new_tokens=max_new_tokens, num_beams=1)
model_type = getattr(getattr(model, "config", None), "model_type", "") or ""
if model_type == "cohere_asr" and "decoder_attention_mask" not in batch:
# Cohere's remote generate() path can leave decoder_attention_mask as None,
# then HF generation tries `decoder_attention_mask.new_ones(...)` and crashes.
# Passing an explicit one-token decoder mask keeps generation state valid.
src = _encoder_tensor_from_batch(batch)
if src is not None and hasattr(src, "shape"):
try:
batch = dict(batch)
batch["decoder_attention_mask"] = torch.ones(
(int(src.shape[0]), 1),
device=src.device,
dtype=torch.long,
)
except Exception:
pass
attempts = (
{**base, "task": "transcribe", "language": language},
base,
)
# #region agent log
_debug_log(
"H4",
"backends/universal.py:_generate:attempts",
"Generate attempts and core tensor shapes",
{
"model_type": model_type,
"attempts": [sorted(extra.keys()) for extra in attempts],
"batch_keys": sorted(batch.keys()),
"input_features_shape": list(batch.get("input_features").shape) if hasattr(batch.get("input_features"), "shape") else None,
"input_values_shape": list(batch.get("input_values").shape) if hasattr(batch.get("input_values"), "shape") else None,
},
)
# #endregion
last: Exception | None = None
for extra in attempts:
try:
return model.generate(**batch, **extra)
except TypeError as e:
last = e
except Exception as e:
# Whisper sometimes raises when a kwarg (language) is unsupported for this model.
last = e
if "language" in extra:
continue
raise
if last is not None:
raise last
return model.generate(**batch, **base)
def _decode(processor, outputs, meta: dict, language: str) -> str:
"""Prefer `processor.decode` (Cohere / long-form chunking); else `batch_decode`."""
if hasattr(processor, "decode"):
try:
if meta.get("audio_chunk_index") is not None:
return str(
processor.decode(
outputs,
skip_special_tokens=True,
audio_chunk_index=meta["audio_chunk_index"],
language=language,
)
).strip()
result = processor.decode(outputs, skip_special_tokens=True)
if isinstance(result, str):
return result.strip()
except TypeError:
pass
except Exception:
pass
try:
decoded = processor.batch_decode(outputs, skip_special_tokens=True)
return str(decoded[0]).strip() if decoded else ""
except Exception:
if hasattr(processor, "tokenizer"):
decoded = processor.tokenizer.batch_decode(outputs, skip_special_tokens=True)
return str(decoded[0]).strip() if decoded else ""
raise
def build_transcriber(model_id: str, device_str: str) -> tuple[Callable[..., str], Callable[[], None]]:
processor = _load_processor(model_id)
dtype = _pick_dtype(device_str)
model, _cls = _load_model(model_id, dtype, device_str)
model.eval()
# #region agent log
_debug_log(
"H1",
"backends/universal.py:build_transcriber:model_loaded",
"Universal backend selected and model loaded",
{
"model_id": model_id,
"model_type": str(getattr(getattr(model, "config", None), "model_type", "")),
"processor_class": processor.__class__.__name__,
"override_processor_id": bool(os.environ.get("FFASR_PROCESSOR_ID", "").strip()),
},
)
# #endregion
language = os.environ.get("FFASR_LANGUAGE", "en").strip() or "en"
env_cap_raw = os.environ.get("FFASR_MAX_NEW_TOKENS", "").strip()
env_cap = int(env_cap_raw) if env_cap_raw.isdigit() else None
max_new = _safe_max_new_tokens(model, env_cap)
def transcribe(audio_np: np.ndarray, sampling_rate: int = 16000) -> str:
arr = safe_pad_audio(audio_np)
# #region agent log
_debug_log(
"H3",
"backends/universal.py:transcribe:audio_in",
"Audio input after safe_pad_audio",
{
"sampling_rate": int(sampling_rate),
"arr_shape": list(arr.shape) if hasattr(arr, "shape") else None,
"arr_dtype": str(arr.dtype) if hasattr(arr, "dtype") else type(arr).__name__,
},
)
# #endregion
inputs = _call_processor(processor, arr, int(sampling_rate), language)
# #region agent log
_debug_log(
"H2",
"backends/universal.py:transcribe:processor_out",
"Processor output keys and shapes",
{
"keys": sorted(list(dict(inputs).keys())) if hasattr(inputs, "keys") else [],
"input_features_shape": list(dict(inputs).get("input_features").shape) if hasattr(dict(inputs).get("input_features"), "shape") else None,
"input_values_shape": list(dict(inputs).get("input_values").shape) if hasattr(dict(inputs).get("input_values"), "shape") else None,
},
)
# #endregion
batch, meta = _move_to_device(inputs, device_str, dtype)
# #region agent log
_debug_log(
"H5",
"backends/universal.py:transcribe:batch_out",
"Batch sent to generate after device move",
{
"batch_keys": sorted(batch.keys()),
"meta_keys": sorted(meta.keys()),
"input_features_shape": list(batch.get("input_features").shape) if hasattr(batch.get("input_features"), "shape") else None,
"input_values_shape": list(batch.get("input_values").shape) if hasattr(batch.get("input_values"), "shape") else None,
},
)
# #endregion
with torch.no_grad():
outputs = _generate(model, batch, max_new, language)
return _decode(processor, outputs, meta, language)
attach_params(transcribe, model)
def cleanup() -> None:
nonlocal model, processor
del model, processor
return transcribe, cleanup