Skip to content

Commit 479be72

Browse files
committed
Revert "fix(transcription): decode audio ourselves so whisper works on PyAV 19"
This reverts commit 717576d. A copy of faster-whisper's decoder is more code to review and maintain than a temporary pin, and it lacked the gc.collect() upstream uses against PyAV resampler leaks (SYSTRAN/faster-whisper#390). Keep the av<19 pin instead: 18.1.0 is the newest PyAV that works with faster-whisper 1.2.1 (17.1.0 on Python 3.10, which PyAV 18 no longer supports). Remove the pin once a faster-whisper release includes SYSTRAN/faster-whisper#1495. Claude-Session: https://claude.ai/code/session_01S1CTNRry7FQw1tK74yFpa3
1 parent 717576d commit 479be72

4 files changed

Lines changed: 14 additions & 109 deletions

File tree

‎pyproject.toml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ proxy = [
4141
# Required only for the local transcription engine (TELEGRAM_TRANSCRIBE_ENGINE=whisper).
4242
whisper = [
4343
"faster-whisper>=1.1.0",
44-
# Decoded directly (see transcription._decode_audio), not only via faster-whisper.
45-
"av>=11",
44+
# faster-whisper 1.2.1 passes av.open(metadata_errors=...), which PyAV 19
45+
# removed; 18.1.0 is the newest that works. Drop once a faster-whisper
46+
# release includes SYSTRAN/faster-whisper#1495.
47+
"av<19",
4648
]
4749

4850
[project.urls]

‎telegram_mcp/transcription.py‎

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -567,42 +567,9 @@ def _load_whisper_model(settings: tuple):
567567
return model
568568

569569

570-
def _decode_audio(data: bytes, sampling_rate: int):
571-
"""Mono float32 samples at ``sampling_rate``, decoded with PyAV.
572-
573-
Mirrors faster_whisper.audio.decode_audio, which faster-whisper 1.2.1
574-
cannot run on PyAV 19: it passes ``av.open(metadata_errors=...)``, an
575-
argument PyAV 19 removed (fix pending upstream in
576-
SYSTRAN/faster-whisper#1495). Handing the model an array skips that
577-
function entirely, so any PyAV version works. Drop this once a
578-
faster-whisper release carries the fix.
579-
"""
580-
import av
581-
import numpy as np
582-
583-
resampler = av.AudioResampler(format="s16", layout="mono", rate=sampling_rate)
584-
chunks = []
585-
with av.open(io.BytesIO(data), mode="r") as container:
586-
frames = container.decode(audio=0)
587-
while True:
588-
try:
589-
frame = next(frames)
590-
except StopIteration:
591-
break
592-
except av.error.InvalidDataError:
593-
continue # a corrupt packet costs one frame, not the recording
594-
frame.pts = None # the resampler rejects non-monotonic timestamps
595-
chunks.extend(out.to_ndarray() for out in resampler.resample(frame))
596-
chunks.extend(out.to_ndarray() for out in resampler.resample(None))
597-
if not chunks:
598-
return np.zeros(0, dtype=np.float32)
599-
return np.concatenate(chunks, axis=None).astype(np.float32) / 32768.0
600-
601-
602570
def _run_whisper(data: bytes, settings: tuple, language: Optional[str]) -> tuple:
603571
model = _load_whisper_model(settings)
604-
audio = _decode_audio(data, model.feature_extractor.sampling_rate)
605-
segments, info = model.transcribe(audio, language=language, vad_filter=True)
572+
segments, info = model.transcribe(io.BytesIO(data), language=language, vad_filter=True)
606573
# segments is a lazy generator: the actual decoding happens here, so it
607574
# must be consumed inside the worker thread.
608575
text = "".join(segment.text for segment in segments).strip()

‎tests/test_transcription.py‎

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -980,11 +980,10 @@ class _FakeWhisperModel:
980980
def __init__(self, name, device=None, compute_type=None, download_root=None):
981981
self.args = (name, device, compute_type, download_root)
982982
self.calls = []
983-
self.feature_extractor = SimpleNamespace(sampling_rate=16000)
984983
_FakeWhisperModel.instances.append(self)
985984

986985
def transcribe(self, audio, language=None, vad_filter=False):
987-
self.calls.append({"audio": audio, "language": language})
986+
self.calls.append({"audio": audio.read(), "language": language})
988987
segments = (SimpleNamespace(text=t) for t in [" hallo", " wereld "])
989988
return segments, SimpleNamespace(language="nl")
990989

@@ -1000,7 +999,6 @@ def fake_whisper(monkeypatch):
1000999
monkeypatch.setattr(transcription, "whisper_available", lambda: True)
10011000
monkeypatch.setattr(transcription, "_WHISPER_MODELS", {})
10021001
monkeypatch.setattr(transcription, "_WHISPER_SEMAPHORE", None)
1003-
monkeypatch.setattr(transcription, "_decode_audio", lambda data, rate: ("decoded", data, rate))
10041002
return _FakeWhisperModel
10051003

10061004

@@ -1025,9 +1023,7 @@ def _no_http(**kw):
10251023
assert len(fake_whisper.instances) == 1
10261024
model = fake_whisper.instances[0]
10271025
assert model.args == ("large-v3-turbo", "cpu", "int8", None)
1028-
# The model gets decoded samples, never the raw bytes: faster-whisper's own
1029-
# decoder breaks on PyAV 19.
1030-
assert model.calls[0] == {"audio": ("decoded", b"audio", 16000), "language": None}
1026+
assert model.calls[0] == {"audio": b"audio", "language": None}
10311027

10321028

10331029
@pytest.mark.asyncio
@@ -1060,33 +1056,3 @@ def _explode(self, audio, language=None, vad_filter=False):
10601056

10611057
assert result["status"] == "error"
10621058
assert "bad audio" in result["error"]
1063-
1064-
1065-
def test_decode_audio_produces_mono_float32_at_the_requested_rate():
1066-
"""Real PyAV round trip: a 1s 48 kHz stereo Opus-in-Ogg tone, like a
1067-
Telegram voice note, comes back as ~16000 mono float32 samples."""
1068-
av = pytest.importorskip("av")
1069-
np = pytest.importorskip("numpy")
1070-
import io as _io
1071-
1072-
buf = _io.BytesIO()
1073-
with av.open(buf, "w", format="ogg") as out:
1074-
stream = out.add_stream("libopus", rate=48000)
1075-
stream.layout = "stereo"
1076-
t = np.arange(48000) / 48000
1077-
tone = (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.int16)
1078-
frame = av.AudioFrame.from_ndarray(
1079-
np.stack([tone, tone]).reshape(1, -1), format="s16", layout="stereo"
1080-
)
1081-
frame.sample_rate = 48000
1082-
for packet in stream.encode(frame):
1083-
out.mux(packet)
1084-
for packet in stream.encode(None):
1085-
out.mux(packet)
1086-
1087-
audio = transcription._decode_audio(buf.getvalue(), 16000)
1088-
1089-
assert audio.dtype == np.float32
1090-
assert audio.ndim == 1
1091-
assert abs(len(audio) - 16000) < 800
1092-
assert 0.1 < float(np.abs(audio).max()) <= 1.0

‎uv.lock‎

Lines changed: 7 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)