Module 5: Audio Processing

8. Project: Audio Pipeline

Description

This project integrates everything learned in Module 5 into a complete and configurable Audio Pipeline. The system receives an audio file, validates and preprocesses it, transcribes it with Whisper, processes the transcript with an LLM (summary, action items, Q&A), and optionally generates audio of the result with TTS. It's a production system with flexible configuration, cost tracking, error handling, and extensions for batch processing and diarization.

Why it matters: This pipeline is the audio component of the Document Analyzer from Module 8. When the final system receives an audio file, it will delegate to exactly this pipeline. Besides, it's a useful system in its own right: any team that records meetings can use it to generate automatic summaries.


Specifications

Input

ParameterTypeRequiredDescription
audio_pathstrYesPath to the audio file
taskstrNo"summarize", "action_items", "qa", "full"
questionslist[str]Only if task="qa"Questions about the audio
generate_audioboolNoIf True, generates TTS of the result
languagestrNoISO code of the language (default: "es")
voicestrNoVoice for TTS (default: "nova")
vocabularylist[str]NoTechnical terms to improve transcription

Output

{
    "status": "success",
    "transcript": "Full transcribed text...",
    "result": {
        "summary": "Summary in key points...",
        "action_items": ["Task 1", "Task 2"],
    },
    "audio_output_path": "summary.mp3",
    "metadata": {
        "audio_duration_min": 15.3,
        "transcript_words": 2340,
        "language_detected": "es",
        "chunks_processed": 1
    },
    "costs": {
        "whisper": 0.0918,
        "llm": 0.0012,
        "tts": 0.0045,
        "total": 0.0975
    },
    "timing": {
        "preprocessing_s": 0.5,
        "transcription_s": 8.2,
        "llm_s": 1.3,
        "tts_s": 2.1,
        "total_s": 12.1
    }
}

Step 1: Configuration

from openai import OpenAI
from pydub import AudioSegment
from pathlib import Path
from dataclasses import dataclass, field
import tempfile
import time
import json

client = OpenAI()

WHISPER_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".flac", ".ogg", ".oga"}
WHISPER_MAX_SIZE = 25 * 1024 * 1024

@dataclass
class PipelineConfig:
    task: str = "summarize"
    language: str = "es"
    voice: str = "nova"
    tts_model: str = "tts-1"
    llm_model: str = "gpt-4o-mini"
    generate_audio: bool = False
    questions: list[str] = field(default_factory=list)
    vocabulary: list[str] = field(default_factory=list)
    max_transcript_chars: int = 12000
    max_tts_chars: int = 4000
    output_dir: str = "pipeline_output"
    auto_preprocess: bool = True

@dataclass
class PipelineMetrics:
    costs: dict = field(default_factory=dict)
    timing: dict = field(default_factory=dict)

    def add_cost(self, service: str, amount: float):
        self.costs[service] = self.costs.get(service, 0) + amount
        self.costs["total"] = sum(v for k, v in self.costs.items() if k != "total")

    def start_timer(self, name: str):
        self.timing[f"_start_{name}"] = time.time()

    def stop_timer(self, name: str):
        start_key = f"_start_{name}"
        if start_key in self.timing:
            self.timing[name] = round(time.time() - self.timing.pop(start_key), 2)

Step 2: Validation and Preprocessing

