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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,31 @@ print(result.markdown)

If any attempt succeeds, image conversion continues normally. If the client raises an error after exhausting its retries, or encounters a non-retryable error, MarkItDown tries other applicable converters and raises `FileConversionException` only if none succeeds.

### Audio transcription provider

The built-in audio converter uses `llm_client` for transcription when the client
exposes a callable `audio.transcriptions.create` endpoint. Original encoded audio
is uploaded with its matching filename and media type; this path does not require
local decoding or the legacy Google dependencies. Once that endpoint is selected,
provider errors propagate without retrying the recording through Google.

Set `audio_model` to the transcription model or Azure deployment name; the default
is `whisper-1`. It is separate from `llm_model`, which configures image descriptions.
A conversion-level `audio_model` overrides the constructor setting:

```python
md = MarkItDown(llm_client=client, llm_model="image-model", audio_model="speech-deployment")
result = md.convert("recording.wav", audio_model="another-speech-deployment")
```

Without a transcription-capable client (including a chat-only client), the legacy
SpeechRecognition Google service remains the default and receives the recording
using its generic API key when the optional audio dependencies are installed.
Without those dependencies, legacy transcription is skipped. This preserves
existing image-only client usage. The `language` hint applies to the legacy Google
path. Callers requiring a particular provider should supply a client with the audio
endpoint and the corresponding transcription model or deployment name.

### Docker

