Fix Whisper transcription for audio over 30 seconds (#33604)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user