def validate_audio(audio_path: str) -> dict:
    path = Path(audio_path)

    if not path.exists():
        return {"valid": False, "error": "File not found", "path": audio_path}

    suffix = path.suffix.lower()
    size_bytes = path.stat().st_size
    size_mb = size_bytes / (1024 * 1024)

    issues = []

    if suffix not in WHISPER_FORMATS:
        issues.append({
            "type": "unsupported_format",
            "detail": f"Format {suffix} not supported",
            "auto_fixable": True
        })

    if size_bytes > WHISPER_MAX_SIZE:
        issues.append({
            "type": "file_too_large",
            "detail": f"File of {size_mb:.1f}MB exceeds 25MB",
            "auto_fixable": True
        })

    try:
        audio = AudioSegment.from_file(audio_path)
        duration_s = len(audio) / 1000

        if audio.dBFS < -35:
            issues.append({
                "type": "low_volume",
                "detail": f"Low volume ({audio.dBFS:.1f} dBFS)",
                "auto_fixable": True
            })

        if audio.frame_rate < 16000:
            issues.append({
                "type": "low_sample_rate",
                "detail": f"Low sample rate ({audio.frame_rate} Hz)",
                "auto_fixable": True
            })

    except Exception as e:
        return {"valid": False, "error": f"Could not read the audio: {str(e)}"}

    return {
        "valid": len([i for i in issues if not i["auto_fixable"]]) == 0,
        "size_mb": round(size_mb, 2),
        "duration_s": round(duration_s, 2),
        "format": suffix,
        "issues": issues,
        "needs_preprocessing": len(issues) > 0
    }


def preprocess_audio(audio_path: str, output_dir: str) -> dict:
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    path = Path(audio_path)
    steps = []

    current = audio_path

    if path.suffix.lower() not in WHISPER_FORMATS:
        converted = str(Path(output_dir) / f"{path.stem}.mp3")
        AudioSegment.from_file(current).export(converted, format="mp3", bitrate="128k")
        current = converted
        steps.append("format_conversion")

    audio = AudioSegment.from_file(current)

    needs_normalize = (
        audio.dBFS < -30
        or audio.channels > 1
        or audio.frame_rate < 16000
    )

    if needs_normalize:
        normalized = str(Path(output_dir) / f"{path.stem}_normalized.mp3")
        audio = audio.set_frame_rate(16000).set_channels(1).normalize()
        audio.export(normalized, format="mp3", bitrate="64k")
        current = normalized
        steps.append("normalization")

    size_mb = Path(current).stat().st_size / (1024 * 1024)
    if size_mb > 24:
        compressed = str(Path(output_dir) / f"{path.stem}_compressed.mp3")
        audio = AudioSegment.from_file(current)
        audio = audio.set_channels(1).set_frame_rate(16000)
        audio.export(compressed, format="mp3", bitrate="48k")
        current = compressed
        steps.append("compression")

    return {
        "output_path": current,
        "steps": steps,
        "final_size_mb": round(Path(current).stat().st_size / (1024 * 1024), 2)
    }

Step 3: Transcription with Whisper

def transcribe_audio(
    audio_path: str,
    config: PipelineConfig,
    metrics: PipelineMetrics
) -> dict:
    metrics.start_timer("transcription")

    path = Path(audio_path)
    size_mb = path.stat().st_size / (1024 * 1024)

    audio = AudioSegment.from_file(audio_path)
    duration_min = len(audio) / 1000 / 60

    if size_mb > 24:
        result = _transcribe_chunked(audio, config, metrics)
    else:
        result = _transcribe_single(audio_path, config)

    metrics.stop_timer("transcription")
    metrics.add_cost("whisper", duration_min * 0.006)

    return {
        "text": result["text"],
        "language": result.get("language", config.language),
        "duration_min": round(duration_min, 2),
        "word_count": len(result["text"].split()),
        "chunks": result.get("chunks", 1)
    }


def _transcribe_single(audio_path: str, config: PipelineConfig) -> dict:
    kwargs = {
        "model": "whisper-1",
        "response_format": "verbose_json",
    }

    if config.language:
        kwargs["language"] = config.language

    if config.vocabulary:
        kwargs["prompt"] = ", ".join(config.vocabulary)

    with open(audio_path, "rb") as f:
        kwargs["file"] = f
        response = client.audio.transcriptions.create(**kwargs)

    return {
        "text": response.text,
        "language": response.language,
        "duration": response.duration,
        "chunks": 1
    }


