Fix Whisper transcription for audio over 30 seconds (#33604)

This commit is contained in:
Shenxiu Liu
2026-08-15 23:51:05 +08:00
committed by GitHub
parent 4beb157e87
commit fb97be4359
9 changed files with 1279 additions and 46 deletions
@@ -0,0 +1,102 @@
"""Energy-aware audio chunking for ASR models with a bounded input window.
Whisper-style encoders ingest a fixed-length window (30 s of audio = 3000
mel frames); the feature extractor silently truncates anything longer, so
long audio must be split into independent chunks before prompting. Cutting
blindly at the window boundary can land mid-word and corrupt the
transcription on both sides of the seam, so the splitter searches the tail
of each window for the quietest stretch (lowest RMS energy) and cuts there.
This matches the chunking behavior of vLLM's
``OpenAISpeechToText._split_audio`` / ``_find_split_point``: chunks are
contiguous and non-overlapping — the "search window" is only the region in
which the cut is allowed to land, and stitching the transcripts back is a
plain in-order concatenation.
"""
from __future__ import annotations
import io
import math
from typing import List, Tuple
import numpy as np
import soundfile as sf
from sglang.srt.utils import load_audio
# Region at the tail of each max-length window in which to search for a
# low-energy split point, in seconds.
SPLIT_SEARCH_WINDOW_S = 1.0
# RMS energy is evaluated over strides of this many samples (100 ms at
# 16 kHz); the quietest stride in the search region wins.
MIN_ENERGY_WINDOW_SIZE = 1600
def find_split_point(wav: np.ndarray, start_idx: int, end_idx: int) -> int:
"""Return the start index of the quietest energy window in
``wav[start_idx:end_idx]``.
Energy is RMS over consecutive ``MIN_ENERGY_WINDOW_SIZE``-sample
strides; the returned index is absolute (into ``wav``). The loop bound
intentionally leaves the final stride of the search region unevaluated:
it mirrors vLLM's ``_find_split_point`` verbatim so split points stay
bit-identical with the old vLLM transcription endpoint.
"""
segment = wav[start_idx:end_idx]
min_energy = math.inf
quietest_idx = start_idx
for i in range(0, len(segment) - MIN_ENERGY_WINDOW_SIZE, MIN_ENERGY_WINDOW_SIZE):
window = segment[i : i + MIN_ENERGY_WINDOW_SIZE]
energy = (window**2).mean() ** 0.5
if energy < min_energy:
quietest_idx = i + start_idx
min_energy = energy
return quietest_idx
def split_audio_energy_aware(
audio_data: bytes,
max_clip_s: float,
sample_rate: int = 16000,
) -> Tuple[List[bytes], List[float]]:
"""Split audio into WAV chunks no longer than ``max_clip_s`` seconds.
Decodes (and resamples to ``sample_rate``) the input, walks it in
``max_clip_s`` strides, and cuts each chunk at the lowest-energy point
within the final ``SPLIT_SEARCH_WINDOW_S`` of the stride so cuts land
on pauses instead of mid-word. Chunks are contiguous and
non-overlapping; concatenated they reproduce the full waveform.
Returns ``(chunk_wav_bytes, chunk_start_offsets_s)`` where
``chunk_start_offsets_s[i]`` is the start time of chunk ``i`` in the
original audio.
"""
if not audio_data:
raise ValueError("audio_data is empty")
audio = load_audio(audio_data, sr=sample_rate, mono=True)
chunk_size = int(sample_rate * max_clip_s)
search_size = int(sample_rate * SPLIT_SEARCH_WINDOW_S)
total = audio.shape[-1]
raw_chunks: List[np.ndarray] = []
offsets_s: List[float] = []
i = 0
while i < total:
offsets_s.append(i / sample_rate)
if i + chunk_size >= total:
raw_chunks.append(audio[i:])
break
search_start = i + chunk_size - search_size
search_end = min(i + chunk_size, total)
split_point = find_split_point(audio, search_start, search_end)
raw_chunks.append(audio[i:split_point])
i = split_point
chunks: List[bytes] = []
for chunk in raw_chunks:
buf = io.BytesIO()
sf.write(buf, chunk, sample_rate, format="WAV")
chunks.append(buf.getvalue())
return chunks, offsets_s
@@ -32,6 +32,7 @@ from typing import TYPE_CHECKING, AsyncGenerator, List, Optional, Union
from fastapi import Request, WebSocket
from fastapi.responses import ORJSONResponse, Response, StreamingResponse
from sglang.srt.entrypoints.openai.audio_chunking import split_audio_energy_aware
from sglang.srt.entrypoints.openai.protocol import (
DeltaMessage,
ErrorResponse,
@@ -113,9 +114,17 @@ class OpenAIServingTranscription(OpenAIServingBase):
info = sf.info(io.BytesIO(audio_data))
return info.duration
except Exception as e:
logger.warning(f"Could not calculate audio duration: {e}")
return 0.0
except Exception:
# soundfile can't parse some containers (e.g. mp3 on older
# libsndfile builds); fall back to a full decode.
try:
from sglang.srt.utils import load_audio
audio = load_audio(audio_data, sr=16000, mono=True)
return audio.shape[-1] / 16000.0
except Exception as e:
logger.warning(f"Could not calculate audio duration: {e}")
return 0.0
async def create_transcription(
self,
@@ -135,8 +144,63 @@ class OpenAIServingTranscription(OpenAIServingBase):
ORJSONResponse,
]:
"""Main entry point for transcription requests."""
# Calculate audio duration for usage reporting
audio_duration_s = self._get_audio_duration(audio_data)
# Calculate audio duration for usage reporting. Run in a thread:
# the fallback path decodes the full file and would block the
# event loop on long inputs.
audio_duration_s = await asyncio.to_thread(self._get_audio_duration, audio_data)
# Audio longer than the model's encoder window (30 s for Whisper)
# would be silently truncated by the feature extractor. Split it
# into chunks cut at low-energy points (pauses) so the cut never
# lands mid-word; each chunk is transcribed as an independent
# request and the texts are stitched back in order.
audio_chunks: Optional[List[bytes]] = None
chunk_offsets_s: Optional[List[float]] = None
max_clip_s = self._adapter.max_audio_clip_s
if max_clip_s is not None and audio_duration_s > max_clip_s:
split_error = (
f"Failed to split audio longer than {max_clip_s:g} seconds into "
"supported chunks."
)
try:
# In a thread: decodes + re-encodes the whole file, which
# would otherwise block the event loop on long inputs.
audio_chunks, chunk_offsets_s = await asyncio.to_thread(
split_audio_energy_aware, audio_data, max_clip_s
)
except Exception as e:
logger.warning(
"Failed to split %.1fs audio into chunks of <=%ss: %s",
audio_duration_s,
max_clip_s,
e,
)
return self.create_error_response(split_error)
else:
if (
not audio_chunks
or len(audio_chunks) <= 1
or chunk_offsets_s is None
or len(chunk_offsets_s) != len(audio_chunks)
):
logger.error(
"Audio splitter returned an invalid result for %.1fs audio: "
"%d chunks and %s offsets",
audio_duration_s,
len(audio_chunks or []),
(
"no"
if chunk_offsets_s is None
else str(len(chunk_offsets_s))
),
)
return self.create_error_response(split_error)
logger.info(
"Split %.1fs audio into %d chunks of <=%ss for transcription",
audio_duration_s,
len(audio_chunks),
max_clip_s,
)
# When language is not specified and the adapter supports detection,
# use a single fused request: SGLang's structured generation (regex)
@@ -172,6 +236,9 @@ class OpenAIServingTranscription(OpenAIServingBase):
# selection see the same boolean — and we don't recompute it on
# every cumulative-text snapshot in streaming.
request._fused_ts_variant = bool(timestamp_granularities)
if audio_chunks is not None and len(audio_chunks) > 1:
request._audio_chunks = audio_chunks
request._chunk_offsets_s = chunk_offsets_s
# Use the base class handle_request pattern
return await self.handle_request(request, raw_request)
@@ -189,6 +256,11 @@ class OpenAIServingTranscription(OpenAIServingBase):
Response,
]:
"""Handle non-streaming transcription request."""
if getattr(request, "_audio_chunks", None):
return await self._handle_chunked_non_streaming_request(
adapted_request, request, raw_request
)
try:
ret = await self.tokenizer_manager.generate_request(
adapted_request, raw_request
@@ -196,27 +268,7 @@ class OpenAIServingTranscription(OpenAIServingBase):
except ValueError as e:
return self.create_error_response(str(e))
text = self._adapter.postprocess_text(ret.get("text", ""))
# For fused auto-detect, parse_fused_output returns the scrubbed
# user-visible text. On parse failure (FSM abort, truncation) it
# returns (None, None) and we fall back to strip_special_tokens —
# the language stays unset rather than reporting a bogus detection.
if getattr(request, "_fused_autodetect", False):
lang, visible = self._adapter.parse_fused_output(
text, ts_variant=getattr(request, "_fused_ts_variant", False)
)
if visible is None:
logger.warning(
"Fused auto-detect parse failed on non-streaming response; "
"falling back to raw-text scrub."
)
text = self._adapter.strip_special_tokens(text)
else:
text = visible
if lang is not None:
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
text = self._finalize_text(request, ret.get("text", ""))
usage = TranscriptionUsage(seconds=int(math.ceil(request.audio_duration_s)))
@@ -233,6 +285,161 @@ class OpenAIServingTranscription(OpenAIServingBase):
# Default JSON format
return TranscriptionResponse(text=text, usage=usage)
def _finalize_text(
self, request: TranscriptionRequest, raw_text: str, strip: bool = True
) -> str:
"""Postprocess one generation's raw text into user-visible text.
For fused auto-detect requests, parse_fused_output returns the
scrubbed user-visible text and the detected language is recorded on
the request (first non-empty parsed chunk wins for chunked audio). On
parse failure (FSM abort, truncation) it returns (None, None) and we
fall back to a best-effort scrub — the language stays unset rather
than reporting a bogus detection.
``strip=False`` keeps the model-emitted boundary whitespace in the
fused path; chunk stitching needs it as the natural separator.
"""
text = self._adapter.postprocess_text(raw_text)
if not getattr(request, "_fused_autodetect", False):
return text
lang, visible = self._adapter.parse_fused_output(
text,
ts_variant=getattr(request, "_fused_ts_variant", False),
strip=strip,
)
if visible is None:
logger.warning(
"Fused auto-detect parse failed on non-streaming response; "
"falling back to raw-text scrub."
)
return self._adapter.strip_special_tokens(text)
if lang is not None and visible.strip() and request.language is None:
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
return visible
def _build_chunk_request(
self,
adapted_request: GenerateReqInput,
chunk_audio: bytes,
stream: bool,
) -> GenerateReqInput:
"""Clone the adapted request for one audio chunk.
``sampling_params`` must be a fresh dict per chunk: the multimodal
processor pops transcription-level keys (language,
timestamp_granularities, the fused-autodetect flag) out of it while
building each chunk's decoder prompt.
"""
sampling_params = adapted_request.sampling_params
assert isinstance(sampling_params, dict)
chunk_request = GenerateReqInput(
text="",
audio_data=chunk_audio,
sampling_params=dict(sampling_params),
stream=stream,
modalities=["audio"],
routing_key=adapted_request.routing_key,
)
chunk_request.received_time = adapted_request.received_time
return chunk_request
def _abort_chunk_requests(self, chunk_requests: List[GenerateReqInput]) -> None:
"""Abort chunk generations engine-side.
The scheduler keeps decoding until a request is aborted by rid (a
no-op for chunks that already finished). A request whose rid has not
been assigned yet cannot be aborted by the immediate pass, so a second
abort fires after the dispatch window — the same approach as
``TokenizerManager.create_abort_task``.
"""
def abort_assigned_rids():
# Read rids at call time: a chunk task that had not started
# executing during the first pass gets its rid assigned later,
# so the delayed pass must not reuse an earlier snapshot.
for chunk_request in chunk_requests:
if isinstance(chunk_request.rid, str):
self.tokenizer_manager.abort_request(chunk_request.rid)
abort_assigned_rids()
async def abort_after_dispatch_window():
await asyncio.sleep(2)
abort_assigned_rids()
asyncio.create_task(abort_after_dispatch_window())
async def _handle_chunked_non_streaming_request(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> Union[
TranscriptionResponse,
TranscriptionVerboseResponse,
ErrorResponse,
ORJSONResponse,
Response,
]:
"""Transcribe pre-split long audio (duration > max_audio_clip_s).
Each chunk is an independent generation. Chunks run sequentially so
one user-controlled upload cannot fan out into an unbounded number of
tokenizer/GPU requests. Results are stitched in chunk (= audio) order.
"""
chunk_requests = [
self._build_chunk_request(adapted_request, chunk_audio, stream=False)
for chunk_audio in request._audio_chunks
]
rets = []
try:
for chunk_request in chunk_requests:
ret = await self.tokenizer_manager.generate_request(
chunk_request, raw_request
).__anext__()
rets.append(ret)
except BaseException as e:
# Abort the in-flight request on failures or parent cancellation.
# Later chunks have not been dispatched.
self._abort_chunk_requests(chunk_requests)
if isinstance(e, ValueError):
return self.create_error_response(str(e))
raise
fused = getattr(request, "_fused_autodetect", False)
# Plain in-order concatenation: each chunk's model-emitted boundary
# whitespace (a leading space for spaced scripts, nothing for
# spaceless scripts like zh/ja/th) is the correct seam separator,
# so the fused path keeps it (strip=False) instead of inventing
# one. Only the full fused text is trimmed at the ends, matching
# the single-request fused response.
texts = [
self._finalize_text(request, ret.get("text", ""), strip=False)
for ret in rets
]
text = "".join(texts)
if fused:
text = text.strip()
usage = TranscriptionUsage(seconds=int(math.ceil(request.audio_duration_s)))
if request.response_format == "text":
return Response(content=text, media_type="text/plain")
if request.response_format == "verbose_json":
return self._adapter.build_verbose_response_chunked(
request,
text,
rets,
request._chunk_offsets_s,
self.tokenizer_manager.tokenizer,
usage,
)
return TranscriptionResponse(text=text, usage=usage)
async def _handle_streaming_request(
self,
adapted_request: GenerateReqInput,
@@ -249,6 +456,15 @@ class OpenAIServingTranscription(OpenAIServingBase):
),
media_type="text/event-stream",
)
if getattr(request, "_audio_chunks", None):
# Long audio pre-split into chunks, transcribed sequentially.
# No background abort_task: the in-flight chunk is aborted in
# the generator's finally on teardown, and disconnect is checked
# between chunks.
return StreamingResponse(
self._generate_long_audio_stream(adapted_request, request, raw_request),
media_type="text/event-stream",
)
return StreamingResponse(
self._generate_transcription_stream(adapted_request, request, raw_request),
media_type="text/event-stream",
@@ -326,7 +542,11 @@ class OpenAIServingTranscription(OpenAIServingBase):
yield f"data: {error}\n\n"
yield "data: [DONE]\n\n"
return
if lang is not None and request.language is None:
if (
lang is not None
and visible.strip()
and request.language is None
):
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
else:
@@ -369,6 +589,136 @@ class OpenAIServingTranscription(OpenAIServingBase):
yield "data: [DONE]\n\n"
async def _generate_long_audio_stream(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> AsyncGenerator[str, None]:
"""Stream transcription of long audio pre-split into chunks.
Chunks are transcribed sequentially (one streaming request at a
time, like ``_generate_chunked_asr_stream``), so the client sees
the transcript in audio order with a single finish frame after the
last chunk. The first abnormal chunk finish_reason (length/abort)
wins so a truncated non-final chunk isn't masked by later chunks
stopping cleanly. Fused auto-detect applies per chunk; the reported
language is the first non-empty chunk's detection. Disconnect is
checked between chunks, and the in-flight chunk is aborted on teardown.
``request._chunk_offsets_s`` is unused here — only the non-streaming
verbose_json path needs segment timing.
"""
created_time = int(time.time())
request_id = f"{self._request_id_prefix()}{uuid.uuid4().hex}"
model = request.model
fused_mode = getattr(request, "_fused_autodetect", False)
ts_variant = getattr(request, "_fused_ts_variant", False)
incremental = getattr(
self.tokenizer_manager.server_args,
"incremental_streaming_output",
False,
)
def _frame(delta: Optional[str], finish_reason: Optional[str] = None) -> str:
chunk = TranscriptionStreamResponse(
id=request_id,
created=created_time,
model=model,
choices=[
TranscriptionStreamChoice(
delta=DeltaMessage(content=delta) if delta else DeltaMessage(),
finish_reason=finish_reason,
)
],
)
return f"data: {chunk.model_dump_json()}\n\n"
finish_reason_type = None
in_flight: Optional[GenerateReqInput] = None
emitted_text = False
try:
for chunk_audio in request._audio_chunks:
if await raw_request.is_disconnected():
break
in_flight = self._build_chunk_request(
adapted_request, chunk_audio, stream=True
)
cumulative_text = ""
visible_buffer = ""
chunk_finish_reason = None
strip_chunk = not emitted_text
async for content in self.tokenizer_manager.generate_request(
in_flight, raw_request
):
finish_reason = content["meta_info"]["finish_reason"]
chunk_finish_reason = (
finish_reason["type"] if finish_reason else None
)
chunk_text = content.get("text", "")
if incremental:
cumulative_text += chunk_text
else:
cumulative_text = chunk_text
if fused_mode:
# Strip the first chunk that emits visible text (no
# leading space at stream start, like the single-request
# path). Later visible chunks keep their model-emitted
# boundary whitespace, which is the correct seam
# separator for spaced and spaceless scripts alike.
lang, visible = self._adapter.parse_fused_output(
cumulative_text,
ts_variant=ts_variant,
strip=strip_chunk,
)
if visible is None:
if not chunk_finish_reason:
continue
logger.warning(
"Fused auto-detect stream finished before prefix "
"was parseable; returning detection-failed error."
)
error = self.create_streaming_error_response(
"language auto-detect failed: forced-prefix "
"sentinel was not produced before stream end"
)
yield f"data: {error}\n\n"
yield "data: [DONE]\n\n"
return
if (
lang is not None
and visible.strip()
and request.language is None
):
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
else:
visible = cumulative_text
delta = visible[len(visible_buffer) :]
visible_buffer = visible
if delta:
yield _frame(delta)
emitted_text = True
in_flight = None
if finish_reason_type in (None, "stop") and chunk_finish_reason:
finish_reason_type = chunk_finish_reason
yield _frame(None, finish_reason=finish_reason_type or "stop")
except ValueError as e:
error = self.create_streaming_error_response(str(e))
yield f"data: {error}\n\n"
finally:
# Abort the chunk still decoding if the stream is torn down
# (client disconnect / generator close) so it doesn't keep
# running in the scheduler. Only one chunk is ever in flight.
if in_flight is not None and isinstance(in_flight.rid, str):
self.tokenizer_manager.abort_request(in_flight.rid)
yield "data: [DONE]\n\n"
async def _generate_chunked_asr_stream(
self,
adapted_request: GenerateReqInput,
@@ -44,7 +44,7 @@ class TranscriptionAdapter(ABC):
@staticmethod
def parse_fused_output(
text: str, *, ts_variant: bool = False
text: str, *, ts_variant: bool = False, strip: bool = True
) -> tuple[Optional[str], Optional[str]]:
"""Parse the fused output into ``(language_code, user_visible_text)``.
@@ -76,6 +76,17 @@ class TranscriptionAdapter(ABC):
"""
return text
@property
def max_audio_clip_s(self) -> Optional[float]:
"""Maximum audio duration (seconds) the model can ingest per request.
Audio longer than this is split at low-energy points into
contiguous chunks (see ``audio_chunking.split_audio_energy_aware``),
transcribed as independent requests, and stitched back in order.
``None`` disables chunking.
"""
return None
@property
def supports_chunked_streaming(self) -> bool:
"""Whether this model uses chunk-based streaming instead of token-level streaming."""
@@ -125,6 +136,31 @@ class TranscriptionAdapter(ABC):
) -> TranscriptionVerboseResponse:
"""Build a ``verbose_json`` response with segments / timestamps."""
def build_verbose_response_chunked(
self,
request: TranscriptionRequest,
text: str,
rets: List[dict],
chunk_offsets_s: List[float],
tokenizer,
usage: TranscriptionUsage,
) -> TranscriptionVerboseResponse:
"""Build a ``verbose_json`` response from multiple chunk results.
Called instead of ``build_verbose_response`` when the audio was
longer than ``max_audio_clip_s`` and split into chunks;
``chunk_offsets_s[i]`` is chunk *i*'s start time in the original
audio. The default implementation returns the stitched text with
no segment timing.
"""
return TranscriptionVerboseResponse(
language=request.language,
duration=round(request.audio_duration_s, 2),
text=text,
segments=[],
usage=usage,
)
_ADAPTER_REGISTRY: dict[str, type[TranscriptionAdapter]] = {}
_DEFAULT_ADAPTER_KEY = "Whisper"
@@ -126,6 +126,13 @@ class WhisperAdapter(TranscriptionAdapter):
TIMESTAMP_BASE_TOKEN_ID = 50365 # <|0.00|>
TIMESTAMP_BASE_OFFSET = 0.02 # each token step = 0.02 s
@property
def max_audio_clip_s(self) -> Optional[float]:
# Whisper's encoder ingests a fixed 30 s window (3000 mel frames);
# the feature extractor truncates anything longer, so longer audio
# must be split into chunks before prompting.
return 30.0
def build_sampling_params(self, request: TranscriptionRequest) -> dict:
params: dict = {
"temperature": request.temperature,
@@ -185,7 +192,7 @@ class WhisperAdapter(TranscriptionAdapter):
@staticmethod
def parse_fused_output(
text: str, *, ts_variant: bool = False
text: str, *, ts_variant: bool = False, strip: bool = True
) -> tuple[Optional[str], Optional[str]]:
"""Parse fused output into ``(language_code, user_visible_text)``.
@@ -209,9 +216,16 @@ class WhisperAdapter(TranscriptionAdapter):
* ``(lang, visible)`` — prefix fully parsed. ``visible`` is the
transcription with the forced prefix removed, any embedded
special tokens (``<|X.XX|>``, ``<|endoftext|>``) scrubbed, and
surrounding whitespace trimmed. It grows monotonically across
streaming chunks because Whisper's special tokens detokenize
atomically, so callers can compute deltas against it directly.
(with ``strip=True``, the default) surrounding whitespace
trimmed. It grows monotonically across streaming chunks because
Whisper's special tokens detokenize atomically, so callers can
compute deltas against it directly.
``strip=False`` preserves the model-emitted boundary whitespace.
Long-audio chunk stitching relies on it: a chunk's own leading
space (or its absence, for spaceless scripts like zh/ja/th) is the
correct separator at the chunk seam, so an artificial one must not
be invented after stripping.
"""
pattern = _FUSED_PREFIX_RE_TS if ts_variant else _FUSED_PREFIX_RE_NOTS
m = pattern.match(text)
@@ -225,7 +239,7 @@ class WhisperAdapter(TranscriptionAdapter):
# are unwanted in the user-visible text (verbose_json gets its
# segments from _parse_segments over output_ids instead).
transcription = _SPECIAL_TOKEN_RE.sub("", transcription)
return m.group(1), transcription.strip()
return m.group(1), transcription.strip() if strip else transcription
@staticmethod
def strip_special_tokens(text: str) -> str:
@@ -260,9 +274,41 @@ class WhisperAdapter(TranscriptionAdapter):
usage=usage,
)
def build_verbose_response_chunked(
self,
request: TranscriptionRequest,
text: str,
rets: List[dict],
chunk_offsets_s: List[float],
tokenizer,
usage: TranscriptionUsage,
) -> TranscriptionVerboseResponse:
segments: list[TranscriptionSegment] = []
for ret, offset_s in zip(rets, chunk_offsets_s):
_, part_segments = self._parse_segments(
ret.get("output_ids", []),
tokenizer,
time_offset_s=offset_s,
seg_id_start=len(segments),
)
segments.extend(part_segments)
return TranscriptionVerboseResponse(
language=request.language,
duration=round(request.audio_duration_s, 2),
# The serving layer already stitched model text using each
# chunk's emitted boundary whitespace. Reconstructing it from
# output_ids with an ASCII join corrupts spaceless scripts.
text=text,
segments=segments,
usage=usage,
)
@staticmethod
def _parse_segments(
output_ids: List[int], tokenizer
output_ids: List[int],
tokenizer,
time_offset_s: float = 0.0,
seg_id_start: int = 0,
) -> tuple[str, List[TranscriptionSegment]]:
"""Parse Whisper timestamp tokens from *output_ids* into segments.
@@ -273,6 +319,11 @@ class WhisperAdapter(TranscriptionAdapter):
Each timestamp token marks the end of the current segment; its value
also becomes the start of the next segment.
``time_offset_s`` / ``seg_id_start`` shift segment times and ids for
audio that was split into chunks — timestamp tokens are relative to
the chunk's own 30 s window, so each chunk's segments are offset by
the chunk's start time within the original audio.
"""
eos_token_id = getattr(tokenizer, "eos_token_id", 50257)
ts_base = WhisperAdapter.TIMESTAMP_BASE_TOKEN_ID
@@ -281,12 +332,13 @@ class WhisperAdapter(TranscriptionAdapter):
segments: list[TranscriptionSegment] = []
full_text_parts: list[str] = []
current_text_tokens: list[int] = []
current_start = 0.0 # First segment starts at 0.0 (from prompt <|0.00|>)
seg_id = 0
# First segment starts at the chunk start (prompt anchors <|0.00|>)
current_start = time_offset_s
seg_id = seg_id_start
for token_id in output_ids:
if token_id >= ts_base:
timestamp = (token_id - ts_base) * ts_step
timestamp = (token_id - ts_base) * ts_step + time_offset_s
if current_text_tokens:
seg_text = tokenizer.decode(
+2 -2
View File
@@ -998,7 +998,7 @@ def get_compiler_backend(mode=None) -> str:
if hasattr(torch, "npu") and torch.npu.is_available():
try:
import torchair
import torchair.ge_concrete_graph.ge_converter.experimental.patch_for_hcom_allreduce
import torchair.ge_concrete_graph.ge_converter.experimental.patch_for_hcom_allreduce # noqa: F401
from torchair.configs.compiler_config import CompilerConfig
except ImportError:
raise ImportError(
@@ -1687,7 +1687,7 @@ CLIENT_MEDIA_EXCEPTIONS = (
def load_audio(
audio_file: str, sr: Optional[int] = None, mono: bool = True
audio_file: Union[str, bytes], sr: Optional[int] = None, mono: bool = True
) -> np.ndarray:
if sr is None:
sr = 16000