# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """WebSocket handler for streaming video input understanding. Accepts video frames incrementally via WebSocket, buffers them, and generates text + optional audio responses using the existing Qwen3-Omni multi-stage pipeline (thinker -> talker -> code2wav). Protocol: Client -> Server: {"type": "session.config", ...} # Session config (sent once) {"type": "video.frame", "data": "..."} # base64 JPEG/PNG frame {"type": "audio.chunk", "data": "..."} # base64 PCM16 16kHz mono {"type": "video.query", "text": "..."} # Submit query about buffered frames {"type": "video.done"} # End of session Server -> Client: {"type": "response.start"} {"type": "response.text.delta", "delta": "..."} {"type": "response.text.done", "text": "..."} {"type": "response.audio.delta", "data": "...", "format": "wav"} {"type": "response.audio.done"} {"type": "session.done"} {"type": "error", "message": "..."} """ import asyncio import base64 import hashlib import io import json import time as _time import uuid import wave from collections.abc import Mapping from typing import Any import torch from fastapi import WebSocket, WebSocketDisconnect from PIL import Image from pydantic import BaseModel, Field, ValidationError from vllm.logger import init_logger from vllm_omni.entrypoints.openai import video_stream_envs from vllm_omni.entrypoints.openai.video_frame_filter import FrameSimilarityFilter from vllm_omni.entrypoints.openai.video_stream_context import ( text_only_message, ) from vllm_omni.outputs import OmniRequestOutput logger = init_logger(__name__) _DEFAULT_IDLE_TIMEOUT = 60.0 _DEFAULT_CONFIG_TIMEOUT = 10.0 _MAX_FRAME_SIZE = 10 * 1024 * 1024 # 10MB per frame _MAX_BUFFER_FRAMES = 64 _MAX_AUDIO_BUFFER_BYTES = 4 * 1024 * 1024 _MAX_MSG_QUEUE = 200 _CODEC_FRAME_SAMPLES = 1920 # CausalConv leading-edge artifact length _BAD_FRAME = object() def _decode_frame_bytes(raw_bytes: bytes) -> Any: return Image.open(io.BytesIO(raw_bytes)).convert("RGB") class StreamingVideoSessionConfig(BaseModel): """Configuration sent as the first WebSocket message.""" model: str | None = None modalities: list[str] = Field( default_factory=lambda: ["text", "audio"], description="Output modalities: 'text', 'audio', or both.", ) num_frames: int = Field( default=4, ge=1, le=128, description="Max frames to sample from buffer for the model.", ) max_frames: int = Field( default=50, ge=1, le=256, description="Max frames to keep in the buffer.", ) system_prompt: str | None = Field( default=None, description="Custom system prompt.", ) use_audio_in_video: bool = Field( default=True, description="Interleave audio chunks with video frames when audio input is present.", ) sampling_params_list: list[dict[str, Any]] | None = Field( default=None, description="Per-stage sampling params [thinker, talker, code2wav].", ) enable_frame_filter: bool = Field( default=True, description="EVS pixel-similarity pre-filter to drop near-duplicate frames.", ) frame_filter_threshold: float = Field( default=0.95, ge=0.0, le=1.0, description="EVS similarity threshold (higher = keep more frames).", ) class OmniStreamingVideoHandler: """Handles WebSocket sessions for streaming video input. Supports: - Concurrent frame reception during query processing (reader/processor split) - PCM audio input (``audio.chunk``) - Async-chunk incremental audio output via ``engine_client.generate()`` - Multi-turn conversation history - Soft interrupt (new query cancels current generation) """ def __init__( self, chat_service: Any, idle_timeout: float = _DEFAULT_IDLE_TIMEOUT, config_timeout: float = _DEFAULT_CONFIG_TIMEOUT, engine_client: Any | None = None, ) -> None: self._chat_service = chat_service self._idle_timeout = idle_timeout self._config_timeout = config_timeout self._engine_client = engine_client async def handle_session(self, websocket: WebSocket) -> None: """Main session loop for a single WebSocket connection.""" await websocket.accept() try: config = await self._receive_config(websocket) if config is None: return frame_buffer: list[str] = [] # base64-encoded JPEG frames # Per-frame PIL cache + uuid for mm_hash reuse. Aligned with frame_buffer by index. frame_pil_cache: dict[str, tuple[Any, str] | object] = {} # b64 -> (PIL.Image, uuid) or _BAD_FRAME frame_filter = ( FrameSimilarityFilter(threshold=config.frame_filter_threshold) if config.enable_frame_filter else None ) audio_buffer = bytearray() # raw PCM16 16kHz mono message_history: list[dict[str, Any]] = [] active_request_id: str | None = None prev_request_id: str | None = None # abort target iff prev was interrupted prev_was_interrupted: bool = False interrupt_event = asyncio.Event() prewarm_tasks: set[asyncio.Task[Any]] = set() query_task: asyncio.Task[Any] | None = None msg_queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=_MAX_MSG_QUEUE) async def _reader() -> None: """Receive WebSocket messages and enqueue them.""" try: while True: try: raw = await asyncio.wait_for( websocket.receive_text(), timeout=self._idle_timeout, ) except asyncio.TimeoutError: await self._send_error(websocket, "Idle timeout") await msg_queue.put(None) return try: msg = json.loads(raw) except json.JSONDecodeError: await self._send_error(websocket, "Invalid JSON") continue if not isinstance(msg, dict): await self._send_error(websocket, "Messages must be JSON objects") continue msg_type = str(msg.get("type", "")) if msg_type.startswith("_internal."): await self._send_error(websocket, f"Unknown type: {msg_type}") continue await msg_queue.put(msg) if msg.get("type") == "video.done": return except WebSocketDisconnect: await msg_queue.put(None) except Exception: await msg_queue.put(None) raise async def _cancel_active_query(*, abort_now: bool = False) -> None: """Signal soft interrupt for the active query.""" nonlocal active_request_id, prev_was_interrupted, query_task if active_request_id is not None: interrupt_event.set() prev_was_interrupted = True logger.info("Interrupt signaled for %s", active_request_id) if abort_now and self._engine_client: try: await self._engine_client.abort(active_request_id) except Exception: logger.debug("Abort failed for %s", active_request_id, exc_info=True) if query_task is not None and not query_task.done(): query_task.cancel() await asyncio.gather(query_task, return_exceptions=True) query_task = None async def _processor() -> None: """Process enqueued messages.""" nonlocal active_request_id, prev_request_id, prev_was_interrupted, query_task while True: msg = await msg_queue.get() if msg is None: await _cancel_active_query(abort_now=True) return msg_type = msg.get("type") if msg_type == "_internal.frame_decode_failed": frame_data = msg.get("b64", "") removed = frame_data in frame_buffer if removed: frame_buffer[:] = [f for f in frame_buffer if f != frame_data] if frame_pil_cache.get(frame_data) is _BAD_FRAME: frame_pil_cache.pop(frame_data, None) if removed: await self._send_error(websocket, "Frame decode failed") elif msg_type == "video.frame": frame_data = msg.get("data", "") if not frame_data: continue if len(frame_data) > _MAX_FRAME_SIZE: await self._send_error(websocket, "Frame too large") continue try: raw_bytes = base64.b64decode(frame_data, validate=True) except Exception: await self._send_error(websocket, "Invalid image data") continue if frame_filter is not None: try: if not frame_filter.should_retain(raw_bytes): continue except Exception: await self._send_error(websocket, "Invalid image data") continue max_buf = config.max_frames if len(frame_buffer) >= max_buf: dropped = frame_buffer.pop(0) frame_pil_cache.pop(dropped, None) frame_buffer.append(frame_data) # Prewarm: decode PIL off the event loop so query-time chat_template # can skip base64+Image.open. uuid=md5 lets mm_cache dedupe identical frames. if frame_data not in frame_pil_cache: mm_uuid = hashlib.md5(raw_bytes, usedforsecurity=False).hexdigest() async def _prewarm(b64: str, b: bytes, u: str) -> None: try: pil = await asyncio.to_thread(_decode_frame_bytes, b) frame_pil_cache[b64] = (pil, u) except Exception: frame_pil_cache[b64] = _BAD_FRAME logger.warning("prewarm decode failed for frame (len=%d)", len(b)) try: msg_queue.put_nowait({"type": "_internal.frame_decode_failed", "b64": b64}) except asyncio.QueueFull: logger.warning( "frame decode failure event dropped because message queue is full" ) task = asyncio.create_task(_prewarm(frame_data, raw_bytes, mm_uuid)) prewarm_tasks.add(task) task.add_done_callback(prewarm_tasks.discard) elif msg_type == "audio.chunk": data_b64 = msg.get("data", "") try: pcm_bytes = base64.b64decode(data_b64) except Exception: continue if len(audio_buffer) + len(pcm_bytes) > _MAX_AUDIO_BUFFER_BYTES: await self._send_error(websocket, "Audio buffer overflow") audio_buffer.clear() continue audio_buffer.extend(pcm_bytes) elif msg_type == "video.query": await _cancel_active_query() query_text = msg.get("text", "") audio_data_b64 = msg.get("audio_data") if audio_data_b64: try: decoded = base64.b64decode(audio_data_b64) if len(audio_buffer) + len(decoded) <= _MAX_AUDIO_BUFFER_BYTES: audio_buffer.extend(decoded) else: await self._send_error(websocket, "Audio buffer overflow") audio_buffer.clear() except Exception: pass if not frame_buffer: await self._send_error(websocket, "No frames buffered") continue # Abort only if the previous turn was interrupted mid-flight. # A naturally-finished request is already released by the scheduler; # aborting it again can race with stage-1/2 tear-down and has been # observed to crash flash_attn with a mixed prefill+decode batch # (scheduler_metadata shape mismatch) under longer sessions. if prev_was_interrupted and prev_request_id and self._engine_client: try: await self._engine_client.abort(prev_request_id) except Exception: pass await asyncio.sleep(0.1) prev_was_interrupted = False request_id = f"video-{uuid.uuid4().hex[:12]}" active_request_id = request_id interrupt_event.clear() query_frames = list(frame_buffer) query_audio_buffer = bytearray(audio_buffer) audio_buffer.clear() query_prewarmed_frames = dict(frame_pil_cache) async def _run_query() -> None: nonlocal active_request_id, prev_request_id try: await self._process_query( websocket, config, query_frames, query_audio_buffer, message_history, query_text, request_id, interrupt_event, query_prewarmed_frames, ) finally: if active_request_id == request_id: prev_request_id = request_id active_request_id = None query_task = asyncio.create_task(_run_query()) elif msg_type == "video.done": if query_task is not None and not query_task.done(): await asyncio.gather(query_task, return_exceptions=True) query_task = None await websocket.send_json({"type": "session.done"}) return elif msg_type == "ping": try: await websocket.send_json({"type": "pong"}) except Exception: pass else: await self._send_error(websocket, f"Unknown type: {msg_type}") reader_task = asyncio.create_task(_reader()) try: await _processor() finally: reader_task.cancel() try: await reader_task except (asyncio.CancelledError, Exception): pass for t in list(prewarm_tasks): t.cancel() if prewarm_tasks: await asyncio.gather(*prewarm_tasks, return_exceptions=True) if query_task is not None and not query_task.done(): await _cancel_active_query(abort_now=True) except WebSocketDisconnect: logger.info("Streaming video: client disconnected") except Exception as e: logger.exception("Streaming video session error: %s", e) try: await self._send_error(websocket, f"Internal error: {e}") except Exception: pass async def _receive_config(self, websocket: WebSocket) -> StreamingVideoSessionConfig | None: """Wait for and validate the session.config message.""" try: raw = await asyncio.wait_for( websocket.receive_text(), timeout=self._config_timeout, ) except asyncio.TimeoutError: await self._send_error(websocket, "Timeout waiting for session.config") return None try: msg = json.loads(raw) except json.JSONDecodeError: await self._send_error(websocket, "Invalid JSON in session.config") return None if not isinstance(msg, dict) or msg.get("type") != "session.config": await self._send_error( websocket, f"Expected session.config, got: {msg.get('type') if isinstance(msg, dict) else type(msg).__name__}", ) return None config_data = {k: v for k, v in msg.items() if k != "type"} alias_map = { "num_sample_frames": "num_frames", "evs_enabled": "enable_frame_filter", "evs_threshold": "frame_filter_threshold", } for old_key, new_key in alias_map.items(): if old_key in config_data and new_key not in config_data: config_data[new_key] = config_data[old_key] try: config = StreamingVideoSessionConfig(**config_data) except ValidationError as e: await self._send_error(websocket, f"Invalid session config: {e}") return None return config async def _process_query( self, websocket: WebSocket, config: StreamingVideoSessionConfig, frame_buffer: list[str], audio_buffer: bytearray, message_history: list[dict[str, Any]], query_text: str, request_id: str, interrupt_event: asyncio.Event, prewarmed_frames: dict[str, tuple[Any, str]], ) -> None: """Build prompt, run inference, stream text + audio response.""" if self._engine_client is None: await self._send_error(websocket, "Streaming video requires an engine client") return await self._process_query_engine( websocket, config, frame_buffer, audio_buffer, message_history, query_text, request_id, interrupt_event, prewarmed_frames, ) # ------------------------------------------------------------------ # Engine-client path (async_chunk audio streaming) # ------------------------------------------------------------------ async def _process_query_engine( self, websocket: WebSocket, config: StreamingVideoSessionConfig, frame_buffer: list[str], audio_buffer: bytearray, message_history: list[dict[str, Any]], query_text: str, request_id: str, interrupt_event: asyncio.Event, prewarmed_frames: dict[str, tuple[Any, str]], ) -> None: """Direct engine_client.generate() path for async_chunk audio.""" from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) messages, user_message = self._build_messages( config, frame_buffer, audio_buffer, message_history, query_text, prewarmed_frames, ) request_kwargs: dict[str, Any] = { "model": config.model or "default", "messages": messages, "stream": True, "modalities": config.modalities, "add_generation_prompt": True, "continue_final_message": False, "add_special_tokens": False, } if config.use_audio_in_video and len(audio_buffer) > 0: request_kwargs["mm_processor_kwargs"] = { "use_audio_in_video": True, } if config.sampling_params_list: request_kwargs["sampling_params_list"] = config.sampling_params_list try: chat_request = ChatCompletionRequest(**request_kwargs) except Exception as e: await self._send_error(websocket, f"Failed to build request: {e}") return try: engine_prompt = await self._preprocess_to_engine_prompt(chat_request) except Exception as e: await self._send_error(websocket, f"Preprocess failed: {e}") return await websocket.send_json({"type": "response.start"}) text_parts: list[str] = [] text_done_sent = False audio_chunk_count = 0 # Number of per-step tensors in OmniRequestOutput.audio_data already # drained. Used by the fast path to skip already-emitted history. audio_chunks_drained = 0 previous_text = "" interrupted = False t_start = _time.monotonic() t_first_text = None t_first_audio = None # Wire-level async-chunk switch. "off" means # buffer all deltas server-side and flush once at the end; the engine # pipeline still overlaps internally. async_chunk_mode = video_stream_envs.VLLM_VIDEO_ASYNC_CHUNK streaming = async_chunk_mode == "on" audio_tail_tensors: list[Any] = [] try: result_gen = self._engine_client.generate( prompt=engine_prompt, request_id=request_id, output_modalities=config.modalities, ) async for output in result_gen: # Soft interrupt: drain without sending if interrupt_event.is_set(): if not interrupted: logger.info("Generation interrupted — draining") interrupted = True continue if not isinstance(output, OmniRequestOutput): continue out_type = getattr(output, "final_output_type", "text") if out_type == "audio": if streaming and not text_done_sent: full_text = "".join(text_parts) await websocket.send_json({"type": "response.text.done", "text": full_text}) text_done_sent = True if t_first_audio is None: t_first_audio = _time.monotonic() audio_chunk_count += 1 if streaming: b64, audio_chunks_drained = self._extract_audio_delta_b64( output, audio_chunks_drained, ) if b64: await websocket.send_json( { "type": "response.audio.delta", "data": b64, "format": "wav", } ) else: audio_data = self._get_audio_data(output) if audio_data is not None: if isinstance(audio_data, list): audio_tail_tensors = list(audio_data) else: audio_tail_tensors = [audio_data] else: delta_text, previous_text = self._extract_text_delta( output, previous_text, ) if delta_text: if t_first_text is None: t_first_text = _time.monotonic() text_parts.append(delta_text) if streaming: await websocket.send_json({"type": "response.text.delta", "delta": delta_text}) if not text_done_sent: full_text = "".join(text_parts) await websocket.send_json({"type": "response.text.done", "text": full_text}) text_done_sent = True if not streaming and audio_tail_tensors: try: coalesced = ( audio_tail_tensors[0] if len(audio_tail_tensors) == 1 else torch.cat(audio_tail_tensors, dim=-1) ) tail_np = self._tensor_to_1d_np(coalesced) b64, _ = self._encode_tail( tail_np, 0, new_drained=len(audio_tail_tensors), is_first=True, ) if b64: await websocket.send_json( { "type": "response.audio.delta", "data": b64, "format": "wav", } ) except Exception: logger.exception("Failed to coalesce off-path audio") if audio_chunk_count > 0: await websocket.send_json({"type": "response.audio.done"}) response_text = "".join(text_parts) message_history.append(user_message) message_history.append({"role": "assistant", "content": response_text}) t_end = _time.monotonic() logger.info( "[TIMING] mode=%s total=%.2fs first_text=%.2fs first_audio=%.2fs audio_chunks=%d", async_chunk_mode, t_end - t_start, (t_first_text - t_start) if t_first_text else -1, (t_first_audio - t_start) if t_first_audio else -1, audio_chunk_count, ) except Exception: logger.exception("Engine query failed") await self._send_error(websocket, "Query processing failed") if not text_done_sent: full_text = "".join(text_parts) await websocket.send_json({"type": "response.text.done", "text": full_text}) # ------------------------------------------------------------------ # Message building # ------------------------------------------------------------------ def _build_messages( self, config: StreamingVideoSessionConfig, frame_buffer: list[str], audio_buffer: bytearray, message_history: list[dict[str, Any]], query_text: str, prewarmed_frames: dict[str, tuple[Any, str]], ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Build OpenAI-style messages list and the current user message. Returns (messages, user_message). """ # Stride sampling (index 0 anchor, last slot = newest). Covers full buffer + stable mm_hash. n_buf = len(frame_buffer) if n_buf <= config.num_frames: frames = list(frame_buffer) else: stride = max(1, n_buf // config.num_frames) idx = [i * stride for i in range(config.num_frames - 1)] + [n_buf - 1] frames = [frame_buffer[i] for i in idx] # Prefer prewarmed PIL + uuid so mm_cache can dedupe by hash. prewarmed = prewarmed_frames or {} user_content: list[dict] = [] for frame_b64 in frames: cached = prewarmed.get(frame_b64) if cached is _BAD_FRAME: continue if cached is not None: pil, pil_uuid = cached user_content.append( { "type": "image_pil", "image_pil": pil, "uuid": pil_uuid, } ) else: user_content.append( { "type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{frame_b64}"}, } ) if len(audio_buffer) > 0: wav_b64 = self._pcm_to_wav_b64(bytes(audio_buffer)) user_content.append( { "type": "input_audio", "input_audio": { "data": wav_b64, "format": "wav", }, } ) if query_text: user_content.append({"type": "text", "text": query_text}) user_message: dict[str, Any] = {"role": "user", "content": user_content} messages: list[dict[str, Any]] = [] if config.system_prompt: messages.append({"role": "system", "content": config.system_prompt}) # Add text-only history (strip images/audio from old turns). # Keep only the last turn (2 messages) to keep prompt short # enough for single-step mm_encoder scheduling. When prompt # exceeds ~50 tokens, the V1 scheduler splits mm_encoder and # text prefill, causing incomplete thinker embeddings and # garbled audio. recent_history = message_history[-2:] if len(message_history) > 2 else message_history for hist_msg in recent_history: messages.append(self._text_only_message(hist_msg)) messages.append(user_message) return messages, user_message # ------------------------------------------------------------------ # Audio helpers # ------------------------------------------------------------------ @staticmethod def _pcm_to_wav_b64(pcm_data: bytes, sample_rate: int = 16000) -> str: """Wrap raw PCM16 mono in a WAV container and return base64.""" buf = io.BytesIO() with wave.open(buf, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(pcm_data) return base64.b64encode(buf.getvalue()).decode() @classmethod def _extract_audio_delta_b64( cls, result: OmniRequestOutput, chunks_drained: int, ) -> tuple[str | None, int]: """Return (base64 WAV of new samples, updated chunks_drained). `chunks_drained` is the number of per-step tensors in ``audio_data`` that have already been emitted. Each engine step appends one tensor, so new samples are ``audio_data[chunks_drained:]`` — no matter how many steps accumulated between reads (handles backpressure cleanly, unlike a simple ``audio_data[-1]``). Two paths, selected at runtime by ``VLLM_VIDEO_AUDIO_DELTA_MODE``: * fast — only D2H the new tail. Per-call cost ∝ new chunks. * slow — full cat + D2H each call. Per-call cost ∝ total history. Retained for A/B; remove once downstream callers confirm. """ audio_data = cls._get_audio_data(result) if audio_data is None: return None, chunks_drained if video_stream_envs.VLLM_VIDEO_AUDIO_DELTA_MODE == "slow": return cls._delta_slow(audio_data, chunks_drained) return cls._delta_fast(audio_data, chunks_drained) @staticmethod def _get_audio_data(result: OmniRequestOutput): """Navigate OmniRequestOutput → multimodal_output['audio']. None on miss.""" request_output = getattr(result, "request_output", None) if request_output is None: return None outputs = getattr(request_output, "outputs", None) if not isinstance(outputs, list) or not outputs: return None mm_output = getattr(outputs[0], "multimodal_output", None) if not isinstance(mm_output, Mapping): return None return mm_output.get("audio") @classmethod def _delta_fast( cls, audio_data, chunks_drained: int, ) -> tuple[str | None, int]: """Emit only tensors appended since the last call.""" # Single tensor: output_processor hands us one tensor before it becomes a # list (see output_processor.py:89). Treat it as chunk #0. if not isinstance(audio_data, list): if chunks_drained >= 1: return None, chunks_drained tail_np = cls._tensor_to_1d_np(audio_data) return cls._encode_tail(tail_np, chunks_drained, new_drained=1, is_first=True) n = len(audio_data) if n <= chunks_drained: return None, chunks_drained new_chunks = audio_data[chunks_drained:] tail = new_chunks[0] if len(new_chunks) == 1 else torch.cat(new_chunks, dim=-1) tail_np = cls._tensor_to_1d_np(tail) return cls._encode_tail(tail_np, chunks_drained, new_drained=n, is_first=(chunks_drained == 0)) @classmethod def _delta_slow( cls, audio_data, chunks_drained: int, ) -> tuple[str | None, int]: """Pre-fix behaviour: concat everything each call and slice on CPU.""" if isinstance(audio_data, list): if not audio_data: return None, chunks_drained audio_tensor = torch.cat(audio_data, dim=-1) new_drained = len(audio_data) else: audio_tensor = audio_data new_drained = 1 full_np = cls._tensor_to_1d_np(audio_tensor) if full_np is None: return None, chunks_drained # chunks_drained doesn't map directly to sample offset without tracking # per-chunk lengths, so we re-derive: replay the tail that corresponds # to chunks appended since last call by slicing off the part produced # by the already-drained prefix. For slow path this is intentionally # wasteful — the point is to reproduce the pre-fix hot loop. if chunks_drained == 0: tail_np = full_np else: # Recover prefix length by re-concatenating the already-drained # prefix tensors (cost intentionally identical to the baseline # implementation this was lifted from). if isinstance(audio_data, list) and chunks_drained < len(audio_data): prefix_len = sum(int(t.shape[-1]) for t in audio_data[:chunks_drained]) tail_np = full_np[prefix_len:] else: tail_np = full_np[0:0] return cls._encode_tail(tail_np, chunks_drained, new_drained=new_drained, is_first=(chunks_drained == 0)) @classmethod def _encode_tail( cls, tail_np, old_drained: int, *, new_drained: int, is_first: bool, ) -> tuple[str | None, int]: """Strip the CausalConv leading artifact on first emit, then b64-encode.""" if tail_np is None or len(tail_np) == 0: return None, new_drained if is_first and len(tail_np) > _CODEC_FRAME_SAMPLES * 2: tail_np = tail_np[_CODEC_FRAME_SAMPLES:] if len(tail_np) == 0: return None, new_drained try: return cls._encode_audio_wav_b64(tail_np), new_drained except Exception: logger.exception("Failed to encode audio delta WAV") return None, old_drained @staticmethod def _tensor_to_1d_np(t): """Tensor → flat float32 numpy on CPU. None on failure.""" if t is None or not hasattr(t, "float"): return None arr = t.float().detach().cpu().numpy() if arr.ndim > 1: arr = arr.flatten() return arr @staticmethod def _encode_audio_wav_b64(audio_np) -> str: """Encode numpy float32 audio to base64 WAV (24kHz).""" from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio audio_obj = CreateAudio( audio_tensor=audio_np, sample_rate=24000, response_format="wav", speed=1.0, stream_format="audio", base64_encode=True, ) mixin = AudioMixin() resp = mixin.create_audio(audio_obj) return resp.audio_data @staticmethod def _extract_text_delta( result: OmniRequestOutput, previous_text: str, ) -> tuple[str, str]: """Extract incremental text delta from OmniRequestOutput.""" if result.final_output_type != "text": return "", previous_text request_output = getattr(result, "request_output", None) if request_output is None: return "", previous_text outputs = getattr(request_output, "outputs", None) if not isinstance(outputs, list) or not outputs: return "", previous_text text = getattr(outputs[0], "text", None) if not isinstance(text, str) or not text: return "", previous_text if text.startswith(previous_text): return text[len(previous_text) :], text return text, text # ------------------------------------------------------------------ # Preprocessing # ------------------------------------------------------------------ async def _preprocess_to_engine_prompt(self, request) -> Any: """Use the chat handler's preprocessing to build an engine prompt.""" handler = self._chat_service renderer = handler.renderer _conversation, engine_prompts = await handler._preprocess_chat( request, request.messages, default_template=getattr(request, "chat_template", None) or handler.chat_template, default_template_content_format=handler.chat_template_content_format, renderer=renderer, add_generation_prompt=request.add_generation_prompt, continue_final_message=request.continue_final_message, add_special_tokens=request.add_special_tokens, ) return engine_prompts[0] # ------------------------------------------------------------------ # Utilities # ------------------------------------------------------------------ _text_only_message = staticmethod(text_only_message) async def _send_error(self, websocket: WebSocket, message: str) -> None: """Send an error message to the client.""" try: await websocket.send_json({"type": "error", "message": message}) except Exception: pass