def _transcribe_chunked(
    audio: AudioSegment,
    config: PipelineConfig,
    metrics: PipelineMetrics,
    chunk_duration_ms: int = 10 * 60 * 1000
) -> dict:
    chunks = [
        audio[i:i + chunk_duration_ms]
        for i in range(0, len(audio), chunk_duration_ms)
    ]

    transcripts = []
    detected_language = None

    with tempfile.TemporaryDirectory() as tmp_dir:
        for i, chunk in enumerate(chunks):
            chunk_path = str(Path(tmp_dir) / f"chunk_{i:03d}.mp3")
            chunk.export(chunk_path, format="mp3", bitrate="128k")

            result = _transcribe_single(chunk_path, config)
            transcripts.append(result["text"])

            if detected_language is None:
                detected_language = result.get("language")

    return {
        "text": " ".join(transcripts),
        "language": detected_language,
        "chunks": len(chunks)
    }

Step 4: Processing with the LLM

def process_with_llm(
    transcript: str,
    config: PipelineConfig,
    metrics: PipelineMetrics
) -> dict:
    metrics.start_timer("llm")

    truncated = transcript[:config.max_transcript_chars]

    if config.task == "summarize":
        result = _summarize(truncated, config)
    elif config.task == "action_items":
        result = _extract_action_items(truncated, config)
    elif config.task == "qa":
        result = _answer_questions(truncated, config)
    elif config.task == "full":
        result = _full_analysis(truncated, config)
    else:
        result = _summarize(truncated, config)

    input_tokens = len(truncated.split())
    metrics.add_cost("llm", input_tokens * 0.00000015 + 1000 * 0.0000006)
    metrics.stop_timer("llm")

    return result


def _summarize(transcript: str, config: PipelineConfig) -> dict:
    response = client.chat.completions.create(
        model=config.llm_model,
        messages=[{
            "role": "user",
            "content": (
                "Summarize the following text in 5-7 key points. "
                "Be specific: include names, dates and data mentioned.\n\n"
                + transcript
            )
        }],
        max_tokens=800
    )
    return {"summary": response.choices[0].message.content}


def _extract_action_items(transcript: str, config: PipelineConfig) -> dict:
    response = client.chat.completions.create(
        model=config.llm_model,
        messages=[{
            "role": "user",
            "content": (
                "Extract ALL the action items from the following text.\n"
                "Format for each one:\n"
                "- [ ] [Owner if mentioned]: [Concrete task] "
                "[Date if mentioned]\n\n"
                "If there are no clear action items, state 'No explicit "
                "action items identified' and suggest possible implicit tasks.\n\n"
                + transcript
            )
        }],
        max_tokens=600
    )
    return {"action_items": response.choices[0].message.content}


def _answer_questions(transcript: str, config: PipelineConfig) -> dict:
    answers = []
    for question in config.questions:
        response = client.chat.completions.create(
            model=config.llm_model,
            messages=[
                {
                    "role": "system",
                    "content": (
                        "Answer based ONLY on the provided context. "
                        "If the information isn't available, say "
                        "'Not mentioned in the audio'."
                    )
                },
                {
                    "role": "user",
                    "content": f"Context:\n{transcript}\n\nQuestion: {question}"
                }
            ],
            max_tokens=300
        )
        answers.append({
            "question": question,
            "answer": response.choices[0].message.content
        })
    return {"qa": answers}


def _full_analysis(transcript: str, config: PipelineConfig) -> dict:
    response = client.chat.completions.create(
        model=config.llm_model,
        messages=[{
            "role": "user",
            "content": (
                "Analyze the following text and generate a complete report with "
                "EXACTLY these sections:\n\n"
                "## Executive Summary\n"
                "(3-5 sentences with the most important points)\n\n"
                "## Main Topics\n"
                "(Numbered list)\n\n"
                "## Decisions Made\n"
                "(List. If there are none, state 'No explicit decisions')\n\n"
                "## Action Items\n"
                "(Format: - [ ] [Owner]: [Task])\n\n"
                "## Data and Figures Mentioned\n"
                "(Dates, amounts, percentages, etc.)\n\n"
                + transcript
            )
        }],
        max_tokens=1200
    )
    return {"full_analysis": response.choices[0].message.content}

