Generating audio waveforms with Go: a step-by-step guide
Create a PNG waveform from a local audio file using Go to run FFmpeg. Go handles validation, cancellation, and output publication; FFmpeg decodes the audio and draws the waveform. This approach needs no third-party Go packages.
Prerequisites
Use Go 1.22 or later and an FFmpeg installation with the PNG encoder and showwavespic filter. The example was tested with FFmpeg 9.0.1. MP3, PCM WAV, Vorbis in Ogg, and FLAC work when the corresponding decoders are present. This is a command-line tool for trusted local audio files.
Setting up your Go environment
Install FFmpeg using the instructions for your platform on the FFmpeg download page, then create a module:
mkdir waveform-generator
cd waveform-generator
go mod init waveform-generator
go mod edit -go=1.22
ffmpeg -version
Basic waveform generation
Save this complete program as main.go. It renders the first audio track into a transparent
PNG, mixing its channels to mono. It refuses to overwrite an existing destination.
package main
import (
"context"
"flag"
"fmt"
"image/png"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"time"
)
func generateWaveform(ctx context.Context, input, output string, width, height int) error {
if width < 16 || width > 4096 || height < 16 || height > 1024 {
return fmt.Errorf("width must be 16–4096 and height 16–1024")
}
input, err := filepath.Abs(input)
if err != nil {
return err
}
info, err := os.Stat(input)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > 100*1024*1024 {
return fmt.Errorf("input must be a nonempty regular file of at most 100 MiB")
}
temporary, err := os.CreateTemp(filepath.Dir(output), ".waveform-*.png")
if err != nil {
return err
}
defer os.Remove(temporary.Name())
defer temporary.Close()
filter := fmt.Sprintf("[0:a:0]aformat=channel_layouts=mono,showwavespic=s=%dx%d:colors=0x167d9a:scale=sqrt:filter=peak[v]", width, height)
command := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error",
"-nostdin", "-protocol_whitelist", "file,pipe", "-i", input,
"-filter_complex", filter, "-map", "[v]", "-frames:v", "1",
"-c:v", "png", "-f", "image2pipe", "pipe:1")
command.Stdout = temporary
command.Stderr = os.Stderr
command.WaitDelay = 5 * time.Second
if err := command.Run(); err != nil {
return fmt.Errorf("render waveform: %w", err)
}
if err := ctx.Err(); err != nil {
return err
}
if _, err := temporary.Seek(0, io.SeekStart); err != nil {
return err
}
image, err := png.Decode(temporary)
if err != nil {
return fmt.Errorf("invalid waveform PNG: %w", err)
}
if image.Bounds().Dx() != width || image.Bounds().Dy() != height {
return fmt.Errorf("unexpected waveform dimensions")
}
if err := temporary.Close(); err != nil {
return err
}
// Linking publishes the complete file without replacing a concurrent writer's output.
if err := os.Link(temporary.Name(), output); err != nil {
return fmt.Errorf("publish waveform (destination must be new): %w", err)
}
return nil
}
func run() error {
width := flag.Int("width", 1024, "image width in pixels")
height := flag.Int("height", 256, "image height in pixels")
flag.Parse()
if flag.NArg() != 2 {
return fmt.Errorf("usage: waveform-generator [-width 1024] [-height 256] input.wav output.png")
}
interrupted, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
ctx, cancel := context.WithTimeout(interrupted, 2*time.Minute)
defer cancel()
return generateWaveform(ctx, flag.Arg(0), flag.Arg(1), *width, *height)
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("Waveform saved")
}
Run it with an audio file you own:
go run . input.wav waveform.png
Open waveform.png in an image viewer. Time runs left to right; taller peaks indicate
larger sample amplitudes. The square-root scale makes quiet sections more visible.
Advanced features
Streaming large files
Although Go writes FFmpeg’s output directly to disk, showwavespic buffers decoded audio
to summarize the whole recording. This is not a constant-memory streaming algorithm. The
100 MiB input limit and two-minute deadline bound this tutorial’s workload, but compressed
file size does not bound decoded memory. For long recordings, render shorter sections in
separate jobs or use a waveform algorithm that aggregates samples incrementally.
Custom colors and styling
Change the fixed colors=0x167d9a value in the filter to select another waveform color.
Use the validated width and height flags to change the image size:
go run . -width 800 -height 200 input.wav compact-waveform.png
The output has a transparent background. Mono mixing makes a compact preview, but opposing
stereo signals can cancel. To inspect channels separately, remove aformat=channel_layouts=mono,
and add split_channels=1 to the showwavespic options.
Error handling and validation
The program checks file size and type before decoding. FFmpeg inspects the actual media; a filename extension alone does not establish that a file contains valid audio. Missing audio tracks, decoding failures, cancellation, and invalid PNG output all prevent publication. Temporary files are removed when the function returns, including on failure.
The destination directory must exist and support hard links. An existing output, including one created while encoding, is preserved. Use a fresh filename for each run.
Testing
Generate a short synthetic tone, then render it:
ffmpeg -nostdin -n -f lavfi -i "sine=frequency=440:duration=2" -c:a pcm_s16le tone.wav
go run . tone.wav tone.png
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height -of json tone.png
The probe should identify a PNG with width 1024 and height 256. Also try an empty file, a non-audio file, and an existing destination; each must fail without replacing existing files.
Performance optimization
Each job launches one FFmpeg process. Keep concurrency low because each process holds its own decoded audio. Cache completed waveforms by the audio content and rendering settings when repeated requests would otherwise reprocess the same recording.
Web service integration
The generateWaveform function accepts a context, so a server can pass a request or job
deadline through to FFmpeg. A public upload service also needs a private job directory,
an upload-size limit, a bounded worker queue, and process memory limits before decoding
untrusted media. Keep temporary paths and decoder diagnostics out of client responses.
Enhance your audio applications
You now have a Go command that renders a real waveform, validates the resulting image, and publishes it without overwriting earlier work. For managed audio processing, explore Transloadit’s audio services.
