main
py 638 lines 24.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import math
4 from collections.abc import Callable
5 from io import BytesIO
6 from pathlib import Path
7
8 import numpy as np
9 import numpy.typing as npt
10 import pybase64
11 import torch
12
13 import vllm.envs as envs
14 from vllm.exceptions import VLLMValidationError
15 from vllm.logger import init_logger
16 from vllm.multimodal.audio import resample_audio_pyav
17 from vllm.utils.import_utils import PlaceholderModule
18 from vllm.utils.math_utils import cdiv
19 from vllm.utils.mem_constants import MiB_bytes
20 from vllm.utils.serial_utils import tensor2base64
21 from vllm.utils.sparse_utils import (
22 check_sparse_tensor_invariants_threadsafe,
23 safe_to_dense,
24 )
25
26 from .base import MediaIO
27
28 logger = init_logger(__name__)
29
30 try:
31 import av
32 except ImportError:
33 av = PlaceholderModule("av") # type: ignore[assignment]
34
35 try:
36 import soundfile
37 except ImportError:
38 soundfile = PlaceholderModule("soundfile") # type: ignore[assignment]
39
40 try:
41 from torchcodec.decoders import AudioDecoder
42 except (ImportError, RuntimeError) as exc:
43 # RuntimeError: torchcodec is installed but the system ffmpeg is missing.
44 AudioDecoder = None # type: ignore[assignment]
45 _torchcodec_import_exc: BaseException | None = exc
46 else:
47 _torchcodec_import_exc = None
48
49
50 # Public libsndfile error codes exposed via `soundfile.LibsndfileError.code`,
51 # soundfile being the main audio loading backend. Used to validate if an audio
52 # loading error is due to a server error vs a client error (invalid audio file).
53 # 0 = sf_error(NULL) race condition: when multiple threads fail sf_open_virtual
54 # concurrently, one thread may clear the global error before another reads it,
55 # producing code=0 ("Garbled error message from libsndfile" in soundfile).
56 # See: https://github.com/bastibe/python-soundfile/issues/479
57 # 1 = unrecognised format (file is not a supported audio container)
58 # 3 = malformed file (corrupt or structurally invalid audio)
59 # 4 = unsupported encoding (codec not supported by this libsndfile build)
60 _BAD_SF_CODES = {0, 1, 3, 4}
61
62 # Audio decoding backends selectable via `load_audio(backend=...)` or
63 # `--media-io-kwargs '{"audio": {"audio_backend": ...}}'`.
64 # "auto" tries torchcodec, then soundfile, then PyAV.
65 AUDIO_BACKENDS = ("auto", "soundfile", "pyav", "torchcodec")
66
67 # Raised as ImportError when the torchcodec backend cannot be used, so
68 # `load_audio(backend="auto")` falls back to the soundfile → PyAV chain.
69 _TORCHCODEC_UNAVAILABLE_MSG = (
70 "torchcodec audio backend is unavailable (requires the torchcodec "
71 "package and a system ffmpeg installation)"
72 )
73
74 # Slack on the torchcodec decode window when enforcing `max_duration_s`, so an
75 # over-long stream is rejected rather than truncated to the limit.
76 _DURATION_GUARD_MARGIN_S = 1.0
77
78 # Largest Vorbis block size in native-rate samples
79 _VORBIS_MAX_BLOCK_SAMPLES = 8192
80
81
82 def load_audio_pyav(
83 path: BytesIO | Path | str,
84 *,
85 sr: float | None = 22050,
86 mono: bool = True,
87 max_duration_s: float | None = None,
88 max_decode_bytes: int | None = None,
89 ) -> tuple[npt.NDArray, float]:
90 """Load an audio file using PyAV (FFmpeg), returning float32 mono waveform.
91
92 Decodes the audio stream at its native sample rate. Channel reduction to
93 mono is performed by averaging across channels. Resampling to a
94 model-specific rate is left to the downstream :class:`AudioResampler`.
95
96 Args:
97 path: A :class:`~io.BytesIO` buffer, a filesystem
98 :class:`~pathlib.Path`, or a string path.
99 sr: Target sample rate, or None to keep the native rate.
100 mono: Whether to average the channels down to mono.
101 max_decode_bytes: If set, abort decoding once this many bytes have
102 been read from the source.
103 max_duration_s: If set, abort decoding once the accumulated
104 sample count exceeds this many seconds of audio. Prevents
105 decompression-bomb attacks where a small compressed file
106 expands into gigabytes of PCM.
107
108 Returns:
109 ``(waveform, sample_rate)`` where *waveform* is a 1-D float32
110 NumPy array and *sample_rate* is the native sample rate in Hz.
111
112 """
113 try:
114 container = av.open(path)
115 except av.error.FFmpegError as e:
116 raise ValueError(
117 "Invalid or corrupted audio data. Ensure the input is a valid "
118 "audio or video file (e.g. a complete WAV, MP3, or MP4)."
119 ) from e
120
121 with container:
122 if not container.streams.audio:
123 raise ValueError("No audio stream found.")
124 stream = container.streams.audio[0]
125 stream.thread_type = "AUTO"
126 native_sr = stream.rate
127 sr = sr or native_sr
128
129 # Early rejection from container/stream metadata to avoid
130 # wasting resources on decoding decompression bombs.
131 if max_duration_s is not None:
132 metadata_duration_s = None
133 if stream.duration and stream.time_base:
134 metadata_duration_s = float(stream.duration * stream.time_base)
135 elif container.duration:
136 metadata_duration_s = container.duration / 1_000_000
137 if metadata_duration_s is not None and metadata_duration_s > max_duration_s:
138 raise ValueError(
139 f"Audio exceeds maximum allowed duration of "
140 f"{max_duration_s}s (metadata reports "
141 f"{metadata_duration_s:.1f}s). Set "
142 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
143 f"increase this limit."
144 )
145
146 max_samples = int(sr * max_duration_s) if max_duration_s is not None else None
147 total_samples = 0
148 total_decode_bytes = 0
149
150 chunks: list[npt.NDArray] = []
151 needs_resampling = not math.isclose(
152 float(sr),
153 float(native_sr),
154 rel_tol=0.0,
155 abs_tol=1e-6,
156 )
157 resampler = (
158 av.AudioResampler(format="fltp", layout="mono", rate=sr)
159 if needs_resampling
160 else None
161 )
162 try:
163 for frame in container.decode(stream):
164 if needs_resampling:
165 assert resampler is not None
166 for out_frame in resampler.resample(frame):
167 arr = out_frame.to_ndarray()
168 total_samples += arr.shape[-1]
169 total_decode_bytes += arr.nbytes
170 chunks.append(arr)
171 else:
172 arr = frame.to_ndarray()
173 total_samples += arr.shape[-1]
174 total_decode_bytes += arr.nbytes
175 chunks.append(arr)
176
177 if max_samples is not None and total_samples > max_samples:
178 raise ValueError(
179 f"Audio exceeds maximum allowed duration of "
180 f"{max_duration_s}s (decoded {total_samples} "
181 f"samples at {sr}Hz). Set "
182 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
183 f"increase this limit."
184 )
185 if (
186 max_decode_bytes is not None
187 and total_decode_bytes > max_decode_bytes
188 ):
189 raise ValueError(
190 f"Audio decode exceeded "
191 f"{max_decode_bytes / MiB_bytes:.0f} MiB memory "
192 f"limit ({total_decode_bytes / MiB_bytes:.0f} MiB "
193 f"decoded so far). Set "
194 f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this "
195 f"limit."
196 )
197 except av.error.FFmpegError as e:
198 raise ValueError(
199 "Invalid or corrupted audio data. Ensure the input is a valid "
200 "audio or video file (e.g. a complete WAV, MP3, or MP4)."
201 ) from e
202
203 if not chunks:
204 raise ValueError("No audio found in the input.")
205
206 audio = np.concatenate(chunks, axis=-1).astype(np.float32)
207 if mono and audio.ndim > 1:
208 audio = np.mean(audio, axis=0)
209
210 return audio, sr
211
212
213 def load_audio_soundfile(
214 path: BytesIO | Path | str,
215 *,
216 sr: float | None = 22050,
217 mono: bool = True,
218 max_duration_s: float | None = None,
219 max_decode_bytes: int | None = None,
220 ) -> tuple[np.ndarray, int]:
221 """Load audio via soundfile."""
222 with soundfile.SoundFile(path) as f:
223 native_sr = f.samplerate
224 if max_duration_s is not None:
225 file_duration_s = f.frames / native_sr
226 if file_duration_s > max_duration_s:
227 raise ValueError(
228 f"Audio exceeds maximum allowed duration of "
229 f"{max_duration_s}s (file contains "
230 f"{file_duration_s:.1f}s at {native_sr}Hz). Set "
231 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
232 f"increase this limit."
233 )
234 if max_decode_bytes is not None:
235 estimated_bytes = f.frames * f.channels * np.dtype(np.float32).itemsize
236 if estimated_bytes > max_decode_bytes:
237 raise ValueError(
238 f"Audio would allocate {estimated_bytes / MiB_bytes:.0f} "
239 f"MiB of PCM ({f.frames} frames x {f.channels} channels"
240 f" x 4B), exceeding the "
241 f"{max_decode_bytes / MiB_bytes:.0f} MiB limit. Set "
242 f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this limit."
243 )
244 y = f.read(dtype="float32", always_2d=False).T
245
246 if mono and y.ndim > 1:
247 y = np.mean(y, axis=tuple(range(y.ndim - 1)))
248
249 if sr is not None and sr != native_sr:
250 y = resample_audio_pyav(y, orig_sr=native_sr, target_sr=sr)
251 return y, int(sr)
252 return y, native_sr
253
254
255 def load_audio_torchcodec(
256 path: BytesIO | Path | str,
257 *,
258 sr: float | None = 22050,
259 mono: bool = True,
260 max_duration_s: float | None = None,
261 max_decode_bytes: int | None = None,
262 ) -> tuple[npt.NDArray, float]:
263 """Load an audio file using torchcodec, returning a float32 waveform.
264
265 Unlike :func:`load_audio_pyav`, which drives FFmpeg through a per-frame
266 Python generator, torchcodec decodes inside a single call that releases
267 the GIL. Resampling also happens in C++ (``sample_rate=sr``), so this
268 backend never falls back to PyAV or libsndfile.
269
270 Args:
271 path: A :class:`~io.BytesIO` buffer, a filesystem
272 :class:`~pathlib.Path`, or a string path.
273 sr: Output sample rate; ``None`` keeps the native rate. Must be an
274 integer when set.
275 mono: Average channels down to a single channel.
276 max_duration_s: Reject audio longer than this many seconds.
277 Prevents decompression-bomb attacks where a small compressed
278 file expands into gigabytes of PCM.
279 max_decode_bytes: Reject audio whose decoded PCM exceeds this many
280 bytes.
281
282 Returns:
283 ``(waveform, sample_rate)`` where *waveform* is a float32 NumPy
284 array (1-D when ``mono=True``) and *sample_rate* is the output
285 sample rate in Hz.
286
287 """
288 if AudioDecoder is None:
289 # Unify "torchcodec not installed" and "system ffmpeg missing" into
290 # ImportError so `load_audio(backend="auto")` falls back cleanly.
291 raise ImportError(_TORCHCODEC_UNAVAILABLE_MSG) from _torchcodec_import_exc
292 if sr is not None and sr != int(sr):
293 raise ValueError(f"torchcodec requires an integer sample rate, got {sr}")
294 sample_rate = int(sr) if sr is not None else None
295
296 # Opening the source and reading container metadata both touch untrusted
297 # input; torchcodec signals corrupt/unsupported data with RuntimeError.
298 try:
299 decoder = AudioDecoder(path, sample_rate=sample_rate)
300 metadata = decoder.metadata
301 except RuntimeError as e:
302 # torchcodec loads its ffmpeg-backed core lazily at construction, so
303 # an ffmpeg-less (or ABI-incompatible) install can fail here rather
304 # than at import. Treat that as backend-unavailable, not bad input.
305 if "Could not load libtorchcodec" in str(e):
306 raise ImportError(_TORCHCODEC_UNAVAILABLE_MSG) from e
307 raise ValueError(
308 "Invalid or corrupted audio data. Ensure the input is a valid "
309 "audio or video file (e.g. a complete WAV, MP3, or MP4)."
310 ) from e
311
312 # Pre-check the memory budget from container metadata so an obviously
313 # oversized stream is rejected before allocating the PCM buffer. Metadata
314 # is attacker-controlled, so this may only reject, never admit; the
315 # post-decode check below is authoritative.
316 if max_decode_bytes is not None:
317 est_duration_s = metadata.duration_seconds
318 # Estimate at the *output* rate: when `sr` is set torchcodec
319 # resamples to it during decode.
320 est_sample_rate = sr if sr is not None else metadata.sample_rate
321 est_num_channels = metadata.num_channels
322 if (
323 est_duration_s is not None
324 and est_sample_rate is not None
325 and est_num_channels is not None
326 ):
327 estimated_bytes = (
328 int(est_duration_s * est_sample_rate)
329 * est_num_channels
330 * np.dtype(np.float32).itemsize
331 )
332 if estimated_bytes > max_decode_bytes:
333 raise ValueError(
334 f"Audio would allocate "
335 f"{estimated_bytes / MiB_bytes:.0f} MiB of PCM "
336 f"(~{est_duration_s:.1f}s at {est_sample_rate}Hz x "
337 f"{est_num_channels}ch), exceeding the "
338 f"{max_decode_bytes / MiB_bytes:.0f} MiB limit. Set "
339 f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this limit."
340 )
341
342 # Duration pre-check, same attacker-controlled → reject-only semantics.
343 if max_duration_s is not None:
344 duration_s = metadata.duration_seconds
345 if duration_s is not None and duration_s > max_duration_s:
346 raise ValueError(
347 f"Audio exceeds maximum allowed duration of "
348 f"{max_duration_s}s (metadata reports "
349 f"{duration_s:.1f}s). Set "
350 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
351 f"increase this limit."
352 )
353
354 # Decode the stream. RuntimeError here covers a corrupt stream or a
355 # container that lies about its length. Bounding the range (when a
356 # duration limit is set) keeps an under-reported duration from expanding
357 # into unbounded PCM.
358 try:
359 if max_duration_s is None:
360 samples = decoder.get_all_samples()
361 else:
362 samples = decoder.get_samples_played_in_range(
363 0.0, max_duration_s + _DURATION_GUARD_MARGIN_S
364 )
365 except RuntimeError as e:
366 raise ValueError(
367 "Invalid or corrupted audio data. Ensure the input is a valid "
368 "audio or video file (e.g. a complete WAV, MP3, or MP4)."
369 ) from e
370
371 # FFmpeg < 5.0 misses Ogg end_trimming, leaving up to one Vorbis block of
372 # padding at the tail; we need to trim for valid audio length.
373 if metadata.codec == "vorbis" and metadata.duration_seconds is not None:
374 expected_samples = round(metadata.duration_seconds * samples.sample_rate)
375 padding = samples.data.shape[-1] - expected_samples
376 native_sr = metadata.sample_rate or samples.sample_rate
377 max_padding = cdiv(_VORBIS_MAX_BLOCK_SAMPLES * samples.sample_rate, native_sr)
378 if 0 < padding <= max_padding:
379 samples.data = samples.data[..., :expected_samples]
380
381 # Authoritative post-decode checks: the metadata estimates above can be
382 # bypassed by a container that lies about its duration or size.
383 if max_duration_s is not None:
384 # Same `int()` semantics as load_audio_pyav's
385 # `total_samples > int(sr * max_duration_s)`.
386 max_samples = int(samples.sample_rate * max_duration_s)
387 if samples.data.shape[-1] > max_samples:
388 raise ValueError(
389 f"Audio exceeds maximum allowed duration of "
390 f"{max_duration_s}s (decoded {samples.data.shape[-1]} "
391 f"samples at {samples.sample_rate}Hz). Set "
392 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
393 f"increase this limit."
394 )
395 if max_decode_bytes is not None and samples.data.nbytes > max_decode_bytes:
396 raise ValueError(
397 f"Audio decode exceeded "
398 f"{max_decode_bytes / MiB_bytes:.0f} MiB memory "
399 f"limit ({samples.data.nbytes / MiB_bytes:.0f} MiB decoded). "
400 f"Set VLLM_MAX_AUDIO_DECODE_BYTES to increase this limit."
401 )
402
403 out_sr = samples.sample_rate
404 audio = samples.data.numpy() # (num_channels, num_samples), float32
405 if audio.size == 0:
406 raise ValueError("No audio found in the input.")
407
408 if mono and audio.ndim > 1:
409 # Same reduction as load_audio_pyav, so both backends agree at the
410 # native sample rate, which is what AudioMediaIO requests.
411 audio = np.mean(audio, axis=0)
412
413 return audio, out_sr
414
415
416 _AUDIO_LOADERS: dict[str, Callable[..., tuple[npt.NDArray, float]]] = {
417 "torchcodec": load_audio_torchcodec,
418 "pyav": load_audio_pyav,
419 "soundfile": load_audio_soundfile,
420 }
421
422
423 def load_audio(
424 path: BytesIO | Path | str,
425 *,
426 sr: float | None = 22050,
427 mono: bool = True,
428 max_duration_s: float | None = None,
429 max_decode_bytes: int | None = None,
430 backend: str | None = None,
431 ):
432 """Load audio using the selected decoding backend.
433
434 Args:
435 path: Audio file path or in-memory buffer.
436 sr: Target sample rate, or None to keep the native rate.
437 mono: Whether to downmix to a single channel.
438 max_duration_s: Reject audio longer than this many seconds.
439 max_decode_bytes: Reject audio that would decode to more than this
440 many bytes.
441 backend: One of ``AUDIO_BACKENDS``. ``None`` (default) selects
442 ``"auto"``, which tries torchcodec, then falls back to the
443 soundfile → PyAV chain; the other values select a single
444 backend with no fallback.
445
446 """
447 backend = backend or "auto"
448 if backend not in AUDIO_BACKENDS:
449 raise ValueError(
450 f"Unknown audio backend {backend!r}. "
451 f"Available backends: {list(AUDIO_BACKENDS)}"
452 )
453
454 if backend != "auto":
455 return _AUDIO_LOADERS[backend](
456 path,
457 sr=sr,
458 mono=mono,
459 max_duration_s=max_duration_s,
460 max_decode_bytes=max_decode_bytes,
461 )
462
463 # "auto": torchcodec → soundfile → PyAV, each called by its bare name so
464 # monkeypatched tests take effect. Only ImportError (or, for soundfile, an
465 # unsupported-format error) defers to the next backend; anything else raises.
466 if isinstance(path, BytesIO):
467 path.seek(0)
468 try:
469 return load_audio_torchcodec(
470 path,
471 sr=sr,
472 mono=mono,
473 max_duration_s=max_duration_s,
474 max_decode_bytes=max_decode_bytes,
475 )
476 except ImportError as exc:
477 # Decode errors don't defer: FFmpeg-based PyAV would fail the same way.
478 logger.warning(
479 "torchcodec unavailable (%r); falling back to soundfile/PyAV.", exc
480 )
481
482 if isinstance(path, BytesIO):
483 path.seek(0)
484 try:
485 return load_audio_soundfile(
486 path,
487 sr=sr,
488 mono=mono,
489 max_duration_s=max_duration_s,
490 max_decode_bytes=max_decode_bytes,
491 )
492 except ImportError as exc:
493 # Reach the LibsndfileError clause below only if exc is not an
494 # ImportError — i.e. soundfile is a real module — so evaluating
495 # ``soundfile.LibsndfileError`` here is safe even when soundfile is a
496 # PlaceholderModule (which would make that attribute access raise).
497 logger.error("Failed to load audio via soundfile: %r", exc)
498 except soundfile.LibsndfileError as exc:
499 # libsndfile covers fewer containers than FFmpeg, so defer only on a
500 # format-detection failure; re-raise anything else (e.g. corrupt but
501 # recognised) since PyAV would hit the same corruption.
502 if exc.code not in _BAD_SF_CODES:
503 raise
504
505 # PyAV is terminal: nothing left to fall back to. Normalize an FFmpeg
506 # failure to ValueError; let a missing soundfile/PyAV surface its
507 # "install vllm[audio]" ImportError.
508 if isinstance(path, BytesIO):
509 path.seek(0)
510 try:
511 return load_audio_pyav(
512 path,
513 sr=sr,
514 mono=mono,
515 max_duration_s=max_duration_s,
516 max_decode_bytes=max_decode_bytes,
517 )
518 except ImportError:
519 raise
520 except av.error.FFmpegError as exc:
521 raise ValueError("Invalid or unsupported audio file.") from exc
522
523
524 class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
525 """Configuration values can be user-provided either by --media-io-kwargs or
526 by the runtime API field "media_io_kwargs". Ensure proper validation and
527 error handling.
528 """
529
530 def __init__(self, **kwargs) -> None:
531 super().__init__()
532
533 # Allow per-server override of the audio decoding backend, e.g.:
534 # --media-io-kwargs '{"audio": {"audio_backend": "torchcodec"}}'
535 # Validate eagerly so a misconfigured deployment fails at startup
536 # rather than per request.
537 backend = kwargs.pop("audio_backend", None)
538 if backend is not None and backend not in AUDIO_BACKENDS:
539 raise ValueError(
540 f"Unknown audio_backend {backend!r}. "
541 f"Available backends: {list(AUDIO_BACKENDS)}"
542 )
543 self.audio_backend = backend
544
545 # `kwargs` contains custom arguments from
546 # --media-io-kwargs for this modality, merged with
547 # per-request runtime media_io_kwargs via merge_kwargs().
548 # They can be passed to the underlying
549 # media loaders (e.g. custom implementations)
550 # for flexible control.
551 self.kwargs = kwargs
552
553 def get_max_bytes(self) -> int:
554 return int(envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB * MiB_bytes)
555
556 def _validate_encoded_size(self, size: int) -> None:
557 max_bytes = self.get_max_bytes()
558 if size > max_bytes:
559 raise VLLMValidationError(
560 "Maximum file size exceeded",
561 parameter="audio_filesize_mb",
562 value=size / MiB_bytes,
563 )
564
565 def load_bytes(self, data: bytes) -> tuple[npt.NDArray, float]:
566 self._validate_encoded_size(len(data))
567 return load_audio(
568 BytesIO(data),
569 sr=None,
570 max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S,
571 max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES,
572 backend=self.audio_backend,
573 )
574
575 def load_base64(
576 self,
577 media_type: str,
578 data: str,
579 ) -> tuple[npt.NDArray, float]:
580 max_encoded_chars = 4 * ((self.get_max_bytes() + 2) // 3)
581 if len(data) > max_encoded_chars:
582 raise VLLMValidationError(
583 "Maximum file size exceeded",
584 parameter="audio_filesize_mb",
585 value=(len(data) * 3 / 4) / MiB_bytes,
586 )
587 return self.load_bytes(pybase64.b64decode(data, validate=True))
588
589 def load_file(self, filepath: Path) -> tuple[npt.NDArray, float]:
590 self._validate_encoded_size(filepath.stat().st_size)
591 return load_audio(
592 filepath,
593 sr=None,
594 max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S,
595 max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES,
596 backend=self.audio_backend,
597 )
598
599 def encode_base64(
600 self,
601 media: tuple[npt.NDArray, int],
602 *,
603 audio_format: str = "WAV",
604 ) -> str:
605 audio, sr = media
606
607 with BytesIO() as buffer:
608 soundfile.write(buffer, audio, sr, format=audio_format)
609 data = buffer.getvalue()
610
611 return pybase64.b64encode(data).decode("utf-8")
612
613
614 class AudioEmbeddingMediaIO(MediaIO[torch.Tensor]):
615 """Configuration values can be user-provided either by --media-io-kwargs or
616 by the runtime API field "media_io_kwargs". Ensure proper validation and
617 error handling.
618 """
619
620 def __init__(self) -> None:
621 super().__init__()
622
623 def load_bytes(self, data: bytes) -> torch.Tensor:
624 buffer = BytesIO(data)
625 with check_sparse_tensor_invariants_threadsafe():
626 tensor = torch.load(buffer, weights_only=True)
627 return safe_to_dense(tensor, parameter="audio_embeds")
628
629 def load_base64(self, media_type: str, data: str) -> torch.Tensor:
630 return self.load_bytes(pybase64.b64decode(data, validate=True))
631
632 def load_file(self, filepath: Path) -> torch.Tensor:
633 with check_sparse_tensor_invariants_threadsafe():
634 tensor = torch.load(filepath, weights_only=True)
635 return safe_to_dense(tensor, parameter="audio_embeds")
636
637 def encode_base64(self, media: torch.Tensor) -> str:
638 return tensor2base64(media)