Step 5: Audio Generation (TTS)

def generate_audio_output(
    text: str,
    config: PipelineConfig,
    metrics: PipelineMetrics,
    output_path: str = None
) -> dict:
    metrics.start_timer("tts")

    if output_path is None:
        output_path = str(Path(config.output_dir) / "audio_output.mp3")

    Path(output_path).parent.mkdir(parents=True, exist_ok=True)

    tts_text = text[:config.max_tts_chars]
    tts_text = _prepare_text_for_tts(tts_text)

    if len(tts_text) > 4000:
        _generate_long_audio(tts_text, config, output_path)
    else:
        response = client.audio.speech.create(
            model=config.tts_model,
            voice=config.voice,
            input=tts_text
        )
        response.stream_to_file(output_path)

    cost_per_char = 0.000015 if config.tts_model == "tts-1" else 0.00003
    metrics.add_cost("tts", len(tts_text) * cost_per_char)
    metrics.stop_timer("tts")

    return {
        "audio_path": output_path,
        "characters": len(tts_text),
        "voice": config.voice,
        "model": config.tts_model
    }


def _prepare_text_for_tts(text: str) -> str:
    text = text.strip()
    text = text.replace("- [ ]", ".")
    text = text.replace("##", ".")
    text = text.replace("**", "")
    text = text.replace("*", "")
    text = text.replace("\n\n", ". ")
    text = text.replace("\n", " ")

    import re
    text = re.sub(r'\s+', ' ', text)

    if not text.endswith((".", "!", "?")):
        text += "."

    return text


def _generate_long_audio(text: str, config: PipelineConfig, output_path: str):
    sentences = text.replace(". ", ".\n").split("\n")
    sentences = [s.strip() for s in sentences if s.strip()]

    chunks = []
    current = ""
    for sentence in sentences:
        if len(current) + len(sentence) + 1 <= 4000:
            current = f"{current} {sentence}".strip()
        else:
            if current:
                chunks.append(current)
            current = sentence
    if current:
        chunks.append(current)

    segments = []
    with tempfile.TemporaryDirectory() as tmp_dir:
        for i, chunk in enumerate(chunks):
            chunk_path = str(Path(tmp_dir) / f"tts_chunk_{i:03d}.mp3")
            response = client.audio.speech.create(
                model=config.tts_model,
                voice=config.voice,
                input=chunk
            )
            response.stream_to_file(chunk_path)
            segments.append(AudioSegment.from_mp3(chunk_path))

        combined = segments[0]
        for seg in segments[1:]:
            combined += seg
        combined.export(output_path, format="mp3")

Step 6: Integrated Pipeline

