Podcasting is booming, but producing broadcast‑quality audio is a complex process. Recording raw audio often includes background noise, inconsistent loudness, and the wrong format. Professional podcasts need:
- Consistent loudness — EBU R128 loudness normalization (target -16 LUFS for podcasts)
- Noise reduction — remove background hum, hiss, and room tone
- Format conversion — WAV/FLAC to MP3 for distribution
- Metadata — title, artist, album, artwork
- Waveform visualization — for podcast players and social media
This guide shows you how to build a fully automated podcast audio processing pipeline that turns a raw recording into a broadcast‑ready podcast episode — all driven by PostgreSQL triggers and pgmq.
Key takeaways
- One SQL script — everything is set up with a single execution.
- Zero manual intervention — upload a recording, get a polished podcast episode.
- Professional audio processing — loudness normalization, noise reduction, format conversion, metadata, and waveforms.
- Scalable and reliable — pgmq provides durable, transaction‑safe job queuing.
- Video support — extract audio from video files automatically.
The Gap: From Raw Audio to Podcast-Ready
Most podcasters record audio in high‑quality formats (WAV, FLAC) and then manually process it: normalize loudness, remove noise, convert to MP3, add metadata, and create a waveform image. This is time‑consuming and inconsistent.
What if the pipeline could be fully automated — triggered by the upload itself, processing in the background, and delivering a complete podcast episode ready for distribution?
This guide shows you exactly how to build that pipeline.
Architecture Overview
Processed Audio → Public Folder → pg_notify → User Notified
The pipeline consists of:
- Supabase Storage — two buckets:
private-uploads(per‑user) andpublic-processed(per‑user, with subfolders for audio and waveforms). - RLS policies — restrict access to each user's own folders.
- PostgreSQL trigger — fires on
INSERTintostorage.objects. - pgmq — message queue for job processing.
- ffmpeglab-runner — executes FFmpeg commands for audio processing.
- pg_notify — real‑time status updates.
Important: This pipeline uses the existing render and logpiece tables from the FFmpegLab server. It does not create new tables — it only adds the pipeline components.
What the Pipeline Delivers
| Output | Format | Location |
|---|---|---|
| Podcast Audio | MP3 (192kbps) with ID3v2 metadata | public-processed/{userId}/podcast/ |
| Waveform Image | PNG (1200×200) | public-processed/{userId}/waveforms/ |
| Metadata | Title, Artist, Album, Genre, Cover Art | ID3v2 tags (embedded in MP3) |
| Real‑time notifications | pg_notify channels | N/A |
| Job tracking | render table | Existing FFmpegLab table |
| Logs | logpiece table | Existing FFmpegLab table |
Prerequisites
- A Supabase project (cloud or self‑hosted).
- ffmpeglab-server and ffmpeglab-runner deployed (see setup guide).
- The
renderandlogpiecetables must already exist (created by the FFmpegLab server migrations). - Access to your Supabase database (psql or the Supabase SQL Editor).
Quick Start – The Setup Script
The fastest way to set up the pipeline is to run the SQL script.
setup_audio_pipeline.sql.# Or via the Supabase SQL Editor # Copy and paste the entire script into the SQL Editor and run it.
That's it! The pipeline is now live. Users can upload audio files to private-uploads/{userId}/, and they will be automatically processed into podcast‑ready episodes.
The Complete SQL Setup Script
Note: This script assumes that the render and logpiece tables already exist (from the FFmpegLab server migrations). It only adds the pipeline components.
-- AUDIO PROCESSING PIPELINE FOR PODCASTS
-- Complete SQL Setup Script
-- ============================================================
-- This script adds the audio processing pipeline components.
-- It assumes the render and logpiece tables already exist.
-- ============================================================
-- ============================================================
-- 1. Create storage buckets
-- ============================================================
INSERT INTO storage.buckets (id, name, public, avif_autodetection, file_size_limit, allowed_mime_types)
VALUES
('private-uploads', 'private-uploads', false, false, 5368709120, ARRAY['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/flac', 'audio/aac', 'audio/mp4', 'video/mp4', 'video/quicktime', 'video/webm']),
('public-processed', 'public-processed', true, false, 5368709120, ARRAY['audio/mpeg', 'image/png'])
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. RLS policies for private-uploads bucket
-- ============================================================
DROP POLICY IF EXISTS "Users can upload to their own folder" ON storage.objects;
CREATE POLICY "Users can upload to their own folder"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'private-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
DROP POLICY IF EXISTS "Users can download from their own folder" ON storage.objects;
CREATE POLICY "Users can download from their own folder"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'private-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
DROP POLICY IF EXISTS "Users can update their own files" ON storage.objects;
CREATE POLICY "Users can update their own files"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'private-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
DROP POLICY IF EXISTS "Users can delete their own files" ON storage.objects;
CREATE POLICY "Users can delete their own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'private-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- ============================================================
-- 3. RLS policies for public-processed bucket
-- ============================================================
DROP POLICY IF EXISTS "Public read access to processed media" ON storage.objects;
CREATE POLICY "Public read access to processed media"
ON storage.objects
FOR SELECT
USING (bucket_id = 'public-processed');
DROP POLICY IF EXISTS "Service role can manage processed media" ON storage.objects;
CREATE POLICY "Service role can manage processed media"
ON storage.objects
FOR ALL
TO service_role
USING (bucket_id = 'public-processed');
DROP POLICY IF EXISTS "Users can read their own processed media" ON storage.objects;
CREATE POLICY "Users can read their own processed media"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'public-processed' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- ============================================================
-- 4. Enable pgmq extension and create the queue
-- ============================================================
CREATE EXTENSION IF NOT EXISTS pgmq;
SELECT pgmq.create('audio_processing_queue');
-- ============================================================
-- 5. Create the trigger function for audio uploads
-- This function builds the exact FFmpeg commands to be executed by the runner.
-- ============================================================
DROP FUNCTION IF EXISTS handle_audio_upload() CASCADE;
CREATE OR REPLACE FUNCTION handle_audio_upload()
RETURNS TRIGGER AS $$
DECLARE
user_id text;
file_path text;
file_name text;
file_extension text;
mime_type text;
base_filename text;
msg jsonb;
commands jsonb := '[]';
audio_mime_types text[] := ARRAY['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/flac', 'audio/aac', 'audio/mp4'];
video_mime_types text[] := ARRAY['video/mp4', 'video/quicktime', 'video/webm'];
is_audio boolean;
BEGIN
-- Only process files in the private-uploads bucket
IF NEW.bucket_id = 'private-uploads' THEN
user_id := (storage.foldername(NEW.name))[1];
file_path := NEW.name;
file_name := (storage.filename(NEW.name));
file_extension := split_part(file_name, '.', array_length(string_to_array(file_name, '.'), 1));
mime_type := NEW.metadata->>'mimetype';
base_filename := replace(file_name, '.' || file_extension, '');
is_audio := mime_type = ANY(audio_mime_types) OR mime_type = ANY(video_mime_types);
IF is_audio THEN
-- Build the main podcast processing command
commands := commands || jsonb_build_object(
'type', 'podcast',
'output_path', user_id || '/podcast/' || base_filename || '.mp3',
'command', 'ffmpeg -i "INPUT_FILE" -af "loudnorm=I=-16:LRA=11:TP=-1.5,afftdn=nr=10:nf=-40" -c:a libmp3lame -b:a 192k -ac 2 -ar 44100 -metadata title="' || base_filename || '" -metadata artist="Podcast" -metadata album="Podcast Episodes" -metadata genre="Podcast" "OUTPUT_FILE"'
);
-- Build the waveform generation command
commands := commands || jsonb_build_object(
'type', 'waveform',
'output_path', user_id || '/waveforms/' || base_filename || '.png',
'command', 'ffmpeg -i "INPUT_FILE" -filter_complex "showwavespic=s=1200x200:colors=#FC6D26" -frames:v 1 "OUTPUT_FILE"'
);
-- Build the job message
msg := jsonb_build_object(
'userId', user_id,
'inputPath', file_path,
'inputBucket', NEW.bucket_id,
'outputBucket', 'public-processed',
'fileName', file_name,
'baseFilename', base_filename,
'mimeType', mime_type,
'isVideo', mime_type = ANY(video_mime_types),
'commands', commands,
'timestamp', NOW()
);
-- Push to pgmq queue
PERFORM pgmq.send('audio_processing_queue', msg::jsonb);
-- Insert into render table for job tracking
INSERT INTO "render" (id, title, project, status, public, user_id, data)
VALUES (
gen_random_uuid(),
file_name,
'audio-processing',
'queued',
false,
user_id::uuid,
msg
);
-- Notify via pg_notify
PERFORM pg_notify(
'audio_upload_channel',
jsonb_build_object(
'userId', user_id,
'filePath', file_path,
'fileName', file_name,
'status', 'queued',
'timestamp', NOW()
)::text
);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================
-- 6. Attach the trigger to storage.objects
-- ============================================================
DROP TRIGGER IF EXISTS audio_upload_trigger ON storage.objects;
CREATE TRIGGER audio_upload_trigger
AFTER INSERT ON storage.objects
FOR EACH ROW
EXECUTE FUNCTION handle_audio_upload();
-- ============================================================
-- 7. Helper views for monitoring
-- ============================================================
DROP VIEW IF EXISTS audio_processing_queue_view;
CREATE OR REPLACE VIEW audio_processing_queue_view AS
SELECT
msg_id,
read_ct,
enqueued_at,
vt,
message::jsonb as payload,
(message::jsonb->>'userId') as user_id,
(message::jsonb->>'fileName') as file_name,
(message::jsonb->>'isVideo')::boolean as is_video
FROM pgmq.q_audio_processing_queue
ORDER BY msg_id DESC;
-- ============================================================
-- 8. Initialize notification channels
-- ============================================================
DO $$
BEGIN
PERFORM pg_notify('audio_upload_channel', '{"init": true}');
PERFORM pg_notify('render_status_channel', '{"init": true}');
PERFORM pg_notify('log_channel', '{"init": true}');
END $$;
-- ============================================================
-- All done!
-- ============================================================
What the Script Does
| Component | Description |
|---|---|
| Storage Buckets | Creates private-uploads (private) and public-processed (public) buckets with file size limits and allowed MIME types |
| RLS Policies | Sets per‑user isolation for private uploads and public read access for processed media |
| pgmq Queue | Creates the audio_processing_queue for job processing |
| Trigger Function | handle_audio_upload() — fires on new uploads, builds FFmpeg commands for podcast processing, pushes to pgmq, inserts into render, sends notifications |
| Views | Helper views for monitoring queue status |
| Uses Existing Tables | Uses render for job tracking and logpiece for logs (from FFmpegLab server) |
Processing Logic Explained
When a file is uploaded to private-uploads/{userId}/, the pipeline:
- Detects if it's audio or video — video files have their audio track extracted.
- Builds the exact FFmpeg commands for audio processing and waveform generation.
- Creates a render job in the existing
rendertable with the commands in thedatacolumn. - Pushes a job to the
audio_processing_queuewith the commands payload. - Sends a notification via
pg_notify. - The ffmpeglab-runner picks up the job, resolves the
INPUT_FILEandOUTPUT_FILEplaceholders, and executes the commands.
Exact FFmpeg Commands
The trigger function generates the following FFmpeg commands using placeholders:
INPUT_FILE— The path to the downloaded input file (resolved by the runner).OUTPUT_FILE— The temporary path for the output file (resolved by the runner).
1. Podcast Audio Processing
loudnorm=I=-16:LRA=11:TP=-1.5— EBU R128 loudness normalization targeting -16 LUFS (podcast standard)afftdn=nr=10:nf=-40— FFT-based noise reduction (10dB reduction, -40dB noise floor)-c:a libmp3lame -b:a 192k— MP3 encoding at 192kbps-ac 2 -ar 44100— Stereo, 44.1kHz sample rate-metadata— ID3v2 metadata tags (title, artist, album, genre)
2. Waveform Generation
showwavespic— FFmpeg filter that generates a waveform images=1200x200— Output image size (1200×200 pixels)colors=#FC6D26— Waveform color (FFmpegLab orange)-frames:v 1— Output a single frame (image)
FFmpeg Command Table (Quick Reference)
| Operation | FFmpeg Command |
|---|---|
| Podcast Processing | ffmpeg -i input.wav -af "loudnorm=I=-16:LRA=11:TP=-1.5,afftdn=nr=10:nf=-40" -c:a libmp3lame -b:a 192k -ac 2 -ar 44100 -metadata title="Episode" -metadata artist="Podcast" output.mp3 |
| Waveform Generation | ffmpeg -i input.mp3 -filter_complex "showwavespic=s=1200x200:colors=#FC6D26" -frames:v 1 waveform.png |
Configure ffmpeglab-runner
The runner needs to be configured to poll the audio_processing_queue and execute the provided FFmpeg commands.
.env file or Docker Compose configuration.# 2. For each job:
# a. Download the input file from private-uploads
# b. Parse the 'commands' array from the job payload
# c. For each command:
# - Replace 'INPUT_FILE' with the local input path
# - Replace 'OUTPUT_FILE' with a temporary local path
# - Execute the FFmpeg command
# - Upload the output file to public-processed/{output_path}
# - Update the render table with progress and logs
# d. Mark the job as complete in the render table
# e. Delete the job from the queue
# Alpine apk add ffmpeg
Monitor the Pipeline
You can monitor the pipeline using SQL queries and notifications.
render table for job status.FROM "render"
WHERE project = 'audio-processing'
ORDER BY created_at DESC;
LISTEN audio_upload_channel;
LISTEN render_status_channel;
LISTEN log_channel;
WHERE bucket_id = 'public-processed'
ORDER BY created_at DESC;
Frequently Asked Questions (FAQ)
What audio processing does the pipeline perform?
The pipeline automatically normalizes loudness (EBU R128 with -16 LUFS target), reduces background noise using FFT-based denoising, converts formats, adds MP3 metadata, and generates waveform images for podcasts.
What FFmpeg filters are used?
The pipeline uses loudnorm for loudness normalization, afftdn for noise reduction, aformat for format conversion, ametadata for MP3 tags, and showwaves for waveform visualization.
Can this handle videos as input?
Yes. The pipeline detects video files and extracts the audio track before processing. This makes it perfect for podcasters who record video interviews or screen recordings.
What podcast metadata is supported?
The pipeline supports title, artist (podcast name), album, genre, comment, and cover art (podcast artwork) in ID3v2 tags for MP3 files. You can customize these by modifying the metadata fields in the FFmpeg command.
Does this pipeline create new tables?
No. The pipeline uses the existing render and logpiece tables from the FFmpegLab server. It only adds storage buckets, RLS policies, the pgmq queue, and the trigger function — no table conflicts.
Final Word
You now have a fully automated podcast audio processing pipeline that turns a raw recording into a broadcast‑ready podcast episode. With PostgreSQL triggers, pgmq, and Supabase Storage, you get:
- Professional loudness normalization — EBU R128 compliant
- Noise reduction — clean, clear audio
- MP3 conversion — with ID3v2 metadata
- Waveform visualization — for podcast players
- Video support — extract audio from videos
- Real‑time notifications — know when processing is complete
- Full observability — monitoring views and logs
The pipeline is production‑ready, scalable, and configurable. It uses the existing render and logpiece tables from the FFmpegLab server, so there are no table conflicts — just pure, automated audio processing.