Files
vllm-omni/vllm_omni/utils/audio_resample.py
T
chickeytonandClaude Opus 5 99ff4f3070 [Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:35:36 +08:00

168 lines
6.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Streaming resampling, shared by the engine and the entrypoints.
Lives here rather than beside the OpenAI audio mixin because the duplex
engine needs it too, and engine code must not import the entrypoints layer.
Pure numpy on purpose: importing this must not pull in torch or the API
server.
"""
from __future__ import annotations
import math
import numpy as np
__all__ = ["StreamingAudioResampler"]
class StreamingAudioResampler:
"""Stateful polyphase resampler for streaming mono float audio.
Retains filter state so output is invariant to input chunk boundaries.
"""
_half_filter_width = 10
_kaiser_beta = 5.0
max_polyphase_factor = 2_048
def __init__(self, source_rate: int, target_rate: int):
if source_rate <= 0 or target_rate <= 0:
raise ValueError("Audio sample rates must be positive")
self.source_rate = source_rate
self.target_rate = target_rate
rate_gcd = math.gcd(source_rate, target_rate)
self._up = target_rate // rate_gcd
self._down = source_rate // rate_gcd
if max(self._up, self._down) > self.max_polyphase_factor:
raise ValueError("source and target sample rates have an unsupported resampling ratio")
self._half_len, self._phase_kernels = self._design_polyphase_filter()
self._history_samples = self._phase_kernels.shape[1] - 1
self.reset()
def _design_polyphase_filter(self) -> tuple[int, np.ndarray]:
if self._up == self._down:
return 0, np.ones((1, 1), dtype=np.float32)
max_rate = max(self._up, self._down)
half_len = self._half_filter_width * max_rate
offsets = np.arange(-half_len, half_len + 1, dtype=np.float64)
cutoff = 1.0 / max_rate
taps = cutoff * np.sinc(cutoff * offsets)
taps *= np.kaiser(taps.size, self._kaiser_beta)
taps /= np.sum(taps)
taps *= self._up
taps = np.ascontiguousarray(taps, dtype=np.float32)
phases = [taps[phase :: self._up] for phase in range(self._up)]
phase_width = max(phase.size for phase in phases)
kernels = np.zeros((self._up, phase_width), dtype=np.float32)
for phase_index, phase in enumerate(phases):
kernels[phase_index, -phase.size :] = phase[::-1]
return half_len, kernels
@property
def scratch_bytes(self) -> int:
pending_samples = self._ceil_div(self._input_samples * self._up, self._down) - self._output_samples
return max(0, pending_samples) * np.dtype(np.float32).itemsize
@staticmethod
def _ceil_div(numerator: int, denominator: int) -> int:
return -(-numerator // denominator)
def _render_outputs(
self,
combined: np.ndarray,
*,
first_output: int,
output_count: int,
input_start: int,
) -> np.ndarray:
if output_count <= 0:
return np.empty(0, dtype=np.float32)
output_indexes = np.arange(first_output, first_output + output_count, dtype=np.int64)
filter_indexes = output_indexes * self._down + self._half_len
source_indexes = filter_indexes // self._up
phases = filter_indexes % self._up
windows = np.lib.stride_tricks.sliding_window_view(
combined,
self._phase_kernels.shape[1],
)
selected_windows = windows[source_indexes - input_start]
return np.sum(
selected_windows * self._phase_kernels[phases],
axis=1,
dtype=np.float32,
)
def _push(self, chunk: np.ndarray) -> np.ndarray:
if chunk.size == 0:
return np.empty(0, dtype=np.float32)
if self._flushed:
raise RuntimeError("cannot process audio after the resampler has been finalized; call reset first")
combined = np.concatenate((self._history, chunk))
next_input_samples = self._input_samples + chunk.size
stable_output_samples = max(
0,
self._ceil_div(next_input_samples * self._up - self._half_len, self._down),
)
output = self._render_outputs(
combined,
first_output=self._output_samples,
output_count=stable_output_samples - self._output_samples,
input_start=self._input_samples,
)
if self._history_samples:
self._history = combined[-self._history_samples :].copy()
self._input_samples = next_input_samples
self._output_samples = stable_output_samples
return output
def _flush(self) -> np.ndarray:
if self._flushed:
return np.empty(0, dtype=np.float32)
total_output_samples = self._ceil_div(self._input_samples * self._up, self._down)
output_count = total_output_samples - self._output_samples
if output_count:
last_output = total_output_samples - 1
last_source_index = (last_output * self._down + self._half_len) // self._up
right_padding = max(0, last_source_index - self._input_samples + 1)
combined = np.concatenate((self._history, np.zeros(right_padding, dtype=np.float32)))
output = self._render_outputs(
combined,
first_output=self._output_samples,
output_count=output_count,
input_start=self._input_samples,
)
else:
output = np.empty(0, dtype=np.float32)
self._output_samples = total_output_samples
self._flushed = True
return output
def process(self, audio: np.ndarray, *, final: bool = False) -> np.ndarray:
chunk = np.asarray(audio, dtype=np.float32)
if chunk.ndim != 1:
raise ValueError(f"Streaming audio resampling only supports mono audio, got shape {chunk.shape}")
chunk = np.ascontiguousarray(chunk, dtype=np.float32)
output = self._push(chunk)
if not final:
return output
tail = self._flush()
if not output.size:
return tail
if not tail.size:
return output
return np.concatenate((output, tail))
def reset(self) -> None:
self._history = np.zeros(self._history_samples, dtype=np.float32)
self._input_samples = 0
self._output_samples = 0
self._flushed = False