class AudioPipeline:
    def __init__(self, config: PipelineConfig = None):
        self.config = config or PipelineConfig()
        Path(self.config.output_dir).mkdir(parents=True, exist_ok=True)

    def run(self, audio_path: str) -> dict:
        metrics = PipelineMetrics()
        metrics.start_timer("total")

        try:
            validation = validate_audio(audio_path)
            if not validation["valid"] and not self.config.auto_preprocess:
                return self._error_result(
                    f"Invalid audio: {validation.get('error', validation.get('issues'))}",
                    metrics
                )

            if validation.get("needs_preprocessing") and self.config.auto_preprocess:
                metrics.start_timer("preprocessing")
                prep = preprocess_audio(audio_path, self.config.output_dir)
                working_path = prep["output_path"]
                metrics.stop_timer("preprocessing")
            else:
                working_path = audio_path

            transcript_data = transcribe_audio(working_path, self.config, metrics)

            llm_result = process_with_llm(
                transcript_data["text"], self.config, metrics
            )

            audio_output = None
            if self.config.generate_audio:
                result_text = self._get_text_for_tts(llm_result)
                if result_text:
                    audio_output = generate_audio_output(
                        result_text, self.config, metrics
                    )

            metrics.stop_timer("total")

            return {
                "status": "success",
                "transcript": transcript_data["text"],
                "result": llm_result,
                "audio_output_path": audio_output["audio_path"] if audio_output else None,
                "metadata": {
                    "audio_duration_min": transcript_data["duration_min"],
                    "transcript_words": transcript_data["word_count"],
                    "language_detected": transcript_data["language"],
                    "chunks_processed": transcript_data["chunks"],
                    "task": self.config.task
                },
                "costs": metrics.costs,
                "timing": {k: v for k, v in metrics.timing.items() if not k.startswith("_")}
            }

        except Exception as e:
            metrics.stop_timer("total")
            return self._error_result(str(e), metrics)

    def _get_text_for_tts(self, llm_result: dict) -> str:
        if "summary" in llm_result:
            return llm_result["summary"]
        if "full_analysis" in llm_result:
            return llm_result["full_analysis"]
        if "action_items" in llm_result:
            return llm_result["action_items"]
        if "qa" in llm_result:
            return "\n".join(
                f"Question: {qa['question']}. Answer: {qa['answer']}"
                for qa in llm_result["qa"]
            )
        return None

    def _error_result(self, error: str, metrics: PipelineMetrics) -> dict:
        return {
            "status": "error",
            "error": error,
            "costs": metrics.costs,
            "timing": {k: v for k, v in metrics.timing.items() if not k.startswith("_")}
        }

Step 7: Demo

Meeting summary

config = PipelineConfig(
    task="summarize",
    language="es",
    generate_audio=True,
    voice="nova",
    vocabulary=["sprint", "deployment", "QA", "staging"]
)

pipeline = AudioPipeline(config)
result = pipeline.run("team_meeting.mp3")

if result["status"] == "success":
    print("=== SUMMARY ===")
    print(result["result"]["summary"])
    print(f"\nAudio duration: {result['metadata']['audio_duration_min']} min")
    print(f"Transcribed words: {result['metadata']['transcript_words']}")
    print(f"Total cost: ${result['costs']['total']:.4f}")
    print(f"Total time: {result['timing']['total']}s")
    if result["audio_output_path"]:
        print(f"Summary audio: {result['audio_output_path']}")

Action-item extraction

config = PipelineConfig(task="action_items", language="es")
pipeline = AudioPipeline(config)
result = pipeline.run("standup.mp3")

if result["status"] == "success":
    print(result["result"]["action_items"])

Q&A over the audio

config = PipelineConfig(
    task="qa",
    language="es",
    questions=[
        "What was the decision about the budget?",
        "Who is responsible for the deploy?",
        "When is the delivery date?"
    ]
)

pipeline = AudioPipeline(config)
result = pipeline.run("planning.mp3")

if result["status"] == "success":
    for qa in result["result"]["qa"]:
        print(f"Q: {qa['question']}")
        print(f"A: {qa['answer']}\n")

Full analysis

config = PipelineConfig(
    task="full",
    language="es",
    generate_audio=True,
    tts_model="tts-1-hd",
    voice="echo"
)

pipeline = AudioPipeline(config)
result = pipeline.run("board_meeting.mp3")

if result["status"] == "success":
    print(result["result"]["full_analysis"])

Extension 1: Batch Processing

Process multiple audio files in batch:

from concurrent.futures import ThreadPoolExecutor

class BatchAudioPipeline:
    def __init__(self, config: PipelineConfig = None, max_workers: int = 3):
        self.config = config or PipelineConfig()
        self.max_workers = max_workers

    def run_batch(self, audio_paths: list[str]) -> dict:
        pipeline = AudioPipeline(self.config)
        results = []

        def process_one(path: str) -> dict:
            result = pipeline.run(path)
            result["source_file"] = Path(path).name
            return result

        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            results = list(executor.map(process_one, audio_paths))

        total_cost = sum(
            r.get("costs", {}).get("total", 0)
            for r in results
        )
        total_time = sum(
            r.get("timing", {}).get("total", 0)
            for r in results
        )
        successful = sum(1 for r in results if r["status"] == "success")

        return {
            "results": results,
            "summary": {
                "total_files": len(audio_paths),
                "successful": successful,
                "failed": len(audio_paths) - successful,
                "total_cost_usd": round(total_cost, 4),
                "total_time_s": round(total_time, 2)
            }
        }

