Skip to content

Commit 44b085b

Browse files
fix(local_audio): encode unsilenced audio as mp3 instead of ogg
When audio_unsilence is on (the default), the silence-removal path re-encoded the audio to .ogg and uploaded that to the whisper endpoint. Some OpenAI-compatible whisper servers reject audio/ogg with a 415 Unsupported Media Type. mp3 is the most widely supported format for these endpoints, and it is already what local_video.py and online_media.py produce, so this also makes the three audio-producing loaders consistent. Adds a regression test asserting the file handed to transcribe_audio_whisper is .mp3 (never .ogg). It mocks torchaudio/ffmpeg/file_hasher and the transcriber, and uses a unique file_hash to bypass the joblib doc-loaders cache so it stays reproducible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0267b9d commit 44b085b

2 files changed

Lines changed: 101 additions & 6 deletions

File tree

tests/test_wdoc.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,3 +841,93 @@ def fake_post(url, files=None, data=None, headers=None):
841841
msg = str(excinfo.value)
842842
assert "415 Unsupported Media Type" in msg
843843
assert "original litellm auth failure" in msg
844+
845+
846+
@pytest.mark.basic
847+
def test_local_audio_unsilence_uploads_mp3_not_ogg(tmp_path, monkeypatch):
848+
"""The silence-removal path must hand a widely-supported format to the
849+
transcriber. It used to re-encode to .ogg, which some OpenAI-compatible
850+
whisper endpoints reject with a 415 Unsupported Media Type. Regression test:
851+
the file passed to transcribe_audio_whisper must be .mp3 (matching the video
852+
and online-media loaders), never .ogg."""
853+
import types
854+
import uuid
855+
from pathlib import Path
856+
857+
from wdoc.utils.loaders import local_audio
858+
859+
# a fake waveform whose only meaningful attribute is its sample count
860+
class FakeWave:
861+
def __init__(self, nsamples):
862+
self.shape = (1, nsamples)
863+
864+
sample_rate = 16000
865+
# 20s of audio -> 15s after "silence removal": passes the >10s and
866+
# new_dur <= dur assertions in the loader. raising=False because torchaudio
867+
# exposes some of these (e.g. sox_effects) only after a lazy submodule import.
868+
monkeypatch.setattr(
869+
local_audio.torchaudio,
870+
"load",
871+
lambda *a, **k: (FakeWave(20 * sample_rate), sample_rate),
872+
raising=False,
873+
)
874+
monkeypatch.setattr(
875+
local_audio.torchaudio,
876+
"sox_effects",
877+
types.SimpleNamespace(
878+
apply_effects_tensor=lambda *a, **k: (
879+
FakeWave(15 * sample_rate),
880+
sample_rate,
881+
)
882+
),
883+
raising=False,
884+
)
885+
monkeypatch.setattr(
886+
local_audio.torchaudio, "save", lambda *a, **k: None, raising=False
887+
)
888+
889+
# no-op ffmpeg chain: ffmpeg.input(...).output(...).run()
890+
class _Chain:
891+
def output(self, *a, **k):
892+
return self
893+
894+
def run(self, *a, **k):
895+
return None
896+
897+
monkeypatch.setattr(local_audio.ffmpeg, "input", lambda *a, **k: _Chain())
898+
monkeypatch.setattr(local_audio, "file_hasher", lambda *a, **k: "fakehash")
899+
900+
captured = {}
901+
902+
def fake_transcribe(audio_path, audio_hash, language, prompt):
903+
captured["audio_path"] = Path(audio_path)
904+
return {
905+
"segments": [{"start": 0.0, "end": 1.0, "text": "hello"}],
906+
"duration": 15.0,
907+
"language": "en",
908+
}
909+
910+
monkeypatch.setattr(local_audio, "transcribe_audio_whisper", fake_transcribe)
911+
monkeypatch.setattr(
912+
local_audio,
913+
"convert_verbose_json_to_timestamped_text",
914+
lambda content: "hello",
915+
)
916+
917+
audio_in = tmp_path / "input.wav"
918+
audio_in.write_bytes(b"not-really-audio")
919+
920+
docs = local_audio.load_local_audio(
921+
path=audio_in,
922+
# unique hash so the joblib doc-loaders cache always misses (reproducible)
923+
file_hash=uuid.uuid4().hex,
924+
audio_backend="whisper",
925+
loaders_temp_dir=tmp_path,
926+
audio_unsilence=True,
927+
)
928+
929+
assert "audio_path" in captured, "transcribe_audio_whisper was never called"
930+
assert captured["audio_path"].suffix == ".mp3", captured["audio_path"]
931+
assert captured["audio_path"].suffix != ".ogg"
932+
assert len(docs) == 1
933+
assert docs[0].page_content == "hello"

wdoc/utils/loaders/local_audio.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,24 +114,29 @@ def load_local_audio(
114114
)
115115

116116
unsilenced_path_wav = loaders_temp_dir / f"unsilenced_audio_{uuid6.uuid6()}.wav"
117-
unsilenced_path_ogg = loaders_temp_dir / f"unsilenced_audio_{uuid6.uuid6()}.ogg"
117+
# mp3 is used for the transcription upload because it is the most widely
118+
# supported format across whisper endpoints. ogg was previously used but
119+
# some OpenAI-compatible servers reject it with a 415 Unsupported Media
120+
# Type. This also matches local_video.py and online_media.py, which both
121+
# extract audio as mp3.
122+
unsilenced_path_mp3 = loaders_temp_dir / f"unsilenced_audio_{uuid6.uuid6()}.mp3"
118123
assert not unsilenced_path_wav.exists()
119-
assert not unsilenced_path_ogg.exists()
124+
assert not unsilenced_path_mp3.exists()
120125
torchaudio.save(
121126
uri=str(unsilenced_path_wav.resolve().absolute()),
122127
src=waveform,
123128
sample_rate=sample_rate,
124129
format="wav",
125130
)
126-
# turn the .wav into .ogg
131+
# turn the .wav into .mp3
127132
ffmpeg.input(str(unsilenced_path_wav.resolve().absolute())).output(
128-
str(unsilenced_path_ogg.resolve().absolute())
133+
str(unsilenced_path_mp3.resolve().absolute())
129134
).run()
130-
unsilenced_hash = file_hasher({"path": unsilenced_path_ogg})
135+
unsilenced_hash = file_hasher({"path": unsilenced_path_mp3})
131136

132137
# old_path = path
133138
# old_hash = file_hash
134-
path = unsilenced_path_ogg
139+
path = unsilenced_path_mp3
135140
file_hash = unsilenced_hash
136141

137142
if audio_backend == "whisper":

0 commit comments

Comments
 (0)