n4nAI

Transcribe and summarize calls with Whisper and LangChain

Build a production-ready pipeline to transcribe audio calls with Whisper and summarize them using LangChain, with code you can run today.

n4n Team3 min read737 words

Audio narration

Coming soon — every post will get a voice note here.

You need to turn recorded calls into searchable transcripts and actionable summaries. Whisper handles the speech-to-text; LangChain orchestrates the summarization with prompt templates, chunking strategies, and model routing. This guide walks through a complete, runnable pipeline — from raw audio to structured JSON output — with verification checkpoints at each stage.

Step 1: Set up the environment and dependencies

Create a virtual environment and install the core packages. You’ll need openai-whisper for transcription, langchain and langchain-openai for the summarization chain, and ffmpeg for audio preprocessing.

python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install openai-whisper langchain langchain-openai pydantic python-dotenv

Install ffmpeg if it’s not already on your system:

# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt-get update && sudo apt-get install -y ffmpeg

# Windows (via chocolatey)
choco install ffmpeg

Verify the installation:

python -c "import whisper; import langchain; print('whisper', whisper.__version__, 'langchain', langchain.__version__)"

You should see version numbers printed without errors.

Step 2: Prepare audio — normalize to 16 kHz mono WAV

Whisper expects 16 kHz mono audio. Real-world call recordings come in various formats (MP3, M4A, stereo, 44.1 kHz, etc.). Normalize everything upfront to avoid silent failures.

# audio_prep.py
import subprocess
from pathlib import Path

