Output:
Exit code: 0
Wall time: 0 seconds
Output:
        raw_request: Request,
        response_class: type[T | V],
        stream_generator_method: Callable[..., AsyncGenerator[str, None]],
    ) -> T | V | AsyncGenerator[str, None] | ErrorResponse:
        """Base method for speech-to-text operations like transcription and
        translation."""
        error_check_ret = await self._check_model(request)
        if error_check_ret is not None:
            return error_check_ret

        # If the engine is dead, raise the engine's DEAD_ERROR.
        # This is required for the streaming case, where we return a
        # success status before we actually start generating text :).
        if self.engine_client.errored:
            raise self.engine_client.dead_error

        if request.response_format not in ["text", "json", "verbose_json"]:
            return self.create_error_response(
                ("Currently only support response_format")
                + ("`text`, `json` or `verbose_json`")
            )

        if (
            request.response_format == "verbose_json"
            and not self.model_cls.supports_segment_timestamp
        ):
            return self.create_error_response(
                f"Currently do not support verbose_json for {request.model}"
            )

        if request.response_format == "verbose_json" and request.stream:
            return self.create_error_response(
                "verbose_json format doesn't support streaming case"
            )
        request_id = f"{self.task_type}-{self._base_request_id(raw_request)}"

        request_metadata = RequestResponseMetadata(request_id=request_id)
        if raw_request:
            raw_request.state.request_metadata = request_metadata

        try:
            lora_request = self._maybe_get_adapters(request)

            prompts, duration_s = await self._preprocess_speech_to_text(
                request=request,
                audio_data=audio_data,
            )

        except ValueError as e:
            logger.exception("Error in preprocessing prompt inputs")
            return self.create_error_response(str(e))

        list_result_generator: list[AsyncGenerator[RequestOutput, None]] | None = None
        try:
            # Unlike most decoder-only models, whisper generation length is not
            # constrained by the size of the input audio, which is mapped to a
            # fixed-size log-mel-spectogram.
            default_max_tokens = self.model_config.max_model_len
            sampling_params = request.to_sampling_params(
                default_max_tokens, self.default_sampling_params
            )

            self._log_inputs(
                request_id,
                # It will not display special tokens like <|startoftranscript|>
                request.prompt,
                params=sampling_params,
                lora_request=lora_request,
            )

            list_result_generator = [
                self.engine_client.generate(
                    prompt,
                    sampling_params,
                    f"{request_id}_{i}",
                    lora_request=lora_request,
                )
                for i, prompt in enumerate(prompts)
            ]
        except ValueError as e:
            # TODO: Use a vllm-specific Validation Error
            return self.create_error_response(str(e))

        if request.stream:
            return stream_generator_method(
                request, list_result_generator, request_id, request_metadata, duration_s
            )
        # Non-streaming response.
        total_segments = []
        text_parts = []
        try:
            assert list_result_generator is not None
            segments_types: dict[str, type[SpeechToTextSegment]] = {
                "transcribe": TranscriptionSegment,
                "translate": TranslationSegment,
            }
            segment_class: type[SpeechToTextSegment] = segments_types[self.task_type]
            text = ""
            for idx, result_generator in enumerate(list_result_generator):
                async for op in result_generator:
                    if request.response_format == "verbose_json":
                        segments: list[SpeechToTextSegment] = (
                            self._get_verbose_segments(
                                tokens=tuple(op.outputs[0].token_ids),
                                segment_class=segment_class,
                                request=request,
                                start_time=idx * self.asr_config.max_audio_clip_s,
                            )
                        )

                        total_segments.extend(segments)
                        text_parts.extend([seg.text for seg in segments])
                    else:
                        text_parts.append(op.outputs[0].text)
            text = "".join(text_parts)
            if self.task_type == "transcribe":
                final_response: ResponseType
                # add usage in TranscriptionResponse.
                usage = {
                    "type": "duration",
                    # rounded up as per openAI specs
                    "seconds": int(math.ceil(duration_s)),
                }
                if request.response_format != "verbose_json":
                    final_response = cast(
                        T, TranscriptionResponse(text=text, usage=usage)
                    )
                else:
                    final_response = cast(
                        V,
                        TranscriptionResponseVerbose(
                            text=text,
                            language=request.language,
                            duration=str(duration_s),
                            segments=total_segments,
                        ),
                    )
            else:
                # no usage in response for translation task
                if request.response_format != "verbose_json":
                    final_response = cast(T, TranslationResponse(text=text))
                else:
                    final_response = cast(
                        V,
                        TranslationResponseVerbose(
                            text=text,
                            language=request.language,
                            duration=str(duration_s),
                            segments=total_segments,
                        ),
                    )
            return final_response
        except asyncio.CancelledError:
            return self.create_error_response("Client disconnected")
        except ValueError as e:
            # TODO: Use a vllm-specific Validation Error
            return self.create_error_response(str(e))

    async def _speech_to_text_stream_generator(
        self,
        request: SpeechToTextRequest,
        list_result_generator: list[AsyncGenerator[RequestOutput, None]],
        request_id: str,
        request_metadata: RequestResponseMetadata,
        audio_duration_s: float,
        chunk_object_type: Literal["translation.chunk", "transcription.chunk"],
        response_stream_choice_class: type[TranscriptionResponseStreamChoice]
        | type[TranslationResponseStreamChoice],
        stream_response_class: type[TranscriptionStreamResponse]
        | type[TranslationStreamResponse],
    ) -> AsyncGenerator[str, None]:
        created_time = int(time.time())
        model_name = request.model

        completion_tokens = 0
        num_prompt_tokens = 0

        include_usage = self.enable_force_include_usage or request.stream_include_usage
        include_continuous_usage = (
            request.stream_continuous_usage_stats
            if include_usage and request.stream_continuous_usage_stats
            else False
        )

        try:
            for result_generator in list_result_generator:
                async for res in result_generator:
                    # On first result.
                    if res.prompt_token_ids is not None:
                        num_prompt_tokens = len(res.prompt_token_ids)
                        if audio_tokens := self.model_cls.get_num_audio_tokens(
                            audio_duration_s, self.asr_config, self.model_config
                        ):
                            num_prompt_tokens += audio_tokens

                    # We need to do it here, because if there are exceptions in
                    # the result_generator, it needs to be sent as the FIRST
                    # response (by the try...catch).

                    # Just one output (n=1) supported.
                    assert len(res.outputs) == 1
                    output = res.outputs[0]

                    delta_message = DeltaMessage(content=output.text)
                    completion_tokens += len(output.token_ids)

                    if output.finish_reason is None:
                        # Still generating, send delta update.
                        choice_data = response_stream_choice_class(delta=delta_message)
                    else:
                        # Model is finished generating.
                        choice_data = response_stream_choice_class(
                            delta=delta_message,
                            finish_reason=output.finish_reason,
                            stop_reason=output.stop_reason,
                        )

                    chunk = stream_response_class(
                        id=request_id,
                        object=chunk_object_type,
                        created=created_time,
                        choices=[choice_data],
                        model=model_name,
                    )

                    # handle usage stats if requested & if continuous
                    if include_continuous_usage:
                        chunk.usage = UsageInfo(
                            prompt_tokens=num_prompt_tokens,
                            completion_tokens=completion_tokens,
                            total_tokens=num_prompt_tokens + completion_tokens,
                        )

                    data = chunk.model_dump_json(exclude_unset=True)
                    yield f"data: {data}\n\n"

            # Once the final token is handled, if stream_options.include_usage
            # is sent, send the usage.
            if include_usage:
                final_usage = UsageInfo(
                    prompt_tokens=num_prompt_tokens,
                    completion_tokens=completion_tokens,
                    total_tokens=num_prompt_tokens + completion_tokens,
                )

                final_usage_chunk = stream_response_class(
                    id=request_id,
                    object=chunk_object_type,
                    created=created_time,
                    choices=[],
                    model=model_name,
                    usage=final_usage,
                )
                final_usage_data = final_usage_chunk.model_dump_json(
                    exclude_unset=True, exclude_none=True
                )
                yield f"data: {final_usage_data}\n\n"

            # report to FastAPI middleware aggregate usage across all choices
            request_metadata.final_usage_info = UsageInfo(
                prompt_tokens=num_prompt_tokens,
                completion_tokens=completion_tokens,
                total_tokens=num_prompt_tokens + completion_tokens,
            )

        except Exception as e:
            # TODO: Use a vllm-specific Validation Error
            logger.exception("Error in %s stream generator.", self.task_type)
            data = self.create_streaming_error_response(str(e))
            yield f"data: {data}\n\n"
        # Send the final done message after all response.n are finished
        yield "data: [DONE]\n\n"

    def _split_audio(
        self, audio_data: np.ndarray, sample_rate: int
    ) -> list[np.ndarray]:
        chunk_size = sample_rate * self.asr_config.max_audio_clip_s
        overlap_size = sample_rate * self.asr_config.overlap_chunk_second
        chunks = []
        i = 0
        while i < audio_data.shape[-1]:


