[Test] Add qwen3-omni tests for audio_in_video and one word prompt (#2097)

Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
This commit is contained in:
wangyuandAlicia authored and GitHub committed 2026-03-31 14:30:12 +08:00
1 parent 1734c1ec23
commit c1a978ae8d
4 files changed
+331 -87

No files matched your search

+163 -38
View File
@@ -688,8 +688,112 @@ def generate_synthetic_audio(
return result
def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_file: bool = False) -> dict[str, Any]:
"""Generate synthetic video with bouncing balls and return base64 string."""
def _mux_mp4_bytes_with_synthetic_audio(
video_mp4_bytes: bytes,
*,
num_frames: int,
fps: float = 30.0,
sample_rate: int = 48000,
) -> bytes:
"""
Mux a video-only MP4 with mono TTS audio from :func:`generate_synthetic_audio` (AAC).
Audio length is at least the video duration in whole seconds (rounded up); ffmpeg
``-shortest`` trims to the video when the WAV is longer.
Uses ffmpeg from ``imageio_ffmpeg`` when available, else ``ffmpeg`` on PATH.
If TTS or mux fails, returns ``video_mp4_bytes`` unchanged.
Mux subprocess does **not** use ``capture_output=True``: ffmpeg can block writing
to a full stderr pipe while :func:`subprocess.run` waits for exit (classic deadlock).
"""
duration_sec = num_frames / fps if fps > 0 else 0.0
# generate_synthetic_audio(duration=int) uses at least 1s of buffer internally
duration_int = max(1, int(math.ceil(duration_sec)))
try:
audio_result = generate_synthetic_audio(
duration=duration_int,
num_channels=1,
sample_rate=sample_rate,
save_to_file=False,
)
audio_pcm = audio_result["np_array"]
except Exception as e:
logger.warning("Synthetic video: generate_synthetic_audio failed (%s); using video-only MP4.", e)
return video_mp4_bytes
try:
import imageio_ffmpeg
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
ffmpeg_exe = "ffmpeg"
import tempfile
try:
with tempfile.TemporaryDirectory(prefix="syn_vid_mux_") as tmp:
vid_path = os.path.join(tmp, "video.mp4")
wav_path = os.path.join(tmp, "audio.wav")
out_path = os.path.join(tmp, "out.mp4")
with open(vid_path, "wb") as f:
f.write(video_mp4_bytes)
sf.write(wav_path, audio_pcm, sample_rate, format="WAV", subtype="PCM_16")
cmd = [
ffmpeg_exe,
"-y",
"-nostdin",
"-hide_banner",
"-loglevel",
"error",
"-i",
vid_path,
"-i",
wav_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-movflags",
"+faststart",
out_path,
]
subprocess.run(
cmd,
check=True,
stdin=subprocess.DEVNULL,
timeout=300,
)
with open(out_path, "rb") as f:
return f.read()
except (
FileNotFoundError,
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
OSError,
) as e:
logger.warning("Synthetic video: audio mux failed (%s); using video-only MP4.", e)
return video_mp4_bytes
def generate_synthetic_video(
width: int,
height: int,
num_frames: int,
save_to_file: bool = False,
*,
embed_audio: bool = False,
) -> dict[str, Any]:
"""Generate synthetic video with bouncing balls and base64 MP4.
When ``embed_audio`` is True, muxes mono AAC from :func:`generate_synthetic_audio`
(TTS + ffmpeg) into the MP4; otherwise returns video-only MP4 (faster when tests do
not need an audio track).
"""
import cv2
import imageio
@@ -762,13 +866,13 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
result = {
"np_array": video_array,
}
video_bytes = None
saved_file_path = None
fps = 30
buffer = io.BytesIO()
writer_kwargs = {
"format": "mp4",
"fps": 30,
"fps": fps,
"codec": "libx264",
"quality": 7,
"pixelformat": "yuv420p",
@@ -787,32 +891,31 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
],
}
if save_to_file:
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"video_{width}x{height}_{timestamp}.mp4"
try:
with imageio.get_writer(output_path, **writer_kwargs) as writer:
for frame in video_frames:
writer.append_data(frame)
saved_file_path = output_path
print(f"Video saved to: {saved_file_path}")
with open(output_path, "rb") as f:
video_bytes = f.read()
except Exception as e:
print(f"Warning: Failed to save video to file {output_path}: {e}")
save_to_file = False
if not save_to_file or video_bytes is None:
try:
with imageio.get_writer(buffer, **writer_kwargs) as writer:
for frame in video_frames:
writer.append_data(frame)
buffer.seek(0)
video_bytes = buffer.read()
video_only_bytes = buffer.read()
except Exception as e:
print(f"Warning: Failed to encode synthetic video: {e}")
raise
if embed_audio:
video_bytes = _mux_mp4_bytes_with_synthetic_audio(video_only_bytes, num_frames=num_frames, fps=float(fps))
else:
video_bytes = video_only_bytes
if save_to_file:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"video_{width}x{height}_{timestamp}.mp4"
try:
with open(output_path, "wb") as f:
f.write(video_bytes)
saved_file_path = output_path
print(f"Video saved to: {saved_file_path}")
except Exception as e:
print(f"Warning: Failed to save video to file {output_path}: {e}")
base64_video = base64.b64encode(video_bytes).decode("utf-8")
@@ -1262,10 +1365,12 @@ def modify_stage_config(
# Direct top-level key
config[key] = value
# Save to new file with timestamp
timestamp = int(time.time())
# Unique suffix: multiple modify_stage_config calls in one process often run
# within the same second (e.g. test_qwen3_omni_expansion imports both
# get_chunk_config and get_batch_token_config). int(time.time()) would collide
# and the later write would overwrite the earlier YAML on disk.
base_name = yaml_path.rsplit(".", 1)[0] if "." in yaml_path else yaml_path
output_path = f"{base_name}_{timestamp}.yaml"
output_path = f"{base_name}_{time.time_ns()}.yaml"
with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
@@ -1733,9 +1838,11 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
"The output does not contain any of the keywords."
)
# Verify similarity
# Verify similarity (Whisper transcript vs streamed/detokenized text)
if "text" in modalities and "audio" in modalities:
assert response.similarity > 0.9, "The audio content is not same as the text"
assert response.similarity is not None and response.similarity > 0.9, (
"The audio content is not same as the text"
)
print(f"similarity is: {response.similarity}")
@@ -2135,7 +2242,11 @@ class OpenAIClientHandler:
Send OpenAI requests.
Args:
request_config: Request configuration dictionary containing parameters like model, messages, stream
request_config: Request configuration dictionary containing parameters like model, messages, stream.
Optional ``use_audio_in_video`` (bool): when true, sets
``extra_body["mm_processor_kwargs"] = {"use_audio_in_video": True}`` for Qwen-Omni video+audio
extraction (merged with any existing ``extra_body`` / ``mm_processor_kwargs``).
Optional ``extra_body`` (dict): passed through to ``chat.completions.create`` after merge.
request_num: Number of requests, defaults to 1 (single request)
Returns:
@@ -2146,14 +2257,28 @@ class OpenAIClientHandler:
stream = request_config.get("stream", False)
modalities = request_config.get("modalities", ["text", "audio"])
extra_body: dict[str, Any] = {}
raw_extra = request_config.get("extra_body")
if raw_extra:
extra_body.update(raw_extra)
if request_config.get("use_audio_in_video"):
mm = dict(extra_body.get("mm_processor_kwargs") or {})
mm["use_audio_in_video"] = True
extra_body["mm_processor_kwargs"] = mm
extra_body_arg: dict[str, Any] | None = extra_body if extra_body else None
create_kwargs: dict[str, Any] = {
"model": request_config.get("model"),
"messages": request_config.get("messages"),
"stream": stream,
"modalities": modalities,
}
if extra_body_arg is not None:
create_kwargs["extra_body"] = extra_body_arg
if request_num == 1:
# Send single request
chat_completion = self.client.chat.completions.create(
model=request_config.get("model"),
messages=request_config.get("messages"),
stream=stream,
modalities=modalities,
)
chat_completion = self.client.chat.completions.create(**create_kwargs)
if stream:
response = self._process_stream_omni_response(chat_completion)