From Transcripts to Summaries: Completing the Audio Pipeline

AI/ML
Table of Contents

Remember that audio transcription pipeline I built? Raw recordings flow in, get compressed, transcribed by Whisper, and stored as JSON. Great for archival, but 204 transcript files sitting on NFS storage aren’t exactly useful. Today I’ll show you how I completed the loopβ€”automatically summarizing transcripts with an LLM and publishing everything to my Obsidian vault.


Part 1: The Problem with Raw Transcripts

The original pipeline worked beautifully. Every morning, new transcripts appeared as JSON files:

{
  "text": "So I was thinking about the architecture for...",
  "duration": 2847.5,
  "language": "en",
  "segments": [...]
}

But here’s the thing: I never actually read them. Opening JSON files, scrolling through walls of unformatted textβ€”it’s friction. And friction means data dies on storage.

What I wanted:

  1. Raw transcripts in Obsidian for reference and search
  2. AI summaries that extract key points, topics, and action items
  3. Zero manual interventionβ€”the pipeline should just work

Part 2: Architecture Evolution

The original pipeline ran two CronJobs:

2 AM: audio-compressor (WAV β†’ MP3)
4 AM: audio-transcriber (MP3 β†’ JSON via Whisper)

I added two more stages:

5 AM: transcript-publisher (JSON β†’ Obsidian Transcripts/Raw/)
6 AM: transcript-summarizer (JSON β†’ LLM β†’ Obsidian Transcripts/)

The one-hour gaps ensure each stage completes before the next begins. The full pipeline now runs daily:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Audio Recordings              β”‚
β”‚   (Rode β†’ NAS β†’ SFTP)          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
    2:00 AM
        ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   audio-compressor              β”‚
β”‚   WAV β†’ MP3 (32kbps, 16kHz)    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
    4:00 AM
        ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   audio-transcriber             β”‚
β”‚   Whisper β†’ JSON transcripts    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
    5:00 AM
        ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   transcript-publisher          β”‚  ← NEW
β”‚   JSON β†’ Obsidian (Raw/)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
    6:00 AM
        ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   transcript-summarizer         β”‚  ← NEW
β”‚   LLM β†’ Obsidian (Summaries)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Part 3: The ConfigMap-Embedded Script Pattern

Here’s a pattern I’ve fallen in love with: instead of building and maintaining custom Docker images, embed the Python script directly in a ConfigMap.

The old way:

  1. Write Python script
  2. Create Dockerfile
  3. Build image (hope your architecture matches the target node)
  4. Push to registry
  5. Update Kubernetes manifest with new image tag
  6. Debug architecture mismatches when arm64 images run on amd64 nodes

The new way:

  1. Write Python script in a ConfigMap
  2. Use python:3.11-slim as the base image
  3. Mount the ConfigMap as a volume
  4. Done

Here’s the publisher CronJob structure:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: transcript-publisher
  namespace: audio-compressor
spec:
  schedule: "0 5 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: transcript-publisher
            image: python:3.11-slim
            command: ['python', '/scripts/publisher.py']
            volumeMounts:
            - name: script
              mountPath: /scripts
            - name: transcriptions
              mountPath: /data/transcriptions
              readOnly: true
          volumes:
          - name: script
            configMap:
              name: transcript-publisher-script
          - name: transcriptions
            nfs:
              server: <NAS_IP>
              path: /volume1/k8s-storage/pvc-xxx

The script lives in a separate ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: transcript-publisher-script
data:
  publisher.py: |
    #!/usr/bin/env python3
    import os
    import json
    import urllib.request
    # ... full script here

The key insight: use only Python’s standard library. No pip install, no dependency management, no init containers. The urllib.request module handles HTTP just fine for simple API calls.

Part 4: Publishing to Obsidian

The Obsidian Local REST API plugin exposes a simple endpoint for creating notes:

def post_to_obsidian(filename, content):
    url = f"{OBSIDIAN_API_URL}/vault/{OBSIDIAN_FOLDER}/{filename}"
    headers = {
        'Authorization': f'Bearer {OBSIDIAN_API_KEY}',
        'Content-Type': 'text/markdown'
    }

    req = urllib.request.Request(
        url,
        data=content.encode('utf-8'),
        headers=headers,
        method='PUT'
    )

    with urllib.request.urlopen(req, context=SSL_CONTEXT) as response:
        return response.status in (200, 201, 204)

One gotcha: my Obsidian instance uses a self-signed certificate. The fix:

import ssl

SSL_CONTEXT = ssl.create_default_context()
SSL_CONTEXT.check_hostname = False
SSL_CONTEXT.verify_mode = ssl.CERT_NONE

Not ideal for production, but this is a homelab talking to itself over the local network.

Part 5: LLM Summarization with GPUStack

The summarizer follows the same pattern but adds an LLM call before posting to Obsidian. I’m running GPUStack with a Gemma 3 27B model:

def call_llm(transcript_text):
    url = f"{SUMMARIZER_BASE_URL}/chat/completions"

    payload = {
        "model": SUMMARIZER_MODEL,
        "messages": [
            {"role": "system", "content": SUMMARY_PROMPT},
            {"role": "user", "content": f"Summarize:\n\n{transcript_text}"}
        ],
        "temperature": 0.3,
        "max_tokens": 2000
    }

    data = json.dumps(payload).encode('utf-8')
    req = urllib.request.Request(url, data=data, headers=headers, method='POST')

    with urllib.request.urlopen(req, timeout=120) as response:
        result = json.loads(response.read().decode('utf-8'))
        return result['choices'][0]['message']['content']

