>FFmpegLab
FFmpegLab Guide

Audio Processing Pipeline for Podcasts – SQL Triggers & Supabase Storage

Build an automated audio processing pipeline that normalizes loudness, reduces noise, converts formats, adds metadata, and generates waveforms for podcasts.

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:

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

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

User Uploads Audio/Video Supabase Storage PostgreSQL Trigger pgmq Queue ffmpeglab-runner
Processed Audio Public Folder pg_notify User Notified

The pipeline consists of:

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

OutputFormatLocation
Podcast AudioMP3 (192kbps) with ID3v2 metadatapublic-processed/{userId}/podcast/
Waveform ImagePNG (1200×200)public-processed/{userId}/waveforms/
MetadataTitle, Artist, Album, Genre, Cover ArtID3v2 tags (embedded in MP3)
Real‑time notificationspg_notify channelsN/A
Job trackingrender tableExisting FFmpegLab table
Logslogpiece tableExisting FFmpegLab table

Prerequisites

Quick Start – The Setup Script

The fastest way to set up the pipeline is to run the SQL script.

Step 1
Save the SQL script
Copy the complete SQL script from the section below and save it as setup_audio_pipeline.sql.
Step 2
Run the script
Execute the script against your Supabase database.
# Via psql psql -U postgres -d your_database -f setup_audio_pipeline.sql

# Or via the Supabase SQL Editor # Copy and paste the entire script into the SQL Editor and run it.
Step 3
Configure the runner
Add the audio processing queue to your runner's environment.
# Add to your .env or docker-compose.yml AUDIO_QUEUE_NAME=audio_processing_queue
Step 4
Restart the runner
Restart the runner to pick up the new queue.
docker compose restart ffmpeglab-runner

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

ComponentDescription
Storage BucketsCreates private-uploads (private) and public-processed (public) buckets with file size limits and allowed MIME types
RLS PoliciesSets per‑user isolation for private uploads and public read access for processed media
pgmq QueueCreates the audio_processing_queue for job processing
Trigger Functionhandle_audio_upload() — fires on new uploads, builds FFmpeg commands for podcast processing, pushes to pgmq, inserts into render, sends notifications
ViewsHelper views for monitoring queue status
Uses Existing TablesUses 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:

  1. Detects if it's audio or video — video files have their audio track extracted.
  2. Builds the exact FFmpeg commands for audio processing and waveform generation.
  3. Creates a render job in the existing render table with the commands in the data column.
  4. Pushes a job to the audio_processing_queue with the commands payload.
  5. Sends a notification via pg_notify.
  6. The ffmpeglab-runner picks up the job, resolves the INPUT_FILE and OUTPUT_FILE placeholders, and executes the commands.

Exact FFmpeg Commands

The trigger function generates the following FFmpeg commands using placeholders:

1. Podcast Audio Processing

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="Episode Title" -metadata artist="Podcast Name" -metadata album="Podcast Episodes" -metadata genre="Podcast" "OUTPUT_FILE"
💡
Audio Processing Parameters Explained
  • 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

ffmpeg -i "INPUT_FILE" -filter_complex "showwavespic=s=1200x200:colors=#FC6D26" -frames:v 1 "OUTPUT_FILE"
💡
Waveform Parameters Explained
  • showwavespic — FFmpeg filter that generates a waveform image
  • s=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)

OperationFFmpeg Command
Podcast Processingffmpeg -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 Generationffmpeg -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.

Step 1
Add the queue to your environment
Add the following to your .env file or Docker Compose configuration.
# Audio processing queue AUDIO_QUEUE_NAME=audio_processing_queue
Step 2
Implement the processing loop
The runner should execute the following steps for each job:
# 1. Connect to Supabase and listen to the queue

# 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
Step 3
Install FFmpeg on the runner
Ensure FFmpeg is installed and available in the PATH.
# Debian/Ubuntu apt-get install -y ffmpeg

# Alpine apk add ffmpeg
Step 4
Restart the runner
After updating the environment, restart the runner service.
docker compose restart ffmpeglab-runner

Monitor the Pipeline

You can monitor the pipeline using SQL queries and notifications.

Step 1
Check queued jobs
Use the helper view to see all queued jobs.
SELECT * FROM audio_processing_queue_view;
Step 2
Check render status
Query the existing render table for job status.
SELECT id, title, status, progress, data
FROM "render"
WHERE project = 'audio-processing'
ORDER BY created_at DESC;
Step 3
Listen to notifications
In your application, listen for real‑time updates.
-- In your PostgreSQL client:
LISTEN audio_upload_channel;
LISTEN render_status_channel;
LISTEN log_channel;
Step 4
Check processed files
List all processed files in the public bucket.
SELECT name, metadata, created_at FROM storage.objects
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:

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.