Skip to content

Commit 2aedaa5

Browse files
authored
[FE] Convert audio to markdown
1 parent 66ff8b3 commit 2aedaa5

9 files changed

Lines changed: 1733 additions & 49 deletions

File tree

examples/example_notebook.ipynb

Lines changed: 1471 additions & 29 deletions
Large diffs are not rendered by default.

examples/example_notebook_colab.ipynb

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@
114114
"name": "stdout",
115115
"output_type": "stream",
116116
"text": [
117-
"\r",
118-
"0% [Working]\r",
119-
" \r",
117+
"\r\n",
118+
"0% [Working]\r\n",
119+
" \r\n",
120120
"Get:1 https://cloud.r-project.org/bin/linux/ubuntu jammy-cran40/ InRelease [3,626 B]\n",
121121
"Get:2 https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64 InRelease [1,581 B]\n",
122122
"Get:3 http://security.ubuntu.com/ubuntu jammy-security InRelease [129 kB]\n",
@@ -1968,14 +1968,72 @@
19681968
"source": [
19691969
"display(Markdown(result_md))"
19701970
]
1971+
},
1972+
{
1973+
"cell_type": "markdown",
1974+
"metadata": {},
1975+
"source": [
1976+
"# Audio to Markdown"
1977+
]
1978+
},
1979+
{
1980+
"cell_type": "code",
1981+
"execution_count": null,
1982+
"metadata": {},
1983+
"outputs": [
1984+
{
1985+
"name": "stderr",
1986+
"output_type": "stream",
1987+
"text": [
1988+
"<>:3: SyntaxWarning: invalid escape sequence '\\h'\n",
1989+
"<>:3: SyntaxWarning: invalid escape sequence '\\h'\n",
1990+
"C:\\Users\\vaish\\AppData\\Local\\Temp\\ipykernel_16776\\2861124903.py:3: SyntaxWarning: invalid escape sequence '\\h'\n",
1991+
" document_path =\"inputs\\harvard.wav\"\n",
1992+
"e:\\Lexoid\\Lexoid\\.venv\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
1993+
" from .autonotebook import tqdm as notebook_tqdm\n",
1994+
"\u001b[32m2025-11-17 16:28:41.463\u001b[0m | \u001b[34m\u001b[1mDEBUG \u001b[0m | \u001b[36mlexoid.core.utils\u001b[0m:\u001b[36mis_supported_file_type\u001b[0m:\u001b[36m92\u001b[0m - \u001b[34m\u001b[1mFile type: audio/wav\u001b[0m\n",
1995+
"\u001b[32m2025-11-17 16:28:41.463\u001b[0m | \u001b[34m\u001b[1mDEBUG \u001b[0m | \u001b[36mlexoid.core.utils\u001b[0m:\u001b[36mrouter\u001b[0m:\u001b[36m559\u001b[0m - \u001b[34m\u001b[1mUsing LLM_PARSE because the type of file is audio.\u001b[0m\n",
1996+
"\u001b[32m2025-11-17 16:28:41.464\u001b[0m | \u001b[34m\u001b[1mDEBUG \u001b[0m | \u001b[36mlexoid.api\u001b[0m:\u001b[36mwrapper\u001b[0m:\u001b[36m70\u001b[0m - \u001b[34m\u001b[1mAuto-detected parser type: ParserType.LLM_PARSE\u001b[0m\n",
1997+
"\u001b[32m2025-11-17 16:28:41.464\u001b[0m | \u001b[34m\u001b[1mDEBUG \u001b[0m | \u001b[36mlexoid.api\u001b[0m:\u001b[36mparse_chunk\u001b[0m:\u001b[36m135\u001b[0m - \u001b[34m\u001b[1mUsing LLM parser\u001b[0m\n"
1998+
]
1999+
},
2000+
{
2001+
"name": "stdout",
2002+
"output_type": "stream",
2003+
"text": [
2004+
"The Stale Smell of Old Beer Lingers\n",
2005+
"\n",
2006+
"- The stale smell of old beer lingers.\n",
2007+
"- It takes heat to bring out the odor.\n",
2008+
"- A cold dip restores health and zest.\n",
2009+
"- A salt pickle tastes fine with ham.\n",
2010+
"- Tacos al pastor are my favorite.\n",
2011+
"- A zestful food is the hot cross bun.\n"
2012+
]
2013+
}
2014+
],
2015+
"source": [
2016+
"from lexoid.api import parse\n",
2017+
"\n",
2018+
"document_path = \"inputs/harvard.wav\"\n",
2019+
"parsed_md = parse(document_path, \"AUTO\", api=\"gemini\")[\"raw\"]\n",
2020+
"print(parsed_md)"
2021+
]
2022+
},
2023+
{
2024+
"cell_type": "code",
2025+
"execution_count": null,
2026+
"metadata": {},
2027+
"outputs": [],
2028+
"source": []
19712029
}
19722030
],
19732031
"metadata": {
19742032
"colab": {
19752033
"provenance": []
19762034
},
19772035
"kernelspec": {
1978-
"display_name": "Python 3 (ipykernel)",
2036+
"display_name": ".venv",
19792037
"language": "python",
19802038
"name": "python3"
19812039
},

harvard.wav

3.1 MB
Binary file not shown.

lexoid/core/parse_type/llm_parser.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import requests
1212
import torch
1313
from anthropic import Anthropic
14+
from google import genai
1415
from huggingface_hub import InferenceClient
1516
from loguru import logger
1617
from mistralai import Mistral
@@ -21,10 +22,11 @@
2122
from transformers import AutoModelForVision2Seq, AutoProcessor
2223

2324
from lexoid.core.conversion_utils import (
24-
convert_image_to_pdf,
2525
convert_doc_to_base64_images,
26+
convert_image_to_pdf,
2627
)
2728
from lexoid.core.prompt_templates import (
29+
AUDIO_TO_MARKDOWN_PROMPT,
2830
INSTRUCTIONS_ADD_PG_BREAK,
2931
LLAMA_PARSER_PROMPT,
3032
OPENAI_USER_PROMPT,
@@ -92,9 +94,9 @@ def wrapper(*args, **kwargs):
9294
@retry_on_error
9395
def parse_llm_doc(path: str, **kwargs) -> List[Dict] | str:
9496
mime_type = get_file_type(path)
95-
if not ("image" in mime_type or "pdf" in mime_type):
97+
if not ("image" in mime_type or "pdf" in mime_type or "audio" in mime_type):
9698
raise ValueError(
97-
f"Unsupported file type: {mime_type}. Only PDF and image files are supported for LLM_PARSE."
99+
f"Unsupported file type: {mime_type}. Only PDF, image, and audio files are supported for LLM_PARSE."
98100
)
99101
if "api_provider" in kwargs:
100102
if kwargs["api_provider"] == "local":
@@ -107,6 +109,11 @@ def parse_llm_doc(path: str, **kwargs) -> List[Dict] | str:
107109

108110
api_provider = get_api_provider_for_model(model)
109111

112+
if mime_type.startswith("audio") and api_provider != "gemini":
113+
raise ValueError(
114+
f"Audio files are only supported with the Gemini API provider. The model '{model}' is not compatible."
115+
)
116+
110117
if api_provider == "gemini":
111118
return parse_with_gemini(path, **kwargs)
112119
elif api_provider == "local":
@@ -398,6 +405,10 @@ def process_match(match):
398405
def parse_with_gemini(path: str, **kwargs) -> List[Dict] | str:
399406
# Check if the file is an image and convert to PDF if necessary
400407
mime_type, _ = mimetypes.guess_type(path)
408+
409+
if mime_type and mime_type.startswith("audio"):
410+
return parse_audio_with_gemini(path, **kwargs)
411+
401412
if mime_type and mime_type.startswith("image"):
402413
pdf_content = convert_image_to_pdf(path)
403414
mime_type = "application/pdf"
@@ -592,13 +603,6 @@ def create_response(
592603
},
593604
include_image_base64=True,
594605
)
595-
596-
class TokenUsage:
597-
def __init__(self, prompt_tokens, completion_tokens, total_tokens):
598-
self.prompt_tokens = prompt_tokens
599-
self.completion_tokens = completion_tokens
600-
self.total_tokens = total_tokens
601-
602606
return {
603607
"response": response.pages[0].markdown,
604608
"usage": {
@@ -607,6 +611,7 @@ def __init__(self, prompt_tokens, completion_tokens, total_tokens):
607611
"total_tokens": 0, # Mistral does not provide token usage
608612
},
609613
}
614+
610615
if api == "anthropic":
611616
image_media_type = image_url.split(";")[0].split(":")[1]
612617
image_data = image_url.split(",")[1]
@@ -767,3 +772,37 @@ def parse_with_api(path: str, api: str, **kwargs) -> List[Dict] | str:
767772
"total": sum(total_tokens for _, _, _, _, total_tokens in all_results),
768773
},
769774
}
775+
776+
777+
def parse_audio_with_gemini(path: str, **kwargs) -> Dict:
778+
client = genai.Client()
779+
audio_file = client.files.upload(file=path)
780+
system_prompt = kwargs.get("system_prompt", None)
781+
if system_prompt == "" or system_prompt is None:
782+
system_prompt = AUDIO_TO_MARKDOWN_PROMPT + f"Audio file name is: {path}\n"
783+
784+
response = client.models.generate_content(
785+
model=kwargs["model"], contents=[system_prompt, audio_file]
786+
)
787+
788+
return {
789+
"raw": response.text,
790+
"segments": [
791+
{
792+
"metadata": {"page": 0},
793+
"content": response.text,
794+
}
795+
],
796+
"title": kwargs.get("title", ""),
797+
"url": kwargs.get("url", ""),
798+
"parent_title": kwargs.get("parent_title", ""),
799+
"recursive_docs": [],
800+
"token_usage": {
801+
"input": response.usage_metadata.prompt_token_count,
802+
"output": response.usage_metadata.candidates_token_count,
803+
"total": (
804+
response.usage_metadata.prompt_token_count
805+
+ response.usage_metadata.candidates_token_count
806+
),
807+
},
808+
}