Usage

batch = BatchAudioPipeline(
    config=PipelineConfig(task="summarize", language="es"),
    max_workers=3
)

audio_files = list(Path("recordings").glob("*.mp3"))
results = batch.run_batch([str(f) for f in audio_files])

print(f"Processed: {results['summary']['successful']}/{results['summary']['total_files']}")
print(f"Total cost: ${results['summary']['total_cost_usd']}")

Extension 2: Diarization with AssemblyAI

Extend the pipeline to identify speakers:

import assemblyai as aai

class DiarizedAudioPipeline(AudioPipeline):
    def __init__(self, config: PipelineConfig = None):
        super().__init__(config)
        aai.settings.api_key = os.getenv("ASSEMBLYAI_API_KEY")

    def run_with_diarization(self, audio_path: str) -> dict:
        base_result = self.run(audio_path)

        if base_result["status"] != "success":
            return base_result

        try:
            diarization = self._diarize(audio_path)
            base_result["diarization"] = diarization

            if diarization["utterances"]:
                dialogue = self._format_dialogue(diarization["utterances"])
                base_result["result"]["dialogue"] = dialogue
        except Exception as e:
            base_result["diarization_error"] = str(e)

        return base_result

    def _diarize(self, audio_path: str) -> dict:
        config = aai.TranscriptionConfig(
            speaker_labels=True,
            language_code=self.config.language
        )
        transcript = aai.Transcriber().transcribe(audio_path, config=config)

        if transcript.status == aai.TranscriptStatus.error:
            raise RuntimeError(f"Diarization error: {transcript.error}")

        utterances = [
            {
                "speaker": u.speaker,
                "text": u.text,
                "start_ms": u.start,
                "end_ms": u.end
            }
            for u in (transcript.utterances or [])
        ]

        speakers = list(set(u["speaker"] for u in utterances))

        return {
            "utterances": utterances,
            "speakers": speakers,
            "speaker_count": len(speakers)
        }

    def _format_dialogue(self, utterances: list[dict]) -> str:
        lines = []
        for u in utterances:
            start_min = u["start_ms"] // 60000
            start_sec = (u["start_ms"] % 60000) // 1000
            timestamp = f"[{start_min:02d}:{start_sec:02d}]"
            lines.append(f"{timestamp} Speaker {u['speaker']}: {u['text']}")
        return "\n\n".join(lines)

Extension 3: Export Results

def export_results(result: dict, output_dir: str, formats: list[str] = None):
    if formats is None:
        formats = ["json", "md", "txt"]

    Path(output_dir).mkdir(parents=True, exist_ok=True)

    if "json" in formats:
        json_path = Path(output_dir) / "pipeline_result.json"
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(result, f, ensure_ascii=False, indent=2)

    if "md" in formats:
        md_path = Path(output_dir) / "pipeline_result.md"
        md_content = _build_markdown_report(result)
        with open(md_path, "w", encoding="utf-8") as f:
            f.write(md_content)

    if "txt" in formats:
        txt_path = Path(output_dir) / "transcript.txt"
        with open(txt_path, "w", encoding="utf-8") as f:
            f.write(result.get("transcript", ""))


