Generating audio waveforms with Go: a step-by-step guide
Create a transparent PNG waveform from an existing recording using Go to run FFmpeg. Go checks the input, rejects errors reported by FFmpeg, and publishes the image without replacing an existing file. FFmpeg decodes the audio and draws the waveform; no third-party Go packages are needed.
Prerequisites
Use a maintained Go release and an FFmpeg installation with the PNG encoder and
showwavespic filter. The example was
tested on Linux with Go 1.26.8 and FFmpeg 9.0.1. You also need ffprobe for the verification
recipe below. MP3, PCM WAV, Vorbis in Ogg, and FLAC work when the corresponding decoders are
present. This is a command-line tool for short, trusted local recordings.
Setting up your Go environment
Install Go using its official instructions and FFmpeg from the FFmpeg download page. The commands below use Bash. Check the tools before creating the project:
go version && ffmpeg -version && ffprobe -version
Create a fresh module. The subshell leaves you in the same parent directory, and
GOWORK=off isolates it from an enclosing Go workspace:
(
mkdir waveform-generator &&
cd waveform-generator &&
GOWORK=off go mod init waveform-generator &&
GOWORK=off go mod edit -go=1.22
)
Stop if setup fails. An existing waveform-generator directory is left untouched; choose a
new parent directory instead. Keep using that parent directory for the commands below.
Basic waveform generation
Save this complete program as waveform-generator/main.go. It renders the first audio track,
mixing its channels to mono. A small writer forwards FFmpeg’s diagnostics to your terminal and
remembers whether any were emitted, without accumulating the log in memory.
package main
import (
"context"
"flag"
"fmt"
"image/png"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"time"
)
type errorOutput struct {
reported bool
}
func (output *errorOutput) Write(data []byte) (int, error) {
if len(data) > 0 {
output.reported = true
}
return os.Stderr.Write(data)
}
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")
diagnostics := &errorOutput{}
command.Stdout = temporary
command.Stderr = diagnostics
command.WaitDelay = 5 * time.Second
if err := command.Run(); err != nil {
return fmt.Errorf("render waveform: %w", err)
}
// FFmpeg can recover from a decoding error and still exit successfully.
if diagnostics.reported {
return fmt.Errorf("FFmpeg reported an error; waveform not published")
}
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")
}
Copy an audio file you own to waveform-generator/input.wav, then run:
(cd waveform-generator && GOWORK=off go run . input.wav waveform.png)
On success, the command prints Waveform saved. Open
waveform-generator/waveform.png in an image viewer. It is 1024 × 256 pixels with a transparent
background. Time runs left to right;
each column summarizes sample magnitudes, and taller columns indicate larger peaks. The
square-root scale makes quiet sections more visible. This is an amplitude overview, not a
signed sample trace or a measure of perceived loudness.
Advanced features
Streaming large files
Although Go writes the PNG directly to disk, showwavespic
queues decoded audio frames
before drawing the image. The 100 MiB limit applies to the input file. The two-minute context
applies to FFmpeg, with up to five seconds for process I/O cleanup; Go compilation is outside
that deadline. Neither limit caps decoded memory. Keep recordings short and concurrency low.
For long recordings, use an 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:
(cd waveform-generator && GOWORK=off go run . -width 800 -height 200 input.wav compact-waveform.png)
Mono mixing makes a compact preview, but opposing stereo signals can cancel. To inspect
channels separately, remove aformat=channel_layouts=mono, from the filter string and add
split_channels=1 to the showwavespic options. Save the edited program and use a fresh output
filename when rerunning. Each channel then occupies its own horizontal band.
Error handling and validation
FFmpeg can recover from a decoding error and return zero while still producing a valid PNG.
Its error log level includes recoverable
errors, so this program rejects both a failed process and any diagnostic at that level before
publishing. For example, a PCM WAV truncated inside its last sample must fail instead of
reporting success with a partial waveform.
This policy does not prove that the recording is intact: warnings are suppressed, and a decoder may conceal damage without an error. Check a recording’s expected duration and listen to it when completeness matters. A filename extension, valid PNG, or zero exit status alone cannot establish input integrity.
Missing audio tracks, cancellation, and invalid PNG output also prevent publication. Temporary files are removed when the function returns, including on failure.
If FFmpeg reports Too few samples, reduce the width: the filter needs at least one decoded
sample per column. Width must stay between 16 and 4096 pixels; height must stay between 16 and
1024 pixels.
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:
(
cd waveform-generator &&
ffmpeg -hide_banner -loglevel error -nostdin -n -f lavfi -i "sine=frequency=440:duration=2" -c:a pcm_s16le tone.wav &&
GOWORK=off 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. Open
waveform-generator/tone.png too: it should contain a band of equal-height peaks across the
image. An empty file, a non-audio file, or a rerun with an existing destination must fail without
replacing existing files. Use fresh names for both tone.wav and tone.png when repeating
the recipe.
Reuse the command
Keep the original recording alongside its preview so you can compare silent sections and stereo mixing against what you hear. The function’s context also lets a local job runner cancel work; it is not an upload server. For managed processing, see the supported workflows in Transloadit’s audio services.