```sh
Expand Down
5 changes: 5 additions & 0 deletions packages/markitdown/src/markitdown/_markitdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def __init__(
# TODO - remove these (see enable_builtins)
self._llm_client: Any = None
self._llm_model: Union[str | None] = None
self._audio_model: Union[str | None] = None
self._llm_prompt: Union[str | None] = None
self._exiftool_path: Union[str | None] = None
self._style_map: Union[str | None] = None
Expand All @@ -195,6 +196,7 @@ def enable_builtins(self, **kwargs) -> None:
# TODO: Move these into converter constructors
self._llm_client = kwargs.get("llm_client")
self._llm_model = kwargs.get("llm_model")
self._audio_model = kwargs.get("audio_model")
self._llm_prompt = kwargs.get("llm_prompt")
self._exiftool_path = kwargs.get("exiftool_path")
self._style_map = kwargs.get("style_map")
Expand Down Expand Up @@ -632,6 +634,9 @@ def _convert(
if "llm_model" not in _kwargs and self._llm_model is not None:
_kwargs["llm_model"] = self._llm_model

if "audio_model" not in _kwargs and self._audio_model is not None:
_kwargs["audio_model"] = self._audio_model

if "llm_prompt" not in _kwargs and self._llm_prompt is not None:
_kwargs["llm_prompt"] = self._llm_prompt

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ def convert(
if audio_format:
try:
transcript = transcribe_audio(
file_stream, audio_format=audio_format, language=language
file_stream,
audio_format=audio_format,
language=language,
client=kwargs.get("llm_client"),
model=kwargs.get("audio_model", "whisper-1"),
)
if transcript:
md_content += "\n\n### Audio Transcript:\n" + transcript
Expand Down
24 changes: 22 additions & 2 deletions packages/markitdown/src/markitdown/converters/_transcribe_audio.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import io
import sys
from typing import BinaryIO
from typing import Any, BinaryIO
from .._exceptions import MissingDependencyException

# Try loading optional (but in this case, required) dependencies
Expand All @@ -27,8 +27,28 @@


def transcribe_audio(
file_stream: BinaryIO, *, audio_format: str = "wav", language: str = "en-US"
file_stream: BinaryIO,
*,
audio_format: str = "wav",
language: str = "en-US",
client: Any = None,
model: str = "whisper-1",
) -> str:
create_transcription = getattr(
getattr(getattr(client, "audio", None), "transcriptions", None), "create", None
)
if callable(create_transcription):
# An explicit provider stays selected, including on authentication/network
# errors. Upload the original encoding with its filename and media type;
# this path does not need the legacy Google or local decoder dependencies.
media_type = {"mp3": "audio/mpeg"}.get(audio_format, f"audio/{audio_format}")
transcription = create_transcription(
model=model,
file=(f"audio.{audio_format}", file_stream, media_type),
)
transcript = transcription.text.strip()
return "[No speech detected]" if not transcript else transcript

# Check for installed dependencies
if _dependency_exc_info is not None:
raise MissingDependencyException(
Expand Down
169 changes: 169 additions & 0 deletions packages/markitdown/tests/test_module_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,3 +878,172 @@ def test_a_declared_charset_still_wins() -> None:

if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))


# Audio provider selection (no recordings leave the test process).
@pytest.fixture
def audio_provider_mocks(monkeypatch):
from contextlib import nullcontext
from markitdown.converters import _audio_converter, _transcribe_audio

recognizer = MagicMock()
recognizer.recognize_google.return_value = "legacy Google transcript"
monkeypatch.setattr(_transcribe_audio, "_dependency_exc_info", None)
monkeypatch.setattr(
_transcribe_audio,
"sr",
SimpleNamespace(
Recognizer=lambda: recognizer, AudioFile=lambda _: nullcontext(object())
),
raising=False,
)
monkeypatch.setattr(_audio_converter, "exiftool_metadata", lambda *a, **kw: {})
return recognizer


def convert_mock_wav(client=None, **kwargs):
return MarkItDown(llm_client=client).convert_stream(
io.BytesIO((Path(TEST_FILES_DIR) / "test.wav").read_bytes()),
stream_info=StreamInfo(extension=".wav", mimetype="audio/x-wav"),
**kwargs,
)


def test_audio_uses_selected_client_without_google(audio_provider_mocks):
client = MagicMock()
client.audio.transcriptions.create.return_value.text = "selected transcript"
result = convert_mock_wav(client)
assert "selected transcript" in result.markdown
audio_provider_mocks.recognize_google.assert_not_called()
request = client.audio.transcriptions.create.call_args.kwargs
assert request["model"] == "whisper-1"
assert request["file"][0] == "audio.wav"
assert request["file"][2] == "audio/wav"


def test_audio_selected_client_failure_never_falls_back(audio_provider_mocks):
client = MagicMock()
client.audio.transcriptions.create.side_effect = RuntimeError(
"selected provider failed"
)
with pytest.raises(FileConversionException, match="selected provider failed"):
convert_mock_wav(client)
audio_provider_mocks.recognize_google.assert_not_called()


def test_audio_selected_client_empty_transcript(audio_provider_mocks):
client = MagicMock()
client.audio.transcriptions.create.return_value.text = " "
assert "[No speech detected]" in convert_mock_wav(client).markdown
audio_provider_mocks.recognize_google.assert_not_called()


def test_audio_without_client_preserves_legacy_language(audio_provider_mocks):
assert "legacy Google transcript" in convert_mock_wav(language="fr-FR").markdown
assert audio_provider_mocks.recognize_google.call_args.kwargs == {
"language": "fr-FR"
}


def test_selected_audio_client_does_not_require_legacy_dependencies(
audio_provider_mocks, monkeypatch
):
from markitdown.converters import _transcribe_audio

monkeypatch.setattr(
_transcribe_audio,
"_dependency_exc_info",
(ImportError, ImportError("legacy dependencies absent"), None),
)
client = MagicMock()
client.audio.transcriptions.create.return_value.text = "selected transcript"
assert "selected transcript" in convert_mock_wav(client).markdown
audio_provider_mocks.recognize_google.assert_not_called()


@pytest.mark.parametrize("audio_format", ["mp3", "mp4", "flac", "aiff"])
def test_selected_audio_client_receives_original_encoded_format(
audio_provider_mocks, audio_format
):
from markitdown.converters._transcribe_audio import transcribe_audio

stream = io.BytesIO(b"original encoded audio")
client = MagicMock()
client.audio.transcriptions.create.return_value.text = "selected transcript"
assert (
transcribe_audio(stream, audio_format=audio_format, client=client)
== "selected transcript"
)
request = client.audio.transcriptions.create.call_args.kwargs
assert request["file"][0] == f"audio.{audio_format}"
assert request["file"][1].read() == b"original encoded audio"
assert request["file"][2] == {"mp3": "audio/mpeg"}.get(
audio_format, f"audio/{audio_format}"
)
audio_provider_mocks.recognize_google.assert_not_called()


@pytest.mark.parametrize("legacy_dependencies", [True, False])
def test_audio_chat_only_client_preserves_legacy_behavior(
audio_provider_mocks, monkeypatch, legacy_dependencies
):
from markitdown.converters import _transcribe_audio

if not legacy_dependencies:
monkeypatch.setattr(
_transcribe_audio,
"_dependency_exc_info",
(ImportError, ImportError("legacy dependencies absent"), None),
)
result = convert_mock_wav(SimpleNamespace(chat=object()), language="fr-FR")
if legacy_dependencies:
assert "legacy Google transcript" in result.markdown
audio_provider_mocks.recognize_google.assert_called_once()
assert audio_provider_mocks.recognize_google.call_args.kwargs == {
"language": "fr-FR"
}
else:
assert "Audio Transcript" not in result.markdown
audio_provider_mocks.recognize_google.assert_not_called()


def test_audio_noncallable_endpoint_preserves_legacy_behavior(audio_provider_mocks):
client = SimpleNamespace(
audio=SimpleNamespace(transcriptions=SimpleNamespace(create=None))
)
assert "legacy Google transcript" in convert_mock_wav(client).markdown
audio_provider_mocks.recognize_google.assert_called_once()


@pytest.mark.parametrize(
"constructor_model, conversion_model, expected_model",
[
("azure-speech", None, "azure-speech"),
("azure-speech", "override-speech", "override-speech"),
(None, "override-speech", "override-speech"),
(None, None, "whisper-1"),
],
)
def test_audio_model_is_separate_from_image_model(
audio_provider_mocks, constructor_model, conversion_model, expected_model
):
client = MagicMock()
client.audio.transcriptions.create.return_value.text = "selected transcript"
constructor_options = (
{} if constructor_model is None else {"audio_model": constructor_model}
)
conversion_options = (
{} if conversion_model is None else {"audio_model": conversion_model}
)
md = MarkItDown(llm_client=client, llm_model="image-model", **constructor_options)
result = md.convert_stream(
io.BytesIO((Path(TEST_FILES_DIR) / "test.wav").read_bytes()),
stream_info=StreamInfo(extension=".wav", mimetype="audio/x-wav"),
**conversion_options,
)
assert "selected transcript" in result.markdown
client.audio.transcriptions.create.assert_called_once()
assert (
client.audio.transcriptions.create.call_args.kwargs["model"] == expected_model
)
audio_provider_mocks.recognize_google.assert_not_called()