Módulo 5: Procesamiento de Audio

8. Proyecto: Audio Pipeline

Descripción

Este proyecto integra todo lo aprendido en el Módulo 5 en un Audio Pipeline completo y configurable. El sistema recibe un archivo de audio, lo valida y preprocesa, lo transcribe con Whisper, procesa el transcript con un LLM (resumen, action items, Q&A), y opcionalmente genera audio del resultado con TTS. Es un sistema de producción con configuración flexible, tracking de costos, manejo de errores, y extensiones para batch processing y diarización.

Por qué importa: Este pipeline es el componente de audio del Document Analyzer del Módulo 8. Cuando el sistema final reciba un archivo de audio, delegará a exactamente este pipeline. Además, es un sistema útil por sí mismo: cualquier equipo que grabe reuniones puede usarlo para generar resúmenes automáticos.


Especificaciones

Input

ParámetroTipoRequeridoDescripción
audio_pathstrRuta al archivo de audio
taskstrNo"summarize", "action_items", "qa", "full"
questionslist[str]Solo si task="qa"Preguntas sobre el audio
generate_audioboolNoSi True, genera TTS del resultado
languagestrNoCódigo ISO del idioma (default: "es")
voicestrNoVoz para TTS (default: "nova")
vocabularylist[str]NoTérminos técnicos para mejorar transcripción

Output

{
    "status": "success",
    "transcript": "Texto completo transcrito...",
    "result": {
        "summary": "Resumen en puntos clave...",
        "action_items": ["Tarea 1", "Tarea 2"],
    },
    "audio_output_path": "resumen.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
    }
}

Paso 1: Configuración

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)

Paso 2: Validación y Preprocesamiento

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

    if not path.exists():
        return {"valid": False, "error": "Archivo no encontrado", "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"Formato {suffix} no soportado",
            "auto_fixable": True
        })

    if size_bytes > WHISPER_MAX_SIZE:
        issues.append({
            "type": "file_too_large",
            "detail": f"Archivo de {size_mb:.1f}MB excede 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"Volumen bajo ({audio.dBFS:.1f} dBFS)",
                "auto_fixable": True
            })

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

    except Exception as e:
        return {"valid": False, "error": f"No se pudo leer el 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)
    }

Paso 3: Transcripción con 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)
    }

Paso 4: Procesamiento con 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": (
                "Resume el siguiente texto en 5-7 puntos clave. "
                "Sé específico: incluye nombres, fechas y datos mencionados.\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": (
                "Extrae TODOS los action items del siguiente texto.\n"
                "Formato por cada uno:\n"
                "- [ ] [Responsable si se menciona]: [Tarea concreta] "
                "[Fecha si se menciona]\n\n"
                "Si no hay action items claros, indica 'No se identificaron "
                "action items explícitos' y sugiere posibles tareas implícitas.\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": (
                        "Responde basándote SOLO en el contexto proporcionado. "
                        "Si la información no está disponible, di "
                        "'No se menciona en el audio'."
                    )
                },
                {
                    "role": "user",
                    "content": f"Contexto:\n{transcript}\n\nPregunta: {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": (
                "Analiza el siguiente texto y genera un reporte completo con "
                "EXACTAMENTE estas secciones:\n\n"
                "## Resumen Ejecutivo\n"
                "(3-5 oraciones con los puntos más importantes)\n\n"
                "## Temas Principales\n"
                "(Lista numerada)\n\n"
                "## Decisiones Tomadas\n"
                "(Lista. Si no hay, indicar 'Sin decisiones explícitas')\n\n"
                "## Action Items\n"
                "(Formato: - [ ] [Responsable]: [Tarea])\n\n"
                "## Datos y Cifras Mencionados\n"
                "(Fechas, montos, porcentajes, etc.)\n\n"
                + transcript
            )
        }],
        max_tokens=1200
    )
    return {"full_analysis": response.choices[0].message.content}

Paso 5: Generación de Audio (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")

Paso 6: Pipeline Integrado

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"Audio inválido: {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"Pregunta: {qa['question']}. Respuesta: {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("_")}
        }

Paso 7: Demo

Resumen de reunión

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

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

if result["status"] == "success":
    print("=== RESUMEN ===")
    print(result["result"]["summary"])
    print(f"\nDuración del audio: {result['metadata']['audio_duration_min']} min")
    print(f"Palabras transcritas: {result['metadata']['transcript_words']}")
    print(f"Costo total: ${result['costs']['total']:.4f}")
    print(f"Tiempo total: {result['timing']['total']}s")
    if result["audio_output_path"]:
        print(f"Audio del resumen: {result['audio_output_path']}")

Extracción de action items

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 sobre el audio