Summary: auto

## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /home/ubuntu/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /home/ubuntu/.codex/skills/.system/skill-installer/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
  2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.


Summary: auto


**Evaluating speech-to-text integration approach**

Explored reusing the existing speech-to-text class but noted its heavy dependencies; decided a leaner approach is to directly use the model's prompt generation paired with LLM.generate, replicating key preprocessing steps like chunking and resampling to fit our pipeline more cleanly.


**Inspecting LLM initialization and model config**

Planning to extract the model class via vLLM's loader using model config and verify how the LLM class holds its model or engine config to instantiate it properly with correct model ID.


python3 - <<'PY'
from vllm import LLM
import inspect
print([m for m in dir(LLM) if 'engine' in m])
print([m for m in dir(LLM) if 'generate' in m])
PY


Output:
Exit code: 0
Wall time: 11.5 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:01:20.778293: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
['_run_engine']
['generate']


**Inspecting LLM object structure**


python3 - <<'PY'
import inspect
from vllm import LLM
print(inspect.getsource(LLM)[:2000])
PY


Output:
Exit code: 0
Wall time: 11.5 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:01:35.426059: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
class LLM:
    """An LLM for generating texts from given prompts and sampling parameters.

    This class includes a tokenizer, a language model (possibly distributed
    across multiple GPUs), and GPU memory space allocated for intermediate
    states (aka KV cache). Given a batch of prompts and sampling parameters,
    this class generates texts from the model, using an intelligent batching
    mechanism and efficient memory management.

    Args:
        model: The name or path of a HuggingFace Transformers model.
        tokenizer: The name or path of a HuggingFace Transformers tokenizer.
        tokenizer_mode: The tokenizer mode. "auto" will use the fast tokenizer
            if available, and "slow" will always use the slow tokenizer.
        skip_tokenizer_init: If true, skip initialization of tokenizer and
            detokenizer. Expect valid prompt_token_ids and None for prompt
            from the input.
        trust_remote_code: Trust remote code (e.g., from HuggingFace) when
            downloading the model and tokenizer.
        allowed_local_media_path: Allowing API requests to read local images
            or videos from directories specified by the server file system.
            This is a security risk. Should only be enabled in trusted
            environments.
        allowed_media_domains: If set, only media URLs that belong to this
            domain can be used for multi-modal inputs.
        tensor_parallel_size: The number of GPUs to use for distributed
            execution with tensor parallelism.
        dtype: The data type for the model weights and activations. Currently,
            we support `float32`, `float16`, and `bfloat16`. If `auto`, we use
            the `dtype` attribute of the Transformers model's config. However,
            if the `dtype` in the config is `float32`, we will use `float16` instead.
        quantization: The method used to quantize the model weights. Currently,
            we support "awq", "gptq", and "f


**Considering presence of llm_engine attribute**


python3 - <<'PY'
from vllm import LLM
import inspect
src = inspect.getsource(LLM)
for line in src.split('\n'):
    if 'self.llm_engine' in line:
        print(line)
PY


Output:
Exit code: 0
Wall time: 11.4 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:01:49.246436: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
        self.llm_engine = LLMEngine.from_engine_args(
        self.engine_class = type(self.llm_engine)
        supported_tasks = self.llm_engine.get_supported_tasks()
        self.model_config = self.llm_engine.model_config
        self.input_processor = self.llm_engine.input_processor
        self.io_processor = self.llm_engine.io_processor
        return self.llm_engine.get_tokenizer()
        self.llm_engine.reset_mm_cache()
        lora_config = self.llm_engine.vllm_config.lora_config
        return self.llm_engine.collective_rpc(method, timeout, args, kwargs)
        return self.llm_engine.apply_model(func)
        self.llm_engine.start_profile()
        self.llm_engine.stop_profile()
        return self.llm_engine.reset_prefix_cache(
        self.llm_engine.sleep(level=level)
        self.llm_engine.wake_up(tags)
        return self.llm_engine.get_metrics()
                self.llm_engine.abort_request(added_request_ids)
        self.llm_engine.add_request(
            num_requests = self.llm_engine.get_num_unfinished_requests()
        while self.llm_engine.has_unfinished_requests():
            step_outputs = self.llm_engine.step()


**Reviewing audio prompt formatting**

The model requires special prompt formatting for audio input, likely using tokens like `<audio>`. I'll inspect the `get_generation_prompt` method in the relevant model file to understand how to build prompts for transcription with audio data.


python3 - <<'PY'
import inspect
from vllm.model_executor.models import voxtral
src = inspect.getsource(voxtral)
idx = src.find('def get_generation_prompt')
print(src[idx:idx+1500])
PY


Output:
Exit code: 0
Wall time: 11.8 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:02:07.113766: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
def get_generation_prompt(
        cls,
        audio: np.ndarray,
        model_config: ModelConfig,
        stt_config: SpeechToTextConfig,
        language: str | None,
        task_type: Literal["transcribe", "translate"],
        request_prompt: str,
        to_language: str | None,
    ) -> PromptType:
        tokenizer = cached_tokenizer_from_config(model_config)
        audio = Audio(audio, int(stt_config.sample_rate), format="wav")  # lossless
        req = TranscriptionRequest(
            model=model_config.model,
            audio=RawAudio.from_audio(audio),
            language=language,
        )

        tokenized = tokenizer.instruct.encode_transcription(req)
        audio = (tokenized.audios[0].audio_array, stt_config.sample_rate)
        prompts_dict = {"multi_modal_data": {"audio": audio}}
        prompts_dict["prompt_token_ids"] = tokenized.tokens
        return cast(PromptType, prompts_dict)

    @classmethod
    def get_num_audio_tokens(
        cls,
        audio_duration_s: float,
        stt_config: SpeechToTextConfig,
        model_config: ModelConfig,
    ) -> int | None:
        """
        Map from audio duration to number of audio tokens produced by the ASR
        model, without running a forward pass.
        This is used for estimating the amount of processing for this audio.
        """
        tokenizer = cached_tokenizer_from_config(model_config)
        adapter = VoxtralProcessorAdapter(tokenizer)
        return adapter.get_num_audio_token


**Comparing get_generation_prompt implementations**

Checked that Voxtral’s get_generation_prompt returns prompt tokens and audio data; next step is to verify if Gemma3n's method follows the same pattern.


python3 - <<'PY'
import inspect
from vllm.model_executor.models import gemma3n_mm
src = inspect.getsource(gemma3n_mm)
idx = src.find('def get_generation_prompt')
print(src[idx:idx+1500])
PY


Output:
Exit code: 0
Wall time: 10.9 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:02:21.835715: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
def get_generation_prompt(
        cls,
        audio: np.ndarray,
        stt_config: SpeechToTextConfig,
        model_config: ModelConfig,
        language: Optional[str],
        task_type: Literal["transcribe", "translate"],
        request_prompt: str,
        to_language: Optional[str],
    ) -> PromptType:
        """
        Gemma3n supports "free-form" transcription.
        We fix its prompt here to standardize transcriptions/translations
        requests.
        """
        # Transcribe this audio [into <>] | for transcription
        # Translate this audio [from <> into <>] | for translation
        prompt = "<start_of_turn>user\n"
        prompt += "Transcribe" if task_type == "transcribe" else "Translate"
        prompt += " this audio"

        # We assume the language is a valid ISO 639-1 code.
        full_lang_name = cls.supported_languages.get(language, "")
        # Translation only for now
        full_lang_name_to = cls.supported_languages.get(to_language, "")

        if task_type == "transcribe" and full_lang_name:
            prompt += f" into {full_lang_name}"
        elif task_type == "translate":
            if full_lang_name:
                prompt += f" from {full_lang_name}"
            if full_lang_name_to:
                prompt += f" into {full_lang_name_to}"

        prompt += ": <audio_soft_token><end_of_turn>\n<start_of_turn>model\n"

        audio = (audio, stt_config.sample_rate)
        prompts_dict = {"multi_modal_data": {"audio": au


python3 - <<'PY'
import inspect
from vllm.model_executor.models import gemma3n_mm
src = inspect.getsource(gemma3n_mm)
idx = src.find('def get_generation_prompt')
print(src[idx+1500:idx+2500])
PY


Output:
Exit code: 0
Wall time: 10.9 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:02:35.113557: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
dio}, "prompt": prompt}
        return cast(PromptType, prompts_dict)

    @classmethod
    def get_speech_to_text_config(
        cls, model_config: ModelConfig, task_type: str
    ) -> SpeechToTextConfig:
        return SpeechToTextConfig(
            # Let's set this to 30 as suggested in the docs for now, although
            # the model is only limited by its context length.
            max_audio_clip_s=30,
            sample_rate=16000,
            # TODO enable chunking after more thorough testing.
            min_energy_split_window_size=None,
        )


**Designing vLLM ASR integration**

Planning a custom ASR class using vLLM that encodes prompts for transcription without running full transformers inference; transformers will only be used for preprocessing audio features. The approach will support sequential loading of Hugging Face models for transcription benchmarking.


**Checking dataset segments and bench files**

Investigating which benchmark dataset corresponds to 200 segments by inspecting directories for Hindi v2 data and outputs, focusing on `data/bench` and `outputs/bench` to clarify dataset mapping.


Output:
Exit code: 0
Wall time: 0 seconds
Output:
maya_hf_hin_v2
maya_hf_v0
maya_hf_v1
maya_hf_v2
model_maps


**Checking dataset file sizes**

I'm inspecting line counts in the bench dataset files maya_hf_hin_v2 manifest and refs to verify dataset size and contents.


wc -l data/bench/maya_hf_hin_v2/refs.jsonl data/bench/maya_hf_v2/refs.jsonl data/bench/maya_hf_v1/refs.jsonl data/bench/maya_hf_v0/refs.jsonl


Output:
Exit code: 0
Wall time: 0 seconds
Output:
    200 data/bench/maya_hf_hin_v2/refs.jsonl
   2400 data/bench/maya_hf_v2/refs.jsonl
   2400 data/bench/maya_hf_v1/refs.jsonl
   2400 data/bench/maya_hf_v0/refs.jsonl
   7400 total


Summary: auto


**Clarifying dataset and language scope**

The benchmark likely uses the Maya HF Hin v2 dataset focused on Hindi with 200 segments, matching the earlier summary. The user wants consistent <5% WER across all languages, but current info centers on Hindi only. Considering running on full dataset (2400 segments, 12 languages) may be heavy, and it's unclear if Voxtral/Gemma3n support all those languages. Next, I'll confirm dataset and model language coverage before testing.


**Confirming model language coverage and access constraints**

Voxtral supports only English and Hindi among the dataset languages, while Gemma3n claims 140 languages but with unclear coverage and gated access requiring a HF token. Running all languages with Voxtral likely yields poor results outside Hindi. To proceed, I'll check for HF token availability to verify Gemma3n access and plan to run manageable Hindi subset tests first, noting limitations on broader language coverage and runtime.


env | rg -i "HUGGINGFACE|HF_TOKEN|HF_HOME"


Output:
Exit code: 1
Wall time: 0 seconds
Output:


**Checking for Huggingface token presence**

I'll look for a Huggingface token in typical config or cache locations to determine if gemma weights can be accessed despite no explicit token found.


Output:
Exit code: 2
Wall time: 0 seconds
Output:
ls: cannot access '/home/ubuntu/.huggingface': No such file or directory


**Checking local Huggingface cache for token**


Output:
Exit code: 0
Wall time: 0 seconds
Output:
.
..
datasets
hub
modules
stored_tokens
token
xet


**Checking for Huggingface token presence**

Planning to verify whether a Huggingface token exists by using the library's token retrieval method, avoiding exposing the token itself.


python3 - <<'PY'
from huggingface_hub import HfFolder
print(bool(HfFolder.get_token()))
PY


Output:
Exit code: 0
Wall time: 0.2 seconds
Output:
True


**Checking token access to gated model**

Planning to verify token permission for Gemma by attempting to download its config file and catching failures to confirm access.


python3 - <<'PY'
from huggingface_hub import hf_hub_download
model_id = "google/gemma-3n-E4B-it"
try:
    path = hf_hub_download(repo_id=model_id, filename="config.json")
    print("downloaded", path)
except Exception as e:
    print("failed", type(e).__name__, e)
PY


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
downloaded /home/ubuntu/.cache/huggingface/hub/models--google--gemma-3n-E4B-it/snapshots/c1221e9c62e34a43ab7ffacd1be0ea71f126ef10/config.json


**Planning vLLM ASR integration**

I'm outlining adding a new ASR backend using vLLM with audio decoding and prompt building from configs, extending the pipeline and CLI for model selection, enforcing single model loading with sequential runs, and planning evaluation via the existing benchmark command with a vLLM model flag.


**Clarifying WER evaluation approach**

I'm deciding to run the pipeline with only the new vLLM model—using its ASR output key—to compute WER comparable to prior final/rover results, noting pipeline verification gating and ensuring evaluation treats all segments consistently for fair comparison.


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from jiwer import cer, wer

from maya_transcribe.io import read_jsonl
from maya_transcribe.textnorm import normalize_text

@dataclass(frozen=True)
class EvalRow:
    segment_id: str
    language: str | None
    decision: str | None
    duration_sec: float | None
    duration_bucket: str | None
    ref: str
    hyp: str
    wer: float
    cer: float
    oracle_best_wer: float | None = None
    oracle_best_cer: float | None = None
    oracle_best_key: str | None = None
    selection_gap_wer: float | None = None

def load_refs(refs_path: Path) -> dict[str, dict[str, Any]]:
    """
    Read a JSONL file of references.
    Expected keys (any one): `text`, `transcript`, `reference`.
    Required key: `segment_id` (or `id`).
    """
    refs: dict[str, dict[str, Any]] = {}
    for row in read_jsonl(refs_path):
        segment_id = str(row.get("segment_id") or row.get("id") or "").strip()
        if not segment_id:
            continue
        refs[segment_id] = row
    return refs

def _get_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None:
    for k in keys:
        v = row.get(k)
        if isinstance(v, str) and v.strip():
            return v
    return None

def evaluate(
    results_path: Path,
    refs_path: Path,
    out_path: Path | None = None,
    hypothesis: str = "final",
    oracle: bool = True,
    language_source: str = "route",
    decision_filter: list[str] | None = None,
) -> dict[str, Any]:
    refs = load_refs(refs_path)
    results = read_jsonl(results_path)

    decision_filter_set = None
    if decision_filter:
        decision_filter_set = {str(d).strip().lower() for d in decision_filter if str(d).strip()}

    rows: list[EvalRow] = []
    for r in results:
        segment_id = str(r.get("segment_id") or "").strip()
        if not segment_id or segment_id not in refs:
            continue

        ref_row = refs[segment_id]
        ref_text = _get_text(ref_row, ("text", "transcript", "reference"))
        if ref_text is None:
            continue

        hyp_text = extract_hypothesis_text(r, hypothesis=hypothesis)
        if hyp_text is None:
            continue

        route_lang = None
        route = r.get("route") or {}
        if isinstance(route, dict):
            route_lang = route.get("language_hint")

        ref_lang = None
        if language_source == "refs":
            v = ref_row.get("lang") or ref_row.get("language")
            if isinstance(v, str) and v.strip():
                ref_lang = v.strip()
        elif language_source != "route":
            raise ValueError("language_source must be 'route' or 'refs'")

        lang = ref_lang or route_lang

        decision = None
        ver = r.get("verification") or {}
        if isinstance(ver, dict):
            decision = ver.get("decision")
        if decision_filter_set is not None:
            dec_norm = str(decision or "").strip().lower()
            if dec_norm not in decision_filter_set:
                continue

        duration_sec = None
        qc = r.get("qc") or {}
        if isinstance(qc, dict):
            try:
                duration_sec = float(qc.get("duration_sec")) if qc.get("duration_sec") is not None else None
            except (TypeError, ValueError):
                duration_sec = None
        bucket = duration_bucket(duration_sec)

        ref_norm = normalize_text(ref_text, lang=lang)
        hyp_norm = normalize_text(hyp_text, lang=lang)
        w = float(wer(ref_norm, hyp_norm))
        c = float(cer(ref_norm.replace(" ", ""), hyp_norm.replace(" ", "")))

        oracle_best_wer = None
        oracle_best_cer = None
        oracle_best_key = None
        selection_gap_wer = None

        if oracle:
            cand_wer: dict[str, float] = {}
            cand_cer: dict[str, float] = {}
            for key, text in extract_all_candidates(result_row=r).items():
                ref_n = ref_norm
                hyp_n = normalize_text(text, lang=lang)
                cand_wer[key] = float(wer(ref_n, hyp_n))
                cand_cer[key] = float(cer(ref_n.replace(" ", ""), hyp_n.replace(" ", "")))

            if cand_wer:
                oracle_best_key, oracle_best_wer = min(cand_wer.items(), key=lambda kv: kv[1])
                oracle_best_cer = cand_cer.get(oracle_best_key)
                selection_gap_wer = float(w) - float(oracle_best_wer)

        rows.append(
            EvalRow(
                segment_id=segment_id,
                language=lang,
                decision=str(decision) if decision is not None else None,
                duration_sec=duration_sec,
                duration_bucket=bucket,
                ref=ref_text,
                hyp=hyp_text,
                wer=w,
                cer=c,
                oracle_best_wer=oracle_best_wer,
                oracle_best_cer=oracle_best_cer,
                oracle_best_key=oracle_best_key,
                selection_gap_wer=selection_gap_wer,
            )
        )

    by_lang: dict[str, list[float]] = {}
    by_dec: dict[str, list[float]] = {}
    by_bucket: dict[str, list[float]] = {}
    by_lang_cer: dict[str, list[float]] = {}
    by_dec_cer: dict[str, list[float]] = {}
    by_bucket_cer: dict[str, list[float]] = {}
    selection_gaps: list[float] = []
    oracle_best_wers: list[float] = []
    for row in rows:
        if row.language:
            by_lang.setdefault(row.language, []).append(row.wer)
            by_lang_cer.setdefault(row.language, []).append(row.cer)
        if row.decision:
            by_dec.setdefault(row.decision, []).append(row.wer)
            by_dec_cer.setdefault(row.decision, []).append(row.cer)
        if row.duration_bucket:
            by_bucket.setdefault(row.duration_bucket, []).append(row.wer)
            by_bucket_cer.setdefault(row.duration_bucket, []).append(row.cer)
        if row.selection_gap_wer is not None:
            selection_gaps.append(float(row.selection_gap_wer))
        if row.oracle_best_wer is not None:
            oracle_best_wers.append(float(row.oracle_best_wer))

    summary = {
        "hypothesis": hypothesis,
        "pairs_evaluated": len(rows),
        "wer_mean": (sum(r.wer for r in rows) / len(rows)) if rows else None,
        "cer_mean": (sum(r.cer for r in rows) / len(rows)) if rows else None,
        "wer_p50": _percentile([r.wer for r in rows], 0.50),
        "wer_p90": _percentile([r.wer for r in rows], 0.90),
        "wer_p95": _percentile([r.wer for r in rows], 0.95),
        "cer_p50": _percentile([r.cer for r in rows], 0.50),
        "cer_p90": _percentile([r.cer for r in rows], 0.90),
        "cer_p95": _percentile([r.cer for r in rows], 0.95),
        "segment_error_rate": (sum(1 for r in rows if r.wer > 0.0) / len(rows)) if rows else None,
        "pct_wer_gt_5pct": (sum(1 for r in rows if r.wer > 0.05) / len(rows)) if rows else None,
        "pct_wer_gt_10pct": (sum(1 for r in rows if r.wer > 0.10) / len(rows)) if rows else None,
        "wer_by_language_mean": {k: sum(v) / len(v) for k, v in sorted(by_lang.items())},
        "wer_by_decision_mean": {k: sum(v) / len(v) for k, v in sorted(by_dec.items())},
        "wer_by_duration_bucket_mean": {k: sum(v) / len(v) for k, v in sorted(by_bucket.items())},
        "cer_by_language_mean": {k: sum(v) / len(v) for k, v in sorted(by_lang_cer.items())},
        "cer_by_decision_mean": {k: sum(v) / len(v) for k, v in sorted(by_dec_cer.items())},
        "cer_by_duration_bucket_mean": {k: sum(v) / len(v) for k, v in sorted(by_bucket_cer.items())},
        "oracle": {
            "enabled": bool(oracle),
            "oracle_best_wer_mean": (sum(oracle_best_wers) / len(oracle_best_wers)) if oracle_best_wers else None,
            "selection_gap_wer_mean": (sum(selection_gaps) / len(selection_gaps)) if selection_gaps else None,
            "selection_gap_wer_p90": _percentile(selection_gaps, 0.90),
        },
    }

    if out_path is not None:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    return summary

def extract_hypothesis_text(result_row: dict[str, Any], hypothesis: str) -> str | None:
    """
    hypothesis:
      - "final": uses result_row["final"]["text"] (falls back to rover if missing)
      - "rover" (default): uses result_row["rover"]["text"]
      - "asr:<key>": uses result_row["asr"][<key>]["text"]
    """
    hypothesis = (hypothesis or "rover").strip()
    if hypothesis == "final":
        final = result_row.get("final") or {}
        if isinstance(final, dict) and isinstance(final.get("text"), str):
            return final["text"]
        hypothesis = "rover"
    if hypothesis == "rover":
        rover = result_row.get("rover") or {}
        if isinstance(rover, dict) and isinstance(rover.get("text"), str):
            return rover["text"]
        return None

    if hypothesis.startswith("asr:"):
        key = hypothesis[len("asr:") :].strip()
        asr = result_row.get("asr") or {}
        if isinstance(asr, dict):
            entry = asr.get(key) or {}
            if isinstance(entry, dict) and isinstance(entry.get("text"), str):
                return entry["text"]
        return None

    return None

def extract_all_candidates(result_row: dict[str, Any]) -> dict[str, str]:
    """
    Collect all text candidates available in a pipeline result row.
    Keys are stable and namespaced (final/rover/asr:<key>).
    """
    candidates: dict[str, str] = {}

    final = extract_hypothesis_text(result_row, "final")
    if final is not None:
        candidates["final"] = final

    rover = extract_hypothesis_text(result_row, "rover")


**Confirming evaluation approach and audio decoding plan**

The evaluation uses all segments but can filter WER by decision for accept/human, so it fits comparing new model outputs. I'll integrate the new backend producing an `asr` entry with a model field. Next, I'll check `maya_transcribe/audio.py` to verify the decode function and confirm using `decode_pcm16` aligns with vLLM's audio processing involving `librosa`.


sed -n '1,200p' maya_transcribe/audio.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import json
import subprocess
from dataclasses import dataclass
from pathlib import Path

import numpy as np

@dataclass(frozen=True)
class AudioInfo:
    path: str
    duration_sec: float | None
    sample_rate: int | None
    channels: int | None

@dataclass(frozen=True)
class AudioQCMetrics:
    duration_sec: float
    rms_dbfs: float
    peak_dbfs: float
    clipping_fraction: float

def ffprobe(path: Path) -> AudioInfo:
    cmd = [
        "ffprobe",
        "-v",
        "error",
        "-show_entries",
        "format=duration",
        "-show_entries",
        "stream=sample_rate,channels",
        "-of",
        "json",
        str(path),
    ]
    proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
    data = json.loads(proc.stdout or "{}")

    duration_sec = None
    try:
        duration_sec = float(data.get("format", {}).get("duration"))
    except (TypeError, ValueError):
        duration_sec = None

    sample_rate = None
    channels = None
    streams = data.get("streams") or []
    if streams:
        s0 = streams[0] or {}
        try:
            sample_rate = int(s0.get("sample_rate")) if s0.get("sample_rate") else None
        except (TypeError, ValueError):
            sample_rate = None
        try:
            channels = int(s0.get("channels")) if s0.get("channels") else None
        except (TypeError, ValueError):
            channels = None

    return AudioInfo(path=str(path), duration_sec=duration_sec, sample_rate=sample_rate, channels=channels)

def decode_pcm16(path: Path, sample_rate: int = 16000, mono: bool = True) -> tuple[np.ndarray, int]:
    """
    Decode audio with ffmpeg into signed 16-bit PCM (optionally mono) at `sample_rate`.
    Returns (float32 waveform in [-1, 1], sample_rate).
    """
    cmd = ["ffmpeg", "-v", "error", "-i", str(path), "-f", "s16le", "-ar", str(sample_rate)]
    if mono:
        cmd += ["-ac", "1"]
    cmd += ["-"]

    proc = subprocess.run(cmd, check=True, capture_output=True)
    pcm = np.frombuffer(proc.stdout, dtype=np.int16)
    if pcm.size == 0:
        return np.zeros((0,), dtype=np.float32), sample_rate
    wav = pcm.astype(np.float32) / 32768.0
    return wav, sample_rate

def compute_qc(path: Path, sample_rate: int = 16000) -> AudioQCMetrics:
    wav, sr = decode_pcm16(path, sample_rate=sample_rate, mono=True)
    duration_sec = float(wav.shape[0]) / float(sr) if sr else 0.0
    if wav.size == 0:
        return AudioQCMetrics(duration_sec=0.0, rms_dbfs=float("-inf"), peak_dbfs=float("-inf"), clipping_fraction=0.0)

    peak = float(np.max(np.abs(wav)))
    rms = float(np.sqrt(np.mean(np.square(wav))))

    peak_dbfs = 20.0 * float(np.log10(max(peak, 1e-12)))
    rms_dbfs = 20.0 * float(np.log10(max(rms, 1e-12)))

    clipping_threshold = 0.999
    clipping_fraction = float(np.mean(np.abs(wav) >= clipping_threshold))

    return AudioQCMetrics(
        duration_sec=duration_sec,
        rms_dbfs=rms_dbfs,
        peak_dbfs=peak_dbfs,
        clipping_fraction=clipping_fraction,
    )

def normalize_audio(
    in_path: Path,
    out_path: Path,
    sample_rate: int = 16000,
    mono: bool = True,
    trim_silence: bool = True,
    silence_threshold_db: float = -40.0,
    min_silence_sec: float = 0.15,
) -> Path:
    """
    Convert audio to 16kHz mono WAV and optionally trim leading/trailing silence.
    """
    out_path.parent.mkdir(parents=True, exist_ok=True)

    cmd = ["ffmpeg", "-v", "error", "-y", "-i", str(in_path)]

    if trim_silence:
        # Trim leading/trailing silence; tuned for already-clean segments.
        # https://ffmpeg.org/ffmpeg-filters.html#silenceremove
        thr = f"{silence_threshold_db}dB"
        sil = (
            f"silenceremove=start_periods=1:start_duration={min_silence_sec}:start_threshold={thr}:"
            f"stop_periods=1:stop_duration={min_silence_sec}:stop_threshold={thr}"
        )
        cmd += ["-af", sil]

    cmd += ["-ar", str(sample_rate)]
    if mono:
        cmd += ["-ac", "1"]
    cmd += ["-c:a", "pcm_s16le", str(out_path)]

    subprocess.run(cmd, check=True)
    return out_path


**Verifying audio input handling**

I'm confirming that decode_pcm16 outputs float32 arrays normalized to -1..1, which fits vLLM's expected input format. Next, I'll verify sample rate handling in voxtral's stt_config to ensure consistency with the 16kHz decoding setup.


python3 - <<'PY'
import inspect
from vllm.model_executor.models import voxtral
src = inspect.getsource(voxtral)
idx = src.find('def get_speech_to_text_config')
print(src[idx:idx+800])
PY


Output:
Exit code: 0
Wall time: 12.1 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:04:14.030184: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
def get_speech_to_text_config(
        cls, model_config: ModelConfig, task_type: str
    ) -> SpeechToTextConfig:
        tokenizer = cached_tokenizer_from_config(model_config)
        audio_config = tokenizer.instruct.audio_encoder.audio_config
        max_audio_clip_s = audio_config.chunk_length_s
        sample_rate = audio_config.sampling_rate
        return SpeechToTextConfig(
            max_audio_clip_s=max_audio_clip_s,
            sample_rate=sample_rate,
            # mistral_common and whisper encoder take care of chunking
            min_energy_split_window_size=None,
        )

    @classmethod
    # for speech-to-text transcription
    def get_generation_prompt(
        cls,
        audio: np.ndarray,
        model_config: ModelConfig,
        stt_config: SpeechToTextConfig,


**Planning audio resampling and chunking**

The ASR class should decode audio directly to the model's sample rate to avoid extra resampling; chunking will depend on the model's max audio length and chunking allowance, with some models requiring manual chunking for longer inputs. Next, I'll confirm chunking behavior and finalize transcribe method design accordingly.


**Assessing audio chunking needs via segment durations**

I'm analyzing model configs and dataset segments to determine if chunking is required, focusing on max audio clip limits and whether the audio encoder handles chunking internally. I'll check dataset durations from refs.jsonl to see if any exceed model thresholds.


python3 - <<'PY'
import json
from pathlib import Path
refs = Path('data/bench/maya_hf_hin_v2/refs.jsonl').read_text(encoding='utf-8').strip().splitlines()
print(refs[0])
PY


Output:
Exit code: 0
Wall time: 0 seconds
Output:
{"segment_id": "Kathbath__hindi__valid__2580__844424933477501-662-m.m4a-c3cd2c9d05d0764b", "text": "इसका निर्माण मुख्यतः दिल्ली और राजस्थान में हुआ है", "lang": "hin", "audio_relpath": "hin/Kathbath__hindi__valid__2580__844424933477501-662-m.m4a.wav", "duration_sec": 5.99075, "source_dataset": "ai4bharat/Kathbath", "source_config": "hindi", "source_split": "valid", "source_id": "844424933477501-662-m.m4a", "source_index": 2580}


Summary: auto


**Planning max duration computation**

I'm focusing on calculating the maximum duration in the dataset using the refs that include duration. Next, I'll implement a quick aggregation to find the max duration efficiently.


python3 - <<'PY'
import json
from pathlib import Path
refs_path = Path('data/bench/maya_hf_hin_v2/refs.jsonl')
max_d = 0
min_d = 999
count=0
for line in refs_path.read_text(encoding='utf-8').splitlines():
    if not line.strip():
        continue
    row = json.loads(line)
    d = float(row.get('duration_sec') or 0)
    max_d = max(max_d, d)
    min_d = min(min_d, d)
    count += 1
print('count', count, 'min', min_d, 'max', max_d)
PY


Output:
Exit code: 0
Wall time: 0 seconds
Output:
count 200 min 2.34525 max 13.4675625


**Planning vLLM ASR integration**

I'm outlining a new ASR class using vLLM without chunking due to manageable audio lengths. The class will lazy-import vLLM, handle resource cleanup, and allow configurable parameters like tensor parallelism, dtype, and trust_remote_code for safety and compatibility.


**Designing transcription flow with vLLM**

I'm finalizing the ASR class to get model_cls from vLLM config, verify transcription support, decode audio, build generation prompts, and run deterministic sampling with controlled max tokens. I'll handle ISO-639-3 to ISO-639-1 language code conversion for compatibility with model requirements.


sed -n '1,200p' maya_transcribe/language.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import unicodedata

ISO3_TO_WHISPER_LANG: dict[str, str] = {
    "eng": "en",
    "hin": "hi",
    "mar": "mr",
    "guj": "gu",
    "pan": "pa",
    "tam": "ta",
    "tel": "te",
    "kan": "kn",
    "mal": "ml",
    "ben": "bn",
    "asm": "as",
    "urd": "ur",
    "san": "sa",
    "ori": "or",
    "ory": "or",
}

# Human-friendly names and scripts (for prompts/UI).
LANG_CODE_TO_NAME: dict[str, str] = {
    "eng": "English",
    "en": "English",
    "hin": "Hindi",
    "hi": "Hindi",
    "mar": "Marathi",
    "mr": "Marathi",
    "guj": "Gujarati",
    "gu": "Gujarati",
    "pan": "Punjabi",
    "pa": "Punjabi",
    "tam": "Tamil",
    "ta": "Tamil",
    "tel": "Telugu",
    "te": "Telugu",
    "kan": "Kannada",
    "kn": "Kannada",
    "mal": "Malayalam",
    "ml": "Malayalam",
    "ben": "Bengali",
    "bn": "Bengali",
    "asm": "Assamese",
    "as": "Assamese",
    "ori": "Odia",
    "ory": "Odia",
    "or": "Odia",
}

LANG_NAME_TO_SCRIPT: dict[str, str] = {
    "English": "Latin",
    "Hindi": "Devanagari",
    "Marathi": "Devanagari",
    "Gujarati": "Gujarati",
    "Punjabi": "Gurmukhi",
    "Tamil": "Tamil",
    "Telugu": "Telugu",
    "Kannada": "Kannada",
    "Malayalam": "Malayalam",
    "Bengali": "Bengali",
    "Assamese": "Assamese",
    "Odia": "Odia",
}

# Coarse script expectations for filtering/normalization heuristics.
# Assamese uses the Bengali Unicode block.
LANG_CODE_TO_SCRIPT: dict[str, str] = {
    "en": "Latin",
    "eng": "Latin",
    "hi": "Devanagari",
    "hin": "Devanagari",
    "mr": "Devanagari",
    "mar": "Devanagari",
    "gu": "Gujarati",
    "guj": "Gujarati",
    "pa": "Gurmukhi",
    "pan": "Gurmukhi",
    "ta": "Tamil",
    "tam": "Tamil",
    "te": "Telugu",
    "tel": "Telugu",
    "kn": "Kannada",
    "kan": "Kannada",
    "ml": "Malayalam",
    "mal": "Malayalam",
    "bn": "Bengali",
    "ben": "Bengali",
    "as": "Bengali",
    "asm": "Bengali",
    "or": "Oriya",
    "ori": "Oriya",
    "ory": "Oriya",
    "ur": "Arabic",
    "urd": "Arabic",
}

def expected_script(lang_code: str | None) -> str | None:
    if not lang_code:
        return None
    return LANG_CODE_TO_SCRIPT.get(lang_code.strip().lower())

def dominant_script(text: str, *, min_letters: int = 4) -> str | None:
    """
    Heuristic: return the dominant script bucket based on Unicode block ranges.
    Returns None if there's not enough signal (e.g., mostly digits/punctuation).
    """
    if not text:
        return None

    counts: dict[str, int] = {}
    total_letters = 0

    def bump(script: str) -> None:
        nonlocal total_letters
        counts[script] = counts.get(script, 0) + 1
        total_letters += 1

    for ch in text:
        if ch.isspace():
            continue
        cat = unicodedata.category(ch)
        if not cat.startswith("L"):
            continue

        o = ord(ch)
        if 0x0900 <= o <= 0x097F:
            bump("Devanagari")
        elif 0x0980 <= o <= 0x09FF:
            bump("Bengali")
        elif 0x0A00 <= o <= 0x0A7F:
            bump("Gurmukhi")
        elif 0x0A80 <= o <= 0x0AFF:
            bump("Gujarati")
        elif 0x0B00 <= o <= 0x0B7F:
            bump("Oriya")
        elif 0x0B80 <= o <= 0x0BFF:
            bump("Tamil")
        elif 0x0C00 <= o <= 0x0C7F:
            bump("Telugu")
        elif 0x0C80 <= o <= 0x0CFF:
            bump("Kannada")
        elif 0x0D00 <= o <= 0x0D7F:
            bump("Malayalam")
        else:
            # Rough Latin bucket for English/code-switch.
            if ("LATIN" in unicodedata.name(ch, "")) or ("A" <= ch <= "z"):
                bump("Latin")

    if total_letters < int(min_letters):
        return None
    return max(counts.items(), key=lambda kv: kv[1])[0] if counts else None

def to_whisper_lang(lang_code: str | None) -> str | None:
    if not lang_code:
        return None
    lang_code = lang_code.strip().lower()
    if len(lang_code) == 2:
        return lang_code
    return ISO3_TO_WHISPER_LANG.get(lang_code, None)

def to_language_name(lang_code: str | None) -> str | None:
    if not lang_code:
        return None
    lang_code = lang_code.strip().lower()
    return LANG_CODE_TO_NAME.get(lang_code)

def to_prompt_language_hint(lang_code: str | None) -> str:
    """
    Return a human-friendly language hint string for prompting audio-native models.
    """
    if not lang_code:
        return "Unknown (auto-detect primary spoken language)"
    lang_code_norm = lang_code.strip().lower()
    name = LANG_CODE_TO_NAME.get(lang_code_norm)
    if not name:
        return f"{lang_code_norm} (auto-detect primary spoken language)"
    script = LANG_NAME_TO_SCRIPT.get(name)
    if script:
        return f"{name} ({script} script)"
    return name


**Reusing language conversion function**

Planning to reuse the existing ISO3 to ISO1 conversion for vLLM since it requires ISO1 codes; if conversion returns None, vLLM will detect language automatically. The validation logic differs between models, so careful conversion avoids errors in vLLM.


**Standardizing ISO1 usage and handling cleanup**

Deciding to map all language codes to ISO1 for consistency, accepting warnings when languages aren't supported but still allowing processing. Planning to add language info and model metadata in ASR results, and investigating proper GPU and worker cleanup by exploring a shutdown method on vLLM's LLM engine or else using deletion and cache clearing.


python3 - <<'PY'
from vllm import LLM
import inspect
# Don't instantiate LLM; just inspect class of LLMEngine
from vllm.engine.llm_engine import LLMEngine
print([m for m in dir(LLMEngine) if 'shutdown' in m or 'close' in m or 'stop' in m])
PY


Output:
Exit code: 0
Wall time: 11.6 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-14 23:05:43.825550: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
['stop_profile']


**Planning separate process runs to manage memory**

To ensure memory is freed, I'll run each model's pipeline in separate processes so their memory releases on exit, avoiding complex shutdown handling; a close method may be added later for completeness but isn't strictly needed.


**Planning vLLM integration with pipeline and CLI**

I'll add a new vLLM ASR backend class and extend the pipeline and CLI to support specifying vLLM model, max tokens, and options to skip faster-whisper for reduced compute. This enables running Gemma3n and Voxtral models sequentially with minimal overhead while respecting user requests to avoid transformer-based inference.


Inspect existing ASR/pipeline codepaths and confirm how to plug in a vLLM transcription backend for audio models.

Implement vLLM-based ASR backend and CLI/pipeline wiring to run a single vLLM model at a time (Gemma3n or Voxtral).

Run benchmark inference sequentially for Voxtral and Gemma3n on the same dataset as the recent results, then compute WER/CER summaries.

Report results vs prior benchmarks and update Agents.md with the new experiment notes.

Explanation: Need a vLLM transcription backend, then run and evaluate the two models sequentially to produce comparable WER scores.


Output: Plan updated


**Designing new ASR class**

Planning a new VllmTranscriptionASR class in asr.py with configurable model, dtype, tensor parallel size, and token limits, defaulting safely to avoid harming performance. Next, I'll finalize initialization logic and parameter bounds.


**Detailing ASR integration with vLLM**

I'm mapping vLLM transcription call flow, handling prompts and sampling params, language code conversion to ISO1, and error handling. Planning to add vLLM model params and skip-whisper option in pipeline and CLI, adjusting backend initialization and primary ASR selection accordingly.


rg -n "def run\(" maya_transcribe/cli.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
208:def run(


sed -n '200,340p' maya_transcribe/cli.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:

    prompt = load_prompt_template(prompt_file)
    backend = GeminiASR(config=GeminiConfig(model=model, max_output_tokens=max_output_tokens), prompt_template=prompt)
    res = backend.transcribe(audio_path=audio, language=language)
    typer.echo(res.text)

@app.command()
def run(
    segments_dir: Path = typer.Option(..., exists=True, file_okay=False, help="Directory containing audio segments."),
    out: Path = typer.Option(Path("outputs/run"), help="Output directory."),
    whisper_model: str = typer.Option("large-v3", help="Whisper model size/path for faster-whisper."),
    lid_model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace MMS-LID model id."),
    require_lid_backend: bool = typer.Option(True, help="Fail if MMS-LID backend can't initialize."),
    transformers_whisper_model: list[str] = typer.Option(
        [], help="Extra whisper-family HF models to run via transformers (repeatable)."
    ),
    transformers_whisper_model_map: Path | None = typer.Option(
        None, help="JSON map {lang_code: hf_model_id} for per-language Whisper-family HF models (transformers)."
    ),
    # MMS ASR for CTC-based model diversity (per master.md plan).
    mms_asr_model: str | None = typer.Option(
        None, help="Optional MMS ASR model id (e.g., facebook/mms-1b-all) for CTC-based diversity."
    ),
    # IndicConformer for Conformer architecture diversity (per master.md plan).
    indicconformer_model_map: Path | None = typer.Option(
        None, help="JSON map {lang_code: nemo_model_name} for per-language IndicConformer models."
    ),
    indicconformer_default_model: str | None = typer.Option(
        None, help="Default IndicConformer NeMo model name for architecture diversity."
    ),
    gemini_model: str | None = typer.Option(None, help="Optional Gemini model name (adds Gemini as a hypothesis)."),
    gemini_prompt_file: Path | None = typer.Option(Path("Prompt.md"), help="Prompt file for Gemini."),
    gemini_hard_only: bool = typer.Option(True, help="Only call Gemini on 'hard' segments."),
    ctc_model: str | None = typer.Option(None, help="Optional HF CTC model id for audio-grounded fit scoring."),
    ctc_model_map: Path | None = typer.Option(None, help="JSON map {lang_code: hf_model_id} for CTC scoring."),
    require_ctc: bool = typer.Option(True, help="When CTC is enabled, require a CTC score to accept."),
    min_ctc_margin: float = typer.Option(0.05, help="Min (second-best - best) CTC loss margin to auto-accept."),
    trim_silence: bool = typer.Option(True, help="Trim leading/trailing silence (ffmpeg silenceremove)."),
    silence_threshold_db: float = typer.Option(-40.0, help="Silence threshold in dB for trimming."),
    min_silence_sec: float = typer.Option(0.15, help="Min silence duration for trimming."),
    limit: int | None = typer.Option(None, help="Process only the first N segments."),
    thresholds_json: Path | None = typer.Option(None, help="Optional JSON file of threshold overrides (verify + routing)."),
) -> None:
    """
    End-to-end pipeline: preprocess -> LID -> ASR ensemble -> ROVER -> verify.
    """
    from maya_transcribe.pipeline import run_pipeline

    ctc_map = None
    if ctc_model_map is not None:
        ctc_map = json.loads(ctc_model_map.read_text(encoding="utf-8"))
        if not isinstance(ctc_map, dict):
            raise typer.BadParameter("ctc_model_map must be a JSON object {lang_code: model_id}")

    tf_map = None
    if transformers_whisper_model_map is not None:
        tf_map = json.loads(transformers_whisper_model_map.read_text(encoding="utf-8"))
        if not isinstance(tf_map, dict):
            raise typer.BadParameter("transformers_whisper_model_map must be a JSON object {lang_code: model_id}")

    ic_map = None
    if indicconformer_model_map is not None:
        ic_map = json.loads(indicconformer_model_map.read_text(encoding="utf-8"))
        if not isinstance(ic_map, dict):
            raise typer.BadParameter("indicconformer_model_map must be a JSON object {lang_code: nemo_model_name}")

    thresholds: dict[str, object] = {}
    if thresholds_json is not None:
        loaded = json.loads(thresholds_json.read_text(encoding="utf-8"))
        if not isinstance(loaded, dict):
            raise typer.BadParameter("thresholds_json must be a JSON object")
        thresholds = loaded
    if ctc_model or ctc_map:
        thresholds["require_ctc"] = require_ctc
        thresholds["min_ctc_margin"] = float(min_ctc_margin)

    results_path = run_pipeline(
        segments_dir=segments_dir,
        out_dir=out,
        lid_model_id=lid_model_id,
        require_lid_backend=require_lid_backend,
        whisper_model=whisper_model,
        extra_transformers_whisper_models=transformers_whisper_model,
        transformers_whisper_model_map=tf_map,
        mms_asr_model=mms_asr_model,
        indicconformer_model_map=ic_map,
        indicconformer_default_model=indicconformer_default_model,
        gemini_model=gemini_model,
        gemini_prompt_path=gemini_prompt_file,
        gemini_hard_only=gemini_hard_only,
        ctc_model=ctc_model,
        ctc_model_map=ctc_map,
        trim_silence=trim_silence,
        silence_threshold_db=silence_threshold_db,
        min_silence_sec=min_silence_sec,
        limit=limit,
        thresholds=thresholds,
    )
    typer.echo(f"Wrote: {results_path}")
    typer.echo(f"Wrote: {out / 'summary.json'}")

@app.command()
def eval(
    results: Path = typer.Option(..., exists=True, dir_okay=False, help="Pipeline results JSONL (outputs/run/results.jsonl)."),
    refs: Path = typer.Option(..., exists=True, dir_okay=False, help="Reference transcripts JSONL."),
    out: Path | None = typer.Option(None, help="Write summary JSON to this path."),
    hypothesis: str = typer.Option('final', help='Hypothesis to evaluate: "final", "rover", or "asr:<key>".'),
    oracle: bool = typer.Option(True, help="Compute oracle-best and selection-gap stats across all candidates."),
    language_source: str = typer.Option("route", help="Use language from: 'route' (LID) or 'refs' (refs.jsonl)."),
    decision: list[str] = typer.Option([], help="Filter to verification decision(s): accept/human/reject (repeatable)."),
) -> None:
    """
    Compute WER/CER for a hypothesis against a reference JSONL keyed by `segment_id`.
    """
    from maya_transcribe.eval import evaluate

    summary = evaluate(
        results_path=results,
        refs_path=refs,
        out_path=out,
        hypothesis=hypothesis,
        oracle=oracle,
        language_source=language_source,
        decision_filter=decision or None,
    )
    typer.echo(json.dumps(summary, ensure_ascii=False, indent=2))

@bench_app.command("build")
def bench_build(
    out: Path = typer.Option(Path("data/bench/maya_hf_v0"), help="Output benchmark corpus directory."),
    samples_per_lang: int = typer.Option(200, help="Target samples per language."),
    seed: int = typer.Option(0, help="Shuffle seed."),
    shuffle: bool = typer.Option(True, help="Shuffle within each source before selecting."),
    lang: list[str] = typer.Option([], help="Limit to ISO-639-3 language codes (repeatable)."),
    min_duration_sec: float = typer.Option(1.0, help="Min audio duration."),
    max_duration_sec: float = typer.Option(30.0, help="Max audio duration."),
    force: bool = typer.Option(False, help="Delete and rebuild output directory if it exists."),
) -> None:


**Planning multi-file updates**

I'm preparing to update `run` and `bench_run` commands with new options, create a new `VllmTranscriptionASR` class in `asr.py` placed near existing ASR backends, and adjust imports as needed; multiple files require patching individually.


**Designing new ASR backend and pipeline integration**

I'm detailing the `VllmTranscriptionASR` class with lazy imports, language mapping, and error handling, while planning pipeline updates to accept a `vllm_asr_model` option plus a `skip_whisper` flag for exclusive model runs, along with corresponding CLI changes.


Input:
*** Begin Patch
*** Update File: maya_transcribe/asr.py
@@
 class TransformersWhisperASRRouter:
@@
         return backend.transcribe(
             audio_path=audio_path,
             language=language,
             max_new_tokens=max_new_tokens,
             num_beams=num_beams,
         )

+class VllmTranscriptionASR:
+    """
+    vLLM-powered speech-to-text backend for multimodal audio models (e.g., Voxtral, Gemma3n).
+    """
+
+    def __init__(
+        self,
+        model_id: str,
+        *,
+        dtype: str | None = None,
+        tensor_parallel_size: int = 1,
+        trust_remote_code: bool = True,
+        max_tokens: int | None = None,
+    ) -> None:
+        try:
+            import torch
+            from vllm import LLM, SamplingParams
+            from vllm.model_executor.model_loader import get_model_cls
+        except Exception as e:  # pragma: no cover
+            raise RuntimeError(
+                "vLLM transcription requires vllm[audio]. Install with: uv pip install -U \"vllm[audio]\" --system"
+            ) from e
+
+        self.model_id = model_id
+        self.dtype = dtype
+        self.tensor_parallel_size = max(1, int(tensor_parallel_size))
+        self.trust_remote_code = bool(trust_remote_code)
+
+        self._llm = LLM(
+            model=model_id,
+            dtype=dtype,
+            tensor_parallel_size=self.tensor_parallel_size,
+            trust_remote_code=self.trust_remote_code,
+        )
+        self._model_config = self._llm.model_config
+        self._model_cls = get_model_cls(self._model_config)
+        if not bool(getattr(self._model_cls, "supports_transcription", False)):
+            raise RuntimeError(f"Model does not support transcription: {model_id}")
+
+        self._stt_config = self._model_cls.get_speech_to_text_config(self._model_config, "transcribe")
+
+        max_model_len = getattr(self._model_config, "max_model_len", None)
+        if max_model_len is None:
+            max_model_len = 1024
+        if max_tokens is None:
+            max_tokens = min(1024, int(max_model_len))
+        else:
+            max_tokens = min(int(max_tokens), int(max_model_len))
+        self._sampling_params = SamplingParams(temperature=0.0, top_p=1.0, max_tokens=max_tokens)
+
+        try:
+            self._torch = torch
+        except Exception:
+            self._torch = None
+
+    @property
+    def name(self) -> str:
+        return f"vllm:{self.model_id}"
+
+    def transcribe(
+        self,
+        audio_path: Path,
+        language: str | None = None,
+    ) -> ASRResult:
+        from maya_transcribe.audio import decode_pcm16
+
+        sr = int(getattr(self._stt_config, "sample_rate", 16000))
+        wav, _ = decode_pcm16(audio_path, sample_rate=sr, mono=True)
+        if wav.size == 0:
+            return ASRResult(
+                model=self.name,
+                text="",
+                language=language,
+                language_probability=None,
+                segments=[],
+                meta={"dtype": self.dtype, "tensor_parallel_size": self.tensor_parallel_size},
+            )
+
+        lang = to_whisper_lang(language)
+        if lang:
+            try:
+                lang = self._model_cls.validate_language(lang)
+            except Exception:
+                lang = None
+
+        prompt = self._model_cls.get_generation_prompt(
+            audio=wav,
+            stt_config=self._stt_config,
+            model_config=self._model_config,
+            language=lang,
+            task_type="transcribe",
+            request_prompt="",
+            to_language=None,
+        )
+
+        outputs = self._llm.generate([prompt], self._sampling_params)
+        text = ""
+        if outputs and outputs[0].outputs:
+            text = outputs[0].outputs[0].text
+
+        return ASRResult(
+            model=self.name,
+            text=str(text).strip(),
+            language=lang,
+            language_probability=None,
+            segments=[],
+            meta={"dtype": self.dtype, "tensor_parallel_size": self.tensor_parallel_size},
+        )
+
+
 def _to_iso3(lang: str | None) -> str | None:
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/asr.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Summary: auto


**Cleaning up unused torch variable and preparing pipeline update**

Noted that the class sets an unused torch attribute which can be removed for cleanliness. Next, I'll update pipeline.py to import the new ASR class and integrate it into the pipeline run logic.


**Extending pipeline with vLLM ASR and skip whisper options**

I'm adding `vllm_asr_model` and `skip_whisper` parameters to the pipeline signature to control ASR backends. I'll instantiate the vLLM backend alongside others and conditionally run Whisper ASR based on `skip_whisper`. The pipeline output will also include these new config flags.


Input:
*** Begin Patch
*** Update File: maya_transcribe/pipeline.py
@@
 from maya_transcribe.asr import (
     ASRResult,
     FasterWhisperASR,
     IndicConformerASRRouter,
     MMSASR,
     TransformersWhisperASR,
     TransformersWhisperASRRouter,
+    VllmTranscriptionASR,
 )
@@
 def run_pipeline(
     *,
     segments_dir: Path,
     out_dir: Path,
     lid_model_id: str = "facebook/mms-lid-256",
     whisper_model: str = "large-v3",
+    skip_whisper: bool = False,
+    vllm_asr_model: str | None = None,
     extra_transformers_whisper_models: list[str] | None = None,
     transformers_whisper_model_map: dict[str, str] | None = None,
     transformers_whisper_default_model: str | None = None,
@@
-    whisper_backend = FasterWhisperASR(model_size_or_path=whisper_model)
+    whisper_backend: FasterWhisperASR | None = None
+    if not skip_whisper and whisper_model:
+        whisper_backend = FasterWhisperASR(model_size_or_path=whisper_model)
@@
-    transformers_router = None
+    transformers_router = None
     if transformers_whisper_model_map:
         transformers_router = TransformersWhisperASRRouter(
             model_map=transformers_whisper_model_map, default_model=transformers_whisper_default_model
         )
@@
     gemini_backend = None
     if gemini_model:
         try:
             from maya_transcribe.gemini_asr import GeminiASR, GeminiConfig, load_prompt_template
@@
         except Exception as e:
             raise RuntimeError("Failed to initialize Gemini backend (check GEMINI_KEY and google-genai).") from e

-    default_whisper_run_sampling = not bool(transformers_backends or transformers_router or gemini_backend is not None)
+    vllm_backend: VllmTranscriptionASR | None = None
+    if vllm_asr_model:
+        try:
+            vllm_backend = VllmTranscriptionASR(model_id=vllm_asr_model)
+        except Exception as e:
+            raise RuntimeError("Failed to initialize vLLM ASR backend.") from e
+
+    default_whisper_run_sampling = bool(whisper_backend is not None) and not bool(
+        transformers_backends or transformers_router or gemini_backend is not None
+    )
@@
-                try:
-                    det = whisper_backend.transcribe(
-                        wav_path, language=route_lang, beam_size=5, best_of=5, temperature=0.0
-                    )
-                    primary_asr_key = f"{det.model}|det"
-                    asr_results[primary_asr_key] = det
-                except Exception as e:
-                    asr_errors[f"{whisper_backend.name}|det"] = f"{type(e).__name__}:{e}"
+                if whisper_backend is not None:
+                    try:
+                        det = whisper_backend.transcribe(
+                            wav_path, language=route_lang, beam_size=5, best_of=5, temperature=0.0
+                        )
+                        primary_asr_key = f"{det.model}|det"
+                        asr_results[primary_asr_key] = det
+                    except Exception as e:
+                        asr_errors[f"{whisper_backend.name}|det"] = f"{type(e).__name__}:{e}"

                 run_sampling = bool(thresholds.get("whisper_run_sampling", default_whisper_run_sampling))
-                if run_sampling:
+                if run_sampling and whisper_backend is not None:
                     try:
                         samp = whisper_backend.transcribe(
                             wav_path, language=route_lang, beam_size=5, best_of=10, temperature=0.2
                         )
                         asr_results[f"{samp.model}|samp_t0.2"] = samp
                     except Exception as e:
                         asr_errors[f"{whisper_backend.name}|samp_t0.2"] = f"{type(e).__name__}:{e}"
@@
                 if transformers_router is not None:
                     try:
                         routed = transformers_router.transcribe(
                             wav_path,
                             language=route_lang,
                             num_beams=5,
                             max_new_tokens=256,
                         )
                         if routed is not None:
                             asr_results[routed.model] = routed
                             router_key = routed.model
                     except Exception as e:
                         asr_errors[f"transformers_router:{route_lang or 'none'}"] = f"{type(e).__name__}:{e}"

+                if vllm_backend is not None:
+                    try:
+                        vllm_res = vllm_backend.transcribe(wav_path, language=route_lang)
+                        asr_results[vllm_res.model] = vllm_res
+                    except Exception as e:
+                        asr_errors[vllm_backend.name] = f"{type(e).__name__}:{e}"
+
                 # MMS ASR for CTC-based diversity (per master.md plan).
                 if mms_backend is not None:
                     try:
                         mms_res = mms_backend.transcribe(wav_path, language=route_lang)
                         asr_results[mms_res.model] = mms_res
@@
                     "gemini_model": gemini_model,
                     "gemini_hard_only": gemini_hard_only,
                     "ctc_model": ctc_model,
                     "mms_asr_model": mms_asr_model,
                     "indicconformer_default_model": indicconformer_default_model,
+                    "vllm_asr_model": vllm_asr_model,
+                    "skip_whisper": skip_whisper,
                 },
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/pipeline.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Updating CLI for new ASR options**

I'm adding `skip_whisper` and `vllm_asr_model` options to the CLI commands `run` and `bench_run`, updating their docstrings and passing these options to `run_pipeline` to support the new vllm backend and whisper skipping features.


Input:
*** Begin Patch
*** Update File: maya_transcribe/cli.py
@@
 def run(
     segments_dir: Path = typer.Option(..., exists=True, file_okay=False, help="Directory containing audio segments."),
     out: Path = typer.Option(Path("outputs/run"), help="Output directory."),
     whisper_model: str = typer.Option("large-v3", help="Whisper model size/path for faster-whisper."),
+    skip_whisper: bool = typer.Option(False, help="Skip faster-whisper decoding (use only other ASR backends)."),
+    vllm_asr_model: str | None = typer.Option(
+        None, help="vLLM multimodal ASR model id (e.g., mistralai/Voxtral-Mini-3B-2507)."
+    ),
     lid_model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace MMS-LID model id."),
     require_lid_backend: bool = typer.Option(True, help="Fail if MMS-LID backend can't initialize."),
@@
     results_path = run_pipeline(
         segments_dir=segments_dir,
         out_dir=out,
         lid_model_id=lid_model_id,
         require_lid_backend=require_lid_backend,
         whisper_model=whisper_model,
+        skip_whisper=skip_whisper,
+        vllm_asr_model=vllm_asr_model,
         extra_transformers_whisper_models=transformers_whisper_model,
         transformers_whisper_model_map=tf_map,
         mms_asr_model=mms_asr_model,
         indicconformer_model_map=ic_map,
@@
 def bench_run(
     bench_dir: Path = typer.Option(Path("data/bench/maya_hf_v0"), exists=True, file_okay=False, help="Benchmark corpus directory."),
     out: Path | None = typer.Option(None, help="Output directory (defaults under outputs/bench/)."),
     whisper_model: str = typer.Option("large-v3", help="Whisper model size/path for faster-whisper."),
+    skip_whisper: bool = typer.Option(False, help="Skip faster-whisper decoding (use only other ASR backends)."),
+    vllm_asr_model: str | None = typer.Option(
+        None, help="vLLM multimodal ASR model id (e.g., mistralai/Voxtral-Mini-3B-2507)."
+    ),
     lid_model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace MMS-LID model id."),
     require_lid_backend: bool = typer.Option(True, help="Fail if MMS-LID backend can't initialize."),
@@
     results_path = run_pipeline(
         segments_dir=audio_dir,
         out_dir=out,
         lid_model_id=lid_model_id,
         require_lid_backend=require_lid_backend,
         whisper_model=whisper_model,
+        skip_whisper=skip_whisper,
+        vllm_asr_model=vllm_asr_model,
         extra_transformers_whisper_models=transformers_whisper_model,
         transformers_whisper_model_map=tf_map,
         mms_asr_model=mms_asr_model,
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/cli.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Reviewing pipeline usage after signature change**

Planning to update all calls to the changed pipeline function, starting with CLI and searching for other references in modules and tests for necessary adjustments.


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
./maya_transcribe/cli.py:281:    results_path = run_pipeline(
./maya_transcribe/cli.py:453:    results_path = run_pipeline(
./maya_transcribe/pipeline.py:61:def run_pipeline(


**Adjusting ASR backend usage and planning benchmarks**

Updated handling to avoid default sampling when skipping whisper and confirmed backend safeguards; identified the need to run benchmarks next using a specific dataset with `--skip-whisper` and `--vllm-asr-model`, keeping default silence trimming and setting hypothesis to `final` while considering additional WER computation for the vllm key due to potential CTC scoring changes.


**Finalizing benchmark setup and CLI invocation**

Confirmed that disabling CTC keeps final=rover and retaining LID matches previous runs; decided on using the larger Voxtral-Small-24B-2507 model for quality despite longer runtime on the test dataset, preparing a precise bench run command ensuring use of local CLI module with `python3 -m` to capture modifications and passing necessary flags for skipping whisper and requiring LID backend.


python3 -m maya_transcribe.cli bench run --bench-dir data/bench/maya_hf_hin_v2 --out outputs/bench/maya_hf_hin_v2/voxtral_small_vllm --skip-whisper --vllm-asr-model mistralai/Voxtral-Small-24B-2507