Skip to content

Commit 7fe25f6

Browse files
committed
Enhance audio format conversion with WAV validation and PCM handling
- Introduced functions to validate WAV data chunk readability and convert PCM16LE to WAV format, addressing issues with ffmpeg's pipe output. - Updated `ensure_wav_bytes` to handle broken WAV data more robustly, ensuring correct WAV headers are generated for non-WAV inputs. - Enhanced tests to validate new functionality, including handling of invalid WAV data and ensuring proper conversion from PCM to WAV format. - Improved overall audio processing reliability by implementing ASR-friendly defaults for sample rate and channels.
1 parent 62756a5 commit 7fe25f6

2 files changed

Lines changed: 139 additions & 12 deletions

File tree

backend/audio_format_convert.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
22

33
from __future__ import annotations
44

5+
import io
56
import shutil
7+
import struct
68
import subprocess
9+
import wave
710
from typing import Optional, Tuple
811

912
from fastapi import HTTPException
1013

1114
MAX_AUDIO_UPLOAD_BYTES = 60 * 1024 * 1024
1215
FFMPEG_TIMEOUT_SECONDS = 60
1316

17+
# ASR-friendly defaults when decoding compressed formats.
18+
ASR_SAMPLE_RATE = 16000
19+
ASR_CHANNELS = 1
20+
1421

1522
class AudioConvertError(Exception):
1623
"""Raised when media cannot be converted to WAV."""
@@ -32,6 +39,39 @@ def ffmpeg_available() -> bool:
3239
return bool(shutil.which("ffmpeg"))
3340

3441

