执行摘要
- 一句话:语音转录长音频时间戳漂移修复
- 推荐动作:核心 fix 思路清晰正确,review 过程中的精度讨论值得关注。建议 merge 前确保所有测试通过,并关注是否有外部消费者依赖旧签名。
功能与动机
Issue #32588 报告了长音频(>30s)转录时每个分段时间戳逐渐漂移(每段约 0.5s),10 段后误差可达 5s。原因是 split_audio 并非在准确 30s 处切割,而是在最后 1s 内搜索低能量分裂点,导致每个 chunk 实际长度略短于 30s。旧实现 start_time = float(idx * chunk_size_in_s) 未考虑这个差异,误差逐段累积。
实现拆解
- 核心偏移计算改为累积方式:在
vllm/entrypoints/speech_to_text/base/serving.py 的 _preprocess_speech_to_text 方法中,新增 chunk_start_offsets 列表,遍历 chunks[:-1],每次累加上一个 chunk 的实际采样点数 chunk.shape[-1] / sample_rate 得到真实的起始时间,替代原先只返回 (engine_inputs, duration) 的签名,现在返回三元组 (engine_inputs, duration, chunk_start_offsets)。
- 消费端使用真实偏移:在
_create_speech_to_text 方法中,将原来的 start_time = float(idx * chunk_size_in_s) 替换为 start_time = chunk_start_offsets[idx],直接从累积列表取值,消除漂移。
- seek 字段类型修正:
_get_verbose_segments 中 segment_class(seek=start_time) 原为 float,因累计偏移可能为浮点且 seek 应为整数位置,改为 seek=int(start_time),只对 seek 取整,不影响 start/end 的时间计算。
- 文档修正:
docs/contributing/model/transcription.md 中删除“重叠 chunk”描述,改为说明 overlap_chunk_second 仅用于搜索窗口而非实际重叠。
- 测试配套:新增
tests/entrypoints/speech_to_text/transcription/test_chunk_timestamp_offset.py,构造 29.5s、29.7s、5s 三个 chunk,验证返回的 offsets 为累积真实值而非固定间隔;更新 test_speech_to_text_cancellation.py 和 test_transcription_inter_chunk_spacing.py 中 mock 的签名,使其返回新增的 chunk_start_offsets 参数。
关键文件:
vllm/entrypoints/speech_to_text/base/serving.py(模块 语音服务;类别 source;类型 core-logic;符号 _preprocess_speech_to_text, _create_speech_to_text, _get_verbose_segments): 核心逻辑修改:新增累积偏移计算,变更返回签名,修正 seek 类型
tests/entrypoints/speech_to_text/transcription/test_chunk_timestamp_offset.py(模块 测试;类别 test;类型 test-coverage;符号 test_chunk_offsets_are_cumulative_not_nominal): 新增回归测试,验证累积偏移计算正确且不等于旧固定间隔
tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py(模块 测试;类别 test;类型 test-coverage): 修改 mock 返回值签名以适配新的三元组返回
tests/entrypoints/speech_to_text/transcription/test_transcription_inter_chunk_spacing.py(模块 测试;类别 test;类型 test-coverage): 修改 mock 返回值签名以适应新的返回类型
docs/contributing/model/transcription.md(模块 文档;类别 docs;类型 documentation): 修正关于重叠 chunk 的文档描述
关键符号:_preprocess_speech_to_text, _create_speech_to_text, _get_verbose_segments
关键源码片段
vllm/entrypoints/speech_to_text/base/serving.py
核心逻辑修改:新增累积偏移计算,变更返回签名,修正 seek 类型
async def _preprocess_speech_to_text(
self,
request: SpeechToTextRequest,
audio_data: bytes,
request_id: str,
) -> tuple[list[EngineInput], float, list[float]]:
# Validate request
request.language = self.model_cls.validate_language(request.language)
request.to_language = (
self.model_cls.validate_language(request.to_language)
if request.to_language
else None
)
if len(audio_data) / 1024**2 > self.max_audio_filesize_mb:
raise VLLMValidationError(
"Maximum file size exceeded",
parameter="audio_filesize_mb",
value=len(audio_data) / 1024**2,
)
# Run cpu intensive preprocess step in a separate thread pool executor.
chunks, duration = await self._decode_and_chunk_speech_async(audio_data)
# 核心修复:计算每个 chunk 在原始音频中的真实起始偏移
# 初始偏移为 0.0,之后每个 chunk 的起始 = 前一个偏移 + 前一个 chunk 的实际时长(采样数 / 采样率)
chunk_start_offsets: list[float] = [0.0]
for chunk in chunks[:-1]:
chunk_start_offsets.append(
chunk_start_offsets[-1] + chunk.shape[-1] / self.asr_config.sample_rate
)
# ... 语言检测(不变)
if request.language is None and getattr(
self.model_cls, "supports_explicit_language_detection", False
):
request.language = await self._detect_language(
chunks[0], f"{request_id}-lang_detect"
)
# 构建 prompts(不变)
parsed_prompts: list[DictPrompt] = []
for chunk in chunks:
stt_params = request.build_stt_params(
audio=chunk,
stt_config=self.asr_config,
model_config=self.model_config,
task_type=self.task_type,
)
prompt = self.model_cls.get_generation_prompt(stt_params)
parsed_prompt = parse_model_prompt(self.model_config, prompt)
parsed_prompts.append(parsed_prompt)
engine_inputs = await self.renderer.render_cmpl_async(parsed_prompts)
# 现在返回三元组,包含每个 chunk 的起始偏移
return engine_inputs, duration, chunk_start_offsets
tests/entrypoints/speech_to_text/transcription/test_chunk_timestamp_offset.py
新增回归测试,验证累积偏移计算正确且不等于旧固定间隔
"""Regression test for chunk timestamp offset drift in _preprocess_speech_to_text."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
from vllm.config.speech_to_text import SpeechToTextConfig
from vllm.entrypoints.speech_to_text.base.serving import SpeechToTextBaseServing
SR = 16_000
_PATCH = "vllm.entrypoints.speech_to_text.base.serving"
@pytest.mark.asyncio
async def test_chunk_offsets_are_cumulative_not_nominal():
"""
chunk_start_offsets 必须是基于实际 chunk 长度的累积值,
而不是旧版的 `idx * max_audio_clip_s`。
当 split_audio 在 30s 前找到安静区域导致 chunk 变短时,
旧公式会产生漂移,新公式保持精确。
"""
# 模拟 split_audio 可能产出的三种实际 chunk 长度(均 < 30s)
chunk_lengths = [int(29.5 * SR), int(29.7 * SR), int(5.0 * SR)]
chunks = [np.zeros(n, dtype=np.float32) for n in chunk_lengths]
duration = sum(chunk_lengths) / SR
# 期待的真实累积偏移(秒)
expected_offsets = [0.0, 29.5, 29.5 + 29.7] # = [0.0, 29.5, 59.2]
# 旧方法可能产生的错误偏移(固定 30s 间隔)
wrong_offsets = [0.0, 30.0, 60.0]
# 构造 mock server 实例
serving = SpeechToTextBaseServing.__new__(SpeechToTextBaseServing)
serving._decode_and_chunk_speech_async = AsyncMock(return_value=(chunks, duration))
serving.asr_config = SpeechToTextConfig(
sample_rate=float(SR),
max_audio_clip_s=30,
overlap_chunk_second=1,
min_energy_split_window_size=1600,
)
serving.max_audio_filesize_mb = 100.0
serving.model_cls = MagicMock()
serving.model_cls.validate_language.side_effect = lambda lang: lang
serving.model_cls.supports_explicit_language_detection = False
serving.model_cls.get_generation_prompt.return_value = {}
serving.model_config = MagicMock()
serving.task_type = "transcribe"
serving.renderer = MagicMock()
serving.renderer.render_cmpl_async = AsyncMock(
return_value=[MagicMock()] * len(chunks)
)
request = MagicMock()
request.language = "en"
request.to_language = None
request.response_format = "json"
request.build_stt_params.return_value = MagicMock()
with patch(f"{_PATCH}.parse_model_prompt", return_value=MagicMock()):
_, _, offsets = await serving._preprocess_speech_to_text(
request=request,
audio_data=b"\x00",
request_id="test",
)
# 断言累积偏移与期待一致,且不等于旧错误值
assert offsets == pytest.approx(expected_offsets, abs=1e-6)
assert offsets != pytest.approx(wrong_offsets, abs=1e-6)
评论区精华
风险与影响
- 风险:
- 签名兼容性:
_preprocess_speech_to_text 返回类型从二元组变为三元组,任何直接调用该方法的代码(如测试或子类)需同步更新。当前仓库内已全部适配,但外部 fork 可能受影响。
- 浮点累积误差:连续浮点加法在极端多 chunk(如数百段)时可能产生亚秒级偏移,但实际场景中 chunk 数量通常少于 20,风险极低。
- seek 取整合理性:
seek=int(start_time) 丢弃小数部分,但 seek 字段在 Whisper 协议中本意是整数索引位置,取整符合规范。
- 影响:
- 用户影响:使用 vLLM Whisper 转录长音频(>30s)的用户的 segment 时间戳将恢复准确,不再需要客户端侧按每段 0.5s 手动修正。
- 系统影响:无性能变化,仅计算偏移方式改变;代码可读性略有提升(累积逻辑比固定间隔更直观)。
- 团队影响:新增回归测试覆盖了此场景;文档同步更新避免后续贡献者误解 split_audio 行为。
- 风险标记:签名变更需同步调用方, 浮点累积微小误差, seek 取整语义正确
关联脉络
参与讨论