def _build_markdown_report(result: dict) -> str:
    lines = ["# Audio Pipeline Report\n"]

    meta = result.get("metadata", {})
    lines.append(f"**Duration:** {meta.get('audio_duration_min', 'N/A')} min")
    lines.append(f"**Words:** {meta.get('transcript_words', 'N/A')}")
    lines.append(f"**Language:** {meta.get('language_detected', 'N/A')}")
    lines.append(f"**Task:** {meta.get('task', 'N/A')}\n")

    lines.append("## Result\n")
    llm_result = result.get("result", {})
    for key, value in llm_result.items():
        lines.append(f"### {key.replace('_', ' ').title()}\n")
        if isinstance(value, list):
            for item in value:
                if isinstance(item, dict):
                    lines.append(f"**{item.get('question', '')}**")
                    lines.append(f"{item.get('answer', '')}\n")
                else:
                    lines.append(f"- {item}")
        else:
            lines.append(str(value))
        lines.append("")

    costs = result.get("costs", {})
    if costs:
        lines.append("## Costs\n")
        lines.append("| Service | Cost USD |")
        lines.append("|----------|-----------|")
        for service, cost in costs.items():
            lines.append(f"| {service} | ${cost:.6f} |")
        lines.append("")

    timing = result.get("timing", {})
    if timing:
        lines.append("## Timing\n")
        lines.append("| Step | Seconds |")
        lines.append("|------|----------|")
        for step, secs in timing.items():
            lines.append(f"| {step} | {secs}s |")

    return "\n".join(lines)

Project Troubleshooting

Problem: Pipeline fails silently

Symptom: status: "error" without useful information.

Solution: Add detailed logging:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("audio_pipeline")

class AudioPipelineDebug(AudioPipeline):
    def run(self, audio_path: str) -> dict:
        logger.info(f"Starting pipeline for: {audio_path}")
        logger.info(f"Config: task={self.config.task}, lang={self.config.language}")

        result = super().run(audio_path)

        if result["status"] == "error":
            logger.error(f"Pipeline failed: {result['error']}")
        else:
            logger.info(f"Pipeline succeeded. Cost: ${result['costs'].get('total', 0):.4f}")

        return result

Problem: Partial transcription on long audio

Symptom: Only part of the audio gets transcribed.

Solution: Verify that the chunking works correctly. The pipeline automatically handles files >25MB, but if the file is already under the limit and is long (e.g., 50 min of audio compressed into 20MB), the transcription may lose context during long pauses.

config = PipelineConfig(
    language="es",
    vocabulary=["sprint", "deployment"],
    auto_preprocess=True
)

Problem: Unexpectedly high cost in batch

Symptom: A batch of 50 files costs much more than expected.

Solution: Estimate costs before running:

def estimate_batch_cost(audio_paths: list[str]) -> dict:
    total_duration = 0
    for path in audio_paths:
        audio = AudioSegment.from_file(path)
        total_duration += len(audio) / 1000 / 60

    return {
        "files": len(audio_paths),
        "total_duration_min": round(total_duration, 1),
        "estimated_whisper_cost": round(total_duration * 0.006, 2),
        "estimated_llm_cost": round(len(audio_paths) * 0.002, 2),
        "estimated_total": round(total_duration * 0.006 + len(audio_paths) * 0.002, 2)
    }

Completeness Checklist

Core Functionality

  • Audio validation (format, size, quality)
  • Automatic preprocessing (conversion, normalization)
  • Transcription with Whisper (single and chunked)
  • Processing with the LLM (summarize, action_items, qa, full)
  • Audio generation with TTS (optional)
  • Error handling at each step

Quality

  • Cost tracking per service
  • Timing tracking per step
  • Support for technical vocabulary
  • Text preprocessed for TTS (no markdown, no bullets)

Extensions

  • Batch processing with ThreadPoolExecutor
  • Diarization with AssemblyAI
  • Export results (JSON, Markdown, TXT)
  • Pre-execution cost estimation

Testing

  • Works with short audio (<1 min)
  • Works with long audio (>25MB, chunking)
  • Works with a non-standard format (conversion)
  • Handles errors gracefully (file not found, API error)
  • Generates output audio correctly

Exercises

Exercise 1: Pipeline with cache

Extend AudioPipeline to cache transcriptions on disk using the audio file's MD5 hash as the key. If the file was already transcribed, don't call Whisper again.

See solution
import hashlib

