[Bugfix][MiniCPM-o] Bind omni-duplex video frames to the unit they close (#6404)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
13173d6bbb
commit
da94c3ebe1
17 files changed
+1530
-144
No files matched your search
@@ -159,6 +159,33 @@ python examples/online_serving/minicpmo/realtime_duplex_demo.py \
|
||||
--output-dir /tmp/minicpmo_realtime_duplex_demo
|
||||
```
|
||||
|
||||
Video input uses the same session. PyAV demuxes JPEG frames at `--video-fps`
|
||||
(default 1.0) and vLLM `load_audio` extracts a 16 kHz mono WAV unless
|
||||
`--input-wav` overrides the soundtrack. Frames keep their capture size
|
||||
(`--frame-max-side 0`); the server `process_image` normalizes at 448.
|
||||
|
||||
`--stack-frames N` raises the visual refresh rate the way official duplex does:
|
||||
each 1 s unit also samples the `N-1` sub-frames captured inside it and tiles
|
||||
them into a single composite image sent next to that unit's base frame. The
|
||||
audio cadence never changes — a unit is always one second — and the wire always
|
||||
carries 2 images per unit however large `N` is, because the sub-frames share one
|
||||
composite. Official uses 5 for high refresh rate mode:
|
||||
|
||||
```bash
|
||||
python examples/online_serving/minicpmo/realtime_duplex_demo.py \
|
||||
--url ws://localhost:8099/v1/realtime?duplex=1 \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--input-video /path/to/clip.mp4 \
|
||||
--stack-frames 5 \
|
||||
--ref-audio /path/to/MiniCPM-o-Demo/assets/ref_audio/ref_minicpm_signature.wav \
|
||||
--output-dir /tmp/minicpmo_realtime_duplex_video_demo
|
||||
```
|
||||
|
||||
Detail inside a composite is capped by `scale_resolution=448` at
|
||||
`max_slice_nums=1`: official suggests HD slicing (`max_slice_nums=[2, 1]`) for
|
||||
stacked frames, which the duplex adapter does not implement yet. Reading small
|
||||
text or digits out of a wide scene is limited by that, not by frame timing.
|
||||
|
||||
## Open the experimental browser client
|
||||
|
||||
The browser UI serves the page and proxies the same-origin Realtime WebSocket to
|
||||
|
||||
@@ -12,7 +12,9 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -22,8 +24,8 @@ if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
PCM16_BYTES_PER_SAMPLE, # noqa: F401 - compatibility export
|
||||
PCM16_SAMPLE_RATE, # noqa: F401 - compatibility export
|
||||
PCM16_BYTES_PER_SAMPLE,
|
||||
PCM16_SAMPLE_RATE,
|
||||
RealtimeDuplexClient,
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
@@ -38,6 +40,10 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
reference_audio_data_url as _ref_audio_data_url,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.video_stacking import ( # noqa: E402
|
||||
concat_frames_b64,
|
||||
unit_subframe_offsets,
|
||||
)
|
||||
|
||||
|
||||
class _StreamingOutputWriter:
|
||||
@@ -159,14 +165,258 @@ def _event_count_after(
|
||||
return sum(event.get("type") == event_type for event in events[index + 1 :])
|
||||
|
||||
|
||||
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
input_pcm16 = read_pcm16_wav(Path(args.input_wav))
|
||||
if not input_pcm16:
|
||||
raise ValueError("input WAV has no audio")
|
||||
def _probe_video_frames_and_fps(video_path: Path) -> tuple[int, float]:
|
||||
"""Frame count and average FPS via PyAV (same approach as Daily-Omni)."""
|
||||
import av
|
||||
|
||||
with av.open(str(video_path)) as container:
|
||||
stream = container.streams.video[0]
|
||||
avg_fps = float(stream.average_rate) if stream.average_rate else 0.0
|
||||
num_frames = stream.frames or 0
|
||||
if num_frames <= 0:
|
||||
duration: float | None = None
|
||||
if stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif container.duration is not None:
|
||||
duration = container.duration / av.time_base
|
||||
if duration and avg_fps:
|
||||
num_frames = int(math.floor(duration * avg_fps))
|
||||
if num_frames <= 0:
|
||||
stream.thread_type = "AUTO"
|
||||
num_frames = sum(1 for _ in container.decode(stream))
|
||||
if num_frames <= 0:
|
||||
raise ValueError(f"No decodable video frames in {video_path}")
|
||||
return num_frames, avg_fps or 1.0
|
||||
|
||||
|
||||
def _upright(image, rotation: int):
|
||||
"""Apply a container display-matrix rotation, in degrees, to ``image``.
|
||||
|
||||
PyAV hands back the stored raster and leaves the display matrix for the
|
||||
caller, unlike the ffmpeg CLI which autorotates. Phone clips routinely
|
||||
carry +/-90, so skipping this feeds the model a sideways frame: arrows and
|
||||
digits stop being readable and the model mirrors the motion it reports.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
quarter_turns = {
|
||||
90: Image.Transpose.ROTATE_90,
|
||||
180: Image.Transpose.ROTATE_180,
|
||||
270: Image.Transpose.ROTATE_270,
|
||||
}
|
||||
turn = quarter_turns.get(int(rotation) % 360)
|
||||
return image.transpose(turn) if turn is not None else image
|
||||
|
||||
|
||||
def _decode_video_frames_rgb(video_path: Path, frame_idx: list[int]) -> list:
|
||||
"""Decode presentation-order ``frame_idx`` into upright RGB ``PIL.Image``s."""
|
||||
import av
|
||||
|
||||
wanted = sorted({int(i) for i in frame_idx})
|
||||
if not wanted:
|
||||
return []
|
||||
|
||||
decoded: dict[int, object] = {}
|
||||
with av.open(str(video_path)) as container:
|
||||
stream = container.streams.video[0]
|
||||
stream.thread_type = "AUTO"
|
||||
cursor = 0
|
||||
for position, frame in enumerate(container.decode(stream)):
|
||||
if wanted[cursor] > position:
|
||||
continue
|
||||
image = _upright(frame.to_image(), getattr(frame, "rotation", 0) or 0)
|
||||
while cursor < len(wanted) and wanted[cursor] <= position:
|
||||
decoded[wanted[cursor]] = image
|
||||
cursor += 1
|
||||
if cursor >= len(wanted):
|
||||
break
|
||||
|
||||
if not decoded:
|
||||
raise ValueError(f"Decoded no frames from {video_path} for indices {wanted[:8]}")
|
||||
last_image = decoded[max(decoded)]
|
||||
return [decoded.get(int(i), last_image) for i in frame_idx]
|
||||
|
||||
|
||||
def _resize_max_side(image, max_side: int):
|
||||
width, height = image.size
|
||||
longest = max(width, height)
|
||||
if longest <= max_side or max_side <= 0:
|
||||
return image
|
||||
scale = max_side / float(longest)
|
||||
new_size = (max(1, int(round(width * scale))), max(1, int(round(height * scale))))
|
||||
return image.resize(new_size)
|
||||
|
||||
|
||||
def _jpeg_bytes(image, *, quality: int = 95) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
image.convert("RGB").save(buf, format="JPEG", quality=quality)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _to_pcm16_bytes(audio_np) -> bytes:
|
||||
import numpy as np
|
||||
|
||||
arr = np.asarray(audio_np).reshape(-1)
|
||||
if arr.dtype != np.int16:
|
||||
arr = (np.clip(arr, -1.0, 1.0) * 32767.0).astype(np.int16)
|
||||
return arr.tobytes()
|
||||
|
||||
|
||||
def _pcm16_wav_duration_s(path: Path) -> float:
|
||||
pcm16 = read_pcm16_wav(path)
|
||||
return len(pcm16) / float(PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE)
|
||||
|
||||
|
||||
def _extract_video_frames(
|
||||
video_path: Path,
|
||||
work_dir: Path,
|
||||
*,
|
||||
fps: float,
|
||||
max_side: int,
|
||||
duration_s: float,
|
||||
stack_frames: int = 1,
|
||||
) -> tuple[list[str], list[str | None]]:
|
||||
"""Demux JPEG frames only. Frame count follows ``duration_s``, not soundtrack.
|
||||
|
||||
Returns the base frames (one per model unit) and the parallel stacked track.
|
||||
With ``stack_frames=N`` each unit also samples its ``N-1`` interior
|
||||
sub-frames and tiles them into one composite, which is how official duplex
|
||||
raises the visual refresh rate while keeping one second of audio per unit.
|
||||
A unit whose interior falls past the end of the clip stacks nothing.
|
||||
"""
|
||||
num_frames, avg_fps = _probe_video_frames_and_fps(video_path)
|
||||
sample_fps = max(float(fps), 1e-6)
|
||||
unit_s = 1.0 / sample_fps
|
||||
timeline_s = max(float(duration_s), unit_s)
|
||||
num_samples = max(1, int(math.ceil(timeline_s * sample_fps - 1e-9)))
|
||||
offsets = unit_subframe_offsets(stack_frames, unit_s=unit_s)
|
||||
|
||||
def frame_at(seconds: float) -> int:
|
||||
# Official get_video_frame_audio_segments uses truncating int(), not
|
||||
# round(), so 29.97 fps lands on the same presentation index.
|
||||
return min(int(seconds * avg_fps), num_frames - 1)
|
||||
|
||||
base_idx = [frame_at(unit * unit_s) for unit in range(num_samples)]
|
||||
sub_times = [
|
||||
[unit * unit_s + offset for offset in offsets if unit * unit_s + offset < timeline_s]
|
||||
for unit in range(num_samples)
|
||||
]
|
||||
sub_idx = [frame_at(seconds) for times in sub_times for seconds in times]
|
||||
images = _decode_video_frames_rgb(video_path, base_idx + sub_idx)
|
||||
sub_images = iter(images[len(base_idx) :])
|
||||
|
||||
frame_dir = work_dir / "frames"
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
for stale in frame_dir.glob("*.jpg"):
|
||||
stale.unlink()
|
||||
frames_b64: list[str] = []
|
||||
stacked_b64: list[str | None] = []
|
||||
for index, (image, times) in enumerate(zip(images[: len(base_idx)], sub_times), start=1):
|
||||
jpeg = _jpeg_bytes(_resize_max_side(image, max_side))
|
||||
(frame_dir / f"frame_{index:05d}.jpg").write_bytes(jpeg)
|
||||
frames_b64.append(base64.b64encode(jpeg).decode("ascii"))
|
||||
interior = [_resize_max_side(next(sub_images), max_side) for _ in times]
|
||||
if not interior:
|
||||
stacked_b64.append(None)
|
||||
continue
|
||||
composite = concat_frames_b64(interior)
|
||||
(frame_dir / f"stack_{index:05d}.jpg").write_bytes(base64.b64decode(composite))
|
||||
stacked_b64.append(composite)
|
||||
return frames_b64, stacked_b64
|
||||
|
||||
|
||||
def _extract_video_soundtrack(video_path: Path, work_dir: Path) -> Path:
|
||||
"""Write a 16 kHz mono WAV from the video soundtrack."""
|
||||
try:
|
||||
from vllm.multimodal.media.audio import load_audio
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("--input-video needs vLLM audio helpers (load_audio)") from exc
|
||||
|
||||
audio_np, sr = load_audio(str(video_path), sr=PCM16_SAMPLE_RATE, mono=True)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = work_dir / "input_16k.wav"
|
||||
write_pcm16_wav(audio_path, _to_pcm16_bytes(audio_np), sample_rate_hz=int(sr))
|
||||
return audio_path
|
||||
|
||||
|
||||
def _extract_video_input(
|
||||
video_path: Path,
|
||||
work_dir: Path,
|
||||
*,
|
||||
fps: float,
|
||||
max_side: int,
|
||||
stack_frames: int,
|
||||
) -> tuple[Path, list[str], list[str | None]]:
|
||||
"""Demux a video into duplex inputs: 16 kHz mono WAV plus JPEG frames.
|
||||
|
||||
Use this only when the video soundtrack is the audio source. When the
|
||||
caller already has ``--input-wav``, extract frames against that WAV
|
||||
duration instead so a silent clip cannot fail ``load_audio``.
|
||||
"""
|
||||
audio_path = _extract_video_soundtrack(video_path, work_dir)
|
||||
frames_b64, stacked_b64 = _extract_video_frames(
|
||||
video_path,
|
||||
work_dir,
|
||||
fps=fps,
|
||||
max_side=max_side,
|
||||
duration_s=_pcm16_wav_duration_s(audio_path),
|
||||
stack_frames=stack_frames,
|
||||
)
|
||||
return audio_path, frames_b64, stacked_b64
|
||||
|
||||
|
||||
def _resolve_duplex_av_inputs(
|
||||
*,
|
||||
input_wav: str | None,
|
||||
input_video: str | None,
|
||||
work_dir: Path,
|
||||
fps: float,
|
||||
max_side: int,
|
||||
stack_frames: int = 1,
|
||||
) -> tuple[str, list[str], list[str | None]]:
|
||||
"""Return ``(wav_path, frames_b64, stacked_b64)``. External WAV wins over video audio."""
|
||||
if input_video:
|
||||
video_path = Path(input_video)
|
||||
if input_wav:
|
||||
frames, stacked = _extract_video_frames(
|
||||
video_path,
|
||||
work_dir,
|
||||
fps=fps,
|
||||
max_side=max_side,
|
||||
duration_s=_pcm16_wav_duration_s(Path(input_wav)),
|
||||
stack_frames=stack_frames,
|
||||
)
|
||||
return input_wav, frames, stacked
|
||||
extracted_wav, frames, stacked = _extract_video_input(
|
||||
video_path,
|
||||
work_dir,
|
||||
fps=fps,
|
||||
max_side=max_side,
|
||||
stack_frames=stack_frames,
|
||||
)
|
||||
return str(extracted_wav), frames, stacked
|
||||
if not input_wav:
|
||||
raise SystemExit("provide --input-wav, --input-video, or both")
|
||||
return input_wav, [], []
|
||||
|
||||
|
||||
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
output_dir = Path(args.output_dir)
|
||||
stream_writer = _StreamingOutputWriter(output_dir)
|
||||
|
||||
input_wav, video_frames, stacked_video_frames = _resolve_duplex_av_inputs(
|
||||
input_wav=args.input_wav,
|
||||
input_video=args.input_video,
|
||||
work_dir=output_dir / "video_input",
|
||||
fps=args.video_fps,
|
||||
max_side=args.frame_max_side,
|
||||
stack_frames=args.stack_frames,
|
||||
)
|
||||
|
||||
input_pcm16 = read_pcm16_wav(Path(input_wav))
|
||||
if not input_pcm16:
|
||||
raise ValueError("input WAV has no audio")
|
||||
|
||||
url = build_realtime_url(
|
||||
args.url,
|
||||
args.model,
|
||||
@@ -184,10 +434,12 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
stream_event_cursor = len(client.events.events)
|
||||
await client.stream_pcm16(
|
||||
frames_sent = await client.stream_pcm16(
|
||||
input_pcm16,
|
||||
chunk_ms=args.chunk_ms,
|
||||
realtime=not args.no_realtime_pacing,
|
||||
video_frames=video_frames,
|
||||
stacked_video_frames=stacked_video_frames,
|
||||
)
|
||||
commit_event_cursor = len(client.events.events)
|
||||
stream_decision = _latest_model_decision(client.events.events, stream_event_cursor)
|
||||
@@ -310,6 +562,10 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
committed_index,
|
||||
),
|
||||
},
|
||||
"input_video": args.input_video,
|
||||
"video_frames_extracted": len(video_frames),
|
||||
"video_frames_sent": frames_sent,
|
||||
"video_stacked_frames_extracted": sum(frame is not None for frame in stacked_video_frames),
|
||||
"audio_bytes": len(audio),
|
||||
"audio_chunk_count": len(stream_writer.audio_chunk_paths),
|
||||
"audio_chunk_files": [str(path) for path in stream_writer.audio_chunk_paths],
|
||||
@@ -356,7 +612,48 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--url", default="ws://localhost:8099/v1/realtime?duplex=1")
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument("--session-id")
|
||||
parser.add_argument("--input-wav", required=True)
|
||||
parser.add_argument(
|
||||
"--input-wav",
|
||||
help="Mono 16 kHz PCM16 clip to stream. Optional when --input-video carries the audio track.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-video",
|
||||
help=(
|
||||
"Video to stream as omni duplex input. PyAV demuxes frames and "
|
||||
"vLLM load_audio extracts a 16 kHz mono WAV (unless --input-wav overrides the audio)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--video-fps",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Frame extraction rate. The wire contract carries ~1 frame per second of audio.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-max-side",
|
||||
type=int,
|
||||
default=0,
|
||||
help=(
|
||||
"Optional client-side downscale of the longest side, in pixels. "
|
||||
"0 (default) keeps the capture resolution so the server "
|
||||
"process_image can normalize at scale_resolution=448 from a "
|
||||
"higher-quality source. The web demo never resizes on the client."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stack-frames",
|
||||
type=int,
|
||||
default=1,
|
||||
help=(
|
||||
"Frames sampled per 1 s audio unit. 1 (default) sends the unit's "
|
||||
"frame alone. N > 1 also samples the N-1 sub-frames inside the unit "
|
||||
"and tiles them into one composite sent next to the base frame, so "
|
||||
"motion (arrows, doors, digits changing) is visible at N fps while "
|
||||
"the audio stays 1 s per unit. Official uses 5 for high refresh "
|
||||
"rate mode; the wire carries 2 images per unit at any N because the "
|
||||
"sub-frames share one composite."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref-audio",
|
||||
required=True,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for Daily-Omni ``--daily-omni-pack-mode`` packing recipes.
|
||||
|
||||
The ``minicpm-interleave`` mode is an accuracy-critical protocol: MiniCPM-o only reaches
|
||||
@@ -27,6 +30,8 @@ _MODULE_NAME = "vllm_omni.benchmarks.data_modules.daily_omni_dataset"
|
||||
|
||||
if _MODULE_NAME not in sys.modules:
|
||||
_spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise ImportError(f"Cannot load {_MODULE_NAME} from {_MODULE_PATH}")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_MODULE_NAME] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
@@ -87,7 +92,9 @@ def _stub_extract(monkeypatch, dataset: DailyOmniDataset, counter: list[int]) ->
|
||||
def fake_extract(video_path, *, audio_path, include_audio, **_kw):
|
||||
counter.append(1)
|
||||
frames = [Image.new("RGB", (16, 16), color=(i, i, i)) for i in range(_NUM_FRAMES)]
|
||||
segments = [np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else []
|
||||
segments: list[np.ndarray] = (
|
||||
[np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else []
|
||||
)
|
||||
return frames, segments
|
||||
|
||||
monkeypatch.setattr(dataset, "_extract_minicpm_frame_audio_segments", fake_extract)
|
||||
|
||||
@@ -18,6 +18,10 @@ from vllm_omni.experimental.fullduplex.client import (
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.policy import (
|
||||
MiniCPMO45DuplexPolicy,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.video_stacking import (
|
||||
concat_frames,
|
||||
unit_subframe_offsets,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -418,3 +422,62 @@ def test_realtime_client_pcm16_wav_round_trip(tmp_path):
|
||||
assert wav_file.getnchannels() == 1
|
||||
assert wav_file.getframerate() == 16_000
|
||||
assert read_pcm16_wav(path) == pcm16
|
||||
|
||||
|
||||
def _stack_test_image(color, size=(40, 30)):
|
||||
from PIL import Image
|
||||
|
||||
return Image.new("RGB", size, color)
|
||||
|
||||
|
||||
def test_video_stacking_tiles_a_units_subframes_into_one_image():
|
||||
frames = [_stack_test_image((255, 0, 0)), _stack_test_image((0, 255, 0))]
|
||||
|
||||
composite = concat_frames(frames)
|
||||
|
||||
# Landscape cells stack into a column because that canvas is squarer, and
|
||||
# the interior seam carries the separator band that lets the model tell the
|
||||
# sub-frames apart.
|
||||
assert composite.size == (40, 30 * 2 + 6)
|
||||
assert composite.getpixel((20, 30 + 3)) == (0, 0, 0)
|
||||
|
||||
|
||||
def test_video_stacking_picks_the_squarest_grid():
|
||||
portrait = [_stack_test_image((255, 0, 0), size=(20, 60)) for _ in range(2)]
|
||||
four = [_stack_test_image((0, 0, 255)) for _ in range(4)]
|
||||
|
||||
# A portrait clip tiles side by side, four frames always tile 2x2.
|
||||
assert concat_frames(portrait).size == (20 * 2 + 6, 60)
|
||||
assert concat_frames(four).size == (40 * 2 + 6, 30 * 2 + 6)
|
||||
|
||||
|
||||
def test_video_stacking_skips_the_subframe_that_duplicates_the_base_frame():
|
||||
# Official samples stack_frames=5 as 0.2/0.4/0.6/0.8 s: offset 0 is the base
|
||||
# frame, already sent as frame_list[0].
|
||||
assert unit_subframe_offsets(5) == pytest.approx([0.2, 0.4, 0.6, 0.8])
|
||||
assert unit_subframe_offsets(1) == []
|
||||
|
||||
|
||||
def test_realtime_client_sends_each_units_composite_beside_its_base_frame():
|
||||
sent: list[dict] = []
|
||||
|
||||
class _Client(RealtimeDuplexClient):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def send(self, event):
|
||||
sent.append(event)
|
||||
|
||||
asyncio.run(
|
||||
_Client().stream_pcm16(
|
||||
b"\x01\x00" * (16_000 * 3),
|
||||
chunk_ms=200,
|
||||
realtime=False,
|
||||
video_frames=["f0", "f1"],
|
||||
stacked_video_frames=["s0", None],
|
||||
)
|
||||
)
|
||||
|
||||
# A composite belongs to the unit it was captured in, so it rides the same
|
||||
# append as that unit's base frame; a unit without one sends the base alone.
|
||||
assert [event["video_frames"] for event in sent if "video_frames" in event] == [["f0", "s0"], ["f1"]]
|
||||
@@ -91,6 +91,19 @@ def resolve_ref_audio() -> Path:
|
||||
return ref_audio
|
||||
|
||||
|
||||
def duplex_camera_frames(*, seconds: int, cache_dir: Path) -> list[str]:
|
||||
"""Base64 JPEG frames of a moving synthetic scene, one per second.
|
||||
|
||||
Mirrors what the realtime web client captures (1 fps, no client-side
|
||||
resize: the server normalizes at ``scale_resolution=448``).
|
||||
"""
|
||||
from tests.e2e.online_serving.helpers.minicpmo_realtime_duplex_scenarios import _video_frames_from_file
|
||||
from tests.helpers.media import generate_synthetic_video
|
||||
|
||||
video = generate_synthetic_video(448, 448, 30 * seconds, cache_dir=cache_dir)
|
||||
return _video_frames_from_file(Path(video["file_path"]))
|
||||
|
||||
|
||||
def realtime_url(omni_server) -> str:
|
||||
return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1"
|
||||
|
||||
@@ -113,6 +126,8 @@ def demo_args(
|
||||
output_audio_format="pcm16",
|
||||
chunk_ms=200,
|
||||
realtime_input=True,
|
||||
input_video=None,
|
||||
stack_frames=1,
|
||||
first_turn_ms=1400,
|
||||
turn_duration_ms=[],
|
||||
first_turn_transcript="duplex CI speech",
|
||||
|
||||
@@ -37,8 +37,10 @@ import json
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
@@ -55,15 +57,100 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
PCM16_SAMPLE_RATE,
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
duplex_unit_boundary_ms,
|
||||
read_pcm16_wav,
|
||||
summarize_session_request_metrics,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
reference_audio_data_url as _ref_audio_data_url,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.video_stacking import ( # noqa: E402
|
||||
concat_frames_b64,
|
||||
unit_subframe_offsets,
|
||||
)
|
||||
|
||||
_url_with_model = build_realtime_url
|
||||
_read_wav_pcm16 = read_pcm16_wav
|
||||
DemoArgs = argparse.Namespace | SimpleNamespace
|
||||
|
||||
|
||||
def _video_frames_from_file(
|
||||
path: Path,
|
||||
*,
|
||||
fps: float = 1.0,
|
||||
jpeg_quality: int = 70,
|
||||
stack_frames: int = 1,
|
||||
) -> tuple[list[str], list[str | None]]:
|
||||
"""Decode ``path`` into base64 JPEG frames at the omni duplex cadence.
|
||||
|
||||
One frame per second matches what the realtime web client captures, so the
|
||||
stream stays representative of a live camera. ``stack_frames > 1`` also
|
||||
samples each unit's interior sub-frames and tiles them into one composite,
|
||||
the official way to raise visual refresh rate without changing the audio
|
||||
cadence. Returns ``(base_frames, stacked_frames)`` with the tracks parallel.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
offsets = unit_subframe_offsets(stack_frames)
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
if not capture.isOpened():
|
||||
raise ValueError(f"cannot open video: {path}")
|
||||
|
||||
def encode(frame_bgr, index: int) -> str:
|
||||
ok, jpeg = cv2.imencode(".jpg", frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality])
|
||||
if not ok:
|
||||
raise ValueError(f"cannot JPEG-encode frame {index} of {path}")
|
||||
return base64.b64encode(jpeg.tobytes()).decode("ascii")
|
||||
|
||||
try:
|
||||
source_fps = capture.get(cv2.CAP_PROP_FPS) or 0.0
|
||||
stride = max(1, round(source_fps / fps)) if source_fps > 0 else 1
|
||||
# Sub-frame k of a unit sits ``offset * stride`` frames past its base.
|
||||
sub_strides = {max(1, round(offset * stride)): position for position, offset in enumerate(offsets)}
|
||||
frames: list[str] = []
|
||||
interiors: list[list[str]] = []
|
||||
index = 0
|
||||
while True:
|
||||
ok, frame_bgr = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
position, within_unit = divmod(index, stride)
|
||||
if within_unit == 0:
|
||||
frames.append(encode(frame_bgr, index))
|
||||
interiors.append([])
|
||||
elif within_unit in sub_strides and position < len(interiors):
|
||||
interiors[position].append(encode(frame_bgr, index))
|
||||
index += 1
|
||||
finally:
|
||||
capture.release()
|
||||
if not frames:
|
||||
raise ValueError(f"video has no decodable frames: {path}")
|
||||
stacked = [concat_frames_b64(interior) if interior else None for interior in interiors]
|
||||
return frames, stacked
|
||||
|
||||
|
||||
def _resolve_video_frames(args: DemoArgs) -> tuple[list[str], list[str | None]]:
|
||||
"""Base64 JPEG frames for the duplex camera track, newest last.
|
||||
|
||||
Returns the base track plus the parallel stacked composites. Only a video
|
||||
source has sub-frames to stack; an explicit frame list or a still image
|
||||
stacks nothing.
|
||||
"""
|
||||
frames = list(getattr(args, "video_frames_b64", None) or [])
|
||||
if frames:
|
||||
stacked = list(getattr(args, "video_stacked_frames_b64", None) or [])
|
||||
stacked += [None] * (len(frames) - len(stacked))
|
||||
return frames, stacked[: len(frames)]
|
||||
input_video = getattr(args, "input_video", None)
|
||||
if input_video:
|
||||
return _video_frames_from_file(
|
||||
Path(input_video).expanduser(),
|
||||
stack_frames=max(1, int(getattr(args, "stack_frames", 1) or 1)),
|
||||
)
|
||||
frame_image = getattr(args, "frame_image", None)
|
||||
if frame_image:
|
||||
return [base64.b64encode(Path(frame_image).expanduser().read_bytes()).decode("ascii")], [None]
|
||||
return [], []
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -373,17 +460,20 @@ class DemoState:
|
||||
commit_indices = [
|
||||
index for index, event in enumerate(self.events) if event.get("type") == "input_audio_buffer.committed"
|
||||
]
|
||||
decision_indices = [
|
||||
index
|
||||
for index, event in enumerate(self.events)
|
||||
if event.get("type") == "response.created"
|
||||
or (
|
||||
event.get("type") == "response.listen"
|
||||
and isinstance(event.get("response"), dict)
|
||||
and isinstance(event["response"].get("metadata"), dict)
|
||||
and event["response"]["metadata"].get("model_listen") is True
|
||||
)
|
||||
]
|
||||
decision_indices: list[int] = []
|
||||
for index, event in enumerate(self.events):
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.created":
|
||||
decision_indices.append(index)
|
||||
continue
|
||||
if event_type != "response.listen":
|
||||
continue
|
||||
response = event.get("response")
|
||||
if not isinstance(response, dict):
|
||||
continue
|
||||
metadata = response.get("metadata")
|
||||
if isinstance(metadata, dict) and metadata.get("model_listen") is True:
|
||||
decision_indices.append(index)
|
||||
first_input_index = self.first_index("input_audio_buffer.speech_started")
|
||||
if first_input_index is None:
|
||||
first_input_index = commit_indices[0] if commit_indices else None
|
||||
@@ -500,7 +590,7 @@ class DemoState:
|
||||
return stale
|
||||
|
||||
|
||||
def _session_update_event(args: argparse.Namespace) -> dict[str, object]:
|
||||
def _session_update_event(args: DemoArgs) -> dict[str, object]:
|
||||
session_payload: dict[str, object] = {
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
@@ -836,7 +926,8 @@ async def _send_pcm16(
|
||||
hints: dict[str, object] | None = None,
|
||||
first_chunk_hints: dict[str, object] | None = None,
|
||||
on_model_unit_ready=None,
|
||||
frame_b64: str | None = None,
|
||||
frames_b64: Sequence[str] | None = None,
|
||||
stacked_frames_b64: Sequence[str | None] | None = None,
|
||||
) -> None:
|
||||
hints = hints or {}
|
||||
first_chunk_hints = first_chunk_hints or {}
|
||||
@@ -844,6 +935,8 @@ async def _send_pcm16(
|
||||
model_unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE
|
||||
audio_ms = 0
|
||||
next_model_unit_bytes = model_unit_bytes
|
||||
frames = list(frames_b64 or [])
|
||||
stacked = list(stacked_frames_b64 or [])
|
||||
frames_sent = 0
|
||||
for offset in range(0, len(pcm16), chunk_bytes):
|
||||
chunk = pcm16[offset : offset + chunk_bytes]
|
||||
@@ -852,9 +945,18 @@ async def _send_pcm16(
|
||||
chunk_hints = dict(hints)
|
||||
if offset == 0:
|
||||
chunk_hints.update(first_chunk_hints)
|
||||
if frame_b64 is not None and audio_ms > frames_sent * 1000:
|
||||
# Omni duplex cadence: one camera frame per 1 s of audio.
|
||||
chunk_hints["video_frames"] = [frame_b64]
|
||||
if frames and audio_ms >= duplex_unit_boundary_ms(frames_sent):
|
||||
# Omni duplex cadence: one camera frame per model unit, riding the
|
||||
# append that closes it, so the frame and the second of audio it was
|
||||
# captured during enter the same unit. A video advances one frame per
|
||||
# unit and holds its last frame when the audio outlives the clip; a
|
||||
# still image is a one-element list and therefore repeats.
|
||||
# A stacked composite of that unit's interior sub-frames rides the
|
||||
# same append right after the base frame (wire maximum of 2).
|
||||
index = min(frames_sent, len(frames) - 1)
|
||||
composite = stacked[index] if index < len(stacked) else None
|
||||
base = frames[index]
|
||||
chunk_hints["video_frames"] = [base] if composite is None else [base, composite]
|
||||
frames_sent += 1
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
@@ -935,7 +1037,8 @@ async def _send_clean_turn(
|
||||
realtime_input: bool = False,
|
||||
model_policy_settle_s: float = 2.0,
|
||||
commit_input: bool = True,
|
||||
frame_b64: str | None = None,
|
||||
frames_b64: Sequence[str] | None = None,
|
||||
stacked_frames_b64: Sequence[str | None] | None = None,
|
||||
) -> tuple[str | None, str]:
|
||||
before_created = state.count("response.created")
|
||||
before_model_listen = state.model_listen_count
|
||||
@@ -957,7 +1060,8 @@ async def _send_clean_turn(
|
||||
chunk_ms=chunk_ms,
|
||||
realtime_delay=realtime_input,
|
||||
hints={"transcript": transcript} if send_transcript_hint else {},
|
||||
frame_b64=frame_b64,
|
||||
frames_b64=frames_b64,
|
||||
stacked_frames_b64=stacked_frames_b64,
|
||||
)
|
||||
if commit_input:
|
||||
state.input_commit_sent_at_s.append(time.monotonic())
|
||||
@@ -1163,7 +1267,8 @@ async def _send_listen_only_overlap_pair(
|
||||
model_policy_settle_s: float,
|
||||
validation_mode: str,
|
||||
silence_ms: int,
|
||||
frame_b64: str | None = None,
|
||||
frames_b64: Sequence[str] | None = None,
|
||||
stacked_frames_b64: Sequence[str | None] | None = None,
|
||||
) -> tuple[list[str | None], list[str], bool]:
|
||||
if not realtime_input:
|
||||
raise ValueError("listen-only-overlap requires --realtime-input")
|
||||
@@ -1194,7 +1299,8 @@ async def _send_listen_only_overlap_pair(
|
||||
realtime_delay=True,
|
||||
hints={"transcript": transcripts[0]} if send_transcript_hint else {},
|
||||
on_model_unit_ready=continue_until_first_speak,
|
||||
frame_b64=frame_b64,
|
||||
frames_b64=frames_b64,
|
||||
stacked_frames_b64=stacked_frames_b64,
|
||||
)
|
||||
state.input_commit_sent_at_s.append(time.monotonic())
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
|
||||
@@ -1233,7 +1339,8 @@ async def _send_listen_only_overlap_pair(
|
||||
realtime_delay=True,
|
||||
hints={"transcript": transcripts[1]} if send_transcript_hint else {},
|
||||
on_model_unit_ready=record_model_unit_ready,
|
||||
frame_b64=frame_b64,
|
||||
frames_b64=frames_b64,
|
||||
stacked_frames_b64=stacked_frames_b64,
|
||||
)
|
||||
state.input_commit_sent_at_s.append(time.monotonic())
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
|
||||
@@ -1297,10 +1404,8 @@ async def _send_listen_only_overlap_pair(
|
||||
return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok
|
||||
|
||||
|
||||
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
demo_frame_b64: str | None = None
|
||||
if getattr(args, "frame_image", None):
|
||||
demo_frame_b64 = base64.b64encode(Path(args.frame_image).read_bytes()).decode("ascii")
|
||||
async def run_demo(args: DemoArgs) -> dict[str, object]:
|
||||
demo_frames_b64, demo_stacked_b64 = _resolve_video_frames(args)
|
||||
turn_input_paths = _turn_input_paths(
|
||||
Path(args.input_wav),
|
||||
list(getattr(args, "turn_input_wav", []) or []),
|
||||
@@ -1371,7 +1476,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
|
||||
validation_mode=validation_mode,
|
||||
silence_ms=max(0, args.silence_ms),
|
||||
frame_b64=demo_frame_b64,
|
||||
frames_b64=demo_frames_b64,
|
||||
stacked_frames_b64=demo_stacked_b64,
|
||||
)
|
||||
for turn_index in range(2, len(turn_specs)):
|
||||
transcript, duration_ms = turn_specs[turn_index]
|
||||
@@ -1389,7 +1495,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
realtime_input=realtime_input,
|
||||
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
|
||||
commit_input=not continuous_input,
|
||||
frame_b64=demo_frame_b64,
|
||||
frames_b64=demo_frames_b64,
|
||||
stacked_frames_b64=demo_stacked_b64,
|
||||
)
|
||||
turn_response_ids.append(response_id)
|
||||
turn_outcomes.append(outcome)
|
||||
@@ -1409,7 +1516,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
realtime_input=realtime_input,
|
||||
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
|
||||
commit_input=not continuous_input,
|
||||
frame_b64=demo_frame_b64,
|
||||
frames_b64=demo_frames_b64,
|
||||
stacked_frames_b64=demo_stacked_b64,
|
||||
)
|
||||
turn_response_ids.append(response_id)
|
||||
turn_outcomes.append(outcome)
|
||||
@@ -1584,6 +1692,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
"continuous_input": continuous_input,
|
||||
"continuous_input_ok": continuous_input_ok,
|
||||
"input_chunk_ms": args.chunk_ms,
|
||||
"video_frame_count": len(demo_frames_b64),
|
||||
"video_stacked_frame_count": sum(frame is not None for frame in demo_stacked_b64),
|
||||
"model_policy_settle_ms": args.model_policy_settle_ms,
|
||||
"turn_outcomes": turn_outcomes,
|
||||
"turn_outcomes_ok": turn_outcomes_ok,
|
||||
@@ -1641,6 +1751,19 @@ def parse_args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Optional image file sent as an omni-duplex camera frame (one per 1 s of audio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-video",
|
||||
default=None,
|
||||
help="Optional video file streamed as omni-duplex camera frames (decoded at 1 fps, "
|
||||
"overrides --frame-image; the last frame is held if the audio outlives the clip)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stack-frames",
|
||||
type=int,
|
||||
default=1,
|
||||
choices=[1, 2],
|
||||
help="Frames per append: 2 also resends the previous frame for motion context.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--turn-input-wav",
|
||||
action="append",
|
||||
|
||||
@@ -16,6 +16,7 @@ import websockets
|
||||
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
|
||||
SERVER_PARAMS,
|
||||
demo_args,
|
||||
duplex_camera_frames,
|
||||
multi_session_args,
|
||||
realtime_url,
|
||||
resolve_ref_audio,
|
||||
@@ -30,10 +31,16 @@ from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import
|
||||
)
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.experimental.fullduplex.client import build_realtime_url
|
||||
from vllm_omni.experimental.fullduplex.video_stacking import concat_frames_b64
|
||||
|
||||
pytestmark = pytest.mark.omni
|
||||
|
||||
|
||||
def _assert_positive_int(value: object) -> None:
|
||||
assert isinstance(value, int)
|
||||
assert value > 0
|
||||
|
||||
|
||||
def _assert_request_metrics(metrics: object, *, expected_count: int) -> None:
|
||||
assert isinstance(metrics, list)
|
||||
assert len(metrics) == expected_count
|
||||
@@ -142,7 +149,45 @@ def test_duplex_single_session_response_required(omni_server, tmp_path: Path) ->
|
||||
args.turn_duration_ms = [args.first_turn_ms] * args.turns
|
||||
result = asyncio.run(run_demo(args))
|
||||
assert result["ok"] is True
|
||||
assert result["audio_delta_count"] > 0
|
||||
_assert_positive_int(result["audio_delta_count"])
|
||||
assert result["done_count"] == 2
|
||||
assert result["error_count"] == 0
|
||||
assert result["all_audio_responses_have_transcript"] is True
|
||||
assert result["transcript_delta_done_ok"] is True
|
||||
_assert_request_metrics(result["request_metrics"], expected_count=2)
|
||||
_assert_session_metrics(result["session_metrics"], expected_count=2)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_single_session_video_input(omni_server, tmp_path: Path) -> None:
|
||||
"""Audio plus a 1 fps camera track, the omni-duplex video contract.
|
||||
|
||||
Frames ride the audio appends, so the response contract is unchanged: what
|
||||
this covers is that interleaved vision input keeps the turn intact instead
|
||||
of stalling or erroring out mid-segment.
|
||||
"""
|
||||
args = demo_args(
|
||||
omni_server=omni_server,
|
||||
input_wav=validated_input_wav(),
|
||||
ref_audio=resolve_ref_audio(),
|
||||
output_dir=tmp_path / "video_input",
|
||||
)
|
||||
args.turns = 2
|
||||
args.turn_duration_ms = [args.first_turn_ms] * args.turns
|
||||
frames = duplex_camera_frames(seconds=4, cache_dir=tmp_path / "camera")
|
||||
args.video_frames_b64 = frames
|
||||
# Each unit also carries a composite of its interior sub-frames, so the
|
||||
# append exercises the official two-image frame_list that carries motion.
|
||||
args.video_stacked_frames_b64 = [concat_frames_b64([frames[index]] * 2) for index in range(len(frames))]
|
||||
|
||||
result = asyncio.run(run_demo(args))
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["video_frame_count"] == 4
|
||||
assert result["video_stacked_frame_count"] == 4
|
||||
_assert_positive_int(result["audio_delta_count"])
|
||||
assert result["done_count"] == 2
|
||||
assert result["error_count"] == 0
|
||||
assert result["all_audio_responses_have_transcript"] is True
|
||||
|
||||
@@ -18,6 +18,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
DRIVER_DIR = Path(__file__).resolve().parent
|
||||
HELPERS_DIR = DRIVER_DIR / "helpers"
|
||||
DEMO_PATH = HELPERS_DIR / "minicpmo_realtime_duplex_scenarios.py"
|
||||
EXAMPLE_DEMO_PATH = DRIVER_DIR.parents[2] / "examples/online_serving/minicpmo/realtime_duplex_demo.py"
|
||||
MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py"
|
||||
PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py"
|
||||
SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py"
|
||||
@@ -50,6 +51,18 @@ def _load_pair_demo_module():
|
||||
return module
|
||||
|
||||
|
||||
def _load_example_demo_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"minicpmo_realtime_duplex_example_demo_test",
|
||||
EXAMPLE_DEMO_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_soft_interrupt_demo_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"minicpmo_realtime_duplex_soft_interrupt_test",
|
||||
@@ -1813,6 +1826,219 @@ def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak():
|
||||
assert len(ws.messages) == 10
|
||||
|
||||
|
||||
def _sent_video_frames(messages: list[dict[str, object]]) -> list[list[str]]:
|
||||
frames: list[list[str]] = []
|
||||
for message in messages:
|
||||
raw = message.get("video_frames")
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
frames.append([frame for frame in raw if isinstance(frame, str)])
|
||||
return frames
|
||||
|
||||
|
||||
def _decode_jpeg_size(frame_b64: str) -> tuple[int, int]:
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
return Image.open(io.BytesIO(base64.b64decode(frame_b64))).size
|
||||
|
||||
|
||||
class _RecordingWebSocket:
|
||||
def __init__(self, demo):
|
||||
self._demo = demo
|
||||
self.messages: list[dict[str, object]] = []
|
||||
|
||||
async def send(self, payload):
|
||||
self.messages.append(self._demo.json.loads(payload))
|
||||
|
||||
|
||||
def _send_seconds_of_audio(demo, *, seconds: int, frames: list[str], stacked: list[str | None] | None = None):
|
||||
ws = _RecordingWebSocket(demo)
|
||||
asyncio.run(
|
||||
demo._send_pcm16(
|
||||
ws,
|
||||
b"\x01\x00" * (demo.PCM16_SAMPLE_RATE * seconds),
|
||||
chunk_ms=200,
|
||||
realtime_delay=False,
|
||||
frames_b64=frames,
|
||||
stacked_frames_b64=stacked,
|
||||
)
|
||||
)
|
||||
return ws.messages
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_streams_one_video_frame_per_model_unit():
|
||||
demo = _load_demo_module()
|
||||
|
||||
messages = _send_seconds_of_audio(demo, seconds=3, frames=["f0", "f1", "f2", "f3"])
|
||||
|
||||
# Frame k rides the append that closes model unit k, so Stage0 can bind it
|
||||
# to that unit's audio. 3 s of audio closes units at 1030 ms and 2030 ms and
|
||||
# leaves a residual too short for a third, so only two frames go out.
|
||||
assert _sent_video_frames(messages) == [["f0"], ["f1"]]
|
||||
frame_indices = [index for index, message in enumerate(messages) if "video_frames" in message]
|
||||
assert frame_indices == [5, 10]
|
||||
# The carrying appends are exactly the ones that reach a unit boundary.
|
||||
assert [messages[index]["audio_end_ms"] for index in frame_indices] == [1200, 2200]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_never_sends_a_frame_before_its_unit_can_close():
|
||||
"""Regression: frame 0 used to ride the very first 200 ms append.
|
||||
|
||||
Stage0 cannot close a unit until 1030 ms of audio has arrived, and it does
|
||||
not carry frames across appends, so a frame sent that early was silently
|
||||
dropped and every later frame ended up one unit ahead of its audio.
|
||||
"""
|
||||
demo = _load_demo_module()
|
||||
|
||||
messages = _send_seconds_of_audio(demo, seconds=3, frames=["f0", "f1", "f2"])
|
||||
|
||||
carried = [message for message in messages if "video_frames" in message]
|
||||
assert carried, "expected at least one frame to be sent"
|
||||
for message in carried:
|
||||
assert message["audio_end_ms"] >= demo.duplex_unit_boundary_ms(0)
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_holds_the_last_video_frame_when_audio_outlives_the_clip():
|
||||
demo = _load_demo_module()
|
||||
|
||||
# 4 s of audio closes three units (1030/2030/3030 ms); the two-frame clip
|
||||
# holds its last frame for the third.
|
||||
assert _sent_video_frames(_send_seconds_of_audio(demo, seconds=4, frames=["a", "b"])) == [["a"], ["b"], ["b"]]
|
||||
# A still image is a one-element clip and therefore repeats every unit.
|
||||
assert _sent_video_frames(_send_seconds_of_audio(demo, seconds=3, frames=["still"])) == [["still"], ["still"]]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_sends_each_units_stacked_composite_next_to_its_base_frame():
|
||||
demo = _load_demo_module()
|
||||
|
||||
messages = _send_seconds_of_audio(
|
||||
demo,
|
||||
seconds=4,
|
||||
frames=["f0", "f1", "f2"],
|
||||
stacked=["s0", "s1", None],
|
||||
)
|
||||
|
||||
# Official pairing is frame_list=[base, composite of that unit's interior],
|
||||
# so the composite belongs to the same unit as the base beside it -- never
|
||||
# to the previous one. A unit without interior sub-frames sends base alone.
|
||||
assert _sent_video_frames(messages) == [["f0", "s0"], ["f1", "s1"], ["f2"]]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_decodes_a_video_file_into_one_frame_per_second(tmp_path):
|
||||
pytest.importorskip("cv2")
|
||||
pytest.importorskip("imageio")
|
||||
from tests.helpers.media import generate_synthetic_video
|
||||
|
||||
demo = _load_demo_module()
|
||||
# 30 fps synthetic clip: 90 frames is 3 s of camera input.
|
||||
video = generate_synthetic_video(64, 64, 90, cache_dir=tmp_path)
|
||||
|
||||
frames, stacked = demo._resolve_video_frames(SimpleNamespace(input_video=video["file_path"]))
|
||||
|
||||
assert len(frames) == 3
|
||||
assert all(base64.b64decode(frame, validate=True)[:3] == b"\xff\xd8\xff" for frame in frames)
|
||||
assert len(set(frames)) == 3
|
||||
# stack_frames defaults to 1, so there is nothing to stack.
|
||||
assert stacked == [None, None, None]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_stacks_a_units_interior_subframes_into_one_composite(tmp_path):
|
||||
pytest.importorskip("cv2")
|
||||
pytest.importorskip("imageio")
|
||||
from tests.helpers.media import generate_synthetic_video
|
||||
|
||||
demo = _load_demo_module()
|
||||
video = generate_synthetic_video(64, 64, 90, cache_dir=tmp_path)
|
||||
|
||||
frames, stacked = demo._resolve_video_frames(SimpleNamespace(input_video=video["file_path"], stack_frames=5))
|
||||
|
||||
# stack_frames=5 raises the visual refresh rate to 5 fps, but the four
|
||||
# interior sub-frames of each second collapse into a single composite, so
|
||||
# the wire still carries 2 images per unit and the audio cadence is
|
||||
# untouched.
|
||||
assert len(frames) == 3
|
||||
assert len(stacked) == 3
|
||||
assert all(frame is not None for frame in stacked)
|
||||
assert all(base64.b64decode(frame, validate=True)[:3] == b"\xff\xd8\xff" for frame in stacked)
|
||||
composite = _decode_jpeg_size(stacked[0])
|
||||
assert composite != _decode_jpeg_size(frames[0])
|
||||
|
||||
|
||||
def test_example_demo_uses_external_wav_duration_and_skips_video_soundtrack(tmp_path, monkeypatch):
|
||||
demo = _load_example_demo_module()
|
||||
wav = tmp_path / "external.wav"
|
||||
demo.write_pcm16_wav(wav, b"\x00\x00" * (demo.PCM16_SAMPLE_RATE * 2), sample_rate_hz=demo.PCM16_SAMPLE_RATE)
|
||||
soundtrack_calls: list[object] = []
|
||||
|
||||
def fake_soundtrack(*args, **kwargs):
|
||||
soundtrack_calls.append(1)
|
||||
raise AssertionError("video soundtrack must not be loaded when --input-wav is set")
|
||||
|
||||
def fake_frames(video_path, work_dir, *, fps, max_side, duration_s, stack_frames):
|
||||
assert video_path == Path("/tmp/silent.mp4")
|
||||
assert fps == 1.0
|
||||
assert duration_s == pytest.approx(2.0)
|
||||
return ["f0", "f1"], [None, None]
|
||||
|
||||
monkeypatch.setattr(demo, "_extract_video_soundtrack", fake_soundtrack)
|
||||
monkeypatch.setattr(demo, "_extract_video_frames", fake_frames)
|
||||
|
||||
wav_out, frames, stacked = demo._resolve_duplex_av_inputs(
|
||||
input_wav=str(wav),
|
||||
input_video="/tmp/silent.mp4",
|
||||
work_dir=tmp_path / "video_input",
|
||||
fps=1.0,
|
||||
max_side=0,
|
||||
)
|
||||
|
||||
assert wav_out == str(wav)
|
||||
assert frames == ["f0", "f1"]
|
||||
assert stacked == [None, None]
|
||||
assert soundtrack_calls == []
|
||||
|
||||
|
||||
def test_example_demo_extracts_video_soundtrack_only_when_wav_is_absent(tmp_path, monkeypatch):
|
||||
demo = _load_example_demo_module()
|
||||
extracted = tmp_path / "from_video.wav"
|
||||
demo.write_pcm16_wav(extracted, b"\x00\x00" * demo.PCM16_SAMPLE_RATE, sample_rate_hz=demo.PCM16_SAMPLE_RATE)
|
||||
|
||||
def fake_extract(video_path, work_dir, *, fps, max_side, stack_frames):
|
||||
assert video_path == Path("/tmp/clip.mp4")
|
||||
return extracted, ["v0"], ["s0"]
|
||||
|
||||
monkeypatch.setattr(demo, "_extract_video_input", fake_extract)
|
||||
|
||||
wav_out, frames, stacked = demo._resolve_duplex_av_inputs(
|
||||
input_wav=None,
|
||||
input_video="/tmp/clip.mp4",
|
||||
work_dir=tmp_path / "video_input",
|
||||
fps=1.0,
|
||||
max_side=0,
|
||||
)
|
||||
|
||||
assert wav_out == str(extracted)
|
||||
assert frames == ["v0"]
|
||||
assert stacked == ["s0"]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_prefers_a_video_over_a_still_frame_image(tmp_path):
|
||||
demo = _load_demo_module()
|
||||
still = tmp_path / "frame.jpg"
|
||||
still.write_bytes(b"\xff\xd8\xff-still")
|
||||
|
||||
# A still has no interior sub-frames, so it never stacks.
|
||||
assert demo._resolve_video_frames(SimpleNamespace(frame_image=str(still))) == (
|
||||
[base64.b64encode(still.read_bytes()).decode("ascii")],
|
||||
[None],
|
||||
)
|
||||
assert demo._resolve_video_frames(
|
||||
SimpleNamespace(frame_image=str(still), video_frames_b64=["preset"]),
|
||||
) == (["preset"], [None])
|
||||
assert demo._resolve_video_frames(SimpleNamespace()) == ([], [])
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_listen_only_overlap_accepts_silence_unit_before_first_done(monkeypatch):
|
||||
demo = _load_demo_module()
|
||||
state = demo.DemoState()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import sys
|
||||
@@ -169,6 +172,64 @@ def test_open_streaming_response_requires_post_commit_drain():
|
||||
)
|
||||
|
||||
|
||||
def _asymmetric_image():
|
||||
from PIL import Image
|
||||
|
||||
image = Image.new("RGB", (4, 2), (0, 0, 0))
|
||||
image.putpixel((0, 0), (255, 0, 0))
|
||||
return image
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rotation,expected_corner",
|
||||
[
|
||||
(0, (0, 0)),
|
||||
(90, (0, 3)),
|
||||
(180, (3, 1)),
|
||||
(-90, (1, 0)),
|
||||
(270, (1, 0)),
|
||||
(-270, (0, 3)),
|
||||
],
|
||||
)
|
||||
def test_upright_applies_the_display_matrix_quarter_turn(rotation, expected_corner):
|
||||
demo = _load_demo_module()
|
||||
|
||||
rotated = demo._upright(_asymmetric_image(), rotation)
|
||||
|
||||
assert rotated.getpixel(expected_corner) == (255, 0, 0)
|
||||
|
||||
|
||||
def test_decoded_frames_apply_the_container_display_matrix(monkeypatch, tmp_path):
|
||||
"""A -90 phone clip must reach the model upright, not on its side."""
|
||||
demo = _load_demo_module()
|
||||
source = _asymmetric_image()
|
||||
|
||||
class _Frame:
|
||||
rotation = -90
|
||||
|
||||
def to_image(self):
|
||||
return source
|
||||
|
||||
class _Container:
|
||||
streams = type("_Streams", (), {"video": [type("_Stream", (), {})()]})()
|
||||
|
||||
def decode(self, stream):
|
||||
yield _Frame()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setitem(sys.modules, "av", type("_Av", (), {"open": staticmethod(lambda path: _Container())}))
|
||||
|
||||
(image,) = demo._decode_video_frames_rgb(tmp_path / "clip.mp4", [0])
|
||||
|
||||
assert image.size == (2, 4)
|
||||
assert image.getpixel((1, 0)) == (255, 0, 0)
|
||||
|
||||
|
||||
def test_streaming_output_writer_persists_audio_deltas_as_they_arrive(tmp_path, capsys):
|
||||
demo = _load_demo_module()
|
||||
writer = demo._StreamingOutputWriter(tmp_path)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
@@ -849,6 +852,161 @@ def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format():
|
||||
assert result["prompt_suffix_len"] == 0
|
||||
|
||||
|
||||
def _stage0_vision_runtime():
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
|
||||
MiniCPMO45Stage0DuplexRuntime,
|
||||
)
|
||||
|
||||
class _StageModel(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.embed = torch.nn.Embedding(256, 2)
|
||||
|
||||
def get_input_embeddings(self):
|
||||
return self.embed
|
||||
|
||||
def get_audio_hidden_states(self, _data):
|
||||
return [torch.tensor([[0.5, 0.5]], dtype=torch.float32)]
|
||||
|
||||
runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime)
|
||||
runtime.stage_model = _StageModel()
|
||||
runtime.thinker = runtime.stage_model
|
||||
runtime.tokenizer = SimpleNamespace(
|
||||
unk_token_id=0,
|
||||
convert_tokens_to_ids=lambda token: {
|
||||
"<unit>": 1,
|
||||
"</unit>": 2,
|
||||
"<|listen|>": 3,
|
||||
"<|speak|>": 4,
|
||||
"<|tts_bos|>": 5,
|
||||
"<|tts_eos|>": 6,
|
||||
"<|tts_pad|>": 7,
|
||||
"<|chunk_eos|>": 8,
|
||||
"<|chunk_tts_eos|>": 9,
|
||||
"<|turn_eos|>": 10,
|
||||
"<|audio|>": 11,
|
||||
"<image>": 12,
|
||||
"</image>": 13,
|
||||
"<slice>": 14,
|
||||
"</slice>": 15,
|
||||
}.get(token, 0),
|
||||
encode=lambda text, add_special_tokens=False: [201, 5],
|
||||
)
|
||||
runtime.processor = SimpleNamespace(get_streaming_chunk_size=lambda: 4)
|
||||
runtime.device = "cpu"
|
||||
runtime._init_token_ids()
|
||||
# One 2-row block per frame keeps the expected token_ids readable; the real
|
||||
# resampler emits VISION_EMBEDS_PER_FRAME rows per slice.
|
||||
runtime._stage_vision_embeddings = lambda frames: [
|
||||
[torch.full((2, 2), float(index), dtype=torch.float32)] for index in range(len(frames))
|
||||
]
|
||||
return runtime
|
||||
|
||||
|
||||
def test_minicpmo_stage0_puts_every_frame_of_an_append_in_one_unit():
|
||||
"""Official streaming_prefill feeds the whole frame_list into one unit.
|
||||
|
||||
Stacked pairs used to be spread one frame per unit, so a two-frame append
|
||||
that closed a single unit left a frame over, failed the prefill, and still
|
||||
advanced the session state.
|
||||
"""
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
|
||||
_MiniCPMO45Stage0SessionState,
|
||||
)
|
||||
|
||||
runtime = _stage0_vision_runtime()
|
||||
state = _MiniCPMO45Stage0SessionState(session_id="sid-stage0-stacked-frames")
|
||||
|
||||
result = runtime._stage_prefill_embeddings_only(
|
||||
state,
|
||||
np.zeros(4, dtype=np.float32),
|
||||
video_frames=["frame-prev", "frame-cur"],
|
||||
seq=1,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
# <unit>, then each frame as <image> + embeds + </image>, then the audio.
|
||||
assert result["input_token_ids"] == [1, 12, 0, 0, 13, 12, 0, 0, 13, 11]
|
||||
assert state.audio_chunk_idx == 1
|
||||
|
||||
|
||||
def test_minicpmo_stage0_wraps_hd_slices_after_the_source_image():
|
||||
"""Official stacked pair is max_slice_nums=[2, 1]: source + slices, then audio."""
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
|
||||
_MiniCPMO45Stage0SessionState,
|
||||
)
|
||||
|
||||
runtime = _stage0_vision_runtime()
|
||||
state = _MiniCPMO45Stage0SessionState(session_id="sid-stage0-hd-slices")
|
||||
runtime._stage_vision_embeddings = lambda frames: [
|
||||
[torch.ones((2, 2)), torch.full((2, 2), 2.0), torch.full((2, 2), 3.0)],
|
||||
[torch.full((2, 2), 4.0)],
|
||||
]
|
||||
|
||||
result = runtime._stage_prefill_embeddings_only(
|
||||
state,
|
||||
np.zeros(4, dtype=np.float32),
|
||||
video_frames=["base", "stack"],
|
||||
seq=1,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
# <unit> <image> src </image> <slice> p0 </slice> <slice> p1 </slice>
|
||||
# <image> stack </image> audio
|
||||
assert result["input_token_ids"] == [
|
||||
1,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
11,
|
||||
]
|
||||
|
||||
|
||||
def test_minicpmo_stage0_reports_frames_that_close_no_unit_instead_of_dropping_them():
|
||||
"""A frame only binds to the unit its append closes.
|
||||
|
||||
Frames are not carried across appends, so one arriving early used to vanish
|
||||
under a generic "audio not enough" buffering result.
|
||||
"""
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
|
||||
_MiniCPMO45Stage0SessionState,
|
||||
)
|
||||
|
||||
runtime = _stage0_vision_runtime()
|
||||
state = _MiniCPMO45Stage0SessionState(session_id="sid-stage0-early-frame")
|
||||
|
||||
assert runtime._stage_prefill_embeddings_only(state, np.zeros(4, dtype=np.float32), seq=1)["success"] is True
|
||||
|
||||
early = runtime._stage_prefill_embeddings_only(
|
||||
state,
|
||||
np.zeros(2, dtype=np.float32),
|
||||
video_frames=["frame-cur"],
|
||||
seq=2,
|
||||
)
|
||||
|
||||
assert early["success"] is False
|
||||
assert "closes no model unit" in early["reason"]
|
||||
# The half-filled append must not advance the unit counter.
|
||||
assert state.audio_chunk_idx == 1
|
||||
|
||||
|
||||
def test_minicpmo_stage0_speech_append_sets_pending_context_once():
|
||||
import torch
|
||||
|
||||
@@ -2514,7 +2672,7 @@ def _ar_runner_classdefs():
|
||||
|
||||
|
||||
def _method_calls(method):
|
||||
calls = {}
|
||||
calls: dict[str, int] = {}
|
||||
for node in ast.walk(method):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
||||
calls.setdefault(node.func.attr, node.lineno)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Daily-Omni Dataset loader for benchmark.
|
||||
|
||||
Daily-Omni is an audio-visual reasoning benchmark with 684 videos
|
||||
@@ -627,10 +630,13 @@ class DailyOmniDataset(BenchmarkDataset):
|
||||
|
||||
def _load_from_local_json(self) -> None:
|
||||
"""Load QA data from local JSON file."""
|
||||
if not self.qa_json_path.exists():
|
||||
raise FileNotFoundError(f"QA JSON file not found: {self.qa_json_path}")
|
||||
qa_json_path = self.qa_json_path
|
||||
if qa_json_path is None:
|
||||
raise RuntimeError("qa_json_path is required for local JSON loading")
|
||||
if not qa_json_path.exists():
|
||||
raise FileNotFoundError(f"QA JSON file not found: {qa_json_path}")
|
||||
|
||||
with open(self.qa_json_path, encoding="utf-8") as f:
|
||||
with open(qa_json_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Support both list format and dict with "train"/"test" splits
|
||||
@@ -682,8 +688,11 @@ class DailyOmniDataset(BenchmarkDataset):
|
||||
one ``sample()`` / request-building implementation for both.
|
||||
"""
|
||||
snapshot_error: Exception | None = None
|
||||
dataset_path = self.dataset_path
|
||||
if dataset_path is None:
|
||||
raise ValueError("dataset_path is required for HuggingFace loading")
|
||||
try:
|
||||
root = ensure_daily_omni_hub_root(self.dataset_path)
|
||||
root = ensure_daily_omni_hub_root(dataset_path)
|
||||
except Exception as e: # noqa: BLE001 - any failure just falls through to `datasets`
|
||||
snapshot_error = e
|
||||
else:
|
||||
@@ -1256,7 +1265,7 @@ class DailyOmniDataset(BenchmarkDataset):
|
||||
num_video_frames, avg_fps = _probe_video_frames_and_fps(video_path)
|
||||
duration = num_video_frames / avg_fps
|
||||
num_seconds = max(1, int(math.ceil(duration)))
|
||||
second_timestamps = list(range(num_seconds))
|
||||
second_timestamps = [float(i) for i in range(num_seconds)]
|
||||
|
||||
if duration > max_num_frames:
|
||||
timestamps = [round(i * 0.1, 1) for i in range(int(duration / 0.1))]
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
import math
|
||||
import time
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -28,6 +28,20 @@ except ImportError as exc: # pragma: no cover - example dependency
|
||||
PCM16_SAMPLE_RATE = 16_000
|
||||
PCM16_BYTES_PER_SAMPLE = 2
|
||||
|
||||
# Server-side model unit boundaries, in cumulative appended audio.
|
||||
# Stage0 configures the streaming mel processor with first_chunk_ms=1035 and
|
||||
# chunk_ms=1000; the processor aligns the first chunk down to a hop_length (160
|
||||
# samples) multiple, so unit 0 closes at 16480 samples and every later unit
|
||||
# closes 16000 samples after it. Camera frames must ride the append that closes
|
||||
# a unit, otherwise Stage0 cannot bind them to that unit's audio.
|
||||
DUPLEX_FIRST_UNIT_MS = 1030
|
||||
DUPLEX_UNIT_MS = 1000
|
||||
|
||||
|
||||
def duplex_unit_boundary_ms(unit_index: int) -> int:
|
||||
"""Cumulative appended audio, in ms, that closes model unit ``unit_index``."""
|
||||
return DUPLEX_FIRST_UNIT_MS + max(0, int(unit_index)) * DUPLEX_UNIT_MS
|
||||
|
||||
|
||||
def _rounded_ms(value: float) -> float:
|
||||
return round(float(value), 3)
|
||||
@@ -561,24 +575,71 @@ class RealtimeDuplexClient:
|
||||
*,
|
||||
chunk_ms: int = 200,
|
||||
realtime: bool = True,
|
||||
) -> None:
|
||||
video_frames: Sequence[str] | None = None,
|
||||
stacked_video_frames: Sequence[str | None] | None = None,
|
||||
) -> int:
|
||||
"""Append PCM16 audio, optionally interleaving omni camera frames.
|
||||
|
||||
``video_frames`` holds base64 JPEG/PNG frames in capture order, one per
|
||||
second of the clip. Frame ``k`` rides the append that closes model unit
|
||||
``k`` (see ``duplex_unit_boundary_ms``), which reproduces the official
|
||||
``streaming_prefill(audio_waveform=<1 s>, frame_list=[frame])`` pairing:
|
||||
a second of audio and the picture captured during it enter the same
|
||||
unit. Sending on whole-second boundaries instead would strand frame 0 on
|
||||
an append that cannot close a unit yet, and shift every later frame one
|
||||
unit ahead of its audio.
|
||||
|
||||
``stacked_video_frames`` is the optional parallel track of composites
|
||||
built by ``video_stacking.concat_frames``: entry ``k`` tiles the
|
||||
sub-frames captured *inside* unit ``k``, and rides the same append right
|
||||
after the base frame, giving the official ``frame_list=[base,
|
||||
composite]``. The audio is untouched — a unit stays one second however
|
||||
many sub-frames the composite carries. ``None`` entries send the base
|
||||
frame alone.
|
||||
|
||||
Returns the number of base frames actually sent (a clip shorter than the
|
||||
frame list leaves the tail unsent).
|
||||
"""
|
||||
chunk_bytes = max(
|
||||
PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_ms // 1000,
|
||||
PCM16_BYTES_PER_SAMPLE,
|
||||
)
|
||||
await self.stream_av_units(
|
||||
((pcm16[offset : offset + chunk_bytes], None) for offset in range(0, len(pcm16), chunk_bytes)),
|
||||
realtime=realtime,
|
||||
)
|
||||
frames = list(video_frames or [])
|
||||
stacked = list(stacked_video_frames or [])
|
||||
|
||||
def units() -> Iterator[tuple[bytes, list[str] | None]]:
|
||||
audio_end_ms = 0
|
||||
frames_sent = 0
|
||||
for offset in range(0, len(pcm16), chunk_bytes):
|
||||
chunk = pcm16[offset : offset + chunk_bytes]
|
||||
audio_end_ms += len(chunk) * 1000 // (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE)
|
||||
if not frames or audio_end_ms < duplex_unit_boundary_ms(frames_sent):
|
||||
yield chunk, None
|
||||
continue
|
||||
# A video advances one frame per unit and holds its last frame
|
||||
# when the audio outlives the clip; a still image is a
|
||||
# one-element list and therefore repeats.
|
||||
index = min(frames_sent, len(frames) - 1)
|
||||
composite = stacked[index] if index < len(stacked) else None
|
||||
frames_sent += 1
|
||||
yield chunk, [frames[index]] if composite is None else [frames[index], composite]
|
||||
|
||||
return await self.stream_av_units(units(), realtime=realtime)
|
||||
|
||||
async def stream_av_units(
|
||||
self,
|
||||
units: Any,
|
||||
*,
|
||||
realtime: bool = True,
|
||||
) -> None:
|
||||
"""Stream PCM16 units, optionally attaching a JPEG to each unit."""
|
||||
) -> int:
|
||||
"""Stream PCM16 units, optionally attaching camera frames to each unit.
|
||||
|
||||
A unit's frame slot takes a single JPEG/PNG (raw bytes or base64) or a
|
||||
sequence of them, which the Realtime wire caps at two per append.
|
||||
Returns the number of appends that carried at least one frame.
|
||||
"""
|
||||
audio_end_ms = 0
|
||||
frames_sent = 0
|
||||
for chunk, frame in units:
|
||||
if not chunk:
|
||||
continue
|
||||
@@ -593,11 +654,18 @@ class RealtimeDuplexClient:
|
||||
"duration_ms": duration_ms,
|
||||
"audio_end_ms": audio_end_ms,
|
||||
}
|
||||
if frame is not None:
|
||||
event["video_frames"] = [base64.b64encode(frame).decode("ascii") if isinstance(frame, bytes) else frame]
|
||||
encoded_frames = [
|
||||
base64.b64encode(item).decode("ascii") if isinstance(item, bytes | bytearray) else item
|
||||
for item in (frame if isinstance(frame, list | tuple) else [frame])
|
||||
if item is not None
|
||||
]
|
||||
if encoded_frames:
|
||||
event["video_frames"] = encoded_frames
|
||||
frames_sent += 1
|
||||
await self.send(event)
|
||||
if realtime:
|
||||
await asyncio.sleep(duration_ms / 1000)
|
||||
return frames_sent
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self.send({"type": "input_audio_buffer.commit", "final": True})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -24,7 +27,9 @@ class MiniCPMO45DuplexPolicy:
|
||||
# Vision framing contract (omni duplex). Official streaming_prefill feeds
|
||||
# each frame as <image> + 64 resampler embeddings + </image> inside the
|
||||
# unit, ahead of the unit's audio embeddings (max_slice_nums=1 in
|
||||
# streaming, so exactly one 64-token block per frame).
|
||||
# streaming, so exactly one 64-token block per frame). A unit may carry
|
||||
# its base frame plus an optional stacked composite of the sub-frames
|
||||
# captured inside that unit (2 blocks).
|
||||
VISION_EMBEDS_PER_FRAME = 64
|
||||
VISION_TOKENS_PER_FRAME = VISION_EMBEDS_PER_FRAME + 2 # <image> + embeds + </image>
|
||||
DEFAULT_MAX_NEW_SPEAK_TOKENS_PER_CHUNK = 20
|
||||
@@ -77,6 +82,8 @@ class MiniCPMO45DuplexPolicy:
|
||||
"audio_placeholder_token_id": "<|audio|>",
|
||||
"image_start_token_id": "<image>",
|
||||
"image_end_token_id": "</image>",
|
||||
"slice_start_token_id": "<slice>",
|
||||
"slice_end_token_id": "</slice>",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -92,7 +99,7 @@ class MiniCPMO45DuplexPolicy:
|
||||
value = value[0] if len(value) == 1 else None
|
||||
unk_token_id = getattr(tokenizer, "unk_token_id", None)
|
||||
try:
|
||||
candidate = int(value)
|
||||
candidate = int(value) if value is not None else -1
|
||||
except (TypeError, ValueError):
|
||||
candidate = -1
|
||||
if candidate >= 0 and candidate != unk_token_id:
|
||||
@@ -118,7 +125,8 @@ class MiniCPMO45DuplexPolicy:
|
||||
if resolved.get("listen_token_id", -1) < 0:
|
||||
eos_id = getattr(tokenizer, "eos_token_id", None)
|
||||
try:
|
||||
resolved["listen_token_id"] = int(eos_id)
|
||||
if eos_id is not None:
|
||||
resolved["listen_token_id"] = int(eos_id)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return resolved
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinasciiError
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
@@ -22,6 +22,9 @@ _DUPLEX_SAMPLES_PER_AUDIO_TOKEN = 1600
|
||||
# <image> + 64 resampler embeddings + </image> per frame (max_slice_nums=1),
|
||||
# matching MiniCPMO45DuplexPolicy.VISION_TOKENS_PER_FRAME.
|
||||
_DUPLEX_VISION_TOKENS_PER_FRAME = 66
|
||||
# Official stacked pair uses max_slice_nums=[2, 1]: the current frame is HD
|
||||
# sliced (1 source + 2 patches on 960x540) and the composite is not.
|
||||
_DUPLEX_HD_SLICES_PER_BASE_FRAME = 3
|
||||
|
||||
|
||||
def _duplex_frame_count(payload: object) -> int:
|
||||
@@ -33,6 +36,22 @@ def _duplex_frame_count(payload: object) -> int:
|
||||
return sum(1 for frame in frames if isinstance(frame, str) and frame)
|
||||
|
||||
|
||||
def _duplex_vision_tokens(payload: object) -> int:
|
||||
"""Scheduler slots for this append's camera track.
|
||||
|
||||
Audio is never stacked: a unit still carries one second of soundtrack.
|
||||
``stack_frames`` only adds a second *image*. Official HD on that pair is
|
||||
``[2, 1]``, so the base frame reserves three 66-token blocks and every
|
||||
extra frame reserves one.
|
||||
"""
|
||||
count = _duplex_frame_count(payload)
|
||||
if count <= 0:
|
||||
return 0
|
||||
if count >= 2:
|
||||
return (_DUPLEX_HD_SLICES_PER_BASE_FRAME + (count - 1)) * _DUPLEX_VISION_TOKENS_PER_FRAME
|
||||
return count * _DUPLEX_VISION_TOKENS_PER_FRAME
|
||||
|
||||
|
||||
def _duplex_pcm_sample_count(payload: object) -> int | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
@@ -48,7 +67,7 @@ def _duplex_pcm_sample_count(payload: object) -> int | None:
|
||||
|
||||
def duplex_payload_is_exact_chunks(payload: object) -> bool:
|
||||
sample_count = _duplex_pcm_sample_count(payload)
|
||||
return bool(sample_count) and sample_count % _DUPLEX_CHUNK_SAMPLES == 0
|
||||
return sample_count is not None and sample_count != 0 and sample_count % _DUPLEX_CHUNK_SAMPLES == 0
|
||||
|
||||
|
||||
def duplex_first_append_unit_count(payload: object) -> int | None:
|
||||
@@ -59,7 +78,7 @@ def duplex_first_append_unit_count(payload: object) -> int | None:
|
||||
|
||||
|
||||
def duplex_scheduler_token_budget(payload: object, *, default: int = 64) -> int:
|
||||
vision_tokens = _duplex_frame_count(payload) * _DUPLEX_VISION_TOKENS_PER_FRAME
|
||||
vision_tokens = _duplex_vision_tokens(payload)
|
||||
sample_count = _duplex_pcm_sample_count(payload)
|
||||
if sample_count is None:
|
||||
return max(1, int(default)) + vision_tokens
|
||||
@@ -114,9 +133,7 @@ def build_duplex_data_plane_prompt(
|
||||
token_budget += context_reserve
|
||||
first_units = duplex_first_append_unit_count(payload)
|
||||
if first_units is not None:
|
||||
token_budget = (
|
||||
context_reserve + first_units * 12 - 1 + _duplex_frame_count(payload) * _DUPLEX_VISION_TOKENS_PER_FRAME
|
||||
)
|
||||
token_budget = context_reserve + first_units * 12 - 1 + _duplex_vision_tokens(payload)
|
||||
if seq > 1 and duplex_payload_is_exact_chunks(payload):
|
||||
token_budget += 1
|
||||
if final and duplex_payload_is_exact_chunks(payload):
|
||||
@@ -124,7 +141,7 @@ def build_duplex_data_plane_prompt(
|
||||
extra_body = session_config.get("extra_body")
|
||||
raw_token_id = runtime_config.get("duplex_scheduler_token_id")
|
||||
try:
|
||||
token_id = max(0, int(raw_token_id))
|
||||
token_id = 0 if raw_token_id is None else max(0, int(raw_token_id))
|
||||
except (TypeError, ValueError):
|
||||
token_id = 0
|
||||
force_listen_count = _duplex_force_listen_count(extra_body)
|
||||
@@ -163,16 +180,17 @@ def build_duplex_data_plane_prompt(
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int | None:
|
||||
if hasattr(value, "detach"):
|
||||
detach = getattr(value, "detach", None)
|
||||
if callable(detach):
|
||||
try:
|
||||
value = value.detach().cpu().reshape(-1)
|
||||
if value.numel() == 0:
|
||||
flat: Any = detach().cpu().reshape(-1)
|
||||
if flat.numel() == 0:
|
||||
return None
|
||||
value = value[0].item()
|
||||
value = flat[0].item()
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
return int(cast(Any, value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -42,6 +45,20 @@ class _MiniCPMO45Stage0SessionState:
|
||||
class MiniCPMO45Stage0DuplexRuntime:
|
||||
"""Build scheduler-owned MiniCPM-o 4.5 Stage0 duplex inputs."""
|
||||
|
||||
unit_token_id: int = -1
|
||||
unit_end_token_id: int = -1
|
||||
listen_token_id: int = -1
|
||||
speak_token_id: int = -1
|
||||
tts_bos_token_id: int = -1
|
||||
tts_eos_token_id: int = -1
|
||||
tts_pad_token_id: int = -1
|
||||
chunk_eos_token_id: int = -1
|
||||
chunk_tts_eos_token_id: int = -1
|
||||
turn_eos_token_id: int = -1
|
||||
audio_placeholder_token_id: int = -1
|
||||
image_start_token_id: int = -1
|
||||
image_end_token_id: int = -1
|
||||
|
||||
def __init__(self, stage_model: Any, *, model_path: str | None = None, device: str = "cuda") -> None:
|
||||
self.stage_model = stage_model
|
||||
self.model_path = model_path
|
||||
@@ -81,7 +98,7 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
processor._streaming_mel_processor = copy.deepcopy(shared_mel)
|
||||
state.streaming_processor = processor
|
||||
if processor is None:
|
||||
return
|
||||
return None
|
||||
set_streaming_mode = getattr(processor, "set_streaming_mode", None)
|
||||
if callable(set_streaming_mode):
|
||||
set_streaming_mode(
|
||||
@@ -175,24 +192,40 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
self._require_special_token_ids()
|
||||
if audio_waveform is None or len(audio_waveform) == 0:
|
||||
return self._stage_prefill_result(False, start_time, "empty audio")
|
||||
# Omni duplex: encode this append's camera frames up front so the unit
|
||||
# loop can interleave one <image> block per unit, mirroring official
|
||||
# streaming_prefill (feed <unit>, then image embeds, then audio).
|
||||
frame_blocks: list[Any] = []
|
||||
if video_frames:
|
||||
self._require_vision_token_ids()
|
||||
frame_blocks = self._stage_vision_embeddings(video_frames)
|
||||
if frame_blocks is None or len(frame_blocks) != len(video_frames):
|
||||
return self._stage_prefill_result(False, start_time, "streaming vision embedding failed")
|
||||
state.audio_buffer = np.concatenate([state.audio_buffer, np.asarray(audio_waveform, dtype=np.float32)])
|
||||
chunk_size = self._streaming_chunk_size(processor)
|
||||
self._pad_first_audio_chunk_if_needed(state, processor)
|
||||
if len(state.audio_buffer) < chunk_size:
|
||||
if video_frames:
|
||||
# Frames belong to the unit this append closes, and the
|
||||
# scheduler already reserved their vision tokens against this
|
||||
# append. A frame arriving before its unit can close has
|
||||
# nowhere to go, so report it instead of dropping it under a
|
||||
# generic buffering result.
|
||||
return self._stage_prefill_result(
|
||||
False,
|
||||
start_time,
|
||||
f"{len(video_frames)} video frame(s) on an append that closes no model unit "
|
||||
f"(need {chunk_size} samples, only {len(state.audio_buffer)}); "
|
||||
"send frames on unit boundaries",
|
||||
)
|
||||
return self._stage_prefill_result(
|
||||
False,
|
||||
start_time,
|
||||
f"audio not enough: need {chunk_size} samples, only {len(state.audio_buffer)}",
|
||||
)
|
||||
# Omni duplex: encode this append's camera frames so the first unit can
|
||||
# carry them, mirroring official streaming_prefill (feed <unit>, then
|
||||
# every frame_list entry as its own <image> block, then the audio).
|
||||
frame_blocks: list[Any] = []
|
||||
if video_frames:
|
||||
self._require_vision_token_ids()
|
||||
encoded_frames = self._stage_vision_embeddings(video_frames)
|
||||
if encoded_frames is None or len(encoded_frames) != len(video_frames):
|
||||
return self._stage_prefill_result(False, start_time, "streaming vision embedding failed")
|
||||
if any(not slices for slices in encoded_frames):
|
||||
return self._stage_prefill_result(False, start_time, "streaming vision embedding failed")
|
||||
frame_blocks = encoded_frames
|
||||
|
||||
embed_parts: list[Any] = []
|
||||
token_ids: list[int] = []
|
||||
@@ -246,16 +279,22 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
embed_parts.append(self._embed_token(self.unit_token_id))
|
||||
token_ids.append(self.unit_token_id)
|
||||
if frame_blocks:
|
||||
# Official order inside a unit: <image> + 64 resampler
|
||||
# embeddings + </image> ahead of the unit's audio embeddings
|
||||
# (max_slice_nums=1 in streaming, one frame per unit).
|
||||
vision_block = self._as_2d_tensor(frame_blocks.pop(0))
|
||||
embed_parts.append(self._embed_token(self.image_start_token_id))
|
||||
token_ids.append(int(self.image_start_token_id))
|
||||
embed_parts.append(vision_block)
|
||||
token_ids.extend([self._vision_embedding_placeholder_token_id()] * int(vision_block.shape[0]))
|
||||
embed_parts.append(self._embed_token(self.image_end_token_id))
|
||||
token_ids.append(int(self.image_end_token_id))
|
||||
# Official order inside a unit: every frame_list entry as
|
||||
# <image> + 64 embeds + </image>, then any HD patches as
|
||||
# <slice> + 64 embeds + </slice>, all ahead of this unit's
|
||||
# *one* second of audio. stack_frames never stacks audio.
|
||||
vision_placeholder = self._vision_embedding_placeholder_token_id()
|
||||
for frame_slices in frame_blocks:
|
||||
for slice_index, frame_block in enumerate(frame_slices):
|
||||
vision_block = self._as_2d_tensor(frame_block)
|
||||
start_id, end_id = self._vision_slice_token_ids(is_source=slice_index == 0)
|
||||
embed_parts.append(self._embed_token(start_id))
|
||||
token_ids.append(int(start_id))
|
||||
embed_parts.append(vision_block)
|
||||
token_ids.extend([vision_placeholder] * int(vision_block.shape[0]))
|
||||
embed_parts.append(self._embed_token(end_id))
|
||||
token_ids.append(int(end_id))
|
||||
frame_blocks = []
|
||||
embed_parts.append(audio_embeds)
|
||||
token_ids.extend(
|
||||
[self._audio_embedding_placeholder_token_id()] * int(self._as_2d_tensor(audio_embeds).shape[0])
|
||||
@@ -264,14 +303,6 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
state.audio_chunk_idx += 1
|
||||
units_built += 1
|
||||
chunk_size = self._streaming_chunk_size(processor)
|
||||
if frame_blocks:
|
||||
# Each frame reserves one image block in the append plan. A frame
|
||||
# without a matching audio unit would desynchronize that plan.
|
||||
return self._stage_prefill_result(
|
||||
False,
|
||||
start_time,
|
||||
f"{len(frame_blocks)} video frame(s) left without a matching audio unit",
|
||||
)
|
||||
# Match official streaming_prefill: per chunk feed ONLY <unit>+audio. The assistant
|
||||
# turn is opened once at session init; re-emitting the turn-open prefix per chunk
|
||||
# re-opened the turn each chunk -> degenerate repetition. tts_bos/listen/turn_eos are
|
||||
@@ -331,6 +362,20 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
embeds = embedder(token)
|
||||
return self._as_2d_tensor(embeds)
|
||||
|
||||
def _token_embedding_dtype(self) -> Any:
|
||||
"""dtype of the decoder token embeddings, the unit's reference dtype.
|
||||
|
||||
Official ``get_vllm_embedding`` casts vision hidden states to the token
|
||||
embedding dtype before they enter the sequence, so the vision tower's
|
||||
own dtype must not leak into the concatenated unit.
|
||||
"""
|
||||
embedder = self._token_embedder()
|
||||
weight = getattr(embedder, "weight", None)
|
||||
dtype = getattr(weight, "dtype", None)
|
||||
if dtype is not None:
|
||||
return dtype
|
||||
return getattr(next(self.thinker.parameters()), "dtype", None)
|
||||
|
||||
def _token_embedder(self) -> Any:
|
||||
nested_embed = getattr(getattr(getattr(self.thinker, "llm", None), "model", None), "embed_tokens", None)
|
||||
if callable(nested_embed):
|
||||
@@ -392,7 +437,7 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
)
|
||||
if len(state.audio_buffer) >= first_chunk_samples:
|
||||
return
|
||||
padding = np.zeros(first_chunk_samples - len(state.audio_buffer), dtype=np.float32)
|
||||
padding: np.ndarray = np.zeros(first_chunk_samples - len(state.audio_buffer), dtype=np.float32)
|
||||
state.audio_buffer = np.concatenate([padding, state.audio_buffer])
|
||||
|
||||
def _stage_param(self, name: str, default: Any) -> Any:
|
||||
@@ -454,7 +499,7 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
previous_audio_past_key_values = (
|
||||
getattr(self.thinker, "audio_past_key_values", None) if has_audio_cache else None
|
||||
)
|
||||
if has_audio_cache:
|
||||
if has_audio_cache and state is not None:
|
||||
self.thinker.audio_past_key_values = state.audio_past_key_values
|
||||
try:
|
||||
for target in (self.stage_model, self.thinker):
|
||||
@@ -469,18 +514,18 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
suffix_extra_frames=2,
|
||||
)
|
||||
)
|
||||
if has_audio_cache:
|
||||
if has_audio_cache and state is not None:
|
||||
state.audio_past_key_values = getattr(self.thinker, "audio_past_key_values", None)
|
||||
return result
|
||||
except TypeError:
|
||||
result = self._cat_nested_tensors(get_streaming(batch_feature))
|
||||
if has_audio_cache:
|
||||
if has_audio_cache and state is not None:
|
||||
state.audio_past_key_values = getattr(self.thinker, "audio_past_key_values", None)
|
||||
return result
|
||||
get_hidden = getattr(target, "get_audio_hidden_states", None)
|
||||
if callable(get_hidden):
|
||||
result = self._cat_nested_tensors(get_hidden(batch_feature))
|
||||
if has_audio_cache:
|
||||
if has_audio_cache and state is not None:
|
||||
state.audio_past_key_values = getattr(self.thinker, "audio_past_key_values", None)
|
||||
return result
|
||||
return None
|
||||
@@ -627,14 +672,14 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
return original_register(config_class, *args, **kwargs)
|
||||
|
||||
with _MINICPMO45_PROCESSOR_LOAD_LOCK:
|
||||
AutoImageProcessor.register = staticmethod(register_image_processor)
|
||||
setattr(AutoImageProcessor, "register", staticmethod(register_image_processor))
|
||||
try:
|
||||
processor = AutoProcessor.from_pretrained(
|
||||
model_path,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
finally:
|
||||
AutoImageProcessor.register = staticmethod(original_register)
|
||||
setattr(AutoImageProcessor, "register", staticmethod(original_register))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to load MiniCPM-o duplex processor from {model_path!r}") from exc
|
||||
if getattr(processor, "tokenizer", None) is None:
|
||||
@@ -764,21 +809,24 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
f"missing or unknown: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
def _stage_vision_embeddings(self, frames: list[Any]) -> list[Any] | None:
|
||||
"""Encode camera frames for omni duplex via the loaded vision tower.
|
||||
def _vision_slice_token_ids(self, *, is_source: bool) -> tuple[int, int]:
|
||||
if is_source:
|
||||
return int(self.image_start_token_id), int(self.image_end_token_id)
|
||||
start = getattr(self, "slice_start_token_id", None)
|
||||
end = getattr(self, "slice_end_token_id", None)
|
||||
if isinstance(start, int) and start >= 0 and isinstance(end, int) and end >= 0:
|
||||
return start, end
|
||||
return int(self.image_start_token_id), int(self.image_end_token_id)
|
||||
|
||||
Semantics mirror ``MiniCPMODuplex.streaming_prefill`` (one 64-embedding
|
||||
block per frame at ``max_slice_nums=1``), executed through the vLLM
|
||||
wrapper's ``get_vision_hidden_states`` (vpm + resampler) since the
|
||||
stage model does not expose the remote-code ``get_vision_embedding``.
|
||||
"""
|
||||
process_image = getattr(self.processor, "process_image", None)
|
||||
if not callable(process_image):
|
||||
return None
|
||||
try:
|
||||
processed = process_image(frames, max_slice_nums=1)
|
||||
except Exception: # noqa: BLE001 - prefill fails with a reason
|
||||
return None
|
||||
def _official_max_slice_nums(self, frame_count: int) -> list[int]:
|
||||
"""Official stacked pair uses HD on the current frame only: ``[2, 1]``."""
|
||||
if frame_count <= 0:
|
||||
return []
|
||||
if frame_count == 1:
|
||||
return [1]
|
||||
return [2] + [1] * (frame_count - 1)
|
||||
|
||||
def _encode_processed_vision(self, processed: Any) -> list[Any] | None:
|
||||
targets = (self.stage_model, self.thinker, getattr(self.stage_model, "model", None))
|
||||
for target in targets:
|
||||
if target is None:
|
||||
@@ -801,17 +849,48 @@ class MiniCPMO45Stage0DuplexRuntime:
|
||||
flat_pixels.append(slice_pixels.to(device=device, dtype=dtype))
|
||||
tgt_tensor = image_tgt if hasattr(image_tgt, "reshape") else torch.tensor(image_tgt)
|
||||
flat_tgt.append(tgt_tensor.reshape(-1, 2))
|
||||
if not flat_pixels:
|
||||
return None
|
||||
tgt_sizes = torch.cat(flat_tgt, dim=0).to(device=device, dtype=torch.int64)
|
||||
with torch.no_grad():
|
||||
hidden = get_hidden({"pixel_values": flat_pixels, "tgt_sizes": tgt_sizes})
|
||||
except Exception: # noqa: BLE001 - prefill fails with a reason
|
||||
return None
|
||||
expected = MiniCPMO45DuplexPolicy.VISION_EMBEDS_PER_FRAME
|
||||
embed_dtype = self._token_embedding_dtype()
|
||||
out: list[Any] = []
|
||||
for block in hidden:
|
||||
block_2d = self._as_2d_tensor(block)
|
||||
if int(block_2d.shape[0]) != expected:
|
||||
return None
|
||||
if embed_dtype is not None and block_2d.dtype != embed_dtype:
|
||||
block_2d = block_2d.to(dtype=embed_dtype)
|
||||
out.append(block_2d)
|
||||
return out
|
||||
return None
|
||||
|
||||
def _stage_vision_embeddings(self, frames: list[Any]) -> list[list[Any]] | None:
|
||||
"""Encode camera frames for omni duplex via the loaded vision tower.
|
||||
|
||||
Official ``streaming_prefill`` encodes each ``frame_list`` entry as its
|
||||
own image (source + optional HD slices) and never stacks audio: the
|
||||
unit still carries one second of soundtrack. A stacked pair uses
|
||||
``max_slice_nums=[2, 1]`` so the current frame keeps the elevator
|
||||
display readable and the composite stays a single 448-normalized tile.
|
||||
Frames are processed one at a time because ``process_image([a, b])``
|
||||
packs both PILs into one batch item.
|
||||
"""
|
||||
process_image = getattr(self.processor, "process_image", None)
|
||||
if not callable(process_image):
|
||||
return None
|
||||
out: list[list[Any]] = []
|
||||
for frame, max_slices in zip(frames, self._official_max_slice_nums(len(frames))):
|
||||
try:
|
||||
processed = process_image([frame], max_slice_nums=max_slices)
|
||||
except Exception: # noqa: BLE001 - prefill fails with a reason
|
||||
return None
|
||||
encoded = self._encode_processed_vision(processed)
|
||||
if encoded is None:
|
||||
return None
|
||||
out.append(encoded)
|
||||
return out
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -17,6 +18,7 @@ from vllm_omni.experimental.fullduplex.openai.audio import (
|
||||
from vllm_omni.experimental.fullduplex.openai.realtime_state import (
|
||||
REALTIME_INPUT_AUDIO_FORMATS,
|
||||
REALTIME_OUTPUT_AUDIO_FORMATS,
|
||||
RealtimeStateOwner,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.openai.vad import (
|
||||
SILERO_VAD_MIN_THRESHOLD,
|
||||
@@ -27,9 +29,42 @@ from vllm_omni.experimental.fullduplex.openai.vad import (
|
||||
)
|
||||
|
||||
|
||||
class RealtimeInputTranslator:
|
||||
class RealtimeInputTranslator(RealtimeStateOwner):
|
||||
"""Translate and validate client Realtime events for the duplex core."""
|
||||
|
||||
_opened: bool
|
||||
_initial_session_update: bool
|
||||
_input_speech_started: bool
|
||||
_input_audio_buffer_has_audio: bool
|
||||
_input_audio_buffer_had_non_speech: bool
|
||||
_input_audio_buffer_transcript_parts: list[str]
|
||||
_active_input_item_id: str | None
|
||||
_last_conversation_item_id: str | None
|
||||
_active_response_id: str | None
|
||||
_last_response_id: str | None
|
||||
_input_audio_format: str
|
||||
_output_audio_format: str
|
||||
_input_sample_rate_hz: int
|
||||
_conversation_items: dict[str, dict[str, object]]
|
||||
_item_truncation_cursors: dict[str, tuple[int, int]]
|
||||
_pending_outbound: asyncio.Queue[dict[str, object]]
|
||||
_pending_commit_item_ids: asyncio.Queue[str]
|
||||
|
||||
async def _send_realtime_payload(self, payload: dict[str, object]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _realtime_error_payload(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
event_id: object | None = None,
|
||||
param: object | None = None,
|
||||
) -> dict[str, object]:
|
||||
raise NotImplementedError
|
||||
|
||||
_response_is_done: Callable[[object], bool]
|
||||
|
||||
async def discard_pending_input_audio(
|
||||
self,
|
||||
*,
|
||||
@@ -77,7 +112,8 @@ class RealtimeInputTranslator:
|
||||
async def _to_duplex_event(self, event: dict[str, object]) -> dict[str, object] | None:
|
||||
event_type = event.get("type")
|
||||
if event_type == "session.update":
|
||||
session_payload = event.get("session") if isinstance(event.get("session"), dict) else event
|
||||
session = event.get("session")
|
||||
session_payload: dict[str, object] = session if isinstance(session, dict) else event
|
||||
format_error = self._validate_realtime_session_audio_formats(session_payload)
|
||||
if format_error is not None:
|
||||
await self._send_realtime_payload(
|
||||
@@ -201,7 +237,7 @@ class RealtimeInputTranslator:
|
||||
)
|
||||
return None
|
||||
item = self._conversation_items.get(item_id)
|
||||
if item is None:
|
||||
if not isinstance(item, dict):
|
||||
await self._send_realtime_payload(
|
||||
self._realtime_error_payload(
|
||||
"item_not_found",
|
||||
@@ -357,7 +393,8 @@ class RealtimeInputTranslator:
|
||||
)
|
||||
)
|
||||
return None
|
||||
payload["video_frames"] = [frame for frame in video_frames if isinstance(frame, str) and frame]
|
||||
if isinstance(video_frames, list):
|
||||
payload["video_frames"] = [frame for frame in video_frames if isinstance(frame, str) and frame]
|
||||
self._copy_realtime_input_hints(event, payload)
|
||||
payload["is_speech"] = looks_like_speech
|
||||
if vad_result is not None:
|
||||
@@ -627,7 +664,8 @@ class RealtimeInputTranslator:
|
||||
def _remove_conversation_item(self, item_id: str) -> bool:
|
||||
removed = self._conversation_items.pop(item_id, None) is not None
|
||||
if self._last_conversation_item_id == item_id:
|
||||
self._last_conversation_item_id = next(reversed(self._conversation_items), None)
|
||||
remaining_ids = list(self._conversation_items)
|
||||
self._last_conversation_item_id = remaining_ids[-1] if remaining_ids else None
|
||||
self._item_truncation_cursors.pop(item_id, None)
|
||||
return removed
|
||||
|
||||
@@ -713,29 +751,34 @@ class RealtimeInputTranslator:
|
||||
audio_config = session_payload.get("audio")
|
||||
audio_input = audio_config.get("input") if isinstance(audio_config, dict) else None
|
||||
audio_output = audio_config.get("output") if isinstance(audio_config, dict) else None
|
||||
extra_body = (
|
||||
dict(session_payload.get("extra_body")) if isinstance(session_payload.get("extra_body"), dict) else {}
|
||||
)
|
||||
extra_body_payload = session_payload.get("extra_body")
|
||||
extra_body = dict(extra_body_payload) if isinstance(extra_body_payload, dict) else {}
|
||||
extra_body["realtime_session_payload"] = self._json_safe_realtime_payload(session_payload)
|
||||
if isinstance(session_payload.get("tools"), list):
|
||||
extra_body["realtime_tools"] = session_payload["tools"]
|
||||
if isinstance(session_payload.get("tool_choice"), str | dict):
|
||||
extra_body["realtime_tool_choice"] = session_payload["tool_choice"]
|
||||
if isinstance(session_payload.get("metadata"), dict):
|
||||
extra_body["realtime_metadata"] = dict(session_payload["metadata"])
|
||||
if isinstance(session_payload.get("include"), list):
|
||||
extra_body["realtime_include"] = list(session_payload["include"])
|
||||
if isinstance(session_payload.get("prompt"), dict):
|
||||
extra_body["realtime_prompt"] = dict(session_payload["prompt"])
|
||||
metadata = session_payload.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
extra_body["realtime_metadata"] = dict(metadata)
|
||||
include = session_payload.get("include")
|
||||
if isinstance(include, list):
|
||||
extra_body["realtime_include"] = list(include)
|
||||
prompt = session_payload.get("prompt")
|
||||
if isinstance(prompt, dict):
|
||||
extra_body["realtime_prompt"] = dict(prompt)
|
||||
input_audio_transcription = self._input_audio_transcription_config(session_payload)
|
||||
if isinstance(input_audio_transcription, dict):
|
||||
extra_body["realtime_input_audio_transcription"] = dict(input_audio_transcription)
|
||||
if isinstance(session_payload.get("input_audio_noise_reduction"), dict):
|
||||
extra_body["realtime_input_audio_noise_reduction"] = dict(session_payload["input_audio_noise_reduction"])
|
||||
if isinstance(audio_input, dict) and isinstance(audio_input.get("noise_reduction"), dict):
|
||||
extra_body["realtime_input_audio_noise_reduction"] = dict(audio_input["noise_reduction"])
|
||||
if isinstance(session_payload.get("audio"), dict):
|
||||
extra_body["realtime_audio"] = dict(session_payload["audio"])
|
||||
noise_reduction = session_payload.get("input_audio_noise_reduction")
|
||||
if isinstance(noise_reduction, dict):
|
||||
extra_body["realtime_input_audio_noise_reduction"] = dict(noise_reduction)
|
||||
audio_noise_reduction = audio_input.get("noise_reduction") if isinstance(audio_input, dict) else None
|
||||
if isinstance(audio_noise_reduction, dict):
|
||||
extra_body["realtime_input_audio_noise_reduction"] = dict(audio_noise_reduction)
|
||||
audio = session_payload.get("audio")
|
||||
if isinstance(audio, dict):
|
||||
extra_body["realtime_audio"] = dict(audio)
|
||||
if isinstance(session_payload.get("tracing"), str | dict):
|
||||
extra_body["realtime_tracing"] = session_payload["tracing"]
|
||||
response_format = self._duplex_response_format(self._output_audio_format)
|
||||
@@ -1032,10 +1075,14 @@ class RealtimeInputTranslator:
|
||||
def _validate_realtime_video_frames(video_frames: object, max_slice_nums: object) -> str | None:
|
||||
"""Validate omni-duplex camera frames on input_audio_buffer.append.
|
||||
|
||||
Wire contract matches the official MiniCPM-o-Demo omni client: one
|
||||
base64 JPEG per ~1 s audio chunk. HD slicing (max_slice_nums > 1) is
|
||||
not implemented by the duplex adapter yet and is rejected explicitly
|
||||
rather than silently ignored.
|
||||
Wire contract matches the official MiniCPM-o duplex loop: one base
|
||||
base64 JPEG per ~1 s audio chunk, optionally followed by that unit's
|
||||
stacked composite tiling the sub-frames captured inside it (at most 2
|
||||
images either way). HD slicing (max_slice_nums > 1) is not implemented
|
||||
by the duplex adapter yet and is rejected explicitly rather than
|
||||
silently ignored; note official suggests it for composites
|
||||
(``max_slice_nums=[2, 1]``), so stacked detail is capped at
|
||||
scale_resolution until that lands.
|
||||
"""
|
||||
if max_slice_nums not in (None, 1):
|
||||
return "max_slice_nums > 1 (HD slicing) is not implemented by the duplex Realtime adapter"
|
||||
@@ -1416,10 +1463,11 @@ class RealtimeInputTranslator:
|
||||
self._input_speech_started = True
|
||||
if self._active_input_item_id is None:
|
||||
self._active_input_item_id = f"item_{uuid4().hex}"
|
||||
audio_start_ms = event.get("audio_start_ms", 0)
|
||||
await self._send_realtime_payload(
|
||||
{
|
||||
"type": "input_audio_buffer.speech_started",
|
||||
"audio_start_ms": int(event.get("audio_start_ms", 0) or 0),
|
||||
"audio_start_ms": int(audio_start_ms) if isinstance(audio_start_ms, int | float) else 0,
|
||||
"item_id": self._active_input_item_id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Intra-unit frame stacking for the omni duplex video track.
|
||||
|
||||
Official MiniCPM-o duplex raises the visual refresh rate without touching the
|
||||
audio cadence: ``get_video_frame_audio_segments(stack_frames=N)`` samples the
|
||||
``N-1`` sub-frames captured inside each second (skipping the one that would
|
||||
duplicate the second's base frame) and tiles them into a *single* composite
|
||||
image. The duplex loop then feeds ``frame_list=[base, composite]`` alongside
|
||||
that second's audio, so a unit still carries one second of audio and at most
|
||||
two images however large ``N`` is.
|
||||
|
||||
Tiling therefore belongs to media preprocessing, not to the wire: a caller
|
||||
turns its sub-frames into one composite here and sends it as the second frame
|
||||
of the append that closes the unit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pybase64 as base64
|
||||
|
||||
_BG_COLOR = (255, 255, 255)
|
||||
_LINE_COLOR = (0, 0, 0)
|
||||
_LINE_WIDTH = 6
|
||||
|
||||
|
||||
def _as_image(frame: Any) -> Any:
|
||||
"""Coerce a PIL image, encoded bytes, or base64/data-URL string to PIL RGB."""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(frame, Image.Image):
|
||||
return frame
|
||||
if isinstance(frame, (bytes, bytearray)):
|
||||
return Image.open(io.BytesIO(bytes(frame))).convert("RGB")
|
||||
if isinstance(frame, str):
|
||||
payload = frame.split(",")[-1] if ";base64," in frame else frame
|
||||
return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
|
||||
raise TypeError(f"unsupported frame type for stacking: {type(frame)}")
|
||||
|
||||
|
||||
def _grid_shape(images: Sequence[Any], *, cell_w: int, cell_h: int) -> tuple[int, int]:
|
||||
"""Rows and columns for ``images``, matching official ``concat_images``.
|
||||
|
||||
4 frames tile 2x2; 2 and 3 frames pick the row/column arrangement whose
|
||||
canvas is closest to square, so a landscape clip stacks vertically and a
|
||||
portrait one horizontally; anything else falls back to a single row.
|
||||
"""
|
||||
count = len(images)
|
||||
if count == 4:
|
||||
return 2, 2
|
||||
if count == 1:
|
||||
return 1, 1
|
||||
if count not in (2, 3):
|
||||
return 1, count
|
||||
|
||||
candidates = [(1, count), (count, 1)]
|
||||
|
||||
def squareness(rows: int, cols: int) -> float:
|
||||
width = cols * cell_w + (cols - 1) * _LINE_WIDTH
|
||||
height = rows * cell_h + (rows - 1) * _LINE_WIDTH
|
||||
return abs(width / max(1, height) - 1.0)
|
||||
|
||||
ratios = [squareness(rows, cols) for rows, cols in candidates]
|
||||
if count == 2 and ratios[0] == ratios[1]:
|
||||
average_aspect = sum(im.width / max(1, im.height) for im in images) / count
|
||||
return (1, 2) if average_aspect >= 1.0 else (2, 1)
|
||||
return candidates[min(range(len(candidates)), key=lambda index: ratios[index])]
|
||||
|
||||
|
||||
def _letterbox(image: Any, target_w: int, target_h: int) -> Any:
|
||||
from PIL import Image
|
||||
|
||||
image = image.convert("RGB")
|
||||
width, height = image.size
|
||||
scale = min(target_w / width, target_h / height)
|
||||
new_size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
||||
resized = image.resize(new_size, Image.Resampling.BICUBIC)
|
||||
canvas = Image.new("RGB", (target_w, target_h), _BG_COLOR)
|
||||
canvas.paste(resized, ((target_w - new_size[0]) // 2, (target_h - new_size[1]) // 2))
|
||||
return canvas
|
||||
|
||||
|
||||
def concat_frames(frames: Sequence[Any]) -> Any:
|
||||
"""Tile a unit's sub-frames into one PIL image, as official stacking does.
|
||||
|
||||
Cells are the largest input width and height, each frame is letterboxed to
|
||||
keep its aspect ratio, and a separator band is drawn only in the interior
|
||||
seams so the model can tell the sub-frames apart.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
images = [_as_image(frame) for frame in frames]
|
||||
if not images:
|
||||
raise ValueError("cannot stack an empty frame list")
|
||||
|
||||
cell_w = max(image.width for image in images)
|
||||
cell_h = max(image.height for image in images)
|
||||
rows, cols = _grid_shape(images, cell_w=cell_w, cell_h=cell_h)
|
||||
|
||||
canvas = Image.new(
|
||||
"RGB",
|
||||
(cols * cell_w + (cols - 1) * _LINE_WIDTH, rows * cell_h + (rows - 1) * _LINE_WIDTH),
|
||||
_LINE_COLOR,
|
||||
)
|
||||
for index, image in enumerate(images[: rows * cols]):
|
||||
row, col = divmod(index, cols)
|
||||
canvas.paste(
|
||||
_letterbox(image, cell_w, cell_h),
|
||||
(col * (cell_w + _LINE_WIDTH), row * (cell_h + _LINE_WIDTH)),
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def concat_frames_b64(frames: Sequence[Any], *, quality: int = 95) -> str:
|
||||
"""``concat_frames`` re-encoded as a base64 JPEG, ready for the wire."""
|
||||
buf = io.BytesIO()
|
||||
concat_frames(frames).save(buf, format="JPEG", quality=quality)
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def unit_subframe_offsets(stack_frames: int, *, unit_s: float = 1.0) -> list[float]:
|
||||
"""Sub-frame capture offsets inside a unit, in seconds from its start.
|
||||
|
||||
``stack_frames=5`` yields 0.2/0.4/0.6/0.8 s for a 1 s unit. Offset 0 is
|
||||
skipped because official skips it: it is the unit's base frame, already
|
||||
sent as ``frame_list[0]``.
|
||||
"""
|
||||
count = max(1, int(stack_frames))
|
||||
return [index / count * unit_s for index in range(1, count)]
|
||||
Reference in new issue
Block a user