42+
def wav_data_chunk_readable(content: bytes) -> bool:
43+
"""Return True when the WAV ``data`` chunk size matches available bytes.
44+
45+
ffmpeg's ``pipe:1`` WAV muxer often writes ``0xFFFFFFFF`` size fields because
46+
it cannot seek the stream to patch the header. audio.cpp then fails with
47+
``failed to read WAV data chunk``.
48+
"""
49+
if not is_wav_content(content):
50+
return False
51+
offset = 12
52+
length = len(content)
53+
while offset + 8 <= length:
54+
chunk_id = content[offset : offset + 4]
55+
chunk_size = struct.unpack_from("<I", content, offset + 4)[0]
56+
data_start = offset + 8
57+
data_end = data_start + chunk_size
58+
if chunk_id == b"data":
59+
return data_end <= length
60+
# Chunks are word-aligned.
61+
offset = data_end + (chunk_size % 2)
62+
return False
63+
64+
65+
def pcm16le_to_wav(pcm: bytes, *, channels: int, sample_rate: int) -> bytes:
66+
buf = io.BytesIO()
67+
with wave.open(buf, "wb") as wf:
68+
wf.setnchannels(channels)
69+
wf.setsampwidth(2)
70+
wf.setframerate(sample_rate)
71+
wf.writeframes(pcm)
72+
return buf.getvalue()
73+
74+
3575
def ensure_wav_bytes(
3676
content: bytes,
3777
*,
@@ -40,8 +80,11 @@ def ensure_wav_bytes(
4080
) -> Tuple[bytes, str]:
4181
"""Return PCM WAV bytes and a .wav filename.
4282
43-
Already-WAV payloads are returned unchanged. Other formats are converted
44-
with ffmpeg (pcm_s16le). Raises AudioConvertError on failure.
83+
Already-WAV payloads with a readable ``data`` chunk are returned unchanged.
84+
Other formats (and broken pipe-WAV) are converted with ffmpeg to mono 16 kHz
85+
PCM16, then wrapped with a correct RIFF header via the stdlib ``wave``
86+
module (avoids ffmpeg stdout size-field bugs). Raises AudioConvertError on
87+
failure.
4588
"""
4689
if not content:
4790
raise AudioConvertError("Empty audio upload")
@@ -50,16 +93,17 @@ def ensure_wav_bytes(
5093
f"Audio upload exceeds {MAX_AUDIO_UPLOAD_BYTES // (1024 * 1024)} MB limit",
5194
)
5295

53-
if is_wav_content(content):
54-
out_name = _wav_filename(filename)
55-
return content, out_name
96+
if is_wav_content(content) and wav_data_chunk_readable(content):
97+
return content, _wav_filename(filename)
5698

5799
if not ffmpeg_available():
58100
raise AudioConvertError(
59101
"ffmpeg is not installed; cannot convert non-WAV audio uploads",
60102
status_code=503,
61103
)
62104

105+
# Decode to raw PCM on stdout (size fields are irrelevant for s16le),
106+
# then write a seek-correct WAV header ourselves.
63107
try:
64108
proc = subprocess.run(
65109
[
@@ -71,9 +115,13 @@ def ensure_wav_bytes(
71115
"-i",
72116
"pipe:0",
73117
"-f",
74-
"wav",
118+
"s16le",
75119
"-acodec",
76120
"pcm_s16le",
121+
"-ac",
122+
str(ASR_CHANNELS),
123+
"-ar",
124+
str(ASR_SAMPLE_RATE),
77125
"pipe:1",
78126
],
79127
input=content,
@@ -95,10 +143,15 @@ def ensure_wav_bytes(
95143
name_hint = filename or content_type or "upload"
96144
raise AudioConvertError(f"Could not convert {name_hint} to WAV: {hint}")
97145

98-
if not is_wav_content(proc.stdout):
99-
raise AudioConvertError("ffmpeg produced output that is not a valid WAV")
146+
wav_bytes = pcm16le_to_wav(
147+
proc.stdout,
148+
channels=ASR_CHANNELS,
149+
sample_rate=ASR_SAMPLE_RATE,
150+
)
151+
if not wav_data_chunk_readable(wav_bytes):
152+
raise AudioConvertError("converted WAV failed validation")
100153

101-
return proc.stdout, _wav_filename(filename)
154+
return wav_bytes, _wav_filename(filename)
102155

103156

104157
def ensure_wav_bytes_http(

backend/tests/test_audio_format_convert.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import io
6+
import struct
67
import wave
78

89
import pytest
@@ -20,12 +21,48 @@ def _minimal_wav(frames: int = 1600, rate: int = 16000) -> bytes:
2021
return buf.getvalue()
2122

2223

24+
def _pipe_style_wav_with_bad_data_size(pcm_bytes: int = 32) -> bytes:
25+
"""Mimic ffmpeg stdout WAV: valid RIFF/WAVE/fmt, data size 0xFFFFFFFF."""
26+
fmt = struct.pack(
27+
"<HHIIHH",
28+
1, # PCM
29+
1, # mono
30+
16000,
31+
32000,
32+
2,
33+
16,
34+
)
35+
pcm = b"\x00" * pcm_bytes
36+
# RIFF size also often left as 0xFFFFFFFF for pipes.
37+
return (
38+
b"RIFF"
39+
+ struct.pack("<I", 0xFFFFFFFF)
40+
+ b"WAVE"
41+
+ b"fmt "
42+
+ struct.pack("<I", 16)
43+
+ fmt
44+
+ b"data"
45+
+ struct.pack("<I", 0xFFFFFFFF)
46+
+ pcm
47+
)
48+
49+
2350
def test_is_wav_content_detects_riff_wave():
2451
assert convert.is_wav_content(_minimal_wav())
2552
assert not convert.is_wav_content(b"OggS\x00\x00")
2653
assert not convert.is_wav_content(b"")
2754

2855

56+
def test_wav_data_chunk_readable_accepts_honest_wav():
57+
assert convert.wav_data_chunk_readable(_minimal_wav())
58+
59+
60+
def test_wav_data_chunk_readable_rejects_pipe_size_bug():
61+
bad = _pipe_style_wav_with_bad_data_size()
62+
assert convert.is_wav_content(bad)
63+
assert not convert.wav_data_chunk_readable(bad)
64+
65+
2966
def test_ensure_wav_bytes_passthrough_for_wav():
3067
wav = _minimal_wav()
3168
out, name = convert.ensure_wav_bytes(wav, filename="clip.ogg")
@@ -39,28 +76,65 @@ def test_ensure_wav_bytes_rejects_empty():
3976

4077

4178
def test_ensure_wav_bytes_converts_with_ffmpeg(monkeypatch):
42-
wav = _minimal_wav()
79+
pcm = b"\x00\x00" * 80
4380

4481
def fake_run(cmd, input=None, capture_output=None, timeout=None, check=None):
4582
class Result:
4683
returncode = 0
47-
stdout = wav
84+
stdout = pcm
4885
stderr = b""
4986

5087
assert "ffmpeg" in cmd[0]
88+
assert "-f" in cmd and "s16le" in cmd
5189
assert input == b"not-wav-bytes"
5290
return Result()
5391

5492
monkeypatch.setattr(convert, "ffmpeg_available", lambda: True)
5593
monkeypatch.setattr(convert.subprocess, "run", fake_run)
5694

5795
out, name = convert.ensure_wav_bytes(b"not-wav-bytes", filename="memo.opus")
58-
assert out == wav
5996
assert name == "memo.wav"
97+
assert convert.is_wav_content(out)
98+
assert convert.wav_data_chunk_readable(out)
99+
with wave.open(io.BytesIO(out), "rb") as wf:
100+
assert wf.getnchannels() == 1
101+
assert wf.getframerate() == 16000
102+
assert wf.getsampwidth() == 2
103+
assert wf.readframes(wf.getnframes()) == pcm
104+
105+
106+
def test_ensure_wav_bytes_rewrites_broken_pipe_wav(monkeypatch):
107+
"""Broken ffmpeg-pipe WAV must be re-decoded, not passed through."""
108+
bad = _pipe_style_wav_with_bad_data_size()
109+
pcm = b"\x01\x00" * 40
110+
111+
def fake_run(cmd, input=None, capture_output=None, timeout=None, check=None):
112+
class Result:
113+
returncode = 0
114+
stdout = pcm
115+
stderr = b""
116+
117+
assert input == bad
118+
return Result()
119+
120+
monkeypatch.setattr(convert, "ffmpeg_available", lambda: True)
121+
monkeypatch.setattr(convert.subprocess, "run", fake_run)
122+
123+
out, _name = convert.ensure_wav_bytes(bad, filename="pipe.wav")
124+
assert convert.wav_data_chunk_readable(out)
125+
assert out != bad
60126

61127

62128
def test_ensure_wav_bytes_missing_ffmpeg(monkeypatch):
63129
monkeypatch.setattr(convert, "ffmpeg_available", lambda: False)
64130
with pytest.raises(convert.AudioConvertError) as exc:
65131
convert.ensure_wav_bytes(b"OggSfake", filename="a.ogg")
66132
assert exc.value.status_code == 503
133+
134+
135+
def test_pcm16le_to_wav_roundtrip():
136+
pcm = b"\x00\x10\xff\x7f"
137+
wav = convert.pcm16le_to_wav(pcm, channels=1, sample_rate=16000)
138+
assert convert.wav_data_chunk_readable(wav)
139+
with wave.open(io.BytesIO(wav), "rb") as wf:
140+
assert wf.readframes(2) == pcm

0 commit comments

Comments
 (0)