class CachedAudioPipeline(AudioPipeline):
    def __init__(self, config: PipelineConfig = None, cache_dir: str = "transcript_cache"):
        super().__init__(config)
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)

    def _get_cache_key(self, audio_path: str) -> str:
        h = hashlib.md5()
        with open(audio_path, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                h.update(chunk)
        return h.hexdigest()

    def run(self, audio_path: str) -> dict:
        metrics = PipelineMetrics()
        metrics.start_timer("total")

        validation = validate_audio(audio_path)
        working_path = audio_path

        if validation.get("needs_preprocessing") and self.config.auto_preprocess:
            prep = preprocess_audio(audio_path, self.config.output_dir)
            working_path = prep["output_path"]

        cache_key = self._get_cache_key(working_path)
        cache_file = self.cache_dir / f"{cache_key}.json"

        if cache_file.exists():
            cached = json.loads(cache_file.read_text(encoding="utf-8"))
            transcript_data = cached
            transcript_data["from_cache"] = True
        else:
            transcript_data = transcribe_audio(working_path, self.config, metrics)
            cache_file.write_text(
                json.dumps(transcript_data, ensure_ascii=False),
                encoding="utf-8"
            )
            transcript_data["from_cache"] = False

        llm_result = process_with_llm(transcript_data["text"], self.config, metrics)

        audio_output = None
        if self.config.generate_audio:
            result_text = self._get_text_for_tts(llm_result)
            if result_text:
                audio_output = generate_audio_output(result_text, self.config, metrics)

        metrics.stop_timer("total")

        return {
            "status": "success",
            "transcript": transcript_data["text"],
            "result": llm_result,
            "audio_output_path": audio_output["audio_path"] if audio_output else None,
            "from_cache": transcript_data.get("from_cache", False),
            "metadata": {
                "audio_duration_min": transcript_data.get("duration_min"),
                "transcript_words": transcript_data.get("word_count"),
                "language_detected": transcript_data.get("language"),
                "chunks_processed": transcript_data.get("chunks", 1),
                "task": self.config.task
            },
            "costs": metrics.costs,
            "timing": {k: v for k, v in metrics.timing.items() if not k.startswith("_")}
        }

Exercise 2: Pipeline with comparative report

Create a function that runs the same audio with different configurations (summarize, action_items, full) and generates a comparative report of costs and results.

See solution
def compare_pipeline_tasks(audio_path: str) -> dict:
    tasks = ["summarize", "action_items", "full"]
    results = {}

    for task in tasks:
        config = PipelineConfig(task=task, language="es")
        pipeline = AudioPipeline(config)
        result = pipeline.run(audio_path)
        results[task] = {
            "status": result["status"],
            "result": result.get("result"),
            "cost": result.get("costs", {}).get("total", 0),
            "time": result.get("timing", {}).get("total", 0),
            "transcript_words": result.get("metadata", {}).get("transcript_words", 0)
        }

    comparison = {
        "results": results,
        "cheapest_task": min(results.items(), key=lambda x: x[1]["cost"])[0],
        "fastest_task": min(results.items(), key=lambda x: x[1]["time"])[0],
        "total_cost": round(sum(r["cost"] for r in results.values()), 4),
        "total_time": round(sum(r["time"] for r in results.values()), 2)
    }

    return comparison

Summary

  • The Audio Pipeline integrates validation → preprocessing → transcription → LLM → TTS into a configurable system.
  • It supports 4 tasks: summarize, action_items, qa, full analysis.
  • It includes cost tracking and timing for each step of the pipeline.
  • Automatic preprocessing: format conversion, audio normalization, handling of large files.
  • Extensions: batch processing, diarization with AssemblyAI, multi-format export.
  • The pipeline is the audio component of the Document Analyzer from Module 8.
  • Typical cost: ~$0.10-0.20 for 15 minutes of audio with a summary and TTS.

Next module: Module 6 — Multimodal RAG. You already process text, images and audio; now you'll build an intelligent search system that combines all the modalities to answer questions about documents with figures.