diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index 0da23a7be2..6928fe5fc7 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -53,7 +53,10 @@ from docling.datamodel.picture_classification_options import ( DocumentPictureClassifierOptions, ) -from docling.datamodel.pipeline_options_asr_model import InlineAsrOptions +from docling.datamodel.pipeline_options_asr_model import ( + ApiAsrOptions, + InlineAsrOptions, +) from docling.datamodel.pipeline_options_vlm_model import ( ApiVlmOptions, InferenceFramework, @@ -1803,11 +1806,13 @@ class AsrPipelineOptions(PipelineOptions): """ asr_options: Annotated[ - InlineAsrOptions, + InlineAsrOptions | ApiAsrOptions, Field( description=( "Automatic Speech Recognition (ASR) model configuration for audio transcription. Specifies which " - "ASR model to use (e.g., Whisper variants) and model-specific parameters for speech-to-text conversion." + "ASR model to use (e.g., Whisper variants) and model-specific parameters for speech-to-text conversion. " + "Use `ApiAsrOptions` (with `enable_remote_services=True`) to transcribe with an OpenAI-compatible " + "server instead of a model loaded in this process." ) ), ] = asr_model_specs.WHISPER_TINY @@ -1829,7 +1834,7 @@ class VideoPipelineOptions(PipelineOptions): """ asr_options: Annotated[ - InlineAsrOptions, + InlineAsrOptions | ApiAsrOptions, Field(description="ASR model configuration for the video audio track."), ] = asr_model_specs.WHISPER_TINY diff --git a/docling/datamodel/pipeline_options_asr_model.py b/docling/datamodel/pipeline_options_asr_model.py index 4fc8f59c3c..80214156c1 100644 --- a/docling/datamodel/pipeline_options_asr_model.py +++ b/docling/datamodel/pipeline_options_asr_model.py @@ -12,6 +12,10 @@ - ``InlineAsrWhisperS2TOptions``: WhisperS2T (CTranslate2), an optional, experimental high-throughput backend installed via the ``format-audio`` extra. +``ApiAsrOptions`` is the alternative to all of them: nothing runs locally, the audio +is sent to an OpenAI-compatible ``/v1/audio/transcriptions`` endpoint (a Whisper +server that is deployed on its own, or a hosted service). + The concrete model presets, the ``AsrModelType`` enum, and the hardware-based auto-selection live in ``docling.datamodel.asr_model_specs``. The auto-selecting ``WHISPER_*`` presets (the ASR pipeline default) use MLX Whisper on Apple Silicon @@ -54,6 +58,99 @@ class InferenceAsrFramework(str, Enum): WHISPER_S2T = "whisper_s2t" +class ApiAsrOptions(BaseAsrOptions): + """Configuration for transcription by a remote, OpenAI-compatible service. + + The audio file is posted as ``multipart/form-data`` to an + ``/audio/transcriptions`` endpoint with ``response_format=verbose_json``, and the + timed segments of the answer become the text items of the document. No model is + loaded in the Docling process, and FFmpeg is not needed for audio files (the + server decodes them). + + Notes: + Requires ``enable_remote_services=True`` on the parent pipeline options to + permit external API calls. + """ + + kind: Literal["api_model_options"] = "api_model_options" + url: Annotated[ + AnyUrl, + Field( + description=( + "Transcription endpoint of an OpenAI-compatible server, for example " + "`http://whisper:8000/v1/audio/transcriptions`. The default points to a " + "local server; update it for a deployed or cloud service." + ), + ), + ] = AnyUrl("http://localhost:8000/v1/audio/transcriptions") + headers: Annotated[ + dict[str, str], + Field( + description=( + "HTTP headers to include in the request. Use for authentication." + ), + examples=[{"Authorization": "Bearer TOKEN"}], + ), + ] = {} + model: Annotated[ + Optional[str], + Field( + description=( + "Value of the `model` form field, for servers that serve several " + "models. Omitted when empty." + ), + examples=["whisper-1", "Systran/faster-whisper-small"], + ), + ] = None + language: Annotated[ + Optional[str], + Field( + description=( + "ISO 639-1 language code of the audio (`en`, `fr`...). Omitted when " + "empty, so that the server detects the language." + ), + examples=["en", "fr", "de"], + ), + ] = None + initial_prompt: Annotated[ + Optional[str], + Field( + description=( + "Text sent as the `prompt` form field, to condition the transcription " + "style or give domain vocabulary (names, call-signs, jargon)." + ) + ), + ] = None + word_timestamps: Annotated[ + bool, + Field( + description=( + "Ask for word-level timestamps (`timestamp_granularities[]=word`). " + "The server must support them." + ) + ), + ] = False + params: Annotated[ + dict[str, Any], + Field( + description=( + "Additional form fields to send, for service-specific settings that " + "are not covered above (for example `temperature` or `vad_filter`)." + ), + examples=[{"temperature": 0.0}], + ), + ] = {} + timeout: Annotated[ + float, + Field( + description=( + "Seconds to wait for the answer. Transcribing a long recording on a " + "busy server can take minutes." + ) + ), + ] = 600.0 + + class InlineAsrOptions(BaseAsrOptions): """Configuration for inline ASR models running locally.""" diff --git a/docling/pipeline/asr_pipeline.py b/docling/pipeline/asr_pipeline.py index 737421cdcb..9498c76898 100644 --- a/docling/pipeline/asr_pipeline.py +++ b/docling/pipeline/asr_pipeline.py @@ -40,6 +40,7 @@ def __init__(self, pipeline_options: AsrPipelineOptions): asr_options=self.pipeline_options.asr_options, artifacts_path=self.artifacts_path, accelerator_options=pipeline_options.accelerator_options, + enable_remote_services=pipeline_options.enable_remote_services, ) def _has_text(self, document: "DoclingDocument") -> bool: diff --git a/docling/pipeline/asr_transcriber.py b/docling/pipeline/asr_transcriber.py index 07827807e0..302b8aa3e3 100644 --- a/docling/pipeline/asr_transcriber.py +++ b/docling/pipeline/asr_transcriber.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Final, Protocol +import requests from docling_core.types.doc import ( ContentLayer, DocItemLabel, @@ -33,11 +34,13 @@ AsrPipelineOptions, ) from docling.datamodel.pipeline_options_asr_model import ( + ApiAsrOptions, InlineAsrMlxWhisperOptions, InlineAsrNativeWhisperOptions, InlineAsrOptions, InlineAsrWhisperS2TOptions, ) +from docling.exceptions import OperationNotAllowed from docling.pipeline.base_pipeline import BasePipeline from docling.utils.accelerator_utils import decide_device from docling.utils.profiling import ProfilingScope, TimeRecorder @@ -696,6 +699,137 @@ def transcribe(self, fpath: Path) -> list[_ConversationItem]: # ============================================================ +class _ApiAsrModel: + """Transcriber that sends the audio to an OpenAI-compatible transcription API. + + Nothing is loaded in this process: the file is posted to ``/audio/transcriptions`` + (a Whisper server of its own, or a hosted service) with + ``response_format=verbose_json`` and the timed segments of the answer become the + conversation. FFmpeg is not needed, the server decodes the audio. + """ + + def __init__(self, asr_options: ApiAsrOptions): + self.asr_options = asr_options + + def _form_fields(self) -> list[tuple[str, str]]: + options = self.asr_options + fields: list[tuple[str, str]] = [("response_format", "verbose_json")] + if options.model: + fields.append(("model", options.model)) + if options.language: + fields.append(("language", options.language)) + if options.initial_prompt: + fields.append(("prompt", options.initial_prompt)) + if options.word_timestamps: + fields.append(("timestamp_granularities[]", "segment")) + fields.append(("timestamp_granularities[]", "word")) + for key, value in options.params.items(): + # `response_format` stays verbose_json: the timed segments are needed + if key != "response_format": + fields.append( + (key, str(value).lower() if isinstance(value, bool) else str(value)) + ) + return fields + + def _post(self, name: str, content: bytes) -> dict: + options = self.asr_options + _log.info(f"transcribing {name} with {options.url}") + response = requests.post( + str(options.url), + headers=options.headers, + data=self._form_fields(), + files={"file": (name, content, _audio_mimetype(name))}, + timeout=options.timeout, + ) + if not response.ok: + detail = " ".join(response.text.split())[:300] + raise RuntimeError( + f"The transcription service answered HTTP {response.status_code}" + + (f": {detail}" if detail else "") + ) + try: + return response.json() + except ValueError as err: + raise RuntimeError( + "The transcription service did not answer with JSON; it must " + "support response_format=verbose_json" + ) from err + + @staticmethod + def _to_conversation(answer: dict) -> list[_ConversationItem]: + segments = answer.get("segments") + if not isinstance(segments, list): + raise RuntimeError( + "The transcription service gave no timed segments; it must " + "support response_format=verbose_json" + ) + # OpenAI puts the words in a list of their own, other servers inside each + # segment: take both, and give a word to the segment it falls in. + top_level_words = [ + _ConversationWord( + start_time=w.get("start"), end_time=w.get("end"), text=w.get("word", "") + ) + for w in answer.get("words") or [] + ] + convo: list[_ConversationItem] = [] + for seg in segments: + start, end = seg.get("start"), seg.get("end") + words = [ + _ConversationWord( + start_time=w.get("start"), + end_time=w.get("end"), + text=w.get("word", ""), + ) + for w in seg.get("words") or [] + ] + if not words and top_level_words and start is not None and end is not None: + words = [ + w + for w in top_level_words + if w.start_time is not None and start <= w.start_time < end + ] + convo.append( + _ConversationItem( + start_time=start, + end_time=end, + text=str(seg.get("text", "")).strip(), + words=words, + ) + ) + return convo + + def transcribe(self, fpath: Path) -> list[_ConversationItem]: + return self._to_conversation(self._post(fpath.name, fpath.read_bytes())) + + def run(self, conv_res: ConversionResult) -> ConversionResult: + path_or_stream = conv_res.input._backend.path_or_stream + try: + if isinstance(path_or_stream, BytesIO): + conversation = self._to_conversation( + self._post(conv_res.input.file.name, path_or_stream.getvalue()) + ) + elif isinstance(path_or_stream, Path): + conversation = self.transcribe(path_or_stream) + else: + raise RuntimeError( + f"ASR pipeline requires a file path or BytesIO stream, " + f"but got {type(path_or_stream)}" + ) + _process_conversation(conversation, conv_res) + conv_res.status = ConversionStatus.SUCCESS + except Exception as exc: + _log.error(f"Audio transcription by the API has an error: {exc}") + conv_res.errors.append( + ErrorItem( + component_type=DoclingComponentType.PIPELINE, + module_name="AsrPipeline", + error_message=str(exc), + ) + ) + conv_res.status = ConversionStatus.FAILURE + return conv_res + + # ============================================================ # Transcriber protocol + factory (new; consumed by AsrPipeline and VideoPipeline) # ============================================================ @@ -773,10 +907,19 @@ class _AsrModelFactory: @staticmethod def create( - asr_options: InlineAsrOptions, + asr_options: InlineAsrOptions | ApiAsrOptions, artifacts_path: Path | None, accelerator_options: AcceleratorOptions, + enable_remote_services: bool = False, ) -> _AsrTranscriber: + if isinstance(asr_options, ApiAsrOptions): + if not enable_remote_services: + raise OperationNotAllowed( + "Transcribing with a remote API is not allowed by default. " + "Set `enable_remote_services=True` in the pipeline options to " + "send the audio to the configured service." + ) + return _ApiAsrModel(asr_options=asr_options) if isinstance(asr_options, InlineAsrNativeWhisperOptions): return _NativeWhisperModel( enabled=True, diff --git a/docling/pipeline/video_pipeline.py b/docling/pipeline/video_pipeline.py index 69a3d99725..1c8fd28871 100644 --- a/docling/pipeline/video_pipeline.py +++ b/docling/pipeline/video_pipeline.py @@ -115,6 +115,7 @@ def __init__(self, pipeline_options: VideoPipelineOptions): asr_options=self.pipeline_options.asr_options, artifacts_path=self.artifacts_path, accelerator_options=pipeline_options.accelerator_options, + enable_remote_services=pipeline_options.enable_remote_services, ) @classmethod diff --git a/docs/usage/processing_audio_media.md b/docs/usage/processing_audio_media.md index 46d77d75d2..15da87fc91 100644 --- a/docs/usage/processing_audio_media.md +++ b/docs/usage/processing_audio_media.md @@ -353,6 +353,56 @@ pipeline_options.asr_options = InlineAsrWhisperS2TOptions( !!! note "WhisperS2T is not available on Apple Silicon" The `whisper-s2t-reborn` dependency installs only on non-Apple-Silicon platforms, so `_S2T` presets can't be used on M-series Macs — use the native or MLX backends there. On Linux with CUDA, see the [cuBLAS note](#installation) above if model loading fails. +### Transcribing with a remote Whisper server + +Instead of loading a model in the Docling process, the ASR pipeline can send the audio to an OpenAI-compatible +`/v1/audio/transcriptions` endpoint — a Whisper server that is deployed on its own (for example faster-whisper-server, +whisper.cpp's server or vLLM) or a hosted service. This keeps the model, and its GPU, out of the process that converts +documents, and lets several services share one transcription server. + +```python +from docling.datamodel.base_models import InputFormat +from docling.datamodel.pipeline_options import AsrPipelineOptions +from docling.datamodel.pipeline_options_asr_model import ApiAsrOptions +from docling.document_converter import AudioFormatOption, DocumentConverter +from docling.pipeline.asr_pipeline import AsrPipeline + +pipeline_options = AsrPipelineOptions( + asr_options=ApiAsrOptions( + url="http://whisper:8000/v1/audio/transcriptions", + model="Systran/faster-whisper-small", + language="en", + initial_prompt="Columbus flight control loop: COL FLIGHT, COL SYSTEMS", + headers={"Authorization": "Bearer TOKEN"}, + ), + enable_remote_services=True, # required: the audio leaves this process +) + +converter = DocumentConverter( + format_options={ + InputFormat.AUDIO: AudioFormatOption( + pipeline_cls=AsrPipeline, pipeline_options=pipeline_options + ) + } +) +result = converter.convert("recording.mp3") +``` + +The file is posted as `multipart/form-data` with `response_format=verbose_json`, and every timed segment of the answer +becomes a text item with a `TrackSource` (start and end time), like with the local backends. Words are added to each +segment when `word_timestamps=True` and the server supports `timestamp_granularities[]=word`. Settings that are specific to a +server go in `params`, which are sent as additional form fields. + +Notes: + +- `enable_remote_services=True` is required, as for remote picture description: without it the pipeline raises + `OperationNotAllowed` and nothing is sent. +- FFmpeg is not needed for audio files, the server decodes them. (The video pipeline still uses it to extract the + audio track.) +- The server must return timed segments (`verbose_json`). An answer without them fails the conversion with a message + saying so. Speaker labels are not part of the OpenAI transcription API, so the items have no speaker. +- The request waits up to `timeout` seconds (600 by default), which long recordings may need to exceed. + ### From the command line The `docling` CLI selects any preset with `--asr-model` (values are the lower-case preset names). Audio inputs route to the ASR pipeline and video inputs to the video pipeline automatically based on file extension, so no extra flag is required to pick a pipeline — `--asr-model` controls the transcription backend for both: diff --git a/tests/test_asr_api.py b/tests/test_asr_api.py new file mode 100644 index 0000000000..1a74610241 --- /dev/null +++ b/tests/test_asr_api.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Tests for the API-based ASR backend (an OpenAI-compatible transcription server). + +The server is a stand-in running in a thread: no speech model is downloaded or run. +""" + +import json +import threading +from email import message_from_bytes +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest +from docling_core.types.doc import TrackSource + +from docling.datamodel.base_models import ConversionStatus, InputFormat +from docling.datamodel.pipeline_options import AsrPipelineOptions, VideoPipelineOptions +from docling.datamodel.pipeline_options_asr_model import ApiAsrOptions +from docling.document_converter import AudioFormatOption, DocumentConverter +from docling.exceptions import OperationNotAllowed +from docling.pipeline.asr_pipeline import AsrPipeline +from docling.pipeline.asr_transcriber import _ApiAsrModel, _AsrModelFactory + +SAMPLE_AUDIO = Path("./tests/data/audio/sources/sample_10s.mp3") + +VERBOSE_JSON = { + "task": "transcribe", + "language": "english", + "duration": 9.0, + "text": "Pump two is at one point eight bar. Go for the switch-over.", + "segments": [ + { + "id": 0, + "start": 0.0, + "end": 3.5, + "text": " Pump two is at one point eight bar.", + }, + {"id": 1, "start": 3.5, "end": 9.0, "text": " Go for the switch-over."}, + ], +} + + +class _FakeTranscriptionServer: + """Answers POST /v1/audio/transcriptions and records what it was sent.""" + + def __init__(self, status: int = 200, body: object = VERBOSE_JSON): + outer = self + self.status = status + self.body = body + self.requests: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + message = message_from_bytes( + b"Content-Type: " + + self.headers["Content-Type"].encode() + + b"\r\n\r\n" + + raw + ) + fields: dict[str, list[str]] = {} + upload = {} + for part in message.get_payload(): + name = part.get_param("name", header="content-disposition") + if part.get_filename(): + upload = { + "filename": part.get_filename(), + "content_type": part.get_content_type(), + "size": len(part.get_payload(decode=True)), + } + else: + fields.setdefault(name, []).append( + part.get_payload(decode=True).decode() + ) + outer.requests.append( + { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "fields": fields, + "file": upload, + } + ) + payload = ( + outer.body + if isinstance(outer.body, str) + else json.dumps(outer.body) + ).encode() + self.send_response(outer.status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): # keep the test output quiet + pass + + self._server = HTTPServer(("127.0.0.1", 0), Handler) + self.url = ( + f"http://127.0.0.1:{self._server.server_address[1]}/v1/audio/transcriptions" + ) + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + def close(self): + self._server.shutdown() + self._server.server_close() + + +@pytest.fixture +def server(): + s = _FakeTranscriptionServer() + yield s + s.close() + + +def _converter(options: ApiAsrOptions) -> DocumentConverter: + pipeline_options = AsrPipelineOptions( + asr_options=options, enable_remote_services=True + ) + return DocumentConverter( + format_options={ + InputFormat.AUDIO: AudioFormatOption( + pipeline_cls=AsrPipeline, pipeline_options=pipeline_options + ) + } + ) + + +class TestApiAsrOptions: + def test_defaults(self): + options = ApiAsrOptions() + + assert options.kind == "api_model_options" + assert str(options.url) == "http://localhost:8000/v1/audio/transcriptions" + assert options.model is None + assert options.language is None + assert options.initial_prompt is None + assert options.word_timestamps is False + assert options.headers == {} + assert options.params == {} + + def test_accepted_by_the_audio_and_video_pipeline_options(self): + options = ApiAsrOptions(model="whisper-1") + + assert AsrPipelineOptions(asr_options=options).asr_options is options + assert VideoPipelineOptions(asr_options=options).asr_options is options + + def test_remote_services_must_be_enabled(self): + with pytest.raises(OperationNotAllowed, match="enable_remote_services"): + AsrPipeline(AsrPipelineOptions(asr_options=ApiAsrOptions())) + + pipeline = AsrPipeline( + AsrPipelineOptions(asr_options=ApiAsrOptions(), enable_remote_services=True) + ) + assert isinstance(pipeline._model, _ApiAsrModel) + + def test_factory_refuses_without_the_switch(self): + with pytest.raises(OperationNotAllowed): + _AsrModelFactory.create( + asr_options=ApiAsrOptions(), + artifacts_path=None, + accelerator_options=AsrPipelineOptions().accelerator_options, + ) + + +class TestApiAsrConversion: + def test_audio_file_becomes_timed_text_items(self, server): + converter = _converter(ApiAsrOptions(url=server.url)) + + result = converter.convert(SAMPLE_AUDIO) + + assert result.status == ConversionStatus.SUCCESS + texts = result.document.texts + assert [t.text for t in texts] == [ + "Pump two is at one point eight bar.", + "Go for the switch-over.", + ] + tracks = [t.source[0] for t in texts] + assert all(isinstance(track, TrackSource) for track in tracks) + assert [(track.start_time, track.end_time) for track in tracks] == [ + (0.0, 3.5), + (3.5, 9.0), + ] + + def test_request_carries_the_audio_and_the_options(self, server): + options = ApiAsrOptions( + url=server.url, + headers={"Authorization": "Bearer secret"}, + model="Systran/faster-whisper-small", + language="en", + initial_prompt="Columbus loop: COL FLIGHT, COL SYSTEMS", + params={"temperature": 0.0, "vad_filter": True, "response_format": "text"}, + ) + + _converter(options).convert(SAMPLE_AUDIO) + + (request,) = server.requests + assert request["path"] == "/v1/audio/transcriptions" + assert request["authorization"] == "Bearer secret" + assert request["file"]["filename"] == "sample_10s.mp3" + assert request["file"]["content_type"] == "audio/mp3" + assert request["file"]["size"] == SAMPLE_AUDIO.stat().st_size + assert request["fields"] == { + "response_format": ["verbose_json"], + "model": ["Systran/faster-whisper-small"], + "language": ["en"], + "prompt": ["Columbus loop: COL FLIGHT, COL SYSTEMS"], + "temperature": ["0.0"], + "vad_filter": ["true"], + } + + def test_nothing_optional_is_sent_when_it_is_not_set(self, server): + _converter(ApiAsrOptions(url=server.url)).convert(SAMPLE_AUDIO) + + assert server.requests[0]["fields"] == {"response_format": ["verbose_json"]} + + def test_word_timestamps_are_requested_and_given_to_their_segment(self, server): + server.body = { + **VERBOSE_JSON, + "words": [ + {"word": "Pump", "start": 0.0, "end": 0.4}, + {"word": "two", "start": 0.4, "end": 0.7}, + {"word": "Go", "start": 3.6, "end": 3.9}, + ], + } + model = _ApiAsrModel(ApiAsrOptions(url=server.url, word_timestamps=True)) + + conversation = model.transcribe(SAMPLE_AUDIO) + + assert server.requests[0]["fields"]["timestamp_granularities[]"] == [ + "segment", + "word", + ] + assert [[w.text for w in item.words or []] for item in conversation] == [ + ["Pump", "two"], + ["Go"], + ] + + def test_words_inside_the_segments_are_taken_as_they_are(self, server): + server.body = { + "segments": [ + { + "start": 0.0, + "end": 1.0, + "text": " Roger.", + "words": [{"word": "Roger", "start": 0.1, "end": 0.9}], + } + ] + } + model = _ApiAsrModel(ApiAsrOptions(url=server.url, word_timestamps=True)) + + (item,) = model.transcribe(SAMPLE_AUDIO) + + assert item.text == "Roger." + assert [(w.text, w.start_time, w.end_time) for w in item.words or []] == [ + ("Roger", 0.1, 0.9) + ] + + def test_a_server_error_fails_the_conversion_with_its_reason(self): + failing = _FakeTranscriptionServer( + status=503, body={"detail": "model is loading"} + ) + try: + result = _converter(ApiAsrOptions(url=failing.url)).convert( + SAMPLE_AUDIO, raises_on_error=False + ) + finally: + failing.close() + + assert result.status == ConversionStatus.FAILURE + message = " ".join(error.error_message for error in result.errors) + assert "HTTP 503" in message and "model is loading" in message + + def test_an_answer_without_segments_is_a_failure_that_says_what_is_needed(self): + plain = _FakeTranscriptionServer(body={"text": "no timings"}) + try: + result = _converter(ApiAsrOptions(url=plain.url)).convert( + SAMPLE_AUDIO, raises_on_error=False + ) + finally: + plain.close() + + assert result.status == ConversionStatus.FAILURE + assert "verbose_json" in result.errors[0].error_message + + def test_an_answer_that_is_not_json_is_a_failure(self): + html = _FakeTranscriptionServer(body="gateway") + try: + result = _converter(ApiAsrOptions(url=html.url)).convert( + SAMPLE_AUDIO, raises_on_error=False + ) + finally: + html.close() + + assert result.status == ConversionStatus.FAILURE + assert "JSON" in result.errors[0].error_message