config = PipelineConfig(
    task="qa",
    language="es",
    questions=[
        "¿Cuál fue la decisión sobre el presupuesto?",
        "¿Quién es responsable del deploy?",
        "¿Cuándo es la fecha de entrega?"
    ]
)

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

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

Análisis completo

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"])

Extensión 1: Batch Processing

Procesar múltiples archivos de audio en lote:

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)
            }
        }

Uso

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"Procesados: {results['summary']['successful']}/{results['summary']['total_files']}")
print(f"Costo total: ${results['summary']['total_cost_usd']}")

Extensión 2: Diarización con AssemblyAI

Extender el pipeline para identificar 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"Error de diarización: {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)

Extensión 3: Exportar Resultados

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"**Duración:** {meta.get('audio_duration_min', 'N/A')} min")
    lines.append(f"**Palabras:** {meta.get('transcript_words', 'N/A')}")
    lines.append(f"**Idioma:** {meta.get('language_detected', 'N/A')}")
    lines.append(f"**Tarea:** {meta.get('task', 'N/A')}\n")

    lines.append("## Resultado\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("## Costos\n")
        lines.append("| Servicio | Costo 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("| Paso | Segundos |")
        lines.append("|------|----------|")
        for step, secs in timing.items():
            lines.append(f"| {step} | {secs}s |")

    return "\n".join(lines)

Troubleshooting del Proyecto

Problema: Pipeline falla silenciosamente

Síntoma: status: "error" sin información útil.

Solución: Agregar logging detallado:

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"Iniciando pipeline para: {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 falló: {result['error']}")
        else:
            logger.info(f"Pipeline exitoso. Costo: ${result['costs'].get('total', 0):.4f}")

        return result

Problema: Transcripción parcial en audio largo

Síntoma: Solo se transcribe una parte del audio.

Solución: Verificar que el chunking funciona correctamente. El pipeline maneja automáticamente archivos >25MB, pero si el archivo ya está bajo el límite y es largo (ej: 50 min de audio comprimido en 20MB), la transcripción puede perder contexto en pausas largas.

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

Problema: Costo inesperadamente alto en batch

Síntoma: Batch de 50 archivos cuesta mucho más de lo esperado.

Solución: Estimar costos antes de ejecutar:

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)
    }

Checklist de Completitud

Funcionalidad Core

  • Validación de audio (formato, tamaño, calidad)
  • Preprocesamiento automático (conversión, normalización)
  • Transcripción con Whisper (single y chunked)
  • Procesamiento con LLM (summarize, action_items, qa, full)
  • Generación de audio con TTS (opcional)
  • Manejo de errores en cada paso

Calidad

  • Tracking de costos por servicio
  • Tracking de timing por paso
  • Soporte para vocabulario técnico
  • Texto preprocesado para TTS (sin markdown, sin bullets)

Extensiones

  • Batch processing con ThreadPoolExecutor
  • Diarización con AssemblyAI
  • Exportar resultados (JSON, Markdown, TXT)
  • Estimación de costos pre-ejecución

Testing

  • Funciona con audio corto (<1 min)
  • Funciona con audio largo (>25MB, chunking)
  • Funciona con formato no estándar (conversión)
  • Maneja errores gracefully (archivo no encontrado, API error)
  • Genera audio de salida correctamente

Ejercicios

Ejercicio 1: Pipeline con caché

Extiende AudioPipeline para cachear transcripciones en disco usando el hash MD5 del archivo de audio como key. Si el archivo ya fue transcrito, no vuelvas a llamar a Whisper.

Ver solución
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("_")}
        }

Ejercicio 2: Pipeline con reporte comparativo

Crea una función que ejecute el mismo audio con diferentes configuraciones (summarize, action_items, full) y genere un reporte comparativo de costos y resultados.

Ver solución
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

Resumen

  • El Audio Pipeline integra validación → preprocesamiento → transcripción → LLM → TTS en un sistema configurable.
  • Soporta 4 tareas: summarize, action_items, qa, full analysis.
  • Incluye tracking de costos y timing por cada paso del pipeline.
  • Preprocesamiento automático: conversión de formato, normalización de audio, manejo de archivos grandes.
  • Extensiones: batch processing, diarización con AssemblyAI, exportación multi-formato.
  • El pipeline es el componente de audio del Document Analyzer del Módulo 8.
  • Costo típico: ~$0.10-0.20 para 15 minutos de audio con resumen y TTS.

Próximo módulo: Módulo 6 — RAG Multimodal. Ya procesas texto, imágenes y audio; ahora construirás un sistema de búsqueda inteligente que combina todas las modalidades para responder preguntas sobre documentos con figuras.