lexoid/core/prompt_templates.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,10 @@
175175
LATEX_USER_PROMPT = """You are an AI agent specialized in parsing PDF documents and converting them into clean, valid LaTeX format.
176176
Your goal is to produce LaTeX code that accurately represents the document's structure, content, and layout while ensuring everything fits within standard page margins.
177177
"""
178+
179+
AUDIO_TO_MARKDOWN_PROMPT = """You are an expert transcription and formatting assistant.
180+
Convert the provided audio into a clean, well-structured Markdown document, preserving the logical flow, sections, and any lists or numbered points mentioned in the speech.
181+
Remove background noise and ignore any irrelevant sounds, side conversations, or filler words like “um” and “uh” that do not add meaning.
182+
Where appropriate, use Markdown headings, bullet points, numbered lists, and bold/italic text to improve clarity and readability.
183+
If the speaker mentions code, equations, or examples, format them using proper Markdown code blocks or inline code.
184+
Determine whether the speaker explicitly states a clear title in the audio; if a title is stated, use it as the main top-level Markdown heading; otherwise, use the audio file name (without its extension) as the main top-level Markdown heading."""

lexoid/core/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def is_supported_file_type(path: str) -> bool:
9797
or "presentation" in file_type
9898
or file_type.startswith("image/")
9999
or file_type.startswith("text")
100+
or file_type.startswith("audio")
100101
):
101102
return True
102103
return False
@@ -554,6 +555,10 @@ def router(path: str, priority: str = "speed", autoselect_llm: bool = False) ->
554555
):
555556
return "STATIC_PARSE", None
556557

558+
if file_type.startswith("audio"):
559+
logger.debug("Using LLM_PARSE because the type of file is audio.")
560+
return "LLM_PARSE", None
561+
557562
if priority == "accuracy":
558563
# If the file is a PDF without images but has hyperlinks, use STATIC_PARSE
559564
# Otherwise, use LLM_PARSE

0 commit comments

Comments
 (0)