Skip to content

Commit 763ee06

Browse files
authored
Decode container-formatted audio bytes before inference (#3435)
Decode encoded audio containers before inference while preserving the raw PCM fast path. Rewind file-like decoder fallbacks and support ffmpeg stdin decoding. Signed-off-by: LauraGPT <lauragpt@users.noreply.github.com>
1 parent 0a9cc00 commit 763ee06

2 files changed

Lines changed: 366 additions & 19 deletions

File tree

funasr/utils/load_utils.py

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,16 @@ def load_audio_text_image_video(
127127
data_or_path_or_list = data_or_path_or_list.mean(0)
128128
except:
129129
try:
130+
if hasattr(data_or_path_or_list, "seek"):
131+
data_or_path_or_list.seek(0)
130132
import soundfile as sf
131133
data_np, audio_fs = sf.read(data_or_path_or_list, dtype="float32")
132134
data_or_path_or_list = torch.from_numpy(data_np).squeeze()
133135
if data_or_path_or_list.ndim > 1 and kwargs.get("reduce_channels", True):
134136
data_or_path_or_list = data_or_path_or_list.mean(-1)
135137
except:
138+
if hasattr(data_or_path_or_list, "seek"):
139+
data_or_path_or_list.seek(0)
136140
data_or_path_or_list = _load_audio_ffmpeg(data_or_path_or_list, sr=fs)
137141
data_or_path_or_list = torch.from_numpy(
138142
data_or_path_or_list
@@ -175,6 +179,96 @@ def load_audio_text_image_video(
175179
return data_or_path_or_list
176180

177181

182+
def _mp3_header_fields(data: bytes, offset: int):
183+
"""Return MPEG audio header fields, including free-format bitrate index zero."""
184+
if offset + 4 > len(data):
185+
return None
186+
187+
header = int.from_bytes(data[offset : offset + 4], "big")
188+
if header & 0xFFE00000 != 0xFFE00000:
189+
return None
190+
191+
version_id = (header >> 19) & 0x3
192+
layer_id = (header >> 17) & 0x3
193+
bitrate_index = (header >> 12) & 0xF
194+
sample_rate_index = (header >> 10) & 0x3
195+
padding = (header >> 9) & 0x1
196+
if (
197+
version_id == 1
198+
or layer_id == 0
199+
or bitrate_index == 15
200+
or sample_rate_index == 3
201+
):
202+
return None
203+
return version_id, layer_id, bitrate_index, sample_rate_index, padding
204+
205+
206+
def _mp3_frame_length(data: bytes, offset: int) -> int:
207+
"""Return a fixed-bitrate MPEG audio frame length, or zero if unavailable."""
208+
fields = _mp3_header_fields(data, offset)
209+
if fields is None:
210+
return 0
211+
version_id, layer_id, bitrate_index, sample_rate_index, padding = fields
212+
if bitrate_index == 0:
213+
return 0
214+
215+
mpeg1_bitrates = {
216+
3: (32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448),
217+
2: (32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384),
218+
1: (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320),
219+
}
220+
mpeg2_bitrates = {
221+
3: (32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256),
222+
2: (8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160),
223+
1: (8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160),
224+
}
225+
sample_rates = {
226+
3: (44100, 48000, 32000),
227+
2: (22050, 24000, 16000),
228+
0: (11025, 12000, 8000),
229+
}
230+
bitrate_table = mpeg1_bitrates if version_id == 3 else mpeg2_bitrates
231+
bitrate = bitrate_table[layer_id][bitrate_index - 1] * 1000
232+
sample_rate = sample_rates[version_id][sample_rate_index]
233+
234+
if layer_id == 3:
235+
return (12 * bitrate // sample_rate + padding) * 4
236+
coefficient = 144 if version_id == 3 or layer_id == 2 else 72
237+
return coefficient * bitrate // sample_rate + padding
238+
239+
240+
def _has_consecutive_mp3_frames(data: bytes) -> bool:
241+
"""Avoid mistaking raw PCM that starts with one sync-like sample for MP3."""
242+
fields = _mp3_header_fields(data, 0)
243+
if fields is None:
244+
return False
245+
version_id, layer_id, bitrate_index, sample_rate_index, _ = fields
246+
if bitrate_index == 0:
247+
signature = version_id, layer_id, sample_rate_index
248+
padding_slot = 4 if layer_id == 3 else 1
249+
first_padding = fields[4] * padding_slot
250+
for second_offset in range(24, min(len(data) - 3, 8192)):
251+
next_fields = _mp3_header_fields(data, second_offset)
252+
if next_fields is not None and (
253+
next_fields[0], next_fields[1], next_fields[3]
254+
) == signature and next_fields[2] == 0:
255+
base_frame_length = second_offset - first_padding
256+
third_offset = (
257+
second_offset
258+
+ base_frame_length
259+
+ next_fields[4] * padding_slot
260+
)
261+
third_fields = _mp3_header_fields(data, third_offset)
262+
if third_fields is not None and (
263+
third_fields[0], third_fields[1], third_fields[3]
264+
) == signature and third_fields[2] == 0:
265+
return True
266+
return False
267+
268+
first_frame_length = _mp3_frame_length(data, 0)
269+
return first_frame_length > 0 and _mp3_frame_length(data, first_frame_length) > 0
270+
271+
178272
def _is_audio_container(data: bytes) -> bool:
179273
"""Return True if *data* starts with a recognised container-format magic header.
180274
@@ -184,10 +278,15 @@ def _is_audio_container(data: bytes) -> bool:
184278
if len(data) < 4:
185279
return False
186280
# WAV – RIFF....WAVE
187-
if data[:4] == b"RIFF":
281+
if (
282+
len(data) >= 12
283+
and data[:4] in (b"RIFF", b"RIFX", b"RF64", b"BW64")
284+
and data[8:12] == b"WAVE"
285+
):
188286
return True
189-
# MP3 – ID3 tag or sync word (0xFF 0xEx)
190-
if data[:3] == b"ID3" or (data[0] == 0xFF and (data[1] & 0xE0) == 0xE0):
287+
# MP3 – ID3 tag or at least two structurally valid MPEG audio frames
288+
has_mpeg_sync = data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
289+
if data[:3] == b"ID3" or (has_mpeg_sync and _has_consecutive_mp3_frames(data)):
191290
return True
192291
# OGG
193292
if data[:4] == b"OggS":
@@ -205,23 +304,28 @@ def _is_audio_container(data: bytes) -> bool:
205304

206305

207306
def load_bytes(input):
208-
"""Convert audio bytes to numpy array.
307+
"""Convert raw PCM or container-formatted audio bytes to a waveform.
209308
210309
Args:
211-
input (bytes): Raw audio bytes.
310+
input (bytes): Raw int16 PCM or encoded audio-file bytes.
212311
213312
Returns:
214-
numpy.ndarray: Decoded audio samples.
313+
numpy.ndarray: Mono float32 samples at 16 kHz.
215314
"""
216-
# Only run the (expensive) frame-rate validation when the payload is an
217-
# actual audio container (WAV, MP3, OGG, …). Raw PCM buffers have no
218-
# recognisable header and would cause pydub to spend ~200 ms before
219-
# raising an exception that is then silently swallowed anyway.
220315
if _is_audio_container(input):
221316
try:
222-
input = validate_frame_rate(input)
223-
except Exception:
224-
pass
317+
waveform = load_audio_text_image_video(BytesIO(input), fs=16000)
318+
except Exception as exc:
319+
raise RuntimeError(
320+
"Failed to decode container-formatted audio bytes. Verify that the input is "
321+
"a complete supported audio file and that torchaudio, soundfile, or ffmpeg "
322+
"is available."
323+
) from exc
324+
else:
325+
if isinstance(waveform, torch.Tensor):
326+
waveform = waveform.detach().cpu().numpy()
327+
return np.asarray(waveform, dtype=np.float32)
328+
225329
middle_data = np.frombuffer(input, dtype=np.int16)
226330
middle_data = np.asarray(middle_data)
227331
if middle_data.dtype.kind not in "iu":
@@ -315,14 +419,14 @@ def extract_fbank(data, data_len=None, data_type: str = "sound", frontend=None,
315419
return data.to(torch.float32), data_len.to(torch.int32)
316420

317421

318-
def _load_audio_ffmpeg(file: str, sr: int = 16000):
422+
def _load_audio_ffmpeg(file, sr: int = 16000):
319423
"""
320424
Open an audio file and read as mono waveform, resampling as necessary
321425
322426
Parameters
323427
----------
324-
file: str
325-
The audio file to open
428+
file: str or file-like object
429+
The audio file or byte stream to open
326430
327431
sr: int
328432
The sample rate to resample the audio if necessary
@@ -336,7 +440,15 @@ def _load_audio_ffmpeg(file: str, sr: int = 16000):
336440
# and resampling as necessary. Requires the ffmpeg CLI in PATH.
337441
# fmt: off
338442
pcm_params = []
339-
if file.lower().endswith('.pcm'):
443+
stdin_data = None
444+
if hasattr(file, "read"):
445+
if hasattr(file, "seek"):
446+
file.seek(0)
447+
stdin_data = file.read()
448+
input_source = "pipe:0"
449+
else:
450+
input_source = os.fspath(file)
451+
if isinstance(input_source, str) and input_source.lower().endswith('.pcm'):
340452
pcm_params = [
341453
"-f", "s16le",
342454
"-ar", str(sr),
@@ -348,7 +460,7 @@ def _load_audio_ffmpeg(file: str, sr: int = 16000):
348460
"-nostdin",
349461
"-threads", "0",
350462
*pcm_params, # PCM files need input format specified before -i since PCM is raw data without headers
351-
"-i", file,
463+
"-i", input_source,
352464
"-f", "s16le",
353465
"-ac", "1",
354466
"-acodec", "pcm_s16le",
@@ -357,7 +469,7 @@ def _load_audio_ffmpeg(file: str, sr: int = 16000):
357469
]
358470
# fmt: on
359471
try:
360-
out = run(cmd, capture_output=True, check=True).stdout
472+
out = run(cmd, input=stdin_data, capture_output=True, check=True).stdout
361473
except CalledProcessError as e:
362474
raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}") from e
363475

0 commit comments

Comments
 (0)