Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions packages/markitdown/src/markitdown/converters/_audio_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,31 @@
from .._stream_info import StreamInfo
from .._exceptions import MissingDependencyException

ACCEPTED_MIME_TYPE_PREFIXES = [
"audio/x-wav",
"audio/mpeg",
"video/mp4",
]
# Map each accepted file extension and mimetype prefix to the audio format
# name understood by transcribe_audio(). The ACCEPTED_* lists are derived from
# these maps so that the formats we accept and the formats we can transcribe
# cannot drift apart.
_AUDIO_FORMAT_BY_EXTENSION = {
".wav": "wav",
".mp3": "mp3",
".m4a": "mp4",
".mp4": "mp4",
}

ACCEPTED_FILE_EXTENSIONS = [
".wav",
".mp3",
".m4a",
".mp4",
]
_AUDIO_FORMAT_BY_MIME_PREFIX = {
"audio/x-wav": "wav",
"audio/wav": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"video/mp4": "mp4",
"audio/mp4": "mp4",
"audio/m4a": "mp4",
"audio/x-m4a": "mp4",
}

ACCEPTED_MIME_TYPE_PREFIXES = list(_AUDIO_FORMAT_BY_MIME_PREFIX)

ACCEPTED_FILE_EXTENSIONS = list(_AUDIO_FORMAT_BY_EXTENSION)


class AudioConverter(DocumentConverter):
Expand Down Expand Up @@ -75,18 +88,18 @@ def convert(
if f in metadata:
md_content += f"{f}: {metadata[f]}\n"

# Figure out the audio format for transcription
if stream_info.extension == ".wav" or stream_info.mimetype == "audio/x-wav":
audio_format = "wav"
elif stream_info.extension == ".mp3" or stream_info.mimetype == "audio/mpeg":
audio_format = "mp3"
elif (
stream_info.extension in [".mp4", ".m4a"]
or stream_info.mimetype == "video/mp4"
):
audio_format = "mp4"
else:
audio_format = None
# Figure out the audio format for transcription. Normalize case here,
# just as accepts() does, so that e.g. "recording.WAV" is transcribed
# rather than silently skipped.
mimetype = (stream_info.mimetype or "").lower()
extension = (stream_info.extension or "").lower()

audio_format = _AUDIO_FORMAT_BY_EXTENSION.get(extension)
if audio_format is None:
for prefix, fmt in _AUDIO_FORMAT_BY_MIME_PREFIX.items():
if mimetype.startswith(prefix):
audio_format = fmt
break

# Transcribe
if audio_format:
Expand Down
93 changes: 93 additions & 0 deletions packages/markitdown/tests/test_audio_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env python3 -m pytest
"""Tests for AudioConverter's format detection.

``accepts()`` lowercases the extension and mimetype before comparing, but
``convert()`` used to compare them verbatim. A file named ``recording.WAV``
was therefore accepted, emitted its metadata, and then silently skipped
transcription. The accepted mimetype list was also missing common aliases
(``audio/wav``, ``audio/mp4``, ``audio/x-m4a``, ...) for formats already
accepted by extension.
"""

import io

import pytest

from markitdown._stream_info import StreamInfo
from markitdown.converters import _audio_converter
from markitdown.converters._audio_converter import AudioConverter


@pytest.fixture
def recorded_formats(monkeypatch):
"""Record the audio_format passed to transcribe_audio, stubbing out the
external exiftool binary and speech recognition service."""
formats = []

def fake_transcribe(file_stream, *, audio_format="wav"):
formats.append(audio_format)
return "the transcript"

monkeypatch.setattr(_audio_converter, "transcribe_audio", fake_transcribe)
monkeypatch.setattr(_audio_converter, "exiftool_metadata", lambda *a, **k: {})
return formats


def test_accepts_is_case_insensitive() -> None:
assert AudioConverter().accepts(io.BytesIO(b""), StreamInfo(extension=".WAV"))


@pytest.mark.parametrize(
"mimetype",
[
"audio/x-wav",
"audio/wav",
"audio/mpeg",
"audio/mp3",
"video/mp4",
"audio/mp4",
"audio/m4a",
"audio/x-m4a",
],
)
def test_accepts_mimetype_aliases(mimetype: str) -> None:
assert AudioConverter().accepts(io.BytesIO(b""), StreamInfo(mimetype=mimetype))


@pytest.mark.parametrize(
"extension,expected_format",
[
(".WAV", "wav"),
(".Mp3", "mp3"),
(".M4A", "mp4"),
(".MP4", "mp4"),
],
)
def test_convert_detects_format_case_insensitively(
recorded_formats, extension: str, expected_format: str
) -> None:
result = AudioConverter().convert(io.BytesIO(b""), StreamInfo(extension=extension))
assert recorded_formats == [expected_format]
assert "the transcript" in result.markdown


@pytest.mark.parametrize(
"mimetype,expected_format",
[
("audio/wav", "wav"),
("audio/mp3", "mp3"),
("audio/mp4", "mp4"),
("AUDIO/X-M4A", "mp4"),
],
)
def test_convert_detects_format_from_mimetype_aliases(
recorded_formats, mimetype: str, expected_format: str
) -> None:
AudioConverter().convert(io.BytesIO(b""), StreamInfo(mimetype=mimetype))
assert recorded_formats == [expected_format]


def test_convert_skips_transcription_for_unknown_format(recorded_formats) -> None:
result = AudioConverter().convert(io.BytesIO(b""), StreamInfo(extension=".bin"))
assert recorded_formats == []
assert result.markdown == ""