def normalize_audio(input_path: str, output_path: str) -> Path:
    """
    Convert any audio file to 16 kHz mono WAV using ffmpeg.
    Overwrites output_path if it exists.
    """
    cmd = [
        "ffmpeg", "-y",
        "-i", input_path,
        "-ar", "16000",
        "-ac", "1",
        "-c:a", "pcm_s16le",
        output_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg failed: {result.stderr}")
    return Path(output_path)

if __name__ == "__main__":
    import sys
    if len(sys.argv) != 3:
        print("Usage: python audio_prep.py <input> <output.wav>")
        sys.exit(1)
    normalize_audio(sys.argv[1], sys.argv[2])
    print(f"Normalized audio written to {sys.argv[2]}")

Test it:

python audio_prep.py sample_call.mp3 sample_call.wav

Verify the output:

ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of csv=p=0 sample_call.wav
# Expected: 16000,1

Step 3: Transcribe with Whisper — choose the right model size

Whisper offers five model sizes: tiny, base, small, medium, large-v3. For call transcription, small or medium hits the sweet spot — large-v3 is overkill for telephony bandwidth (8 kHz effective) and runs 5-10x slower.

# transcribe.py
import whisper
import json
from pathlib import Path
from typing import Dict, Any

def transcribe_audio(
    audio_path: str,
    model_size: str = "small",
    language: str = "en",
    **kwargs
) -> Dict[str, Any]:
    """
    Transcribe audio file with Whisper.
    Returns the full result dict including segments with timestamps.
    """
    model = whisper.load_model(model_size)
    result = model.transcribe(
        audio_path,
        language=language,
        word_timestamps=True,
        **kwargs
    )
    return result

def save_transcript(result: Dict[str, Any], output_path: str) -> None:
    """Save transcript as JSON for downstream processing."""
    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Usage: python transcribe.py <audio.wav> [model_size]")
        sys.exit(1)
    
    audio_path = sys.argv[1]
    model_size = sys.argv[2] if len(sys.argv) > 2 else "small"
    
    print(f"Loading {model_size} model...")
    result = transcribe_audio(audio_path, model_size=model_size)
    
    output_path = Path(audio_path).with_suffix(".transcript.json")
    save_transcript(result, output_path)
    print(f"Transcript saved to {output_path}")
    print(f"Detected language: {result.get('language')}")
    print(f"Duration: {result.get('duration', 'N/A')}s")
    print(f"Segments: {len(result.get('segments', []))}")

Run it:

python transcribe.py sample_call.wav small

Verify success — check the JSON output:

head -50 sample_call.transcript.json

You should see a segments array with start, end, text, and words (if word_timestamps=True). The text field at the root contains the full concatenated transcript.

Step 4: Build the summarization chain with LangChain

Now turn the raw transcript into a structured summary. A good call summary extracts: participants, key topics, action items, decisions, and follow-ups. Use a prompt template that enforces JSON output for programmatic consumption.

# summarize.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.runnables import RunnablePassthrough
from pydantic import BaseModel, Field
from typing import List, Optional
import json

class CallSummary(BaseModel):
    """Structured output schema for call summaries."""
    participants: List[str] = Field(description="Names or roles of speakers identified")
    duration_seconds: float = Field(description="Total call duration")
    key_topics: List[str] = Field(description="Main discussion topics")
    decisions: List[str] = Field(description="Decisions made during the call")
    action_items: List[dict] = Field(description="Action items with owner and deadline")
    follow_ups: List[str] = Field(description="Items requiring follow-up")
    sentiment: str = Field(description="Overall sentiment: positive, neutral, negative")
    summary: str = Field(description="2-3 paragraph narrative summary")

SUMMARIZATION_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are an expert call analyst. Analyze the transcript and produce a structured summary.
    
    Guidelines:
    - Identify speakers by name when mentioned, otherwise use roles (e.g., "Agent", "Customer")
    - Extract specific, verifiable action items with owners and deadlines when stated
    - Decisions must be explicit agreements, not suggestions
    - Sentiment reflects the overall tone and outcome
    - Keep the narrative summary concise but complete
    
    Output ONLY valid JSON matching the schema."""),
    ("human", """Transcript:
    {transcript}
    
    Call metadata:
    - Duration: {duration_seconds}s
    - Language: {language}
    
    Produce the structured summary."""),
])

def build_summarization_chain(model: str = "gpt-4o-mini", temperature: float = 0.1):
    """Create the LangChain runnable for call summarization."""
    llm = ChatOpenAI(model=model, temperature=temperature)
    parser = JsonOutputParser(pydantic_object=CallSummary)
    
    chain = (
        RunnablePassthrough.assign(
            transcript=lambda x: x["transcript"][:120000]  # truncate to ~30k tokens
        )
        | SUMMARIZATION_PROMPT
        | llm
        | parser
    )
    return chain

def summarize_transcript(
    transcript_path: str,
    output_path: str,
    model: str = "gpt-4o-mini"
) -> CallSummary:
    """Load transcript, run summarization, save structured output."""
    with open(transcript_path) as f:
        transcript_data = json.load(f)
    
    full_text = transcript_data.get("text", "")
    duration = transcript_data.get("duration", 0)
    language = transcript_data.get("language", "en")
    
    if not full_text.strip():
        raise ValueError("Transcript text is empty")
    
    chain = build_summarization_chain(model=model)
    result = chain.invoke({
        "transcript": full_text,
        "duration_seconds": duration,
        "language": language
    })
    
    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)
    
    return result

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Usage: python summarize.py <transcript.json> [model]")
        sys.exit(1)
    
    transcript_path = sys.argv[1]
    model = sys.argv[2] if len(sys.argv) > 2 else "gpt-4o-mini"
    output_path = transcript_path.replace(".transcript.json", ".summary.json")
    
    print(f"Summarizing with {model}...")
    summary = summarize_transcript(transcript_path, output_path, model)
    print(f"Summary saved to {output_path}")
    print(json.dumps(summary, indent=2))

Run the summarization:

python summarize.py sample_call.transcript.json gpt-4o-mini

Verify the output:

cat sample_call.summary.json | jq '.key_topics, .action_items, .sentiment'

You should see valid JSON with all CallSummary fields populated. If the transcript exceeds the model’s context window, the chain truncates at ~120k characters (roughly 30k tokens). For longer calls, see Step 6.

Step 5: Add speaker diarization for multi-party calls

Whisper alone doesn’t identify speakers. For calls with 2+ participants, add pyannote.audio diarization to label segments before summarization. This dramatically improves summary quality.

# diarize.py
from pyannote.audio import Pipeline
from pyannote.core import Segment
import torch
import json
from pathlib import Path

def diarize_audio(audio_path: str, hf_token: str) -> list:
    """
    Run speaker diarization on audio file.
    Returns list of {start, end, speaker} segments.
    """
    pipeline = Pipeline.from_pretrained(
        "pyannote/speaker-diarization-3.1",
        use_auth_token=hf_token
    )
    
    # Move to GPU if available
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    pipeline.to(device)
    
    diarization = pipeline(audio_path)
    
    segments = []
    for turn, _, speaker in diarization.itertracks(yield_label=True):
        segments.append({
            "start": turn.start,
            "end": turn.end,
            "speaker": speaker
        })
    
    return segments

def merge_transcript_with_diarization(
    transcript_path: str,
    diarization_segments: list
) -> str:
    """
    Merge Whisper word-level timestamps with diarization segments
    to produce a speaker-labeled transcript.
    """
    with open(transcript_path) as f:
        data = json.load(f)
    
    words = []
    for segment in data.get("segments", []):
        for word in segment.get("words", []):
            words.append({
                "start": word["start"],
                "end": word["end"],
                "text": word["word"]
            })
    
    # Assign speaker to each word based on overlap
    for word in words:
        word_center = (word["start"] + word["end"]) / 2
        for seg in diarization_segments:
            if seg["start"] <= word_center <= seg["end"]:
                word["speaker"] = seg["speaker"]
                break
    
    # Group consecutive words by speaker
    labeled_lines = []
    current_speaker = None
    current_text = []
    current_start = None
    
    for word in words:
        speaker = word.get("speaker", "UNKNOWN")
        if speaker != current_speaker:
            if current_text:
                labeled_lines.append(f"[{current_start:.1f}s] {current_speaker}: {''.join(current_text).strip()}")
            current_speaker = speaker
            current_text = [word["text"]]
            current_start = word["start"]
        else:
            current_text.append(word["text"])
    
    if current_text:
        labeled_lines.append(f"[{current_start:.1f}s] {current_speaker}: {''.join(current_text).strip()}")
    
    return "\n".join(labeled_lines)

if __name__ == "__main__":
    import sys
    import os
    
    if len(sys.argv) < 3:
        print("Usage: python diarize.py <audio.wav> <transcript.json> [hf_token]")
        sys.exit(1)
    
    audio_path = sys.argv[1]
    transcript_path = sys.argv[2]
    hf_token = sys.argv[3] if len(sys.argv) > 3 else os.getenv("HF_TOKEN")
    
    if not hf_token:
        print("Error: Hugging Face token required (pass as arg or set HF_TOKEN env var)")
        sys.exit(1)
    
    print("Running diarization...")
    segments = diarize_audio(audio_path, hf_token)
    
    print("Merging with transcript...")
    labeled_transcript = merge_transcript_with_diarization(transcript_path, segments)
    
    output_path = Path(transcript_path).with_suffix(".diarized.txt")
    with open(output_path, "w") as f:
        f.write(labeled_transcript)
    
    print(f"Diarized transcript saved to {output_path}")
    print(labeled_transcript[:500] + "..." if len(labeled_transcript) > 500 else labeled_transcript)

You’ll need a Hugging Face token with access to pyannote/speaker-diarization-3.1 (accept the user conditions on the model page). Install the extra dependency:

pip install pyannote.audio torch

Run it:

export HF_TOKEN=your_token_here
python diarize.py sample_call.wav sample_call.transcript.json

Verify the output — you should see speaker-labeled lines like [12.3s] SPEAKER_00: Hello, thanks for calling....

Step 6: Handle long calls with map-reduce summarization

Calls over ~30 minutes exceed the context window even for 128k models. Use LangChain’s map-reduce pattern: chunk the transcript, summarize each chunk, then combine.

# summarize_long.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from pydantic import BaseModel, Field
from typing import List, Dict, Any
import json

class ChunkSummary(BaseModel):
    key_points: List[str] = Field(description="Key points from this chunk")
    action_items: List[Dict[str, str]] = Field(description="Action items with owner/deadline")
    decisions: List[str] = Field(description="Decisions made")
    speakers: List[str] = Field(description="Speakers in this chunk")

MAP_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """Summarize this transcript chunk. Extract key points, action items, decisions, and speakers.
     Output JSON only."""),
    ("human", "Chunk:\n{chunk}"),
])

REDUCE_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """Combine multiple chunk summaries into a final call summary.
     Deduplicate action items and decisions. Identify overall topics and sentiment.
     Output the complete CallSummary JSON schema."""),
    ("human", "Chunk summaries:\n{chunk_summaries}"),
])

def build_map_reduce_chain(model: str = "gpt-4o-mini"):
    llm = ChatOpenAI(model=model, temperature=0.1)
    map_parser = JsonOutputParser(pydantic_object=ChunkSummary)
    reduce_parser = JsonOutputParser(pydantic_object=CallSummary)
    
    map_chain = MAP_PROMPT | llm | map_parser
    reduce_chain = REDUCE_PROMPT | llm | reduce_parser
    
    def map_chunks(inputs: Dict[str, Any]) -> List[Dict]:
        chunks = inputs["chunks"]
        return map_chain.batch([{"chunk": c} for c in chunks])
    
    def format_for_reduce(inputs: Dict[str, Any]) -> Dict[str, str]:
        summaries = inputs["chunk_summaries"]
        formatted = "\n\n---\n\n".join(json.dumps(s, indent=2) for s in summaries)
        return {"chunk_summaries": formatted}
    
    full_chain = (
        RunnablePassthrough.assign(chunk_summaries=RunnableLambda(map_chunks))
        | RunnableLambda(format_for_reduce)
        | reduce_chain
    )
    return full_chain

def summarize_long_transcript(
    transcript_path: str,
    output_path: str,
    chunk_size: int = 8000,
    chunk_overlap: int = 500,
    model: str = "gpt-4o-mini"
) -> CallSummary:
    with open(transcript_path) as f:
        data = json.load(f)
    
    full_text = data.get("text", "")
    
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    chunks = splitter.split_text(full_text)
    
    print(f"Split into {len(chunks)} chunks")
    
    chain = build_map_reduce_chain(model)
    result = chain.invoke({"chunks": chunks})
    
    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)
    
    return result

Use this instead of summarize.py for calls longer than 25-30 minutes. The chunk size of 8000 characters leaves room for the prompt and response within a 128k context window.

Step 7: Wire it into a single CLI pipeline

Combine all steps into one command for production use. This script handles the full flow: normalize → transcribe → (optional diarize) → summarize.

# pipeline.py
#!/usr/bin/env python3
"""
End-to-end call transcription and summarization pipeline.
Usage: python pipeline.py <input_audio> [--model small] [--summarize-model gpt-4o-mini] [--diarize]
"""
import argparse
import subprocess
import sys
from pathlib import Path
import os

from transcribe import transcribe_audio, save_transcript
from summarize import summarize_transcript, summarize_long_transcript

def run_pipeline(
    input_path: str,
    whisper_model: str = "small",
    summarize_model: str = "gpt-4o-mini",
    diarize: bool = False,
    long_call: bool = False
) -> dict:
    base = Path(input_path).stem
    work_dir = Path("output") / base
    work_dir.mkdir(parents=True, exist_ok=True)
    
    # Step 1: Normalize audio
    wav_path = work_dir / "normalized.wav"
    print(f"[1/4] Normalizing audio to {wav_path}...")
    subprocess.run([
        "ffmpeg", "-y", "-i", input_path,
        "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
        str(wav_path)
    ], check=True, capture_output=True)
    
    # Step 2: Transcribe
    transcript_path = work_dir / "transcript.json"
    print(f"[2/4] Transcribing with {whisper_model}...")
    result = transcribe_audio(str(wav_path), model_size=whisper_model)
    save_transcript(result, str(transcript_path))
    print(f"       Duration: {result.get('duration', 0):.1f}s, Segments: {len(result.get('segments', []))}")
    
    # Step 3: Optional diarization
    final_transcript = transcript_path
    if diarize:
        diarized_path = work_dir / "transcript.diarized.txt"
        print(f"[3/4] Running diarization...")
        hf_token = os.getenv("HF_TOKEN")
        if not hf_token:
            print("       WARNING: HF_TOKEN not set, skipping diarization")
        else:
            from diarize import diarize_audio, merge_transcript_with_diarization
            segments = diarize_audio(str(wav_path), hf_token)
            labeled = merge_transcript_with_diarization(str(transcript_path), segments)
            with open(diarized_path, "w") as f:
                f.write(labeled)
            # For summarization, use the labeled text by creating a modified transcript
            result["text"] = labeled
            final_transcript = work_dir / "transcript.for_summary.json"
            save_transcript(result, str(final_transcript))
            print(f"       Diarized transcript saved to {diarized_path}")
    
    # Step 4: Summarize
    summary_path = work_dir / "summary.json"
    print(f"[4/4] Summarizing with {summarize_model}...")
    if long_call or result.get("duration", 0) > 1800:
        summary = summarize_long_transcript(str(final_transcript), str(summary_path), model=summarize_model)
    else:
        summary = summarize_transcript(str(final_transcript), str(summary_path), model=summarize_model)
    
    print(f"\n✓ Pipeline complete. Outputs in {work_dir}:")
    print(f"  - Normalized audio: {wav_path}")
    print(f"  - Transcript: {transcript_path}")
    if diarize:
        print(f"  - Diarized transcript: {diarized_path}")
    print(f"  - Summary: {summary_path}")
    
    return summary

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Call transcription + summarization pipeline")
    parser.add_argument("input", help="Input audio file (any format ffmpeg supports)")
    parser.add_argument("--whisper-model", default="small", choices=["tiny", "base", "small", "medium", "large-v3"])
    parser.add_argument("--summarize-model", default="gpt-4o-mini")
    parser.add_argument("--diarize", action="store_true", help="Enable speaker diarization (requires HF_TOKEN)")
    parser.add_argument("--long-call", action="store_true", help="Force map-reduce summarization")
    args = parser.parse_args()
    
    try:
        run_pipeline(
            args.input,
            whisper_model=args.whisper_model,
            summarize_model=args.summarize_model,
            diarize=args.diarize,
            long_call=args.long_call
        )
    except subprocess.CalledProcessError as e:
        print(f"ffmpeg error: {e.stderr.decode() if e.stderr else e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Pipeline failed: {e}", file=sys.stderr)
        sys.exit(1)

Make it executable and run:

chmod +x pipeline.py
python pipeline.py recorded_call.mp3 --whisper-model small --summarize-model gpt-4o-mini --diarize

For a 45-minute board meeting:

python pipeline.py board_meeting.wav --whisper-model medium --summarize-model gpt-4o --long-call --diarize

Step 8: Verify and monitor in production

Add structured logging and basic quality checks so you can detect regressions.

# verify.py
import json
from pathlib import Path
from typing import Dict, Any

def verify_summary(summary_path: str) -> Dict[str, Any]:
    """Run quality checks on a generated summary."""
    with open(summary_path) as f:
        summary = json.load(f)
    
    checks = {
        "has_participants": len(summary.get("participants", [])) > 0,
        "has_key_topics": len(summary.get("key_topics", [])) > 0,
        "has_action_items": len(summary.get("action_items", [])) > 0,
        "has_decisions": len(summary.get("decisions", [])) >= 0,  # zero is valid
        "has_sentiment": summary.get("sentiment") in ["positive", "neutral", "negative"],
        "has_narrative": len(summary.get("summary", "").strip()) > 50,
        "action_items_have_owners": all(
            "owner" in item and item["owner"] 
            for item in summary.get("action_items", [])
        ),
    }
    
    all_passed = all(checks.values())
    
    return {
        "passed": all_passed,
        "checks": checks,
        "summary_stats": {
            "participants": len(summary.get("participants", [])),
            "key_topics": len(summary.get("key_topics", [])),
            "action_items": len(summary.get("action_items", [])),
            "decisions": len(summary.get("decisions", [])),
            "summary_length": len(summary.get("summary", ""))
        }
    }

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Usage: python verify.py <summary.json>")
        sys.exit(1)
    
    result = verify_summary(sys.argv[1])
    print(json.dumps(result, indent=2))
    sys.exit(0 if result["passed"] else 1)

Run verification as a post-step:

python verify.py output/recorded_call/summary.json

Exit code 0 means all checks passed. Wire this into your CI/CD or cron job to catch model drift or prompt regressions.

Operational notes

Model selection: whisper-small transcribes ~1 hour of audio in 2-3 minutes on a modern CPU. medium improves accuracy on accented speech and noisy lines but doubles latency. large-v3 rarely justifies its cost for telephony audio.

Cost control: gpt-4o-mini summarizes a 30-minute call for ~$0.02. gpt-4o is ~10x more expensive; reserve it for high-stakes calls (legal, medical, executive).

Diarization latency: pyannote adds 30-60 seconds per hour of audio. Run it asynchronously if your pipeline is user-facing.

Provider routing: If you’re running this at scale and want automatic fallback when your primary LLM provider is rate-limited or degraded, n4n.ai forwards provider cache-control hints and honors client routing directives so you can swap models without code changes.

Storage: Keep the normalized WAV and raw transcript JSON. Re-summarization with improved prompts is cheaper than re-transcription.

Next steps

  • Add PII redaction before sending transcripts to the summarization model (use presidio or spacy NER)
  • Build a simple UI with Streamlit or FastAPI + React for non-technical reviewers
  • Store summaries in a vector database (pgvector, Pinecone) for semantic search across calls
  • Implement few-shot prompting with your best human-written summaries to improve consistency

The pipeline above runs end-to-end on a laptop and scales to hundreds of calls/day on a modest GPU instance. Start with small + gpt-4o-mini, measure your WER and summary quality, then upgrade components only where the data justifies it.

Tagswhisperlangchaintranscriptionsummarization

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All multimodal & voice apps with ai frameworks posts →