From Transcripts to Summaries: Completing the Audio Pipeline
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:
- Raw transcripts in Obsidian for reference and search
- AI summaries that extract key points, topics, and action items
- 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:
- Write Python script
- Create Dockerfile
- Build image (hope your architecture matches the target node)
- Push to registry
- Update Kubernetes manifest with new image tag
- Debug architecture mismatches when arm64 images run on amd64 nodes
The new way:
- Write Python script in a ConfigMap
- Use
python:3.11-slimas the base image - Mount the ConfigMap as a volume
- 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
ConfigMap-embedded scripts beat custom images for simple jobs. No build pipeline, no architecture mismatches, just Python and stdlib.
Context windows are real constraints. Know your model’s limits and handle long inputs gracefullyβtruncate, chunk, or pick a larger model.
Make jobs idempotent. Track processed files so you can re-run safely without duplicating work.
Log everything. When your 6 AM CronJob fails, you want to know exactly why without having to reproduce it manually.
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.