The summary prompt requests a structured output:

1. **Main Topics**: Key subjects discussed
2. **Summary**: 2-3 paragraph overview
3. **Key Points**: Important facts and insights
4. **Action Items**: Tasks or follow-ups mentioned

The Context Window Problem

Here’s where I hit a wall. My first transcript was 81,505 characters. Gemma 3’s context window? 8,192 tokens (~32K characters). The API returned:

{"error": "prompt tokens size exceeds the context size"}

The fix: truncate long transcripts before sending to the LLM:

MAX_TRANSCRIPT_CHARS = 25000  # ~6K tokens, leaving room for prompt and response

if len(transcript_text) > MAX_TRANSCRIPT_CHARS:
    logger.warning(f"Truncating from {len(transcript_text)} to {MAX_TRANSCRIPT_CHARS}")
    transcript_text = transcript_text[:MAX_TRANSCRIPT_CHARS] + "\n\n[...truncated...]"

Is this ideal? No. A chunking strategy with map-reduce summarization would be better. But for a first pass, truncation gets the job done.

Part 6: State Management

Both jobs track which files they’ve processed to avoid duplicate work:

STATE_FILE = Path(STATE_DIR) / 'publisher-processed.txt'

def load_processed():
    if STATE_FILE.exists():
        return set(STATE_FILE.read_text().strip().split('\n'))
    return set()

def save_processed(processed):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text('\n'.join(sorted(processed)))

A simple text file with one filename per line. The state directory is mounted from NFS, so it persists across job runs. If a job fails mid-way, it resumes from where it left off.

Part 7: Secrets Management

The jobs need credentials for:

  • Obsidian REST API (URL + API key)
  • GPUStack LLM API (URL + API key + model name)

These flow through External Secrets Operator from AWS Secrets Manager:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: summarizer-credentials-secret
spec:
  secretStoreRef:
    name: aws-secret-store
  target:
    name: summarizer-credentials
  data:
    - secretKey: summarizer_base_url
      remoteRef:
        key: gpustack/summarizer
        property: summarizer_base_url
    - secretKey: summarizer_api_key
      remoteRef:
        key: gpustack/summarizer
        property: summarizer_api_key

The CronJob references these secrets:

env:
- name: SUMMARIZER_BASE_URL
  valueFrom:
    secretKeyRef:
      name: summarizer-credentials
      key: summarizer_base_url

No credentials in Git. The IAM policy scopes access to only the secrets this namespace needs.

Part 8: Debugging the Pipeline

When things go wrong (and they will), here’s my debugging workflow:

# Create a manual test job
kubectl create job test-summarizer-$(date +%s) \
  --from=cronjob/transcript-summarizer -n audio-compressor

# Watch the logs
kubectl logs -n audio-compressor -l job-name=test-summarizer-xxx -f

# Check if secrets exist
kubectl get secret summarizer-credentials -n audio-compressor

# Force ExternalSecret to sync
kubectl annotate externalsecret summarizer-credentials-secret \
  -n audio-compressor force-sync=$(date +%s) --overwrite

The logs are your friend. I added detailed logging at every step:

2025-12-20 02:29:56 - INFO - Processing: recording_20251126.json
2025-12-20 02:29:56 - WARNING - Truncating from 81505 to 25000 chars
2025-12-20 02:30:52 - INFO - Posted recording_20251126.md to Obsidian

Part 9: Results

After the backlog clears, my Obsidian vault now has:

Transcripts/
β”œβ”€β”€ Raw/
β”‚   β”œβ”€β”€ recording_20251126.md
β”‚   β”œβ”€β”€ recording_20251127.md
β”‚   └── ... (204 files)
└──
    β”œβ”€β”€ recording_20251126.md (with AI summary)
    β”œβ”€β”€ recording_20251127.md
    └── ...

The raw transcripts are searchable. The summaries give me the gist in seconds. Everything’s interlinked in my second brain.

Key Takeaways

  1. ConfigMap-embedded scripts beat custom images for simple jobs. No build pipeline, no architecture mismatches, just Python and stdlib.

  2. Context windows are real constraints. Know your model’s limits and handle long inputs gracefullyβ€”truncate, chunk, or pick a larger model.

  3. Make jobs idempotent. Track processed files so you can re-run safely without duplicating work.

  4. Log everything. When your 6 AM CronJob fails, you want to know exactly why without having to reproduce it manually.

  5. Stagger your jobs. Give each pipeline stage time to complete before the next begins.

What’s Next

The pipeline works, but there’s room for improvement:

  • Chunked summarization for transcripts that exceed context limits
  • Speaker diarization to identify who’s speaking
  • Semantic search over the transcript corpus
  • Daily digest summarizing all recordings from the previous day

But for now? I finally have a system where pressing “record” automatically produces searchable, summarized notes in my vault. That’s the dream.


The full manifests are in my fako-cluster repository. Questions? Find me where the pipelines run.