[Benchmark]Omni-modality model accuracy benchmark(Daily-Omni & seed-tts-eval) (#2558)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
a782ae4780
commit
227bab3038
10 files changed
+3041
-9
No files matched your search
@@ -61,6 +61,17 @@ demo = [
|
||||
"gradio>=6.7.0",
|
||||
]
|
||||
|
||||
# Seed-TTS serve benchmark WER (BytedanceSpeech/seed-tts-eval run_wer.py protocol).
|
||||
seed-tts-eval = [
|
||||
"jiwer>=3.0.0",
|
||||
"zhon>=2.0.0",
|
||||
"zhconv>=1.4.2",
|
||||
"scipy>=1.10.0",
|
||||
"soundfile>=0.12.0",
|
||||
"transformers>=4.36.0",
|
||||
"funasr>=1.0.0",
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs>=1.5.0",
|
||||
"mkdocs-api-autonav",
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
"""Daily-Omni Dataset loader for benchmark.
|
||||
|
||||
Daily-Omni is an audio-visual reasoning benchmark with 684 videos
|
||||
and 1,197 multiple-choice QA pairs across 6 major task types.
|
||||
|
||||
Dataset source: https://huggingface.co/datasets/liarliar/Daily-Omni
|
||||
|
||||
Supports loading QA metadata from:
|
||||
- Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
|
||||
- HuggingFace datasets (``dataset_path``): legacy online mode
|
||||
|
||||
The videos must be separately downloaded and extracted from Videos.tar.
|
||||
|
||||
Why ``BenchmarkDataset`` instead of ``HuggingFaceDataset``?
|
||||
vLLM's ``HuggingFaceDataset`` is a thin wrapper whose ``__init__`` always ends by calling
|
||||
``load_data()`` → ``datasets.load_dataset(...)`` with a required Hub id and split. That
|
||||
contract fits "Hub-only" benches, but Daily-Omni also needs **offline QA metadata** from a
|
||||
local ``qa.json`` without touching the network. Subclassing ``HuggingFaceDataset`` would
|
||||
mean fighting the parent constructor (fake ``dataset_path``, reordering ``load_data``, or
|
||||
duplicating half the parent) and would still imply ``datasets`` is always relevant.
|
||||
|
||||
This class therefore inherits only ``BenchmarkDataset`` (minimal: ``dataset_path``,
|
||||
``random_seed``, ``self.data``) and implements **two explicit loaders**:
|
||||
``_load_from_local_json`` (default path for air-gapped runs) and ``_load_from_huggingface``
|
||||
(optional legacy path for users who prefer ``datasets`` + Hub cache). The latter is **not**
|
||||
inheritance; it is the same Hub rows as before, factored into a helper so one class can
|
||||
serve both deployment modes without mandatory ``datasets`` when using ``qa_json_path``.
|
||||
|
||||
Usage:
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset
|
||||
|
||||
# Local JSON mode (recommended)
|
||||
dataset = DailyOmniDataset(
|
||||
qa_json_path="/path/to/qa.json",
|
||||
video_dir="/path/to/Videos",
|
||||
random_seed=42,
|
||||
)
|
||||
|
||||
# HuggingFace mode (legacy, requires network)
|
||||
dataset = DailyOmniDataset(
|
||||
dataset_path="liarliar/Daily-Omni",
|
||||
dataset_split="train",
|
||||
random_seed=42,
|
||||
)
|
||||
requests = dataset.sample(
|
||||
tokenizer=tokenizer,
|
||||
num_requests=100,
|
||||
output_len=256,
|
||||
)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
try:
|
||||
from vllm.benchmarks.datasets import BenchmarkDataset, SampleRequest
|
||||
except ImportError:
|
||||
# Fallback: if BenchmarkDataset not available, use base class from same module
|
||||
from vllm.benchmarks.datasets import HuggingFaceDataset as BenchmarkDataset
|
||||
from vllm.benchmarks.datasets import SampleRequest
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tokenizers.hf import get_cached_tokenizer
|
||||
|
||||
try:
|
||||
from datasets import load_dataset
|
||||
except ImportError:
|
||||
load_dataset = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ListDatasetIterator:
|
||||
"""Simple iterator wrapper around a list to mimic HuggingFace streaming dataset behavior."""
|
||||
|
||||
def __init__(self, data: list[dict[str, Any]]) -> None:
|
||||
self._data = data
|
||||
self._index = 0
|
||||
|
||||
def __iter__(self):
|
||||
self._index = 0
|
||||
return self
|
||||
|
||||
def __next__(self) -> dict[str, Any]:
|
||||
if self._index >= len(self._data):
|
||||
raise StopIteration
|
||||
item = self._data[self._index]
|
||||
self._index += 1
|
||||
return item
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def __getitem__(self, idx: int | slice) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return self._data[idx]
|
||||
|
||||
|
||||
# Aligns with Lliar-liar/Daily-Omni CLI ``--input_mode`` (test_model/*/testmodel.py).
|
||||
DailyOmniInputMode = Literal["all", "visual", "audio"]
|
||||
|
||||
# ``build_conversation()`` in Daily-Omni ``test_model/Qwen2.5-Omni/testmodel.py`` (verbatim).
|
||||
DAILY_OMNI_SYSTEM_TEXT = (
|
||||
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
|
||||
"capable of perceiving auditory and visual inputs, as well as generating text and speech."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyOmniSampleRequest(SampleRequest):
|
||||
"""``SampleRequest`` with Daily-Omni gold labels for post-run accuracy scoring."""
|
||||
|
||||
daily_omni_gold_answer: str = ""
|
||||
daily_omni_video_id: str = ""
|
||||
daily_omni_task_type: str = ""
|
||||
#: Official qa.json ``video_duration`` (e.g. ``30s``, ``60s``) for leaderboard-style breakdown.
|
||||
daily_omni_video_duration: str = ""
|
||||
#: Official ``video_category`` (YouTube-style category string) for per-category accuracy.
|
||||
daily_omni_video_category: str = ""
|
||||
#: Extra JSON fields merged into chat-completions ``extra_body`` (e.g. ``mm_processor_kwargs``).
|
||||
omni_extra_body: dict[str, Any] | None = None
|
||||
#: Full OpenAI ``messages`` (system + user) mirroring upstream Daily-Omni conversation.
|
||||
omni_chat_messages: list[dict[str, Any]] | None = None
|
||||
#: Used only when ``omni_chat_messages`` is None (non-Daily-Omni-style requests).
|
||||
omni_chat_mm_position: Literal["first", "last"] = "last"
|
||||
|
||||
|
||||
class DailyOmniDataset(BenchmarkDataset):
|
||||
"""Daily-Omni audio-visual QA dataset for benchmarking.
|
||||
|
||||
Inherits ``BenchmarkDataset`` only (not ``HuggingFaceDataset``): see module docstring for why
|
||||
Hub loading lives in ``_load_from_huggingface`` instead of subclassing the HF base class.
|
||||
|
||||
The dataset includes:
|
||||
- 684 videos from daily life scenarios (available in Videos.tar)
|
||||
- 1,197 multiple-choice QA pairs in qa.json
|
||||
- 6 major task categories
|
||||
|
||||
QA metadata can be loaded from:
|
||||
- Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
|
||||
- HuggingFace datasets (``dataset_path``): legacy online mode
|
||||
|
||||
The videos must be separately downloaded and extracted from Videos.tar.
|
||||
|
||||
Args:
|
||||
qa_json_path: Path to local qa.json file (offline mode, preferred). When provided,
|
||||
``dataset_path`` and ``dataset_split`` are ignored.
|
||||
dataset_path: HuggingFace dataset path (e.g., "liarliar/Daily-Omni"). Used only if
|
||||
``qa_json_path`` is not provided (legacy online mode).
|
||||
dataset_split: Dataset split to use (default: "train"). Used only in online mode.
|
||||
random_seed: Random seed for shuffling
|
||||
video_dir: Directory containing extracted video files (default: None)
|
||||
input_mode: Which modalities to send, matching upstream Daily-Omni ``--input_mode``:
|
||||
``all`` — video + WAV (default; official audio-visual protocol);
|
||||
``visual`` — video only;
|
||||
``audio`` — extracted WAV only (requires ``{video_id}/{video_id}_audio.wav`` under ``video_dir``).
|
||||
max_duration_seconds: Reserved for future ffprobe-based filtering; currently **not applied**
|
||||
when building requests (metadata ``video_duration`` is still passed through for eval).
|
||||
dataset_subset: Optional HuggingFace subset name (``load_dataset(..., name=...)``); used by bench
|
||||
``--hf-subset`` / patch.
|
||||
no_stream: If True, load the Hub split non-streaming (matches bench ``--no-stream``).
|
||||
inline_local_video: If True, embed local MP4 as ``data:video/mp4;base64,...`` in requests so
|
||||
the API server does not need ``--allowed-local-media-path`` (large JSON; use for small runs).
|
||||
When ``input_mode`` is ``audio`` or ``all``, local WAV is embedded the same way
|
||||
(``data:audio/wav;base64,...``).
|
||||
trust_remote_code: Whether to trust remote code when loading HuggingFace dataset
|
||||
(online mode only).
|
||||
"""
|
||||
|
||||
SUPPORTED_DATASET_PATHS: set[str] = {
|
||||
"liarliar/Daily-Omni",
|
||||
}
|
||||
#: Default Hub id for synthetic video URLs when ``qa_json_path`` is used (``dataset_path`` None).
|
||||
DEFAULT_HF_DATASET_ID = "liarliar/Daily-Omni"
|
||||
IS_MULTIMODAL = True
|
||||
DEFAULT_OUTPUT_LEN = 256
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
qa_json_path: str | None = None,
|
||||
dataset_path: str | None = None,
|
||||
dataset_split: str = "train",
|
||||
random_seed: int = 0,
|
||||
video_dir: str | None = None,
|
||||
input_mode: DailyOmniInputMode = "all",
|
||||
inline_local_video: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
max_duration_seconds: float | None = None,
|
||||
dataset_subset: str | None = None,
|
||||
no_stream: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if input_mode not in ("all", "visual", "audio"):
|
||||
raise ValueError(f"input_mode must be 'all', 'visual', or 'audio', got {input_mode!r}")
|
||||
|
||||
# Validate arguments: need either local JSON or HF path
|
||||
if qa_json_path is None and dataset_path is None:
|
||||
raise ValueError(
|
||||
"Either 'qa_json_path' (local JSON) or 'dataset_path' (HuggingFace) must be provided. "
|
||||
"For offline/air-gapped environments, download qa.json and use qa_json_path."
|
||||
)
|
||||
|
||||
# Store configuration
|
||||
self.qa_json_path = Path(qa_json_path) if qa_json_path else None
|
||||
self.dataset_path = dataset_path
|
||||
self.dataset_split = dataset_split
|
||||
self.dataset_subset = dataset_subset
|
||||
#: Match vLLM ``HuggingFaceDataset`` / bench CLI ``--no-stream``.
|
||||
self._hf_streaming = not no_stream
|
||||
self.video_dir = Path(video_dir) if video_dir else None
|
||||
self.inline_local_video = inline_local_video
|
||||
self.input_mode: DailyOmniInputMode = input_mode
|
||||
self.max_duration_seconds = max_duration_seconds
|
||||
self.trust_remote_code = trust_remote_code
|
||||
|
||||
#: In-process cache of ffprobe durations only (no disk persistence).
|
||||
self._video_durations: dict[str, float] = {}
|
||||
|
||||
# Initialize parent BenchmarkDataset
|
||||
super().__init__(
|
||||
dataset_path=dataset_path if qa_json_path is None else None,
|
||||
random_seed=random_seed,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Load data based on mode
|
||||
self.load_data()
|
||||
|
||||
# Verify dataset info
|
||||
logger.info(
|
||||
"Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, input_mode=%s, max_duration=%s",
|
||||
"local_json" if self.qa_json_path else "huggingface",
|
||||
str(self.qa_json_path) if self.qa_json_path else f"{dataset_path}/{dataset_split}",
|
||||
random_seed,
|
||||
input_mode,
|
||||
f"{max_duration_seconds}s" if max_duration_seconds else "unlimited",
|
||||
)
|
||||
|
||||
def load_data(self) -> None:
|
||||
"""Populate ``self.data`` from either local JSON or the Hub.
|
||||
|
||||
See module docstring: we do not subclass ``HuggingFaceDataset`` because Daily-Omni needs
|
||||
a first-class offline path; Hub loading is an optional branch implemented below.
|
||||
"""
|
||||
if self.qa_json_path is not None:
|
||||
self._load_from_local_json()
|
||||
else:
|
||||
self._load_from_huggingface()
|
||||
|
||||
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}")
|
||||
|
||||
with open(self.qa_json_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Support both list format and dict with "train"/"test" splits
|
||||
if isinstance(data, dict):
|
||||
# Try to get the requested split, fallback to first available
|
||||
split_data = data.get(self.dataset_split)
|
||||
if split_data is None:
|
||||
available = list(data.keys())
|
||||
if available:
|
||||
logger.warning(
|
||||
"Split '%s' not found in %s, using '%s' instead",
|
||||
self.dataset_split,
|
||||
self.qa_json_path,
|
||||
available[0],
|
||||
)
|
||||
split_data = data[available[0]]
|
||||
else:
|
||||
split_data = []
|
||||
data = split_data
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"Expected list of QA items in JSON, got {type(data).__name__}")
|
||||
|
||||
# Shuffle if requested
|
||||
if not getattr(self, "disable_shuffle", False) and self.random_seed is not None:
|
||||
import random
|
||||
|
||||
rng = random.Random(self.random_seed)
|
||||
shuffled = data[:]
|
||||
rng.shuffle(shuffled)
|
||||
data = shuffled
|
||||
|
||||
# Create an iterator-like wrapper for compatibility
|
||||
self.data = _ListDatasetIterator(data)
|
||||
|
||||
def _load_from_huggingface(self) -> None:
|
||||
"""Load QA rows via ``datasets.load_dataset`` (legacy / convenience path).
|
||||
|
||||
Kept for backward compatibility: callers can still pass ``dataset_path=liarliar/Daily-Omni``
|
||||
and get the same parquet-backed rows as the Hub dataset card, with streaming (or
|
||||
non-streaming if ``no_stream=True``) and shuffle.
|
||||
|
||||
This is intentionally **not** implemented by subclassing ``HuggingFaceDataset``: that base
|
||||
always runs Hub ``load_dataset`` from its constructor and expects a Hub id as the primary
|
||||
API; Daily-Omni instead chooses the source in ``load_data()`` (JSON vs Hub) while sharing
|
||||
one ``sample()`` / request-building implementation for both.
|
||||
"""
|
||||
if load_dataset is None:
|
||||
raise ImportError(
|
||||
"datasets library is required for HuggingFace mode. "
|
||||
"Install with: pip install datasets, or use local JSON mode instead."
|
||||
)
|
||||
|
||||
ds = load_dataset(
|
||||
self.dataset_path,
|
||||
name=self.dataset_subset,
|
||||
split=self.dataset_split,
|
||||
streaming=self._hf_streaming,
|
||||
trust_remote_code=self.trust_remote_code,
|
||||
)
|
||||
if not getattr(self, "disable_shuffle", False):
|
||||
ds = ds.shuffle(seed=self.random_seed)
|
||||
self.data = ds
|
||||
|
||||
def get_task_statistics(self) -> dict[str, int]:
|
||||
"""Get distribution of task types in the dataset.
|
||||
|
||||
Returns:
|
||||
Dict mapping task type to count
|
||||
"""
|
||||
stats: dict[str, int] = {}
|
||||
for item in self.data:
|
||||
row = self._coerce_row(item)
|
||||
fields = self._normalize_qa_fields(row)
|
||||
task_type = fields["task_type"] or "unknown"
|
||||
stats[task_type] = stats.get(task_type, 0) + 1
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
def _coerce_row(item: Any) -> dict[str, Any]:
|
||||
"""Turn a dataset row into a plain dict (Arrow / Mapping)."""
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
if hasattr(item, "as_py"):
|
||||
return dict(item.as_py()) # pyarrow Row
|
||||
try:
|
||||
return dict(item)
|
||||
except (TypeError, ValueError):
|
||||
return {k: item[k] for k in item} # type: ignore[misc]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_qa_fields(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map official Daily-Omni qa.json / Hub schema to internal fields.
|
||||
|
||||
Official fields (see liarliar/Daily-Omni ``qa.json``): ``Question``, ``Choice`` (list),
|
||||
``Answer``, ``video_id``, ``Type``, ``video_duration`` (``30s`` / ``60s``), ``video_category``,
|
||||
plus other category columns. Legacy aliases (lowercase / older loaders) are still accepted.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
out["question"] = str(row.get("Question") or row.get("question") or "").strip()
|
||||
vid = row.get("video_id") if row.get("video_id") is not None else row.get("video")
|
||||
out["video_id"] = str(vid).strip() if vid is not None else ""
|
||||
out["task_type"] = str(row.get("Type") or row.get("task_type") or row.get("type") or "").strip()
|
||||
vc = row.get("video_category") if row.get("video_category") is not None else row.get("videoCategory")
|
||||
out["video_category"] = str(vc).strip() if vc is not None else ""
|
||||
vd = row.get("video_duration") if row.get("video_duration") is not None else row.get("videoDuration")
|
||||
out["video_duration"] = str(vd).strip() if vd is not None else ""
|
||||
out["answer"] = str(row.get("Answer") or row.get("answer") or "").strip()
|
||||
vu = row.get("video_url") if row.get("video_url") is not None else row.get("Video_URL")
|
||||
out["video_url"] = str(vu).strip() if vu is not None and str(vu).strip() else None
|
||||
|
||||
choice = row.get("Choice")
|
||||
if choice is None:
|
||||
choice = row.get("options") or row.get("choice")
|
||||
out["choice"] = choice
|
||||
|
||||
return out
|
||||
|
||||
def sample(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
num_requests: int,
|
||||
output_len: int | None = None,
|
||||
request_id_prefix: str = "",
|
||||
no_oversample: bool = False,
|
||||
**kwargs,
|
||||
) -> list[SampleRequest]:
|
||||
"""Sample requests from Daily-Omni dataset.
|
||||
|
||||
Args:
|
||||
tokenizer: Tokenizer for computing prompt length
|
||||
num_requests: Number of requests to sample
|
||||
output_len: Target output length in tokens (default: 256)
|
||||
request_id_prefix: Prefix for request IDs
|
||||
no_oversample: If True, do not oversample if fewer examples available
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
List of SampleRequest objects with video URLs and prompts
|
||||
"""
|
||||
if output_len is None:
|
||||
output_len = self.DEFAULT_OUTPUT_LEN
|
||||
|
||||
sampled_requests: list[SampleRequest] = []
|
||||
ind = 0
|
||||
cached_tokenizer = get_cached_tokenizer(tokenizer)
|
||||
|
||||
# Iterate over shuffled dataset
|
||||
for item in self.data:
|
||||
if len(sampled_requests) >= num_requests:
|
||||
break
|
||||
|
||||
request = self._create_sample_request(
|
||||
self._coerce_row(item), cached_tokenizer, output_len, request_id_prefix, ind
|
||||
)
|
||||
if request:
|
||||
sampled_requests.append(request)
|
||||
ind += 1
|
||||
|
||||
logger.info("Created %d sample requests from Daily-Omni dataset", len(sampled_requests))
|
||||
|
||||
# Handle oversampling if needed
|
||||
self.maybe_oversample_requests(sampled_requests, num_requests, request_id_prefix, no_oversample)
|
||||
|
||||
return sampled_requests
|
||||
|
||||
def _create_sample_request(
|
||||
self,
|
||||
qa_item: dict[str, Any],
|
||||
tokenizer: TokenizerLike,
|
||||
output_len: int,
|
||||
request_id_prefix: str,
|
||||
index: int,
|
||||
) -> SampleRequest | None:
|
||||
"""Create a SampleRequest from a QA item.
|
||||
|
||||
Args:
|
||||
qa_item: QA pair from the dataset
|
||||
tokenizer: Tokenizer
|
||||
output_len: Target output length
|
||||
request_id_prefix: Prefix for request ID
|
||||
index: Request index
|
||||
|
||||
Returns:
|
||||
SampleRequest or None if invalid
|
||||
"""
|
||||
fields = self._normalize_qa_fields(qa_item)
|
||||
video_id = fields["video_id"]
|
||||
question = fields["question"]
|
||||
choice = fields["choice"]
|
||||
task_type = fields["task_type"]
|
||||
video_url = fields["video_url"]
|
||||
video_duration = fields.get("video_duration") or ""
|
||||
video_category = fields.get("video_category") or ""
|
||||
|
||||
if not video_id and not video_url:
|
||||
logger.warning("Skipping item: no video_id / video_url")
|
||||
return None
|
||||
|
||||
if not question:
|
||||
logger.warning("Skipping item: no question found")
|
||||
return None
|
||||
|
||||
# Official layout after extracting Videos.tar (see Lliar-liar/Daily-Omni test_model):
|
||||
# {video_base_dir}/{video_id}/{video_id}_video.mp4
|
||||
mm_payload, omni_extra, mm_pos = self._compose_daily_omni_multimodal(video_id, video_url)
|
||||
if not mm_payload:
|
||||
return None
|
||||
|
||||
messages = self._build_daily_omni_openai_messages(mm_payload, question, choice)
|
||||
user_text = self._official_daily_omni_user_prompt(question, choice)
|
||||
# Text-only length estimate (same as before: no MM token count in bench).
|
||||
prompt_len = len(tokenizer.encode(f"{DAILY_OMNI_SYSTEM_TEXT}\n{user_text}"))
|
||||
|
||||
return DailyOmniSampleRequest(
|
||||
prompt=user_text,
|
||||
prompt_len=prompt_len,
|
||||
expected_output_len=output_len,
|
||||
multi_modal_data=None,
|
||||
request_id=f"{request_id_prefix}{index}",
|
||||
daily_omni_gold_answer=fields["answer"],
|
||||
daily_omni_video_id=video_id,
|
||||
daily_omni_task_type=task_type,
|
||||
daily_omni_video_duration=video_duration,
|
||||
daily_omni_video_category=video_category,
|
||||
omni_extra_body=omni_extra,
|
||||
omni_chat_messages=messages,
|
||||
omni_chat_mm_position=mm_pos,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _official_video_relpath(video_id: str) -> str:
|
||||
"""Relative path inside extracted ``Videos/`` per upstream Daily-Omni scripts."""
|
||||
return f"{video_id}/{video_id}_video.mp4"
|
||||
|
||||
@staticmethod
|
||||
def _official_audio_relpath(video_id: str) -> str:
|
||||
"""Relative path for extracted WAV per upstream ``get_audio_path``."""
|
||||
return f"{video_id}/{video_id}_audio.wav"
|
||||
|
||||
def _resolve_local_video_path(self, video_id: str) -> Path | None:
|
||||
"""Pick an existing file under ``video_dir`` (official layout + flat fallback)."""
|
||||
if not self.video_dir or not video_id:
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
self.video_dir / self._official_video_relpath(video_id),
|
||||
self.video_dir / f"{video_id}.mp4", # flat layout (custom mirrors / outdated docs)
|
||||
]
|
||||
seen: set[Path] = set()
|
||||
for p in candidates:
|
||||
rp = p.resolve()
|
||||
if rp in seen:
|
||||
continue
|
||||
seen.add(rp)
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
def _resolve_local_audio_path(self, video_id: str) -> Path | None:
|
||||
"""Pick an existing WAV under ``video_dir`` (official layout + flat fallback)."""
|
||||
if not self.video_dir or not video_id:
|
||||
return None
|
||||
candidates = [
|
||||
self.video_dir / self._official_audio_relpath(video_id),
|
||||
self.video_dir / f"{video_id}.wav",
|
||||
]
|
||||
seen: set[Path] = set()
|
||||
for p in candidates:
|
||||
rp = p.resolve()
|
||||
if rp in seen:
|
||||
continue
|
||||
seen.add(rp)
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
def _local_file_to_video_url_payload(self, video_path: Path) -> dict[str, Any]:
|
||||
"""Build OpenAI-style video_url part for a resolved local file.
|
||||
|
||||
vLLM rejects ``file://`` unless the server was started with
|
||||
``--allowed-local-media-path`` set to a directory that **contains** the file
|
||||
(typically the extracted ``Videos`` root). Use ``inline_local_video=True`` to
|
||||
send base64 data URLs instead (no server path allowlist; larger requests).
|
||||
"""
|
||||
path = video_path.expanduser().resolve()
|
||||
if self.inline_local_video:
|
||||
raw = path.read_bytes()
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return {
|
||||
"type": "video_url",
|
||||
"video_url": {"url": f"data:video/mp4;base64,{b64}"},
|
||||
}
|
||||
return {
|
||||
"type": "video_url",
|
||||
"video_url": {"url": path.as_uri()},
|
||||
}
|
||||
|
||||
def _local_file_to_audio_url_payload(self, audio_path: Path) -> dict[str, Any]:
|
||||
"""Build OpenAI-style ``audio_url`` part for a resolved local WAV file."""
|
||||
path = audio_path.expanduser().resolve()
|
||||
if self.inline_local_video:
|
||||
raw = path.read_bytes()
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return {
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": f"data:audio/wav;base64,{b64}"},
|
||||
}
|
||||
return {
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": path.as_uri()},
|
||||
}
|
||||
|
||||
def _get_video_content(
|
||||
self,
|
||||
video_id: str,
|
||||
video_url: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve video for OpenAI-style ``video_url`` content.
|
||||
|
||||
Upstream uses ``get_video_path(video_id, base) -> base/video_id/video_id_video.mp4``.
|
||||
The Hub repo only publishes ``Videos.tar``; use ``--daily-omni-video-dir`` pointing
|
||||
at the extracted ``Videos`` folder (parent of per-``video_id`` subdirs).
|
||||
|
||||
For ``file://`` URLs, start ``vllm serve`` with e.g.
|
||||
``--allowed-local-media-path /same/path/as/daily-omni-video-dir``.
|
||||
"""
|
||||
if video_url:
|
||||
url = video_url
|
||||
if not url.startswith(("http://", "https://", "file://")):
|
||||
url = f"https://{url.lstrip('/')}"
|
||||
return {"type": "video_url", "video_url": {"url": url}}
|
||||
|
||||
if self.video_dir and video_id:
|
||||
video_path = self._resolve_local_video_path(video_id)
|
||||
if video_path is not None:
|
||||
return self._local_file_to_video_url_payload(video_path)
|
||||
logger.warning(
|
||||
"Video not found under video_dir=%s for video_id=%r (expected %s or %s)",
|
||||
self.video_dir,
|
||||
video_id,
|
||||
self._official_video_relpath(video_id),
|
||||
f"{video_id}.mp4",
|
||||
)
|
||||
|
||||
if video_id:
|
||||
repo = self.dataset_path or self.DEFAULT_HF_DATASET_ID
|
||||
rel = self._official_video_relpath(video_id)
|
||||
hf_video_url = f"https://huggingface.co/datasets/{repo}/resolve/main/Videos/{rel}"
|
||||
logger.debug(
|
||||
"Using HF video URL (likely 404 — Hub ships Videos.tar only): %s",
|
||||
hf_video_url,
|
||||
)
|
||||
return {"type": "video_url", "video_url": {"url": hf_video_url}}
|
||||
|
||||
logger.error("Could not determine video source for video_id=%r", video_id)
|
||||
return None
|
||||
|
||||
def _get_audio_content(self, video_id: str) -> dict[str, Any] | None:
|
||||
"""Resolve extracted WAV for OpenAI-style ``audio_url`` (local files only)."""
|
||||
if not self.video_dir or not video_id:
|
||||
logger.warning(
|
||||
"Daily-Omni input_mode %r requires --daily-omni-video-dir with %s",
|
||||
self.input_mode,
|
||||
self._official_audio_relpath(video_id),
|
||||
)
|
||||
return None
|
||||
audio_path = self._resolve_local_audio_path(video_id)
|
||||
if audio_path is not None:
|
||||
return self._local_file_to_audio_url_payload(audio_path)
|
||||
logger.warning(
|
||||
"Audio not found under video_dir=%s for video_id=%r (expected %s or %s)",
|
||||
self.video_dir,
|
||||
video_id,
|
||||
self._official_audio_relpath(video_id),
|
||||
f"{video_id}.wav",
|
||||
)
|
||||
return None
|
||||
|
||||
def _compose_daily_omni_multimodal(
|
||||
self,
|
||||
video_id: str,
|
||||
video_url: str | None,
|
||||
) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
|
||||
"""Build ``multi_modal_data`` and request extras for the active ``input_mode``.
|
||||
|
||||
Mirrors upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``.
|
||||
"""
|
||||
extra: dict[str, Any] = {"mm_processor_kwargs": {"use_audio_in_video": False}}
|
||||
mode = self.input_mode
|
||||
|
||||
if mode == "visual":
|
||||
v = self._get_video_content(video_id, video_url)
|
||||
return v, extra, "last"
|
||||
|
||||
if mode == "audio":
|
||||
a = self._get_audio_content(video_id)
|
||||
return a, extra, "first"
|
||||
|
||||
v = self._get_video_content(video_id, video_url)
|
||||
a = self._get_audio_content(video_id)
|
||||
if not v or not a:
|
||||
return None, None, "first"
|
||||
return [v, a], extra, "first"
|
||||
|
||||
@staticmethod
|
||||
def _media_desc_for_official_prompt(mode: DailyOmniInputMode) -> str:
|
||||
"""``media_desc`` in upstream ``build_conversation``."""
|
||||
if mode == "audio":
|
||||
return "given audio"
|
||||
if mode == "all":
|
||||
return "given video and audio together"
|
||||
return "given video"
|
||||
|
||||
@staticmethod
|
||||
def _choices_repr_for_official_prompt(choice: Any) -> str:
|
||||
"""Format ``Choice`` from qa.json for the model (one option per line when possible).
|
||||
|
||||
Using ``str(list)`` embeds Python list brackets and quotes, which is poor for MCQ
|
||||
reading; lists/tuples are joined with newlines instead. Other shapes fall back to
|
||||
``str(choice)`` for parity with exotic upstream payloads.
|
||||
"""
|
||||
if choice is None:
|
||||
return ""
|
||||
if isinstance(choice, (list, tuple)):
|
||||
lines = [str(x).strip() for x in choice if str(x).strip()]
|
||||
return "\n".join(lines)
|
||||
if isinstance(choice, dict):
|
||||
return "\n".join(f"{k}. {v}" for k, v in choice.items())
|
||||
return str(choice)
|
||||
|
||||
def _official_daily_omni_user_prompt(self, question: str, choice: Any) -> str:
|
||||
"""User text block from Daily-Omni ``build_conversation`` (after media parts)."""
|
||||
task_prompt = self._media_desc_for_official_prompt(self.input_mode)
|
||||
choices = self._choices_repr_for_official_prompt(choice)
|
||||
# Single f-string with explicit newlines avoids accidental implicit concatenation
|
||||
# gluing sentences (e.g. ``...media_desc.Select...``) when editing.
|
||||
return (
|
||||
"Your task is to accurately answer multiple-choice questions "
|
||||
f"based on the {task_prompt}.\n"
|
||||
"Select the single most accurate answer from the given choices.\n"
|
||||
f"Question: {question}\n"
|
||||
f"Choices: {choices}\n"
|
||||
"Your answer should be a capital letter representing your choice: "
|
||||
"A, B, C, or D. Don't generate any other text.\n"
|
||||
)
|
||||
|
||||
def _build_daily_omni_openai_messages(
|
||||
self,
|
||||
mm_payload: dict[str, Any] | list[dict[str, Any]],
|
||||
question: str,
|
||||
choice: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Map upstream conversation to OpenAI Chat Completions ``messages`` (video_url / audio_url parts)."""
|
||||
user_text = self._official_daily_omni_user_prompt(question, choice)
|
||||
mm_list: list[dict[str, Any]] = mm_payload if isinstance(mm_payload, list) else [mm_payload]
|
||||
user_content: list[dict[str, Any]] = [*mm_list, {"type": "text", "text": user_text}]
|
||||
return [
|
||||
{"role": "system", "content": [{"type": "text", "text": DAILY_OMNI_SYSTEM_TEXT}]},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
def sample_by_task_type(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
task_type: str,
|
||||
num_samples: int,
|
||||
output_len: int | None = None,
|
||||
request_id_prefix: str = "",
|
||||
**kwargs,
|
||||
) -> list[SampleRequest]:
|
||||
"""Sample requests filtered by task type.
|
||||
|
||||
Args:
|
||||
tokenizer: Tokenizer
|
||||
task_type: Task type to filter by
|
||||
num_samples: Number of samples
|
||||
output_len: Target output length
|
||||
request_id_prefix: Prefix for request IDs
|
||||
**kwargs: Additional sampling arguments
|
||||
|
||||
Returns:
|
||||
List of SampleRequest objects matching the task type
|
||||
"""
|
||||
if output_len is None:
|
||||
output_len = self.DEFAULT_OUTPUT_LEN
|
||||
|
||||
filtered = [
|
||||
item for item in self.data if self._normalize_qa_fields(self._coerce_row(item))["task_type"] == task_type
|
||||
]
|
||||
|
||||
available = len(filtered)
|
||||
if available < num_samples:
|
||||
logger.warning(
|
||||
"Only %d samples available for task type '%s', requested %d",
|
||||
available,
|
||||
task_type,
|
||||
num_samples,
|
||||
)
|
||||
num_samples = available
|
||||
|
||||
sampled_requests: list[SampleRequest] = []
|
||||
cached_tokenizer = get_cached_tokenizer(tokenizer)
|
||||
|
||||
for i, item in enumerate(filtered[:num_samples]):
|
||||
request = self._create_sample_request(item, cached_tokenizer, output_len, request_id_prefix, i)
|
||||
if request:
|
||||
sampled_requests.append(request)
|
||||
|
||||
return sampled_requests
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"DailyOmniDataset("
|
||||
f"dataset_path={self.dataset_path!r}, "
|
||||
f"dataset_split={self.dataset_split!r}, "
|
||||
f"video_dir={self.video_dir!r}, "
|
||||
f"input_mode={self.input_mode!r}, "
|
||||
f"inline_local_video={self.inline_local_video!r}, "
|
||||
f"max_duration_seconds={self.max_duration_seconds}, "
|
||||
f"random_seed={self.random_seed}"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def load_daily_omni_dataset(
|
||||
qa_json_path: str | None = None,
|
||||
dataset_path: str | None = None,
|
||||
dataset_split: str = "train",
|
||||
random_seed: int = 0,
|
||||
video_dir: str | None = None,
|
||||
input_mode: DailyOmniInputMode = "all",
|
||||
max_duration_seconds: float | None = None,
|
||||
dataset_subset: str | None = None,
|
||||
no_stream: bool = False,
|
||||
**kwargs,
|
||||
) -> DailyOmniDataset:
|
||||
"""Convenience function to load Daily-Omni dataset.
|
||||
|
||||
Args:
|
||||
qa_json_path: Path to local qa.json file (recommended for offline/air-gapped environments).
|
||||
When provided, ``dataset_path`` is ignored.
|
||||
dataset_path: HuggingFace dataset path (default: liarliar/Daily-Omni). Used only if
|
||||
``qa_json_path`` is not provided (legacy online mode).
|
||||
dataset_split: Dataset split to use (default: "train")
|
||||
random_seed: Random seed for shuffling
|
||||
video_dir: Directory containing extracted ``Videos/`` tree (MP4 and, for ``all``/``audio``, WAV)
|
||||
input_mode: ``visual`` | ``audio`` | ``all`` (same semantics as upstream Daily-Omni)
|
||||
max_duration_seconds: Maximum video duration in seconds (e.g., 30 for 30s subset, 60 for 60s subset);
|
||||
uses ffprobe on local files under ``video_dir`` (in-memory cache only for this process).
|
||||
**kwargs: Additional arguments passed to DailyOmniDataset
|
||||
|
||||
Returns:
|
||||
DailyOmniDataset instance
|
||||
|
||||
Example:
|
||||
>>> from vllm_omni.benchmarks.data_modules.daily_omni_dataset import load_daily_omni_dataset
|
||||
|
||||
# Local JSON mode (recommended for offline)
|
||||
>>> dataset = load_daily_omni_dataset(
|
||||
... qa_json_path="/path/to/qa.json",
|
||||
... video_dir="/path/to/Daily-Omni/Videos",
|
||||
... random_seed=42,
|
||||
... max_duration_seconds=30,
|
||||
... )
|
||||
|
||||
# HuggingFace mode (legacy online)
|
||||
>>> dataset = load_daily_omni_dataset(
|
||||
... dataset_path="liarliar/Daily-Omni",
|
||||
... video_dir="/path/to/Daily-Omni/Videos",
|
||||
... random_seed=42,
|
||||
... )
|
||||
>>> requests = dataset.sample(tokenizer, num_requests=100)
|
||||
"""
|
||||
return DailyOmniDataset(
|
||||
qa_json_path=qa_json_path,
|
||||
dataset_path=dataset_path,
|
||||
dataset_split=dataset_split,
|
||||
random_seed=random_seed,
|
||||
video_dir=video_dir,
|
||||
input_mode=input_mode,
|
||||
max_duration_seconds=max_duration_seconds,
|
||||
dataset_subset=dataset_subset,
|
||||
no_stream=no_stream,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def get_daily_omni_statistics(
|
||||
qa_json_path: str | None = None,
|
||||
dataset_path: str | None = DailyOmniDataset.DEFAULT_HF_DATASET_ID,
|
||||
dataset_split: str = "train",
|
||||
) -> dict[str, Any]:
|
||||
"""Get statistics about the Daily-Omni dataset.
|
||||
|
||||
Args:
|
||||
qa_json_path: Path to local qa.json file (recommended for offline/air-gapped environments).
|
||||
When provided, ``dataset_path`` is ignored.
|
||||
dataset_path: HuggingFace dataset path. Defaults to ``DailyOmniDataset.DEFAULT_HF_DATASET_ID``
|
||||
when ``qa_json_path`` is omitted. Pass ``None`` only together with ``qa_json_path``.
|
||||
dataset_split: Dataset split to use (default: "train")
|
||||
|
||||
Returns:
|
||||
Statistics dict with task type distribution and other info
|
||||
|
||||
Example:
|
||||
>>> from vllm_omni.benchmarks.data_modules.daily_omni_dataset import get_daily_omni_statistics
|
||||
|
||||
# Local JSON mode
|
||||
>>> stats = get_daily_omni_statistics(qa_json_path="/path/to/qa.json")
|
||||
|
||||
# HuggingFace mode
|
||||
>>> stats = get_daily_omni_statistics(dataset_path="liarliar/Daily-Omni")
|
||||
>>> print(f"Total QA pairs: {stats['total_qa_pairs']}")
|
||||
>>> print(f"Task distribution: {stats['task_distribution']}")
|
||||
"""
|
||||
dataset = DailyOmniDataset(
|
||||
qa_json_path=qa_json_path,
|
||||
dataset_path=dataset_path,
|
||||
dataset_split=dataset_split,
|
||||
)
|
||||
task_stats = dataset.get_task_statistics()
|
||||
|
||||
source = str(qa_json_path) if qa_json_path else f"{dataset_path}/{dataset_split}"
|
||||
return {
|
||||
"source": source,
|
||||
"total_qa_pairs": len(list(dataset.data)),
|
||||
"task_distribution": task_stats,
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Daily-Omni multiple-choice accuracy scoring for vLLM-Omni bench serve.
|
||||
|
||||
Compares model ``generated_text`` to dataset ``Answer`` (A/B/C/D).
|
||||
|
||||
**Alignment with open-source** (`Lliar-liar/Daily-Omni` ``test_model/.../testmodel.py``):
|
||||
|
||||
- Answer extraction defaults to the same rules as ``extract_choice_letter`` (strip after an
|
||||
``assistant`` marker, then leading ``A``–``D``, else first ``\\b[A-D]\\b``). Set env
|
||||
``DAILY_OMNI_EXTRACT_MODE=relaxed`` to use the older vLLM-Omni heuristics (last ``answer:``,
|
||||
tail scan, etc.).
|
||||
- Overall accuracy comparable to the official script uses **successful HTTP responses only** as
|
||||
the denominator (their ``valid_questions = total - failed`` excludes inference / I/O skips).
|
||||
We also report ``daily_omni_accuracy_incl_http_fail`` where each failed request counts as a
|
||||
wrong answer in the denominator (stricter throughput-bench view).
|
||||
- **By video length:** mirrors upstream ``--- Accuracy by Video Duration ---`` for ``30s`` /
|
||||
``60s`` (``qa.json`` ``video_duration``): ``daily_omni_per_duration*`` metrics and a printed block.
|
||||
- **By video category:** mirrors ``--- Accuracy by Video Category ---`` using ``video_category``
|
||||
from ``qa.json`` (``daily_omni_per_category*``; empty category is bucketed as ``unknown``).
|
||||
- **Correctness:** uses the same ``evaluate_answer`` rule as upstream (truthy extracted letter vs
|
||||
raw ``Answer`` string, both ``strip().upper()``). Rows with empty ``Answer`` are skipped
|
||||
(``no_gold``), matching missing-field skips in the official loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncOutput
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest
|
||||
|
||||
_VALID = frozenset("ABCD")
|
||||
|
||||
# Official ``testmodel.py`` buckets (``qa.json`` ``video_duration``).
|
||||
DAILY_OMNI_DURATION_KEYS: tuple[str, ...] = ("30s", "60s")
|
||||
|
||||
|
||||
def extract_choice_letter_official(text: str | None) -> str | None:
|
||||
"""Port of Daily-Omni ``extract_choice_letter`` (first A–D, assistant-tail semantics)."""
|
||||
if not text:
|
||||
return None
|
||||
raw = str(text).strip()
|
||||
if not raw:
|
||||
return None
|
||||
match = re.search(r"assistant\s*([\s\S]*)$", raw, flags=re.IGNORECASE)
|
||||
candidate = match.group(1).strip() if match else raw
|
||||
direct = re.match(r"(?i)^\s*([A-D])(?:[\s\.\)::]|$)", candidate)
|
||||
if direct:
|
||||
return direct.group(1).upper()
|
||||
fallback = re.search(r"\b([A-D])\b", candidate.upper())
|
||||
if fallback:
|
||||
return fallback.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_answer_official(model_answer: str | None, correct_answer: str) -> bool:
|
||||
"""Port of Daily-Omni ``evaluate_answer`` (strict string match after strip/upper)."""
|
||||
if not model_answer:
|
||||
return False
|
||||
return model_answer.strip().upper() == (correct_answer or "").strip().upper()
|
||||
|
||||
|
||||
def normalize_gold_answer(gold: str) -> str | None:
|
||||
"""Best-effort single letter from ``Answer`` (for ``gold_normalized`` in saved items only)."""
|
||||
g = (gold or "").strip().upper()
|
||||
if len(g) == 1 and g in _VALID:
|
||||
return g
|
||||
m = re.search(r"([ABCD])\b", g)
|
||||
if m:
|
||||
return m.group(1).upper()
|
||||
return None
|
||||
|
||||
|
||||
def _extract_predicted_choice_relaxed(text: str) -> str | None:
|
||||
"""Legacy vLLM-Omni heuristics (last ``answer:`` patterns, tail scan)."""
|
||||
if not text or not str(text).strip():
|
||||
return None
|
||||
t = str(text).strip()
|
||||
|
||||
strong_patterns = [
|
||||
r"(?i)\*\*answer\*\*\s*[::]?\s*\(?([ABCD])\)?",
|
||||
r"(?i)\banswer\s*[::]?\s*\(?([ABCD])\)?",
|
||||
r"(?i)\bfinal\s+answer\s*[::]?\s*\(?([ABCD])\)?",
|
||||
r"(?i)\bcorrect\s+(?:answer|option)\s*[::]?\s*\(?([ABCD])\)?",
|
||||
r"(?i)\bthe\s+(?:correct\s+)?option\s+(?:is|would\s+be)\s*\(?([ABCD])\)?",
|
||||
r"(?i)\bI\s+(?:would\s+)?(?:choose|select|pick)\s*\(?([ABCD])\)?",
|
||||
]
|
||||
last_letter: str | None = None
|
||||
for pat in strong_patterns:
|
||||
for m in re.finditer(pat, t):
|
||||
last_letter = m.group(1).upper()
|
||||
if last_letter:
|
||||
return last_letter
|
||||
|
||||
# Weaker phrases: first match can be spurious; still prefer last occurrence.
|
||||
weak_patterns = [
|
||||
r"(?i)\boption\s*[::]?\s*\(?([ABCD])\)?",
|
||||
r"(?i)\bchoice\s*[::]?\s*\(?([ABCD])\)?",
|
||||
]
|
||||
for pat in weak_patterns:
|
||||
for m in re.finditer(pat, t):
|
||||
last_letter = m.group(1).upper()
|
||||
if last_letter:
|
||||
return last_letter
|
||||
|
||||
paren = list(re.finditer(r"\(([ABCD])\)", t))
|
||||
if paren:
|
||||
return paren[-1].group(1).upper()
|
||||
|
||||
# First line sometimes is just "B" or "B." — allow if whole output is short
|
||||
one_line = t.split("\n", 1)[0].strip()
|
||||
if len(t) < 120 and len(one_line) <= 6:
|
||||
m0 = re.match(r"^([ABCD])\s*[.:\)]?\s*$", one_line, re.I)
|
||||
if m0:
|
||||
return m0.group(1).upper()
|
||||
|
||||
# Tail-only: avoids matching echoed "A. ..." option blocks at the start
|
||||
tail_len = min(500, len(t))
|
||||
tail = t[-tail_len:]
|
||||
# ``\b`` after the letter avoids "Because"/"Definitely" false positives
|
||||
m = re.search(r"(?:^|[^\w])([ABCD])\b", tail, re.I)
|
||||
if m:
|
||||
return m.group(1).upper()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_predicted_choice(text: str | None) -> str | None:
|
||||
"""Parse model output to A–D (official Daily-Omni rules by default)."""
|
||||
if not text or not str(text).strip():
|
||||
return None
|
||||
mode = os.environ.get("DAILY_OMNI_EXTRACT_MODE", "official").strip().lower()
|
||||
if mode in ("relaxed", "heuristic", "legacy"):
|
||||
return _extract_predicted_choice_relaxed(str(text))
|
||||
return extract_choice_letter_official(text)
|
||||
|
||||
|
||||
def compute_daily_omni_accuracy_metrics(
|
||||
input_requests: list[Any],
|
||||
outputs: list[RequestFuncOutput],
|
||||
*,
|
||||
include_per_item: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""If all requests are :class:`DailyOmniSampleRequest`, compute accuracy stats.
|
||||
|
||||
Rows with empty ``Answer`` (after strip) are skipped as ``no_gold``, like upstream missing
|
||||
``correct_answer``.
|
||||
|
||||
**Denominators:** The open-source script excludes items that hit inference / I/O failures
|
||||
from ``valid_questions``; we mirror that with ``daily_omni_accuracy`` (= correct /
|
||||
successful responses). Failed HTTP requests are also tracked and used in
|
||||
``daily_omni_accuracy_incl_http_fail`` (each failure counts as incorrect in the
|
||||
denominator).
|
||||
"""
|
||||
if not input_requests or len(input_requests) != len(outputs):
|
||||
return None
|
||||
if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests):
|
||||
return None
|
||||
|
||||
# total / correct: all rows with gold (incl. HTTP fail in total)
|
||||
# total_ok / correct_ok: successful HTTP only (GitHub-style per-type denominator)
|
||||
per_task: dict[str, dict[str, int]] = {}
|
||||
per_category: dict[str, dict[str, int]] = {}
|
||||
per_duration: dict[str, dict[str, int]] = {
|
||||
k: {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0} for k in DAILY_OMNI_DURATION_KEYS
|
||||
}
|
||||
items: list[dict[str, Any]] = []
|
||||
correct = 0
|
||||
evaluated = 0
|
||||
no_gold = 0
|
||||
request_failed = 0
|
||||
parse_failed = 0 # success but could not extract A–D
|
||||
|
||||
for req, out in zip(input_requests, outputs, strict=True):
|
||||
assert isinstance(req, DailyOmniSampleRequest)
|
||||
gold_raw = (req.daily_omni_gold_answer or "").strip()
|
||||
gold_norm = normalize_gold_answer(req.daily_omni_gold_answer)
|
||||
tt = (req.daily_omni_task_type or "unknown").strip() or "unknown"
|
||||
dur_key = (req.daily_omni_video_duration or "").strip()
|
||||
dur_active = dur_key in per_duration
|
||||
cat_key = (req.daily_omni_video_category or "").strip() or "unknown"
|
||||
if tt not in per_task:
|
||||
per_task[tt] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0}
|
||||
if cat_key not in per_category:
|
||||
per_category[cat_key] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0}
|
||||
|
||||
if not gold_raw:
|
||||
no_gold += 1
|
||||
items.append(
|
||||
{
|
||||
"request_id": req.request_id,
|
||||
"skipped": True,
|
||||
"reason": "no_gold",
|
||||
"task_type": tt,
|
||||
"video_id": req.daily_omni_video_id,
|
||||
"video_duration": dur_key or None,
|
||||
"video_category": cat_key if cat_key != "unknown" else None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not out.success:
|
||||
request_failed += 1
|
||||
evaluated += 1
|
||||
per_task[tt]["total"] += 1
|
||||
per_category[cat_key]["total"] += 1
|
||||
if dur_active:
|
||||
per_duration[dur_key]["total"] += 1
|
||||
# GitHub: failed inference not in valid_questions — do not increment total_ok
|
||||
items.append(
|
||||
{
|
||||
"request_id": req.request_id,
|
||||
"gold": gold_raw,
|
||||
"gold_normalized": gold_norm,
|
||||
"predicted": None,
|
||||
"correct": False,
|
||||
"task_type": tt,
|
||||
"video_id": req.daily_omni_video_id,
|
||||
"video_duration": dur_key or None,
|
||||
"video_category": cat_key if cat_key != "unknown" else None,
|
||||
"error": (out.error or "")[:500],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
pred = extract_predicted_choice(out.generated_text)
|
||||
evaluated += 1
|
||||
per_task[tt]["total"] += 1
|
||||
per_task[tt]["total_ok"] += 1
|
||||
per_category[cat_key]["total"] += 1
|
||||
per_category[cat_key]["total_ok"] += 1
|
||||
if dur_active:
|
||||
per_duration[dur_key]["total"] += 1
|
||||
per_duration[dur_key]["total_ok"] += 1
|
||||
if pred is None:
|
||||
parse_failed += 1
|
||||
is_correct = evaluate_answer_official(pred, req.daily_omni_gold_answer)
|
||||
if is_correct:
|
||||
correct += 1
|
||||
per_task[tt]["correct"] += 1
|
||||
per_task[tt]["correct_ok"] += 1
|
||||
per_category[cat_key]["correct"] += 1
|
||||
per_category[cat_key]["correct_ok"] += 1
|
||||
if dur_active:
|
||||
per_duration[dur_key]["correct"] += 1
|
||||
per_duration[dur_key]["correct_ok"] += 1
|
||||
|
||||
items.append(
|
||||
{
|
||||
"request_id": req.request_id,
|
||||
"gold": gold_raw,
|
||||
"gold_normalized": gold_norm,
|
||||
"predicted": pred,
|
||||
"correct": is_correct,
|
||||
"parse_failed": pred is None,
|
||||
"task_type": tt,
|
||||
"video_id": req.daily_omni_video_id,
|
||||
"video_duration": dur_key or None,
|
||||
"video_category": cat_key if cat_key != "unknown" else None,
|
||||
}
|
||||
)
|
||||
|
||||
evaluated_ok = evaluated - request_failed
|
||||
accuracy_github = (correct / evaluated_ok) if evaluated_ok else None
|
||||
accuracy_incl_fail = (correct / evaluated) if evaluated else None
|
||||
|
||||
per_task_accuracy: dict[str, float | None] = {}
|
||||
per_task_accuracy_github: dict[str, float | None] = {}
|
||||
for name, st in per_task.items():
|
||||
tot = st["total"]
|
||||
per_task_accuracy[name] = (st["correct"] / tot) if tot else None
|
||||
tok = st["total_ok"]
|
||||
per_task_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
|
||||
|
||||
per_category_accuracy: dict[str, float | None] = {}
|
||||
per_category_accuracy_github: dict[str, float | None] = {}
|
||||
for name, st in per_category.items():
|
||||
tot = st["total"]
|
||||
per_category_accuracy[name] = (st["correct"] / tot) if tot else None
|
||||
tok = st["total_ok"]
|
||||
per_category_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
|
||||
|
||||
per_duration_accuracy: dict[str, float | None] = {}
|
||||
per_duration_accuracy_github: dict[str, float | None] = {}
|
||||
for name, st in per_duration.items():
|
||||
tot = st["total"]
|
||||
per_duration_accuracy[name] = (st["correct"] / tot) if tot else None
|
||||
tok = st["total_ok"]
|
||||
per_duration_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
|
||||
|
||||
out: dict[str, Any] = {
|
||||
# Comparable to GitHub testmodel.py: correct / successful inferences
|
||||
"daily_omni_accuracy": accuracy_github,
|
||||
"daily_omni_accuracy_incl_http_fail": accuracy_incl_fail,
|
||||
"daily_omni_correct": correct,
|
||||
"daily_omni_evaluated": evaluated,
|
||||
"daily_omni_evaluated_ok": evaluated_ok,
|
||||
"daily_omni_no_gold": no_gold,
|
||||
"daily_omni_request_failed": request_failed,
|
||||
"daily_omni_parse_failed": parse_failed,
|
||||
"daily_omni_per_task": {k: dict(v) for k, v in per_task.items()},
|
||||
"daily_omni_per_task_accuracy": per_task_accuracy,
|
||||
"daily_omni_per_task_accuracy_github_style": per_task_accuracy_github,
|
||||
"daily_omni_per_category": {k: dict(v) for k, v in per_category.items()},
|
||||
"daily_omni_per_category_accuracy": per_category_accuracy,
|
||||
"daily_omni_per_category_accuracy_github_style": per_category_accuracy_github,
|
||||
"daily_omni_per_duration": {k: dict(v) for k, v in per_duration.items()},
|
||||
"daily_omni_per_duration_accuracy": per_duration_accuracy,
|
||||
"daily_omni_per_duration_accuracy_github_style": per_duration_accuracy_github,
|
||||
}
|
||||
if include_per_item:
|
||||
out["daily_omni_eval_items"] = items
|
||||
return out
|
||||
|
||||
|
||||
def print_daily_omni_accuracy_summary(metrics: dict[str, Any]) -> None:
|
||||
"""Pretty-print accuracy block (stdout)."""
|
||||
acc = metrics.get("daily_omni_accuracy")
|
||||
acc_fail = metrics.get("daily_omni_accuracy_incl_http_fail")
|
||||
if acc is None and acc_fail is None and metrics.get("daily_omni_evaluated", 0) == 0:
|
||||
return
|
||||
print("{s:{c}^{n}}".format(s=" Daily-Omni accuracy (MCQ) ", n=50, c="="))
|
||||
ok = int(metrics.get("daily_omni_evaluated_ok", 0) or 0)
|
||||
cor = int(metrics.get("daily_omni_correct", 0) or 0)
|
||||
if ok > 0 and acc is not None:
|
||||
print(f"Overall Accuracy: {cor}/{ok} = {acc:.2%}")
|
||||
elif int(metrics.get("daily_omni_evaluated", 0) or 0) > 0:
|
||||
print("Overall Accuracy: 0/0 = N/A (no successful HTTP responses)")
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"Submitted (gold present):",
|
||||
metrics.get("daily_omni_evaluated", 0),
|
||||
)
|
||||
)
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"Successful HTTP (GitHub denom.):",
|
||||
metrics.get("daily_omni_evaluated_ok", 0),
|
||||
)
|
||||
)
|
||||
print("{:<40} {:<10}".format("Correct:", metrics.get("daily_omni_correct", 0)))
|
||||
if acc is not None:
|
||||
print("{:<40} {:<10.4f}".format("Accuracy (ratio, same as above):", acc))
|
||||
if acc_fail is not None and metrics.get("daily_omni_request_failed", 0):
|
||||
print(
|
||||
"{:<40} {:<10.4f}".format(
|
||||
"Accuracy (incl. HTTP as wrong):",
|
||||
acc_fail,
|
||||
)
|
||||
)
|
||||
print("{:<40} {:<10}".format("Skipped (no gold):", metrics.get("daily_omni_no_gold", 0)))
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"HTTP failed (excl. from GitHub acc.):",
|
||||
metrics.get("daily_omni_request_failed", 0),
|
||||
)
|
||||
)
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"Parsed OK but no A–D found:",
|
||||
metrics.get("daily_omni_parse_failed", 0),
|
||||
)
|
||||
)
|
||||
pt = metrics.get("daily_omni_per_task") or {}
|
||||
pta = metrics.get("daily_omni_per_task_accuracy_github_style") or {}
|
||||
if pta:
|
||||
print("\n--- Accuracy by QA Type ---")
|
||||
for name in sorted(pta.keys()):
|
||||
a = pta[name]
|
||||
st = pt.get(name) or {}
|
||||
tok = int(st.get("total_ok", 0) or 0)
|
||||
cok = int(st.get("correct_ok", 0) or 0)
|
||||
if tok and a is not None:
|
||||
print(f"{name}: {cok}/{tok} = {a:.2%}")
|
||||
else:
|
||||
print(f"{name}: 0/0 = N/A")
|
||||
|
||||
pc = metrics.get("daily_omni_per_category") or {}
|
||||
ptc = metrics.get("daily_omni_per_category_accuracy_github_style") or {}
|
||||
if ptc:
|
||||
print("\n--- Accuracy by Video Category ---")
|
||||
for name in sorted(ptc.keys()):
|
||||
a = ptc[name]
|
||||
st = pc.get(name) or {}
|
||||
tok = int(st.get("total_ok", 0) or 0)
|
||||
cok = int(st.get("correct_ok", 0) or 0)
|
||||
if tok and a is not None:
|
||||
print(f"{name}: {cok}/{tok} = {a:.2%}")
|
||||
else:
|
||||
print(f"{name}: 0/0 = N/A")
|
||||
|
||||
pdf = metrics.get("daily_omni_per_duration_accuracy_github_style") or {}
|
||||
if pdf:
|
||||
print("\n--- Accuracy by Video Duration ---")
|
||||
for name in DAILY_OMNI_DURATION_KEYS:
|
||||
a = pdf.get(name)
|
||||
st = (metrics.get("daily_omni_per_duration") or {}).get(name) or {}
|
||||
tok = int(st.get("total_ok", 0) or 0)
|
||||
cor = int(st.get("correct_ok", 0) or 0)
|
||||
if tok and a is not None:
|
||||
print(f"{name} Duration: {cor}/{tok} = {a:.2%}")
|
||||
else:
|
||||
print(f"{name} Duration: 0/0 = N/A")
|
||||
print("=" * 50)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Daily-Omni: optional consistency check between text stream and generated speech.
|
||||
|
||||
The benchmark MCQ accuracy uses ``generated_text`` only. When the omni server also
|
||||
streams ``modality=audio`` (TTS), this module can transcribe the concatenated WAV
|
||||
with Whisper and compare the inferred option letter to the one parsed from text.
|
||||
|
||||
Requires ``openai-whisper`` (``pip install openai-whisper``). Enable via env
|
||||
``DAILY_OMNI_TEXT_AUDIO_CONSISTENCY=1`` or CLI ``--daily-omni-text-audio-consistency``.
|
||||
|
||||
Whisper model name defaults to ``tiny`` (override with ``DAILY_OMNI_WHISPER_MODEL``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_eval import extract_predicted_choice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_whisper_model = None
|
||||
_whisper_model_name: str | None = None
|
||||
_whisper_lock = threading.Lock()
|
||||
|
||||
|
||||
def env_text_audio_check_enabled() -> bool:
|
||||
return os.environ.get("DAILY_OMNI_TEXT_AUDIO_CONSISTENCY", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def extract_choice_from_asr_transcript(transcript: str) -> str | None:
|
||||
"""Parse A–D from ASR text; extends :func:`extract_predicted_choice` with spoken Chinese phrases."""
|
||||
c = extract_predicted_choice(transcript)
|
||||
if c:
|
||||
return c
|
||||
t = transcript or ""
|
||||
for pat in (
|
||||
r"(?i)选项\s*([ABCD])\b",
|
||||
r"(?i)选\s*([ABCD])\b",
|
||||
r"(?i)答案\s*是\s*([ABCD])\b",
|
||||
r"(?i)答案\s*([ABCD])\b",
|
||||
):
|
||||
m = re.search(pat, t)
|
||||
if m:
|
||||
return m.group(1).upper()
|
||||
return None
|
||||
|
||||
|
||||
def _get_whisper_model(model_name: str):
|
||||
global _whisper_model, _whisper_model_name
|
||||
with _whisper_lock:
|
||||
if _whisper_model is None or _whisper_model_name != model_name:
|
||||
import whisper
|
||||
|
||||
logger.warning(
|
||||
"Loading Whisper model %r for Daily-Omni text/audio consistency (one-time)...",
|
||||
model_name,
|
||||
)
|
||||
_whisper_model = whisper.load_model(model_name)
|
||||
_whisper_model_name = model_name
|
||||
return _whisper_model
|
||||
|
||||
|
||||
def transcribe_wav_bytes(
|
||||
wav_bytes: bytes,
|
||||
*,
|
||||
language: str | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Transcribe WAV bytes. Returns ``(transcript, error)`` — one of them is set.
|
||||
|
||||
Args:
|
||||
wav_bytes: RIFF WAV file bytes.
|
||||
language: Optional Whisper language code (e.g. ``en``, ``zh``); improves accuracy/latency.
|
||||
model_name: Override model id; else ``DAILY_OMNI_WHISPER_MODEL`` or ``tiny``.
|
||||
"""
|
||||
if not wav_bytes:
|
||||
return None, "empty_wav"
|
||||
if model_name is None or not str(model_name).strip():
|
||||
model_name = os.environ.get("DAILY_OMNI_WHISPER_MODEL") or "tiny"
|
||||
model_name = str(model_name).strip() or "tiny"
|
||||
path: str | None = None
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
model = _get_whisper_model(model_name)
|
||||
fd, path = tempfile.mkstemp(suffix=".wav")
|
||||
with os.fdopen(fd, "wb") as fp:
|
||||
fp.write(wav_bytes)
|
||||
kwargs: dict = {}
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
result = model.transcribe(path, **kwargs)
|
||||
text = (result.get("text") or "").strip()
|
||||
return (text if text else None), None
|
||||
except ImportError:
|
||||
return None, "openai-whisper is not installed (pip install openai-whisper)"
|
||||
except Exception as e:
|
||||
return None, str(e)[:500]
|
||||
finally:
|
||||
if path:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def compute_daily_omni_text_audio_consistency_metrics(
|
||||
input_requests: list[Any],
|
||||
outputs: list[Any],
|
||||
*,
|
||||
include_per_item: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Compare option letter from ``generated_text`` vs Whisper transcript of output audio.
|
||||
|
||||
Only considers requests where ``outputs[i]`` has ``generated_audio_wav_bytes`` set
|
||||
(populated by the omni benchmark when TA check is enabled).
|
||||
"""
|
||||
if not input_requests or len(input_requests) != len(outputs):
|
||||
return None
|
||||
if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests):
|
||||
return None
|
||||
|
||||
ta_no_wav = 0
|
||||
ta_asr_failed = 0
|
||||
ta_text_unparsed = 0
|
||||
ta_audio_unparsed = 0
|
||||
ta_consistent = 0
|
||||
ta_mismatch = 0
|
||||
ta_both_parsed = 0
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
for req, out in zip(input_requests, outputs, strict=True):
|
||||
assert isinstance(req, DailyOmniSampleRequest)
|
||||
rid = req.request_id
|
||||
if not getattr(out, "success", False):
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"request_id": rid,
|
||||
"skipped": True,
|
||||
"reason": "request_not_success",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
wav = getattr(out, "generated_audio_wav_bytes", None)
|
||||
if not wav:
|
||||
ta_no_wav += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"request_id": rid,
|
||||
"skipped": False,
|
||||
"reason": "no_output_audio",
|
||||
"text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
transcript, asr_err = transcribe_wav_bytes(wav)
|
||||
if asr_err:
|
||||
ta_asr_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"request_id": rid,
|
||||
"asr_error": asr_err,
|
||||
"text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
text_choice = extract_predicted_choice(getattr(out, "generated_text", "") or "")
|
||||
audio_choice = extract_choice_from_asr_transcript(transcript or "")
|
||||
|
||||
if text_choice is None:
|
||||
ta_text_unparsed += 1
|
||||
if audio_choice is None:
|
||||
ta_audio_unparsed += 1
|
||||
|
||||
if text_choice is not None and audio_choice is not None:
|
||||
ta_both_parsed += 1
|
||||
if text_choice == audio_choice:
|
||||
ta_consistent += 1
|
||||
else:
|
||||
ta_mismatch += 1
|
||||
|
||||
if include_per_item:
|
||||
consistent: bool | None
|
||||
if text_choice is None or audio_choice is None:
|
||||
consistent = None
|
||||
else:
|
||||
consistent = text_choice == audio_choice
|
||||
items.append(
|
||||
{
|
||||
"request_id": rid,
|
||||
"text_choice": text_choice,
|
||||
"audio_choice": audio_choice,
|
||||
"asr_transcript": (transcript or "")[:500],
|
||||
"text_audio_consistent": consistent,
|
||||
}
|
||||
)
|
||||
|
||||
comparable = ta_consistent + ta_mismatch
|
||||
rate = (ta_consistent / comparable) if comparable else None
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"daily_omni_ta_enabled": True,
|
||||
"daily_omni_ta_no_output_audio": ta_no_wav,
|
||||
"daily_omni_ta_asr_failed": ta_asr_failed,
|
||||
"daily_omni_ta_text_unparsed": ta_text_unparsed,
|
||||
"daily_omni_ta_audio_unparsed": ta_audio_unparsed,
|
||||
"daily_omni_ta_both_parsed": ta_both_parsed,
|
||||
"daily_omni_ta_consistent": ta_consistent,
|
||||
"daily_omni_ta_mismatch": ta_mismatch,
|
||||
"daily_omni_ta_consistency_rate": rate,
|
||||
}
|
||||
if include_per_item:
|
||||
out["daily_omni_ta_items"] = items
|
||||
return out
|
||||
|
||||
|
||||
def print_daily_omni_text_audio_summary(metrics: dict[str, Any]) -> None:
|
||||
if not metrics.get("daily_omni_ta_enabled"):
|
||||
return
|
||||
print("{s:{c}^{n}}".format(s=" Daily-Omni text vs audio (ASR) ", n=50, c="="))
|
||||
print("{:<40} {:<10}".format("No output audio captured:", metrics.get("daily_omni_ta_no_output_audio", 0)))
|
||||
print("{:<40} {:<10}".format("ASR failed:", metrics.get("daily_omni_ta_asr_failed", 0)))
|
||||
print("{:<40} {:<10}".format("Both text+audio letter parsed:", metrics.get("daily_omni_ta_both_parsed", 0)))
|
||||
print("{:<40} {:<10}".format("Consistent (same letter):", metrics.get("daily_omni_ta_consistent", 0)))
|
||||
print("{:<40} {:<10}".format("Mismatch:", metrics.get("daily_omni_ta_mismatch", 0)))
|
||||
r = metrics.get("daily_omni_ta_consistency_rate")
|
||||
if r is not None:
|
||||
print("{:<40} {:<10.4f}".format("Consistency rate (of both parsed):", r))
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"Text unparsed (among w/ audio):",
|
||||
metrics.get("daily_omni_ta_text_unparsed", 0),
|
||||
)
|
||||
)
|
||||
print(
|
||||
"{:<40} {:<10}".format(
|
||||
"Audio unparsed (among w/ audio):",
|
||||
metrics.get("daily_omni_ta_audio_unparsed", 0),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Seed-TTS zero-shot evaluation-style prompts for ``vllm bench serve``.
|
||||
|
||||
Loads rows from the `meta.lst` format used in `BytedanceSpeech/seed-tts-eval`_ (or any
|
||||
HuggingFace dataset repo with the same layout)::
|
||||
|
||||
utt_id|prompt_transcript|prompt_wav_relative_path|text_to_synthesize
|
||||
|
||||
Each benchmark request supplies target text plus ``ref_text`` / ``ref_audio`` (Qwen3-TTS ``Base`` /
|
||||
voice clone), merged into the JSON body. By default ``ref_audio`` is an inline ``data:`` URL so
|
||||
the server does not need ``--allowed-local-media-path``. Use ``--seed-tts-file-ref-audio`` for
|
||||
``file://`` (smaller bodies; requires that flag). Use ``--backend openai-audio-speech``
|
||||
(``/v1/audio/speech``) or ``--backend openai-chat-omni`` (``/v1/chat/completions`` with the same
|
||||
fields on the body plus a Qwen3-Omni-style ``system`` message and the target text as ``user`` content).
|
||||
|
||||
.. _BytedanceSpeech/seed-tts-eval: https://github.com/BytedanceSpeech/seed-tts-eval
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from vllm.benchmarks.datasets import BenchmarkDataset, SampleRequest
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tokenizers.hf import get_cached_tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches Qwen3-Omni serving examples (``openai_chat_completion_client_for_multimodal_generation`` /
|
||||
# ``qwen3_omni/gradio_demo``) plus explicit TTS / voice-clone instructions for chat completions.
|
||||
SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT = (
|
||||
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
|
||||
"capable of perceiving auditory and visual inputs, as well as generating text and speech.\n"
|
||||
"For this request you act as a text-to-speech engine with zero-shot voice cloning: "
|
||||
"the API provides reference audio and its transcript (ref_audio, ref_text) and task_type Base. "
|
||||
"The user message is the exact text you must speak. "
|
||||
"Synthesize natural speech in the same language as that user text, "
|
||||
"matching the timbre, prosody, and speaking style of the reference audio while reading the new content clearly."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedTTSSampleRequest(SampleRequest):
|
||||
"""``SampleRequest`` with per-row fields merged into ``/v1/audio/speech`` JSON."""
|
||||
|
||||
#: Shallow-merged into ``RequestFuncInput.extra_body`` (ref_audio, ref_text, task_type, …).
|
||||
seed_tts_speech_extra: dict[str, Any] | None = None
|
||||
seed_tts_utterance_id: str = ""
|
||||
seed_tts_locale: str = ""
|
||||
#: For ``openai-chat-omni``: becomes the chat ``system`` message (Qwen3-Omni + TTS behavior).
|
||||
seed_tts_system_prompt: str = ""
|
||||
#: Local path to reference prompt WAV (for SIM vs. synthesized PCM in ``seed_tts_eval``).
|
||||
seed_tts_ref_wav_path: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SeedTTSRow:
|
||||
utterance_id: str
|
||||
ref_text: str
|
||||
prompt_wav_rel: str
|
||||
target_text: str
|
||||
|
||||
|
||||
def _parse_meta_line(line: str) -> _SeedTTSRow | None:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split("|")
|
||||
if len(parts) < 4:
|
||||
logger.warning("Skipping malformed meta.lst line (need 4 '|'-fields): %r", line[:120])
|
||||
return None
|
||||
utt_id, ref_text, wav_rel, target = parts[0], parts[1], parts[2], parts[3]
|
||||
if not target.strip():
|
||||
return None
|
||||
return _SeedTTSRow(
|
||||
utterance_id=utt_id.strip(),
|
||||
ref_text=ref_text.strip(),
|
||||
prompt_wav_rel=wav_rel.strip(),
|
||||
target_text=target.strip(),
|
||||
)
|
||||
|
||||
|
||||
def _load_meta_rows(meta_file: Path) -> list[_SeedTTSRow]:
|
||||
text = meta_file.read_text(encoding="utf-8")
|
||||
rows: list[_SeedTTSRow] = []
|
||||
for line in text.splitlines():
|
||||
r = _parse_meta_line(line)
|
||||
if r is not None:
|
||||
rows.append(r)
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_seed_tts_root(dataset_path: str | None, *, explicit_root: str | None) -> Path:
|
||||
"""Return directory containing ``{locale}/meta.lst`` and ``{locale}/prompt-wavs/``."""
|
||||
if explicit_root:
|
||||
root = Path(explicit_root).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f"--seed-tts-root is not a directory: {root}")
|
||||
return root
|
||||
|
||||
if not dataset_path:
|
||||
raise ValueError("Seed-TTS requires --dataset-path (HF repo id or local root) or --seed-tts-root.")
|
||||
|
||||
p = Path(dataset_path).expanduser()
|
||||
if p.exists() and p.is_dir():
|
||||
return p.resolve()
|
||||
|
||||
repo_id = dataset_path.strip()
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Install huggingface_hub to download Seed-TTS from the Hub, or clone the dataset "
|
||||
"locally and pass --dataset-path / --seed-tts-root to that directory."
|
||||
) from e
|
||||
cache = snapshot_download(repo_id=repo_id, repo_type="dataset")
|
||||
return Path(cache).resolve()
|
||||
|
||||
|
||||
def _ref_audio_payload(wav_path: Path, *, inline: bool) -> str:
|
||||
if inline:
|
||||
raw = wav_path.read_bytes()
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:audio/wav;base64,{b64}"
|
||||
return wav_path.expanduser().resolve().as_uri()
|
||||
|
||||
|
||||
class SeedTTSDataset(BenchmarkDataset):
|
||||
"""Seed-TTS-style zero-shot TTS rows for throughput/latency benchmarking.
|
||||
|
||||
Args:
|
||||
dataset_path: HuggingFace dataset repo id (``org/dataset``) or local directory with
|
||||
``en/meta.lst`` (and ``zh/meta.lst`` if using zh).
|
||||
locale: ``en`` or ``zh`` — which subfolder under the root to read.
|
||||
inline_ref_audio: If True (default), embed prompt WAV as ``data:audio/wav;base64,...``
|
||||
so Qwen3-TTS / ``/v1/audio/speech`` works without server
|
||||
``--allowed-local-media-path``. If False, use ``file://`` (smaller
|
||||
requests; server must set ``--allowed-local-media-path`` to the dataset root).
|
||||
seed_tts_root: Optional override for the root directory (same layout as HF dataset).
|
||||
system_prompt: Optional override for the chat system message when using
|
||||
``--backend openai-chat-omni``; defaults to :data:`SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT`.
|
||||
"""
|
||||
|
||||
IS_MULTIMODAL = False
|
||||
DEFAULT_OUTPUT_LEN = 2048
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset_path: str,
|
||||
random_seed: int = 0,
|
||||
locale: str = "en",
|
||||
inline_ref_audio: bool = True,
|
||||
seed_tts_root: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
disable_shuffle: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if locale not in ("en", "zh"):
|
||||
raise ValueError("locale must be 'en' or 'zh'")
|
||||
self.locale = locale
|
||||
self.inline_ref_audio = inline_ref_audio
|
||||
self._explicit_root = seed_tts_root
|
||||
sp = (system_prompt or "").strip()
|
||||
self._system_prompt = sp if sp else SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
|
||||
super().__init__(
|
||||
dataset_path=dataset_path,
|
||||
random_seed=random_seed,
|
||||
disable_shuffle=disable_shuffle,
|
||||
**kwargs,
|
||||
)
|
||||
self._root = resolve_seed_tts_root(self.dataset_path, explicit_root=self._explicit_root)
|
||||
self._rows: list[_SeedTTSRow] = []
|
||||
self.load_data()
|
||||
|
||||
def load_data(self) -> None:
|
||||
meta = self._root / self.locale / "meta.lst"
|
||||
if not meta.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Seed-TTS meta not found: {meta}. "
|
||||
f"Expected layout from seed-tts-eval (e.g. {self._root}/{self.locale}/meta.lst)."
|
||||
)
|
||||
self._rows = _load_meta_rows(meta)
|
||||
if not self._rows:
|
||||
raise ValueError(f"No valid rows in {meta}")
|
||||
if not self.disable_shuffle:
|
||||
rng = random.Random(self.random_seed)
|
||||
rng.shuffle(self._rows)
|
||||
self.data = self._rows
|
||||
logger.info(
|
||||
"Loaded Seed-TTS: root=%s locale=%s rows=%d inline_ref_audio=%s",
|
||||
self._root,
|
||||
self.locale,
|
||||
len(self._rows),
|
||||
self.inline_ref_audio,
|
||||
)
|
||||
|
||||
def sample(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
num_requests: int,
|
||||
output_len: int | None = None,
|
||||
request_id_prefix: str = "",
|
||||
no_oversample: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> list[SampleRequest]:
|
||||
if output_len is None:
|
||||
output_len = self.DEFAULT_OUTPUT_LEN
|
||||
|
||||
tok = get_cached_tokenizer(tokenizer)
|
||||
out: list[SampleRequest] = []
|
||||
for i, row in enumerate(self._rows):
|
||||
if len(out) >= num_requests:
|
||||
break
|
||||
wav_path = (self._root / self.locale / row.prompt_wav_rel).resolve()
|
||||
if not wav_path.is_file():
|
||||
logger.warning("Missing prompt wav for %s: %s", row.utterance_id, wav_path)
|
||||
continue
|
||||
|
||||
target = row.target_text
|
||||
prompt_len = len(tok.encode(f"{self._system_prompt}\n{target}"))
|
||||
lang = "English" if self.locale == "en" else "Chinese"
|
||||
ref_uri = _ref_audio_payload(wav_path, inline=self.inline_ref_audio)
|
||||
speech_extra: dict[str, Any] = {
|
||||
"ref_audio": ref_uri,
|
||||
"ref_text": row.ref_text,
|
||||
"task_type": "Base",
|
||||
"language": lang,
|
||||
"max_new_tokens": output_len,
|
||||
}
|
||||
|
||||
out.append(
|
||||
SeedTTSSampleRequest(
|
||||
prompt=target,
|
||||
prompt_len=prompt_len,
|
||||
expected_output_len=output_len,
|
||||
multi_modal_data=None,
|
||||
request_id=f"{request_id_prefix}{i}",
|
||||
seed_tts_speech_extra=speech_extra,
|
||||
seed_tts_utterance_id=row.utterance_id,
|
||||
seed_tts_locale=self.locale,
|
||||
seed_tts_system_prompt=self._system_prompt,
|
||||
seed_tts_ref_wav_path=str(wav_path),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Seed-TTS: built %d requests (asked %d)", len(out), num_requests)
|
||||
self.maybe_oversample_requests(out, num_requests, request_id_prefix, no_oversample)
|
||||
return out
|
||||
|
||||
|
||||
def load_seed_tts_dataset(
|
||||
dataset_path: str,
|
||||
random_seed: int = 0,
|
||||
locale: str = "en",
|
||||
inline_ref_audio: bool = True,
|
||||
seed_tts_root: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> SeedTTSDataset:
|
||||
return SeedTTSDataset(
|
||||
dataset_path=dataset_path,
|
||||
random_seed=random_seed,
|
||||
locale=locale,
|
||||
inline_ref_audio=inline_ref_audio,
|
||||
seed_tts_root=seed_tts_root,
|
||||
system_prompt=system_prompt,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Seed-TTS WER aligned with Bytedance ``seed-tts-eval`` / ``run_wer.py``.
|
||||
|
||||
Matches the published protocol (see Hugging Face dataset card and
|
||||
https://github.com/BytedanceSpeech/seed-tts-eval):
|
||||
|
||||
- **EN**: ``openai/whisper-large-v3`` via ``transformers``, audio resampled to **16 kHz**
|
||||
(same as ``run_wer.py``).
|
||||
- **ZH**: ``funasr`` **paraformer-zh**, hypothesis converted with **zhconv** to zh-cn.
|
||||
- **WER**: ``jiwer`` after punctuation stripping (``zhon.hanzi.punctuation`` + ``string.punctuation``,
|
||||
preserving ``'``) and EN lowercasing / ZH per-character spacing. Supports jiwer 3.x
|
||||
(``compute_measures``) and 4.x (``process_words``).
|
||||
|
||||
- **SIM** (speaker similarity proxy): cosine similarity of L2-normalized mean-pooled **WavLM**
|
||||
embeddings (reference prompt WAV vs. synthesized PCM), 16 kHz. Official ``cal_sim.sh`` uses
|
||||
UniSpeech ``verification_pair_list_v2.py`` with a **fine-tuned** WavLM SV checkpoint — set
|
||||
``SEED_TTS_WAVLM_MODEL`` to another HF id if you need closer parity. Disable with
|
||||
``SEED_TTS_SIM_EVAL=0``. Optional: ``SEED_TTS_SIM_DEVICE`` (e.g. ``cpu``) to avoid GPU
|
||||
issues when Whisper already uses CUDA; ``SEED_TTS_WAVLM_MIN_SAMPLES`` pads very short
|
||||
waveforms so the WavLM CNN front-end does not fail.
|
||||
|
||||
- **UTMOS** (predicted MOS from TorchScript): default ``balacoon/utmos`` → ``utmos.jit``
|
||||
(Sarulab-style demo export). Uses ``torch`` + ``huggingface_hub`` only. Aggregate metrics
|
||||
are over **all requests with captured PCM** (independent of ASR/WER). Non-finite scores are
|
||||
dropped and counted as failures. Override repo/file via ``SEED_TTS_UTMOS_HF_REPO`` /
|
||||
``SEED_TTS_UTMOS_JIT_FILE``. **Device**: defaults to **CPU** when ``SEED_TTS_UTMOS_DEVICE``
|
||||
is unset; set ``SEED_TTS_UTMOS_DEVICE=cuda:0`` (or ``cuda:1`` etc.) to run on GPU. The JIT
|
||||
model is loaded directly onto the target device via ``map_location`` to avoid cross-device
|
||||
issues (some PyTorch builds/Windows have problems moving TorchScript modules after load).
|
||||
Forward uses **float32** waveform in ``[-1, 1]`` (same as the WER resampled array) so
|
||||
tensor dtypes match JIT weights; using int16 triggers
|
||||
``RuntimeError: input type and weight type should be same`` on common exports. Disable
|
||||
with ``SEED_TTS_UTMOS_EVAL=0``.
|
||||
|
||||
Enable with ``SEED_TTS_WER_EVAL=1`` or ``--seed-tts-wer-eval``. Install optional deps::
|
||||
|
||||
pip install 'vllm-omni[seed-tts-eval]'
|
||||
|
||||
Env: ``SEED_TTS_EVAL_DEVICE`` (e.g. ``cuda:0``, ``cpu``); ``SEED_TTS_HF_WHISPER_MODEL``
|
||||
defaults to ``openai/whisper-large-v3`` (override for debugging only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import string
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from vllm.benchmarks.datasets import SampleRequest
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSSampleRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mirrors seed-tts-eval/run_wer.py
|
||||
OFFICIAL_WHISPER_HF_ID = "openai/whisper-large-v3"
|
||||
PARAFORMER_MODEL_ID = "paraformer-zh"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_device: str | None = None
|
||||
_en_processor = None
|
||||
_en_model = None
|
||||
_zh_paraformer = None
|
||||
_wavlm_model = None
|
||||
_wavlm_processor = None
|
||||
_wavlm_device: str | None = None
|
||||
_utmos_jit_model = None
|
||||
_utmos_jit_device: str | None = None
|
||||
_utmos_jit_load_failed = False
|
||||
_utmos_forward_warned = False
|
||||
|
||||
|
||||
def pcm_s16le_mono_to_wav_bytes(pcm: bytes, *, sample_rate: int = 24000) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _get_eval_device() -> str:
|
||||
explicit = os.environ.get("SEED_TTS_EVAL_DEVICE", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
import torch
|
||||
|
||||
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
||||
except ImportError:
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _punctuation_all() -> str:
|
||||
from zhon.hanzi import punctuation
|
||||
|
||||
return punctuation + string.punctuation
|
||||
|
||||
|
||||
def _jiwer_wer(reference: str, hypothesis: str) -> float:
|
||||
"""Word-level WER; strings are normalized like ``run_wer.process_one``.
|
||||
|
||||
jiwer 4.x removed ``compute_measures`` (``ImportError``); fall back to ``process_words``.
|
||||
"""
|
||||
try:
|
||||
from jiwer import compute_measures
|
||||
|
||||
return float(compute_measures(reference, hypothesis)["wer"])
|
||||
except ImportError:
|
||||
import jiwer
|
||||
|
||||
out = jiwer.process_words(reference, hypothesis)
|
||||
return float(out.wer)
|
||||
|
||||
|
||||
def process_one_official(hypo: str, truth: str, lang: str) -> tuple[float, str, str]:
|
||||
"""Same normalization + ``jiwer`` call as ``run_wer.process_one`` (hypo=ASR, truth=reference)."""
|
||||
raw_truth = truth
|
||||
raw_hypo = hypo
|
||||
truth_n = truth
|
||||
hypo_n = hypo
|
||||
for x in _punctuation_all():
|
||||
if x == "'":
|
||||
continue
|
||||
truth_n = truth_n.replace(x, "")
|
||||
hypo_n = hypo_n.replace(x, "")
|
||||
truth_n = truth_n.replace(" ", " ")
|
||||
hypo_n = hypo_n.replace(" ", " ")
|
||||
if lang == "zh":
|
||||
truth_n = " ".join([x for x in truth_n])
|
||||
hypo_n = " ".join([x for x in hypo_n])
|
||||
elif lang == "en":
|
||||
truth_n = truth_n.lower()
|
||||
hypo_n = hypo_n.lower()
|
||||
else:
|
||||
raise ValueError(f"unsupported lang {lang!r}")
|
||||
wer = _jiwer_wer(truth_n, hypo_n)
|
||||
return wer, raw_truth, raw_hypo
|
||||
|
||||
|
||||
def _pcm_s16le_to_f32_16k(pcm: bytes, pcm_sample_rate: int = 24000) -> np.ndarray:
|
||||
import scipy.signal
|
||||
|
||||
if not pcm:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
raw = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
target_len = int(len(raw) * 16000 / pcm_sample_rate)
|
||||
if target_len <= 0:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
return scipy.signal.resample(raw, target_len).astype(np.float32)
|
||||
|
||||
|
||||
def _eval_submetric_enabled(env_name: str, *, default: bool = True) -> bool:
|
||||
raw = os.environ.get(env_name, "").strip().lower()
|
||||
if raw in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if raw in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
return default
|
||||
|
||||
|
||||
def _audio_path_to_f32_16k(path: str) -> np.ndarray:
|
||||
import scipy.signal
|
||||
import soundfile as sf
|
||||
|
||||
data, sr = sf.read(path, dtype="float32", always_2d=True)
|
||||
mono = np.mean(data, axis=1).astype(np.float32)
|
||||
if int(sr) == 16000:
|
||||
return mono
|
||||
target_len = max(1, int(len(mono) * 16000 / int(sr)))
|
||||
return scipy.signal.resample(mono, target_len).astype(np.float32)
|
||||
|
||||
|
||||
def _ensure_wavlm_sim() -> None:
|
||||
global _wavlm_model, _wavlm_processor, _wavlm_device
|
||||
with _lock:
|
||||
if _wavlm_model is not None:
|
||||
return
|
||||
from transformers import AutoFeatureExtractor, AutoModel
|
||||
|
||||
mid = os.environ.get("SEED_TTS_WAVLM_MODEL", "microsoft/wavlm-base-plus").strip() or "microsoft/wavlm-base-plus"
|
||||
_wavlm_device = os.environ.get("SEED_TTS_SIM_DEVICE", "").strip() or _get_eval_device()
|
||||
logger.warning(
|
||||
"Loading WavLM %r on %s for Seed-TTS SIM (embedding cosine; not identical to "
|
||||
"seed-tts-eval UniSpeech SV checkpoint).",
|
||||
mid,
|
||||
_wavlm_device,
|
||||
)
|
||||
_wavlm_processor = AutoFeatureExtractor.from_pretrained(mid)
|
||||
_wavlm_model = AutoModel.from_pretrained(mid).to(_wavlm_device)
|
||||
_wavlm_model.eval()
|
||||
|
||||
|
||||
def _wavlm_prepare_waveform(wav: np.ndarray) -> np.ndarray:
|
||||
"""Trim, pad to a minimum length WavLM/Wav2Vec2 CNN stack accepts, float32 mono."""
|
||||
max_sec = float(os.environ.get("SEED_TTS_WAVLM_MAX_SECONDS", "30"))
|
||||
cap = int(max_sec * 16000)
|
||||
w = np.asarray(wav, dtype=np.float32).reshape(-1)
|
||||
if len(w) == 0:
|
||||
return w
|
||||
if len(w) > cap:
|
||||
w = w[:cap].copy()
|
||||
# Very short clips make the strided conv front-end fail (shape / empty time dim).
|
||||
min_samples = int(os.environ.get("SEED_TTS_WAVLM_MIN_SAMPLES", "4000"))
|
||||
if len(w) < min_samples:
|
||||
w = np.pad(w, (0, min_samples - len(w)), mode="constant")
|
||||
return w
|
||||
|
||||
|
||||
def _wavlm_mean_embedding_f32_16k(wav: np.ndarray) -> np.ndarray | None:
|
||||
import torch
|
||||
|
||||
_ensure_wavlm_sim()
|
||||
w = _wavlm_prepare_waveform(wav)
|
||||
if len(w) == 0:
|
||||
return None
|
||||
assert _wavlm_processor is not None and _wavlm_model is not None and _wavlm_device is not None
|
||||
# Single utterance: avoid padding=True (adds zeros that distort mean pooling). Still pass
|
||||
# attention_mask when the extractor provides it (sample-level; do not mix with hidden length).
|
||||
try:
|
||||
inputs = _wavlm_processor(
|
||||
w,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
padding=False,
|
||||
return_attention_mask=True,
|
||||
)
|
||||
except TypeError:
|
||||
inputs = _wavlm_processor(
|
||||
w,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
padding=False,
|
||||
)
|
||||
iv = inputs["input_values"].to(_wavlm_device)
|
||||
am = inputs.get("attention_mask")
|
||||
if am is not None:
|
||||
am = am.to(_wavlm_device)
|
||||
with torch.inference_mode():
|
||||
out = _wavlm_model(iv, attention_mask=am)
|
||||
h = out.last_hidden_state
|
||||
v = h.mean(dim=1).squeeze(0).float().cpu().numpy()
|
||||
n = float(np.linalg.norm(v))
|
||||
if not np.isfinite(n) or n < 1e-8:
|
||||
return None
|
||||
return (v / n).astype(np.float32)
|
||||
|
||||
|
||||
def _cosine_similarity_unit_vectors(a: np.ndarray, b: np.ndarray) -> float:
|
||||
return float(np.dot(a, b))
|
||||
|
||||
|
||||
def _ensure_utmos_jit_model() -> Any | None:
|
||||
"""Load UTMOS as TorchScript (``balacoon/utmos`` style): no ``import utmos`` / fairseq."""
|
||||
global _utmos_jit_model, _utmos_jit_device, _utmos_jit_load_failed
|
||||
with _lock:
|
||||
if _utmos_jit_load_failed:
|
||||
return None
|
||||
if _utmos_jit_model is not None:
|
||||
return _utmos_jit_model
|
||||
try:
|
||||
import torch
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
repo = os.environ.get("SEED_TTS_UTMOS_HF_REPO", "balacoon/utmos").strip() or "balacoon/utmos"
|
||||
fname = os.environ.get("SEED_TTS_UTMOS_JIT_FILE", "utmos.jit").strip() or "utmos.jit"
|
||||
logger.warning(
|
||||
"Loading UTMOS TorchScript from Hugging Face %r file %r (one-time download/cache)...",
|
||||
repo,
|
||||
fname,
|
||||
)
|
||||
path = hf_hub_download(repo_id=repo, filename=fname, repo_type="model")
|
||||
|
||||
# TODO The model weights in UTMOS must be loaded in cuda:0; otherwise, the model execution will fail.
|
||||
want = "cuda:0"
|
||||
if want.startswith("cuda") and torch.cuda.is_available():
|
||||
idx = want.split(":")[-1] if ":" in want else "0"
|
||||
target_dev = f"cuda:{idx}"
|
||||
else:
|
||||
target_dev = "cpu"
|
||||
|
||||
try:
|
||||
m = torch.jit.load(path, map_location=target_dev)
|
||||
m.eval()
|
||||
_utmos_jit_device = target_dev
|
||||
except Exception as load_e:
|
||||
if target_dev.startswith("cuda"):
|
||||
logger.warning(
|
||||
"UTMOS JIT load on %s failed (%s), retrying on CPU...",
|
||||
target_dev,
|
||||
load_e,
|
||||
)
|
||||
m = torch.jit.load(path, map_location="cpu")
|
||||
m.eval()
|
||||
_utmos_jit_device = "cpu"
|
||||
else:
|
||||
raise
|
||||
_utmos_jit_model = m
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"UTMOS JIT unavailable (install torch + huggingface_hub; check HF access): %s",
|
||||
e,
|
||||
)
|
||||
_utmos_jit_load_failed = True
|
||||
return None
|
||||
return _utmos_jit_model
|
||||
|
||||
|
||||
def _utmos_predict_f32_16k(wav_f32: np.ndarray) -> float | None:
|
||||
"""MOS from JIT model; input is float32 mono @ 16 kHz in ``[-1, 1]`` (WER pipeline).
|
||||
|
||||
``balacoon/utmos`` demos sometimes use int16 numpy, but the exported ``.jit`` weights are
|
||||
float32; passing int16 tensors causes: "RuntimeError: ... input type and weight type
|
||||
should be same".
|
||||
"""
|
||||
import torch
|
||||
|
||||
if len(wav_f32) == 0:
|
||||
return None
|
||||
model = _ensure_utmos_jit_model()
|
||||
if model is None:
|
||||
return None
|
||||
# Infer model's device from its first parameter/buffer to guarantee input sits with weights.
|
||||
try:
|
||||
model_dev = next(model.parameters()).device
|
||||
except StopIteration:
|
||||
try:
|
||||
model_dev = next(model.buffers()).device
|
||||
except StopIteration:
|
||||
model_dev = torch.device("cpu")
|
||||
w = np.ascontiguousarray(wav_f32, dtype=np.float32)
|
||||
x = torch.from_numpy(w).unsqueeze(0).to(device=model_dev, dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
out = model(x)
|
||||
val = float(out.reshape(-1)[0].item())
|
||||
if not math.isfinite(val):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def _ensure_en_asr() -> None:
|
||||
global _en_processor, _en_model, _device
|
||||
with _lock:
|
||||
if _en_processor is not None:
|
||||
return
|
||||
from transformers import WhisperForConditionalGeneration, WhisperProcessor
|
||||
|
||||
_device = _get_eval_device()
|
||||
mid = os.environ.get("SEED_TTS_HF_WHISPER_MODEL", OFFICIAL_WHISPER_HF_ID).strip() or OFFICIAL_WHISPER_HF_ID
|
||||
logger.warning(
|
||||
"Loading Seed-TTS eval Whisper HF model %r on %s (one-time, seed-tts-eval protocol)...",
|
||||
mid,
|
||||
_device,
|
||||
)
|
||||
_en_processor = WhisperProcessor.from_pretrained(mid)
|
||||
_en_model = WhisperForConditionalGeneration.from_pretrained(mid).to(_device)
|
||||
_en_model.eval()
|
||||
|
||||
|
||||
def _ensure_zh_asr() -> None:
|
||||
global _zh_paraformer, _device
|
||||
with _lock:
|
||||
if _zh_paraformer is not None:
|
||||
return
|
||||
from funasr import AutoModel
|
||||
|
||||
_device = _get_eval_device()
|
||||
logger.warning(
|
||||
"Loading Seed-TTS eval Paraformer %r on %s (one-time, seed-tts-eval protocol)...",
|
||||
PARAFORMER_MODEL_ID,
|
||||
_device,
|
||||
)
|
||||
try:
|
||||
_zh_paraformer = AutoModel(model=PARAFORMER_MODEL_ID, device=_device)
|
||||
except TypeError:
|
||||
_zh_paraformer = AutoModel(model=PARAFORMER_MODEL_ID)
|
||||
|
||||
|
||||
def _transcribe_en_f32_16k(wav_f32: np.ndarray) -> str:
|
||||
import torch
|
||||
|
||||
_ensure_en_asr()
|
||||
if len(wav_f32) == 0:
|
||||
return ""
|
||||
with _lock:
|
||||
assert _en_processor is not None and _en_model is not None and _device is not None
|
||||
inputs = _en_processor(wav_f32, sampling_rate=16000, return_tensors="pt")
|
||||
input_features = inputs.input_features.to(_device)
|
||||
with torch.no_grad():
|
||||
try:
|
||||
forced = _en_processor.get_decoder_prompt_ids(language="english", task="transcribe")
|
||||
predicted_ids = _en_model.generate(input_features, forced_decoder_ids=forced)
|
||||
except Exception:
|
||||
predicted_ids = _en_model.generate(
|
||||
input_features,
|
||||
language="english",
|
||||
task="transcribe",
|
||||
)
|
||||
text = _en_processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
|
||||
return (text or "").strip()
|
||||
|
||||
|
||||
def _transcribe_zh_wav_path(wav_path: str) -> str:
|
||||
import zhconv
|
||||
|
||||
_ensure_zh_asr()
|
||||
with _lock:
|
||||
assert _zh_paraformer is not None
|
||||
res = _zh_paraformer.generate(input=wav_path, batch_size_s=300)
|
||||
transcription = res[0]["text"] if res else ""
|
||||
return zhconv.convert(transcription, "zh-cn").strip()
|
||||
|
||||
|
||||
def _missing_deps_message(lang: str) -> str | None:
|
||||
try:
|
||||
import jiwer # noqa: F401
|
||||
from zhon.hanzi import punctuation # noqa: F401
|
||||
except ImportError as e:
|
||||
return f"Seed-TTS WER eval needs jiwer and zhon ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
|
||||
try:
|
||||
import scipy.signal # noqa: F401
|
||||
import soundfile # noqa: F401
|
||||
except ImportError as e:
|
||||
return f"Seed-TTS WER eval needs scipy and soundfile ({e!s})."
|
||||
if lang == "en":
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
from transformers import WhisperForConditionalGeneration # noqa: F401
|
||||
except ImportError as e:
|
||||
return f"English WER needs torch and transformers ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
|
||||
else:
|
||||
try:
|
||||
import zhconv # noqa: F401
|
||||
from funasr import AutoModel # noqa: F401
|
||||
except ImportError as e:
|
||||
return f"Chinese WER needs funasr and zhconv ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
|
||||
return None
|
||||
|
||||
|
||||
def compute_seed_tts_wer_metrics(
|
||||
input_requests: list[SampleRequest],
|
||||
outputs: list[Any],
|
||||
*,
|
||||
include_per_item: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""If all requests are :class:`SeedTTSSampleRequest`, run seed-tts-eval-style WER."""
|
||||
global _utmos_forward_warned
|
||||
if not input_requests or len(input_requests) != len(outputs):
|
||||
return None
|
||||
if not all(isinstance(r, SeedTTSSampleRequest) for r in input_requests):
|
||||
return None
|
||||
|
||||
first = input_requests[0]
|
||||
assert isinstance(first, SeedTTSSampleRequest)
|
||||
lang = "zh" if (first.seed_tts_locale or "en").lower().startswith("zh") else "en"
|
||||
|
||||
setup_err = _missing_deps_message(lang)
|
||||
if setup_err:
|
||||
logger.error("%s", setup_err)
|
||||
return {
|
||||
"seed_tts_eval_setup_error": setup_err,
|
||||
"seed_tts_eval_protocol": "seed-tts-eval",
|
||||
"seed_tts_content_evaluated": 0,
|
||||
"seed_tts_content_error_mean": None,
|
||||
"seed_tts_content_error_median": None,
|
||||
"seed_tts_request_failed": 0,
|
||||
"seed_tts_no_pcm": 0,
|
||||
"seed_tts_asr_failed": 0,
|
||||
"seed_tts_content_metric": "wer",
|
||||
}
|
||||
|
||||
import soundfile as sf
|
||||
|
||||
errs: list[float] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
asr_failed = 0
|
||||
no_pcm = 0
|
||||
request_failed = 0
|
||||
sim_values: list[float] = []
|
||||
utmos_values: list[float] = []
|
||||
sim_failed = 0
|
||||
sim_skipped_no_ref = 0
|
||||
utmos_failed = 0
|
||||
utmos_on = _eval_submetric_enabled("SEED_TTS_UTMOS_EVAL", default=True)
|
||||
|
||||
for req, out in zip(input_requests, outputs, strict=True):
|
||||
assert isinstance(req, SeedTTSSampleRequest)
|
||||
ref = req.prompt
|
||||
locale = req.seed_tts_locale or "en"
|
||||
row_lang = "zh" if locale.lower().startswith("zh") else "en"
|
||||
utmos_v: float | None = None
|
||||
|
||||
if not out.success:
|
||||
request_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "request_failed",
|
||||
"detail": (out.error or "")[:500],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
pcm = getattr(out, "tts_output_pcm_bytes", None)
|
||||
if not pcm:
|
||||
no_pcm += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "no_pcm",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
wav_16k = _pcm_s16le_to_f32_16k(pcm)
|
||||
if len(wav_16k) == 0:
|
||||
asr_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "empty_audio",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# UTMOS scores synthesized audio only; do not gate on ASR/WER (those can fail independently).
|
||||
if utmos_on:
|
||||
try:
|
||||
utmos_v = _utmos_predict_f32_16k(wav_16k)
|
||||
if utmos_v is not None:
|
||||
utmos_values.append(utmos_v)
|
||||
elif not _utmos_jit_load_failed:
|
||||
utmos_failed += 1
|
||||
except Exception:
|
||||
if not _utmos_forward_warned:
|
||||
_utmos_forward_warned = True
|
||||
logger.warning(
|
||||
"UTMOS JIT forward failed (first utterance=%s; set logging DEBUG for "
|
||||
"full trace). Check sample rate (16 kHz), input shape, or "
|
||||
"SEED_TTS_UTMOS_DEVICE.",
|
||||
req.seed_tts_utterance_id,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"UTMOS forward failed for %s",
|
||||
req.seed_tts_utterance_id,
|
||||
exc_info=True,
|
||||
)
|
||||
utmos_failed += 1
|
||||
|
||||
try:
|
||||
if row_lang == "en":
|
||||
hyp = _transcribe_en_f32_16k(wav_16k)
|
||||
else:
|
||||
fd, tmp_wav = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
try:
|
||||
sf.write(tmp_wav, wav_16k, 16000, subtype="PCM_16")
|
||||
hyp = _transcribe_zh_wav_path(tmp_wav)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_wav)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception("Seed-TTS ASR failed for %s", req.seed_tts_utterance_id)
|
||||
asr_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "asr_exception",
|
||||
"detail": str(e)[:500],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not hyp:
|
||||
asr_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "empty_asr",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
wer, raw_truth, raw_hypo = process_one_official(hyp, ref, row_lang)
|
||||
except Exception as e:
|
||||
logger.warning("jiwer/normalize failed for %s: %s", req.seed_tts_utterance_id, e)
|
||||
asr_failed += 1
|
||||
if include_per_item:
|
||||
items.append(
|
||||
{
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "wer_compute_failed",
|
||||
"detail": str(e)[:500],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
errs.append(wer)
|
||||
sim_v: float | None = None
|
||||
|
||||
if _eval_submetric_enabled("SEED_TTS_SIM_EVAL", default=True):
|
||||
ref_path = getattr(req, "seed_tts_ref_wav_path", "") or ""
|
||||
if ref_path and os.path.isfile(ref_path):
|
||||
try:
|
||||
ref_wav = _audio_path_to_f32_16k(ref_path)
|
||||
e_ref = _wavlm_mean_embedding_f32_16k(ref_wav)
|
||||
e_hyp = _wavlm_mean_embedding_f32_16k(wav_16k)
|
||||
if e_ref is not None and e_hyp is not None:
|
||||
sim_v = _cosine_similarity_unit_vectors(e_ref, e_hyp)
|
||||
sim_values.append(sim_v)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"SIM embedding failed for utterance=%s: %s: %s",
|
||||
req.seed_tts_utterance_id,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
sim_failed += 1
|
||||
else:
|
||||
sim_skipped_no_ref += 1
|
||||
|
||||
if include_per_item:
|
||||
row: dict[str, Any] = {
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"wer": wer,
|
||||
"reference_raw": raw_truth,
|
||||
"asr_raw": raw_hypo,
|
||||
}
|
||||
if sim_v is not None:
|
||||
row["sim"] = sim_v
|
||||
if utmos_v is not None:
|
||||
row["utmos"] = utmos_v
|
||||
items.append(row)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"seed_tts_eval_protocol": "seed-tts-eval",
|
||||
"seed_tts_content_evaluated": len(errs),
|
||||
"seed_tts_content_error_mean": statistics.fmean(errs) if errs else None,
|
||||
"seed_tts_content_error_median": statistics.median(errs) if errs else None,
|
||||
"seed_tts_request_failed": request_failed,
|
||||
"seed_tts_no_pcm": no_pcm,
|
||||
"seed_tts_asr_failed": asr_failed,
|
||||
"seed_tts_content_metric": "wer",
|
||||
"seed_tts_sim_evaluated": len(sim_values),
|
||||
"seed_tts_sim_mean": statistics.fmean(sim_values) if sim_values else None,
|
||||
"seed_tts_sim_median": statistics.median(sim_values) if sim_values else None,
|
||||
"seed_tts_sim_failed": sim_failed,
|
||||
"seed_tts_sim_skipped_no_ref": sim_skipped_no_ref,
|
||||
"seed_tts_utmos_evaluated": len(utmos_values),
|
||||
"seed_tts_utmos_mean": statistics.fmean(utmos_values) if utmos_values else None,
|
||||
"seed_tts_utmos_median": statistics.median(utmos_values) if utmos_values else None,
|
||||
"seed_tts_utmos_failed": utmos_failed,
|
||||
}
|
||||
if include_per_item:
|
||||
result["seed_tts_wer_eval_items"] = items
|
||||
return result
|
||||
|
||||
|
||||
def print_seed_tts_wer_summary(metrics: dict[str, Any]) -> None:
|
||||
setup = metrics.get("seed_tts_eval_setup_error")
|
||||
if setup:
|
||||
print("{s:{c}^{n}}".format(s=" Seed-TTS eval (seed-tts-eval protocol) ", n=50, c="="))
|
||||
print(setup)
|
||||
return
|
||||
|
||||
ev = int(metrics.get("seed_tts_content_evaluated", 0) or 0)
|
||||
rf = int(metrics.get("seed_tts_request_failed", 0) or 0)
|
||||
npc = int(metrics.get("seed_tts_no_pcm", 0) or 0)
|
||||
af = int(metrics.get("seed_tts_asr_failed", 0) or 0)
|
||||
sim_ev = int(metrics.get("seed_tts_sim_evaluated", 0) or 0)
|
||||
ut_ev = int(metrics.get("seed_tts_utmos_evaluated", 0) or 0)
|
||||
if ev == 0 and rf == 0 and npc == 0 and af == 0 and sim_ev == 0 and ut_ev == 0:
|
||||
return
|
||||
print("{s:{c}^{n}}".format(s=" Seed-TTS eval (seed-tts-eval protocol) ", n=50, c="="))
|
||||
print("{:<40} {:<10}".format("Evaluated (WER, lower is better):", ev))
|
||||
mean = metrics.get("seed_tts_content_error_mean")
|
||||
if mean is not None:
|
||||
print("{:<40} {:<10.4f}".format("Mean WER:", float(mean)))
|
||||
med = metrics.get("seed_tts_content_error_median")
|
||||
if med is not None:
|
||||
print("{:<40} {:<10.4f}".format("Median WER:", float(med)))
|
||||
print("{:<40} {:<10}".format("Request failed:", metrics.get("seed_tts_request_failed", 0)))
|
||||
print("{:<40} {:<10}".format("No PCM captured:", metrics.get("seed_tts_no_pcm", 0)))
|
||||
print("{:<40} {:<10}".format("ASR / WER failed:", metrics.get("seed_tts_asr_failed", 0)))
|
||||
if sim_ev or metrics.get("seed_tts_sim_skipped_no_ref") or metrics.get("seed_tts_sim_failed"):
|
||||
print("{:<40} {:<10}".format("SIM evaluated (higher ~ closer):", sim_ev))
|
||||
sm = metrics.get("seed_tts_sim_mean")
|
||||
if sm is not None:
|
||||
print("{:<40} {:<10.4f}".format("Mean SIM:", float(sm)))
|
||||
s_med = metrics.get("seed_tts_sim_median")
|
||||
if s_med is not None:
|
||||
print("{:<40} {:<10.4f}".format("Median SIM:", float(s_med)))
|
||||
print("{:<40} {:<10}".format("SIM skipped (no ref path):", metrics.get("seed_tts_sim_skipped_no_ref", 0)))
|
||||
print("{:<40} {:<10}".format("SIM embedding errors:", metrics.get("seed_tts_sim_failed", 0)))
|
||||
if ut_ev or metrics.get("seed_tts_utmos_failed"):
|
||||
print("{:<40} {:<10}".format("UTMOS evaluated (JIT MOS, higher better):", ut_ev))
|
||||
um = metrics.get("seed_tts_utmos_mean")
|
||||
if um is not None:
|
||||
print("{:<40} {:<10.4f}".format("Mean UTMOS:", float(um)))
|
||||
u_med = metrics.get("seed_tts_utmos_median")
|
||||
if u_med is not None:
|
||||
print("{:<40} {:<10.4f}".format("Median UTMOS:", float(u_med)))
|
||||
print("{:<40} {:<10}".format("UTMOS errors:", metrics.get("seed_tts_utmos_failed", 0)))
|
||||
print("=" * 50)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Omni benchmark monkey-patches (side effects in ``patch.patch``)."""
|
||||
|
||||
from . import patch as _patch_module # noqa: F401
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import random
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Iterable
|
||||
@@ -33,15 +34,245 @@ from vllm.logger import init_logger
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset, DailyOmniSampleRequest
|
||||
from vllm_omni.benchmarks.data_modules.random_multi_modal_dataset import OmniRandomMultiModalDataset
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
|
||||
SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT,
|
||||
SeedTTSDataset,
|
||||
SeedTTSSampleRequest,
|
||||
)
|
||||
|
||||
get_samples_old = datasets.get_samples
|
||||
|
||||
_DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni"
|
||||
|
||||
|
||||
def _seed_tts_capture_pcm_for_wer() -> bool:
|
||||
return os.environ.get("SEED_TTS_WER_EVAL", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _merge_extra_body_mm_kwargs(base: dict | None, overlay: dict | None) -> dict | None:
|
||||
"""Shallow-merge ``extra_body`` dicts; deep-merge ``mm_processor_kwargs`` if both set."""
|
||||
if not base and not overlay:
|
||||
return None
|
||||
out = dict(base or {})
|
||||
if not overlay:
|
||||
return out
|
||||
for k, v in overlay.items():
|
||||
if k == "mm_processor_kwargs" and isinstance(v, dict):
|
||||
prev = out.get("mm_processor_kwargs")
|
||||
merged_kw = {**(prev if isinstance(prev, dict) else {}), **v}
|
||||
out["mm_processor_kwargs"] = merged_kw
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _attach_daily_omni_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
|
||||
"""Apply per-request OpenAI fields (``mm_processor_kwargs``, messages) for Daily-Omni."""
|
||||
if not isinstance(sample, DailyOmniSampleRequest):
|
||||
return
|
||||
rfi.extra_body = _merge_extra_body_mm_kwargs(rfi.extra_body, sample.omni_extra_body)
|
||||
if sample.omni_chat_messages is not None:
|
||||
setattr(rfi, "omni_chat_messages", sample.omni_chat_messages)
|
||||
else:
|
||||
setattr(rfi, "mm_position", sample.omni_chat_mm_position)
|
||||
|
||||
|
||||
def _attach_seed_tts_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
|
||||
"""Merge Seed-TTS per-row TTS fields (ref_audio, ref_text, task_type, …) into ``extra_body``.
|
||||
|
||||
Used by both ``/v1/audio/speech`` and ``/v1/chat/completions`` (flattened into JSON body).
|
||||
For ``openai-chat-omni``, also sets ``omni_chat_messages`` (system + user) so Qwen3-Omni
|
||||
follows the same role layout as official TTS / multimodal demos. ``/v1/audio/speech`` ignores
|
||||
``messages`` and only uses ``input`` + body fields.
|
||||
Flags ``openai-chat-omni`` to request audio output and optionally export PCM for WER.
|
||||
"""
|
||||
if not isinstance(sample, SeedTTSSampleRequest):
|
||||
return
|
||||
ex = sample.seed_tts_speech_extra
|
||||
if not ex:
|
||||
return
|
||||
base = dict(rfi.extra_body) if rfi.extra_body else {}
|
||||
base.update(ex)
|
||||
rfi.extra_body = base
|
||||
# Used by request funcs to force streaming TTS behavior and to export PCM when WER is on.
|
||||
setattr(rfi, "seed_tts_row", True)
|
||||
sys_prompt = (sample.seed_tts_system_prompt or "").strip() or SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
|
||||
setattr(
|
||||
rfi,
|
||||
"omni_chat_messages",
|
||||
[
|
||||
{"role": "system", "content": [{"type": "text", "text": sys_prompt}]},
|
||||
{"role": "user", "content": [{"type": "text", "text": sample.prompt}]},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _daily_omni_repo_from_args(args) -> str | None:
|
||||
"""Resolve HuggingFace repo id for Daily-Omni from CLI args.
|
||||
|
||||
vLLM allows ``--dataset-path`` to be a local path while the real HF id is
|
||||
passed via ``--hf-name``. Upstream ``get_samples`` for ``hf`` only matches
|
||||
a fixed elif-chain and never discovers Omni's loader, so we must detect
|
||||
Daily-Omni here using either field.
|
||||
"""
|
||||
dp = getattr(args, "dataset_path", None)
|
||||
hn = getattr(args, "hf_name", None)
|
||||
if dp in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
|
||||
return dp
|
||||
if hn in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
|
||||
return hn
|
||||
return None
|
||||
|
||||
|
||||
def get_samples(args, tokenizer):
|
||||
if args.backend not in ["openai-chat-omni", "openai-audio-speech"]:
|
||||
# Daily-Omni: explicit dataset name, or hf + matching path/hf-name
|
||||
is_daily_omni = args.dataset_name == "daily-omni" or (
|
||||
args.dataset_name == "hf" and _daily_omni_repo_from_args(args) is not None
|
||||
)
|
||||
is_seed_tts = args.dataset_name == "seed-tts"
|
||||
|
||||
# Check if we need to handle omni-related backends/datasets
|
||||
is_omni_backend = args.backend in ["openai-chat-omni", "openai-audio-speech", "daily-omni"]
|
||||
is_omni_dataset = is_daily_omni or is_seed_tts or args.dataset_name == "random-mm"
|
||||
|
||||
if not is_omni_backend and not is_omni_dataset:
|
||||
# Not an omni-related request, delegate to original implementation
|
||||
return get_samples_old(args, tokenizer)
|
||||
elif args.dataset_name == "random-mm":
|
||||
|
||||
# Handle Daily-Omni dataset
|
||||
if is_daily_omni:
|
||||
# Support:
|
||||
# --dataset-name daily-omni [--dataset-path liarliar/Daily-Omni]
|
||||
# --dataset-name daily-omni --daily-omni-qa-json /path/to/qa.json (offline QA)
|
||||
# --dataset-name hf --dataset-path liarliar/Daily-Omni
|
||||
# --dataset-name hf --hf-name liarliar/Daily-Omni (dataset-path may be local)
|
||||
|
||||
# Validate backend supports multimodal (video)
|
||||
if args.backend not in ["openai-chat-omni", "daily-omni"]:
|
||||
raise ValueError(
|
||||
f"Daily-Omni dataset requires a multimodal backend that supports video. "
|
||||
f"Got backend='{args.backend}'. Please use '--backend openai-chat-omni'"
|
||||
)
|
||||
|
||||
# Determine video directory if specified (for local video files)
|
||||
video_dir = getattr(args, "daily_omni_video_dir", None)
|
||||
|
||||
# Get HF split (default to "train"; unused when loading from local qa.json)
|
||||
dataset_split = getattr(args, "hf_split", None) or "train"
|
||||
|
||||
qa_json = getattr(args, "daily_omni_qa_json", None)
|
||||
if isinstance(qa_json, str):
|
||||
qa_json = qa_json.strip() or None
|
||||
|
||||
if qa_json is not None:
|
||||
logger.info(
|
||||
"Loading Daily-Omni dataset: qa_json=%s, video_dir=%s (Hub not used for QA)",
|
||||
qa_json,
|
||||
video_dir,
|
||||
)
|
||||
dataset = DailyOmniDataset(
|
||||
qa_json_path=qa_json,
|
||||
dataset_path=None,
|
||||
dataset_split=dataset_split,
|
||||
random_seed=args.seed,
|
||||
video_dir=video_dir,
|
||||
input_mode=getattr(args, "daily_omni_input_mode", "all"),
|
||||
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
|
||||
trust_remote_code=getattr(args, "trust_remote_code", False),
|
||||
disable_shuffle=getattr(args, "disable_shuffle", False),
|
||||
)
|
||||
else:
|
||||
repo_id = _daily_omni_repo_from_args(args)
|
||||
if args.dataset_name == "daily-omni":
|
||||
if repo_id is None:
|
||||
repo_id = _DEFAULT_DAILY_OMNI_REPO
|
||||
elif repo_id is None:
|
||||
raise ValueError(
|
||||
"Daily-Omni with --dataset-name hf requires "
|
||||
f"--dataset-path {_DEFAULT_DAILY_OMNI_REPO} or "
|
||||
f"--hf-name {_DEFAULT_DAILY_OMNI_REPO}."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Loading Daily-Omni dataset: hf_repo=%s, split=%s, video_dir=%s",
|
||||
repo_id,
|
||||
dataset_split,
|
||||
video_dir,
|
||||
)
|
||||
|
||||
dataset = DailyOmniDataset(
|
||||
dataset_path=repo_id,
|
||||
dataset_split=dataset_split,
|
||||
dataset_subset=getattr(args, "hf_subset", None),
|
||||
random_seed=args.seed,
|
||||
video_dir=video_dir,
|
||||
input_mode=getattr(args, "daily_omni_input_mode", "all"),
|
||||
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
|
||||
trust_remote_code=getattr(args, "trust_remote_code", False),
|
||||
no_stream=getattr(args, "no_stream", False),
|
||||
disable_shuffle=getattr(args, "disable_shuffle", False),
|
||||
)
|
||||
|
||||
out_len = getattr(args, "output_len", None)
|
||||
if out_len is None:
|
||||
out_len = getattr(args, "hf_output_len", None)
|
||||
if out_len is None:
|
||||
out_len = DailyOmniDataset.DEFAULT_OUTPUT_LEN
|
||||
|
||||
input_requests = dataset.sample(
|
||||
tokenizer=tokenizer,
|
||||
num_requests=args.num_prompts,
|
||||
output_len=out_len,
|
||||
request_id_prefix=args.request_id_prefix,
|
||||
no_oversample=args.no_oversample,
|
||||
)
|
||||
return input_requests
|
||||
|
||||
if is_seed_tts:
|
||||
if args.backend not in ("openai-audio-speech", "openai-chat-omni"):
|
||||
raise ValueError(
|
||||
"Seed-TTS requires --backend openai-audio-speech (POST /v1/audio/speech) or "
|
||||
"--backend openai-chat-omni (POST /v1/chat/completions with ref_audio/ref_text). "
|
||||
f"Got backend={args.backend!r}."
|
||||
)
|
||||
repo_id = getattr(args, "dataset_path", None) or getattr(args, "hf_name", None)
|
||||
if not repo_id:
|
||||
raise ValueError(
|
||||
"Seed-TTS requires --dataset-path (HF dataset repo id or local directory) or "
|
||||
"--hf-name for the Hub dataset id."
|
||||
)
|
||||
|
||||
dataset = SeedTTSDataset(
|
||||
dataset_path=repo_id,
|
||||
random_seed=args.seed,
|
||||
locale=getattr(args, "seed_tts_locale", "en"),
|
||||
inline_ref_audio=not getattr(args, "seed_tts_file_ref_audio", False),
|
||||
seed_tts_root=getattr(args, "seed_tts_root", None),
|
||||
system_prompt=getattr(args, "seed_tts_system_prompt", None),
|
||||
disable_shuffle=getattr(args, "disable_shuffle", False),
|
||||
)
|
||||
out_len = getattr(args, "output_len", None)
|
||||
if out_len is None:
|
||||
out_len = getattr(args, "hf_output_len", None)
|
||||
if out_len is None:
|
||||
out_len = SeedTTSDataset.DEFAULT_OUTPUT_LEN
|
||||
return dataset.sample(
|
||||
tokenizer=tokenizer,
|
||||
num_requests=args.num_prompts,
|
||||
output_len=out_len,
|
||||
request_id_prefix=args.request_id_prefix,
|
||||
no_oversample=args.no_oversample,
|
||||
)
|
||||
|
||||
# Handle random-mm dataset (Omni's synthetic multimodal dataset)
|
||||
if args.dataset_name == "random-mm":
|
||||
dataset = OmniRandomMultiModalDataset(random_seed=args.seed, dataset_path=args.dataset_path)
|
||||
input_requests = dataset.sample(
|
||||
tokenizer=tokenizer,
|
||||
@@ -64,6 +295,10 @@ def get_samples(args, tokenizer):
|
||||
|
||||
datasets.get_samples = get_samples
|
||||
|
||||
_serve_mod = sys.modules.get("vllm.benchmarks.serve")
|
||||
if _serve_mod is not None:
|
||||
_serve_mod.get_samples = get_samples
|
||||
|
||||
|
||||
@dataclass
|
||||
class MixRequestFuncOutput(RequestFuncOutput):
|
||||
@@ -72,6 +307,9 @@ class MixRequestFuncOutput(RequestFuncOutput):
|
||||
audio_frames: int = 0
|
||||
audio_rtf: float = 0.0
|
||||
text_latency: float = 0.0
|
||||
#: Raw PCM s16le mono at 24 kHz for Seed-TTS WER: from ``/v1/audio/speech`` stream or
|
||||
#: resampled export after ``openai-chat-omni`` audio deltas.
|
||||
tts_output_pcm_bytes: bytes | None = None
|
||||
|
||||
|
||||
async def async_request_openai_chat_omni_completions(
|
||||
@@ -83,13 +321,17 @@ async def async_request_openai_chat_omni_completions(
|
||||
api_url = request_func_input.api_url
|
||||
_validate_api_url(api_url, "OpenAI Chat Completions API", "chat/completions")
|
||||
|
||||
content = _get_chat_content(request_func_input, mm_position=mm_position)
|
||||
omni_messages = getattr(request_func_input, "omni_chat_messages", None)
|
||||
if omni_messages is not None:
|
||||
messages_payload = omni_messages
|
||||
else:
|
||||
effective_mm_position = getattr(request_func_input, "mm_position", mm_position)
|
||||
content = _get_chat_content(request_func_input, mm_position=effective_mm_position)
|
||||
messages_payload = [{"role": "user", "content": content}]
|
||||
|
||||
payload = {
|
||||
"model": request_func_input.model_name if request_func_input.model_name else request_func_input.model,
|
||||
"messages": [
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
"messages": messages_payload,
|
||||
"temperature": 0.0,
|
||||
"max_tokens": request_func_input.output_len,
|
||||
"stream": True,
|
||||
@@ -98,6 +340,10 @@ async def async_request_openai_chat_omni_completions(
|
||||
},
|
||||
}
|
||||
_update_payload_common(payload, request_func_input)
|
||||
# Seed-TTS via chat: voice-clone fields live on the body; ensure audio is streamed.
|
||||
if getattr(request_func_input, "seed_tts_row", False):
|
||||
if payload.get("modalities") is None:
|
||||
payload["modalities"] = ["text", "audio"]
|
||||
|
||||
response_format = payload.get("response_format", "wav")
|
||||
if response_format == "pcm":
|
||||
@@ -167,7 +413,10 @@ async def async_request_openai_chat_omni_completions(
|
||||
data = json.loads(chunk)
|
||||
if choices := data.get("choices"):
|
||||
modality = data.get("modality")
|
||||
content = choices[0]["delta"].get("content")
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if not content and isinstance(delta.get("audio"), dict):
|
||||
content = delta["audio"].get("data")
|
||||
if modality == "text":
|
||||
# First token
|
||||
if ttft == 0.0:
|
||||
@@ -182,7 +431,7 @@ async def async_request_openai_chat_omni_completions(
|
||||
if output.audio_ttfp == 0.0:
|
||||
output.audio_ttfp = timestamp - st
|
||||
audio_generate_time = timestamp - st
|
||||
if content != "":
|
||||
if content:
|
||||
audio_bytes = base64.b64decode(content)
|
||||
seg = AudioSegment.from_file(io.BytesIO(audio_bytes))
|
||||
if seg is not None:
|
||||
@@ -214,6 +463,12 @@ async def async_request_openai_chat_omni_completions(
|
||||
else:
|
||||
output.audio_rtf = 0
|
||||
logger.warning("Audio duration is zero")
|
||||
if _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False):
|
||||
try:
|
||||
seg = generated_audio.set_frame_rate(24000).set_channels(1).set_sample_width(2)
|
||||
output.tts_output_pcm_bytes = bytes(seg.raw_data)
|
||||
except Exception as ex:
|
||||
logger.warning("seed_tts WER PCM export failed: %s", ex)
|
||||
output.success = True
|
||||
else:
|
||||
output.error = response.reason or ""
|
||||
@@ -268,6 +523,10 @@ async def async_request_openai_audio_speech(
|
||||
"response_format": "pcm",
|
||||
}
|
||||
_update_payload_common(payload, request_func_input)
|
||||
# Seed-TTS + WER: ``--extra-body`` may set stream=false / other formats; speech must stream PCM.
|
||||
if getattr(request_func_input, "seed_tts_row", False) and _seed_tts_capture_pcm_for_wer():
|
||||
payload["stream"] = True
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -286,6 +545,8 @@ async def async_request_openai_audio_speech(
|
||||
st = time.perf_counter()
|
||||
output.start_time = st
|
||||
total_pcm_bytes = 0
|
||||
capture_wer_pcm = _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False)
|
||||
pcm_capture = bytearray() if capture_wer_pcm else None
|
||||
try:
|
||||
async with session.post(url=api_url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
@@ -297,6 +558,8 @@ async def async_request_openai_audio_speech(
|
||||
output.audio_ttfp = timestamp - st
|
||||
output.ttft = output.audio_ttfp
|
||||
total_pcm_bytes += len(chunk)
|
||||
if pcm_capture is not None:
|
||||
pcm_capture.extend(chunk)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
output.latency = end_time - st
|
||||
@@ -309,6 +572,16 @@ async def async_request_openai_audio_speech(
|
||||
else:
|
||||
output.audio_rtf = 0
|
||||
logger.warning("Audio duration is zero")
|
||||
if pcm_capture is not None and pcm_capture:
|
||||
output.tts_output_pcm_bytes = bytes(pcm_capture)
|
||||
elif capture_wer_pcm:
|
||||
ct = response.headers.get("Content-Type", "")
|
||||
logger.warning(
|
||||
"Seed-TTS WER: HTTP 200 but no PCM bytes (Content-Type=%r, url=%s). "
|
||||
"Check stream=true and response_format=pcm on the server.",
|
||||
ct,
|
||||
api_url,
|
||||
)
|
||||
output.success = True
|
||||
else:
|
||||
output.error = response.reason or ""
|
||||
@@ -331,6 +604,12 @@ ASYNC_REQUEST_FUNCS["openai-audio-speech"] = async_request_openai_audio_speech
|
||||
if "openai-audio-speech" not in OPENAI_COMPATIBLE_BACKENDS:
|
||||
OPENAI_COMPATIBLE_BACKENDS.append("openai-audio-speech")
|
||||
|
||||
# Daily-Omni backend for audio-visual reasoning benchmark
|
||||
# Reuses openai-chat-omni completions for video+text understanding
|
||||
ASYNC_REQUEST_FUNCS["daily-omni"] = async_request_openai_chat_omni_completions
|
||||
if "daily-omni" not in OPENAI_COMPATIBLE_BACKENDS:
|
||||
OPENAI_COMPATIBLE_BACKENDS.append("daily-omni")
|
||||
|
||||
# ruff: noqa: E402
|
||||
# Prevent import order from causing patch failures
|
||||
from vllm.benchmarks import serve
|
||||
@@ -422,6 +701,8 @@ async def benchmark(
|
||||
extra_headers=extra_headers,
|
||||
extra_body=extra_body,
|
||||
)
|
||||
_attach_daily_omni_to_request_func_input(input_requests[0], test_input)
|
||||
_attach_seed_tts_to_request_func_input(input_requests[0], test_input)
|
||||
|
||||
if ready_check_timeout_sec > 0:
|
||||
test_output = await wait_for_endpoint(
|
||||
@@ -484,6 +765,8 @@ async def benchmark(
|
||||
extra_headers=extra_headers,
|
||||
extra_body=extra_body,
|
||||
)
|
||||
_attach_daily_omni_to_request_func_input(input_requests[0], profile_input)
|
||||
_attach_seed_tts_to_request_func_input(input_requests[0], profile_input)
|
||||
profile_output = await request_func(request_func_input=profile_input, session=session)
|
||||
if profile_output.success:
|
||||
print("Profiler started")
|
||||
@@ -564,6 +847,8 @@ async def benchmark(
|
||||
extra_body=extra_body,
|
||||
request_id=request_id,
|
||||
)
|
||||
_attach_daily_omni_to_request_func_input(request, request_func_input)
|
||||
_attach_seed_tts_to_request_func_input(request, request_func_input)
|
||||
tasks.append(
|
||||
asyncio.create_task(limited_request_func(request_func_input=request_func_input, session=session, pbar=pbar))
|
||||
)
|
||||
@@ -631,6 +916,37 @@ async def benchmark(
|
||||
"errors": [output.error for output in outputs],
|
||||
}
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_eval import (
|
||||
compute_daily_omni_accuracy_metrics,
|
||||
print_daily_omni_accuracy_summary,
|
||||
)
|
||||
|
||||
_save_items = os.environ.get("DAILY_OMNI_SAVE_EVAL_ITEMS", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
_daily_acc = compute_daily_omni_accuracy_metrics(input_requests, outputs, include_per_item=_save_items)
|
||||
if _daily_acc is not None:
|
||||
result.update(_daily_acc)
|
||||
print_daily_omni_accuracy_summary(_daily_acc)
|
||||
|
||||
if _seed_tts_capture_pcm_for_wer():
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
|
||||
compute_seed_tts_wer_metrics,
|
||||
print_seed_tts_wer_summary,
|
||||
)
|
||||
|
||||
_save_wer = os.environ.get("SEED_TTS_WER_SAVE_ITEMS", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
_wer_m = compute_seed_tts_wer_metrics(input_requests, outputs, include_per_item=_save_wer)
|
||||
if _wer_m is not None:
|
||||
result.update(_wer_m)
|
||||
print_seed_tts_wer_summary(_wer_m)
|
||||
|
||||
if rps_change_events:
|
||||
result["rps_change_events"] = rps_change_events
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from vllm.benchmarks.serve import main_async
|
||||
|
||||
# Import patch to register daily-omni dataset and omni backends
|
||||
# This monkey-patches vllm.benchmarks.datasets.get_samples before it's used
|
||||
# Must be imported before any vllm.benchmarks module usage
|
||||
import vllm_omni.benchmarks.patch.patch # noqa: F401
|
||||
|
||||
|
||||
def main(args: argparse.Namespace) -> dict[str, Any]:
|
||||
if getattr(args, "seed_tts_wer_eval", False):
|
||||
os.environ["SEED_TTS_WER_EVAL"] = "1"
|
||||
if getattr(args, "seed_tts_wer_save_items", False):
|
||||
os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1"
|
||||
if getattr(args, "daily_omni_save_eval_items", False):
|
||||
os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1"
|
||||
return asyncio.run(main_async(args))
|
||||
@@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from vllm.benchmarks.serve import add_cli_args
|
||||
|
||||
@@ -6,15 +7,149 @@ from vllm_omni.benchmarks.serve import main
|
||||
from vllm_omni.entrypoints.cli.benchmark.base import OmniBenchmarkSubcommandBase
|
||||
|
||||
|
||||
def add_daily_omni_cli_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add CLI arguments specific to Daily-Omni dataset.
|
||||
|
||||
This function should be called by the CLI entrypoint to add additional
|
||||
arguments for daily-omni benchmark support.
|
||||
|
||||
Args:
|
||||
parser: The ArgumentParser instance to extend
|
||||
"""
|
||||
# Daily-Omni specific arguments
|
||||
daily_omni_group = parser.add_argument_group("Daily-Omni Dataset Options")
|
||||
|
||||
daily_omni_group.add_argument(
|
||||
"--daily-omni-qa-json",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to local upstream qa.json. When set, QA rows are read from this file and "
|
||||
"the HuggingFace dataset is not loaded (no network). Use with --daily-omni-video-dir "
|
||||
"for fully offline runs. --dataset-path / Hub split flags are then ignored for QA loading.",
|
||||
)
|
||||
daily_omni_group.add_argument(
|
||||
"--daily-omni-video-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Root directory of extracted Daily-Omni videos (contents of Videos.tar: "
|
||||
"each video_id in its own subdir with {video_id}_video.mp4). "
|
||||
"When using file URLs, you MUST start the vLLM server with "
|
||||
"--allowed-local-media-path set to this same directory (or a parent), "
|
||||
"otherwise requests fail with 'Cannot load local files without "
|
||||
"--allowed-local-media-path'.",
|
||||
)
|
||||
daily_omni_group.add_argument(
|
||||
"--daily-omni-inline-local-video",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="For local videos only: embed MP4 as base64 data URLs in benchmark "
|
||||
"requests so the server does not need --allowed-local-media-path. "
|
||||
"Increases request size and client memory; use for small --num-prompts. "
|
||||
"When using --daily-omni-input-mode audio or all, local WAV files are "
|
||||
"embedded the same way.",
|
||||
)
|
||||
daily_omni_group.add_argument(
|
||||
"--daily-omni-input-mode",
|
||||
type=str,
|
||||
choices=["all", "visual", "audio"],
|
||||
default="all",
|
||||
help="Daily-Omni input protocol (mirrors upstream Lliar-liar/Daily-Omni "
|
||||
"--input_mode). 'visual': video only (default). 'audio': WAV only, "
|
||||
"requires {video_id}/{video_id}_audio.wav under --daily-omni-video-dir. "
|
||||
"'all': video + WAV together. Sets mm_processor_kwargs.use_audio_in_video=false "
|
||||
"and matches official separate video/audio streams.",
|
||||
)
|
||||
daily_omni_group.add_argument(
|
||||
"--daily-omni-save-eval-items",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include per-request Daily-Omni accuracy rows (gold/predicted/correct) "
|
||||
"in the saved JSON under key daily_omni_eval_items. "
|
||||
"Alternatively set env DAILY_OMNI_SAVE_EVAL_ITEMS=1.",
|
||||
)
|
||||
|
||||
# Note: --dataset-name daily-omni via get_samples patch; use either Hub (--dataset-path
|
||||
# liarliar/Daily-Omni) or local --daily-omni-qa-json (offline).
|
||||
|
||||
|
||||
def add_seed_tts_cli_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""CLI for Seed-TTS zero-shot TTS benchmark (``--dataset-name seed-tts``)."""
|
||||
g = parser.add_argument_group("Seed-TTS Dataset Options")
|
||||
g.add_argument(
|
||||
"--seed-tts-locale",
|
||||
type=str,
|
||||
choices=["en", "zh"],
|
||||
default="en",
|
||||
help="Which Seed-TTS split to load: en/meta.lst or zh/meta.lst under the dataset root.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-root",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override root directory that contains en/ and zh/ (meta.lst + prompt-wavs). "
|
||||
"If set, --dataset-path can still name the HF repo for logging; this path is used for files.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-file-ref-audio",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Send ref_audio as file:// URIs (smaller HTTP bodies). Requires the API server "
|
||||
"to be started with --allowed-local-media-path covering the Seed-TTS dataset root. "
|
||||
"Default is inline data:audio/wav;base64 so Qwen3-TTS works without that flag.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-inline-ref-audio",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-system-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override chat system message for --backend openai-chat-omni (Qwen3-Omni TTS). "
|
||||
"Default follows official Qwen3-Omni identity + zero-shot voice-clone instructions.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-wer-eval",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep synthesized audio as 24 kHz mono PCM for WER (works with "
|
||||
"--backend openai-audio-speech or openai-chat-omni). Scoring follows "
|
||||
"BytedanceSpeech/seed-tts-eval (Whisper-large-v3 / Paraformer-zh + jiwer). "
|
||||
"Sets SEED_TTS_WER_EVAL=1. Install: pip install 'vllm-omni[seed-tts-eval]'. "
|
||||
"Optional: SEED_TTS_EVAL_DEVICE, SEED_TTS_HF_WHISPER_MODEL.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--seed-tts-wer-save-items",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include per-utterance ASR rows in the saved JSON under key seed_tts_wer_eval_items. "
|
||||
"Or set SEED_TTS_WER_SAVE_ITEMS=1.",
|
||||
)
|
||||
|
||||
|
||||
class OmniBenchmarkServingSubcommand(OmniBenchmarkSubcommandBase):
|
||||
"""The `serve` subcommand for vllm bench."""
|
||||
|
||||
name = "serve"
|
||||
help = "Benchmark the online serving throughput."
|
||||
help = "Benchmark the online serving throughput. Supports Daily-Omni and Seed-TTS datasets."
|
||||
|
||||
@classmethod
|
||||
def add_cli_args(cls, parser: argparse.ArgumentParser) -> None:
|
||||
add_cli_args(parser)
|
||||
|
||||
# Add Daily-Omni specific arguments
|
||||
add_daily_omni_cli_args(parser)
|
||||
add_seed_tts_cli_args(parser)
|
||||
|
||||
for action in parser._actions:
|
||||
if action.dest == "dataset_name" and action.choices is not None:
|
||||
extra = [c for c in ("daily-omni", "seed-tts") if c not in action.choices]
|
||||
if extra:
|
||||
action.choices = list(action.choices) + extra
|
||||
|
||||
# Update help messages for omni-specific features
|
||||
for action in parser._actions:
|
||||
if action.dest == "percentile_metrics":
|
||||
action.help = (
|
||||
@@ -48,4 +183,10 @@ class OmniBenchmarkServingSubcommand(OmniBenchmarkSubcommandBase):
|
||||
|
||||
@staticmethod
|
||||
def cmd(args: argparse.Namespace) -> None:
|
||||
if getattr(args, "daily_omni_save_eval_items", False):
|
||||
os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1"
|
||||
if getattr(args, "seed_tts_wer_eval", False):
|
||||
os.environ["SEED_TTS_WER_EVAL"] = "1"
|
||||
if getattr(args, "seed_tts_wer_save_items", False):
|
||||
os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1"
|
||||
main(args)
|
||||
Reference in new issue
Block a user