Generate and visualize audio waveforms using Go
Create a two-second, 440 Hz tone in Go, then turn that WAV file into an 800 × 200 SVG waveform. You will save the generator and visualizer as separate commands in one Go module, so you can run each independently and try square, triangle, and filled waveform variants.
Introduction to waveform visualization and its applications
A waveform plots sample amplitude against time. It can help you locate pauses in a recording or compare synthetic signals. This example draws a static overview of the first channel; it does not play audio, combine stereo channels, or provide a live player.
Setting up your Go environment for audio processing
Dependencies
The commands below use Bash on Linux and were tested with Go 1.26.8. Install Go using the
official instructions, then check that it is on your PATH:
go version
Create a new directory and resolve these exact library versions. The
go-audio/wav repository is archived; this example uses its
pinned release for a small local exercise. The setup requires internet access for module downloads.
The subshell keeps your original directory, and GOWORK=off isolates the example from an enclosing
Go workspace.
(
mkdir waveform-generator &&
cd waveform-generator &&
export GOWORK=off &&
go mod init waveform-generator &&
go get github.com/go-audio/audio@v1.0.0 &&
go get github.com/go-audio/wav@v1.1.0 &&
go get github.com/ajstarks/svgo@v0.0.0-20211024235047-1546f124cd8b &&
mkdir -p cmd/generate cmd/visualize
)
Stop if setup fails. An existing waveform-generator directory is left untouched; choose a new
parent directory instead of pasting later steps into an existing project. Save the two complete Go
programs at waveform-generator/cmd/generate/main.go and
waveform-generator/cmd/visualize/main.go. Each directory has its own main function.
Generating basic waveforms (sine, square, triangle) with Go
Save this program as waveform-generator/cmd/generate/main.go:
package main
import (
"fmt"
"log"
"math"
"os"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
)
func generateWaveform() error {
const sampleRate = 44100
const frequency = 440.0 // A4 note
const duration = 2 // seconds
// Validate input parameters
if sampleRate <= 0 || duration <= 0 || frequency <= 0 {
return fmt.Errorf("sampleRate, duration, and frequency must be positive")
}
numSamples := sampleRate * duration
buf := &audio.IntBuffer{
Format: &audio.Format{NumChannels: 1, SampleRate: sampleRate},
Data: make([]int, numSamples),
}
// Generate sine wave
amplitude := 32767.0 // Max amplitude for 16-bit audio
for i := 0; i < numSamples; i++ {
time := float64(i) / float64(sampleRate)
buf.Data[i] = int(amplitude * math.Sin(2*math.Pi*frequency*time))
}
// Create output file with proper error handling
f, err := os.OpenFile("sine.wav", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return fmt.Errorf("create sine.wav: %w", err)
}
completed := false
defer func() {
f.Close()
if !completed {
os.Remove(f.Name())
}
}()
// Encode audio data to WAV format
// Parameters: output io.WriteSeeker, sample rate, bit depth, num channels, audio format (1 = PCM)
encoder := wav.NewEncoder(f, sampleRate, 16, 1, 1)
if err := encoder.Write(buf); err != nil {
return fmt.Errorf("write WAV data: %w", err)
}
// Close the encoder to finalize the WAV file
if err := encoder.Close(); err != nil {
return fmt.Errorf("finalize WAV: %w", err)
}
if err := f.Close(); err != nil {
return err
}
completed = true
return nil
}
func main() {
if err := generateWaveform(); err != nil {
log.Fatal(err)
}
log.Println("Successfully generated sine.wav")
}
Run it from the parent directory where you pasted the setup:
(cd waveform-generator && GOWORK=off go run ./cmd/generate)
The program writes waveform-generator/sine.wav: mono, 16-bit integer PCM at 44,100 samples per
second. Open it in an audio player to hear the tone. Both programs refuse to overwrite existing
outputs; move or deliberately delete the old output before rerunning an edited program.
To generate a square wave, replace the amplitude declaration and the sine generation loop in
cmd/generate/main.go with the following block. The output is still named sine.wav; move the
previous WAV and SVG aside, then rerun the generator and visualizer commands for each variant.
// Generate square wave
amplitude := 32767.0
for i := 0; i < numSamples; i++ {
time := float64(i) / float64(sampleRate)
if math.Sin(2*math.Pi*frequency*time) >= 0 {
buf.Data[i] = int(amplitude)
} else {
buf.Data[i] = int(-amplitude)
}
}
For a triangle wave, replace that same declaration and loop with:
// Generate triangle wave
amplitude := 32767.0
period := float64(sampleRate) / frequency
for i := 0; i < numSamples; i++ {
// Calculate phase within the period (0.0 to 1.0)
phase := math.Mod(float64(i), period) / period
if phase < 0.5 {
// Rising edge: scales from -1 to 1 over the first half
buf.Data[i] = int(amplitude * (phase*4.0 - 1.0))
} else {
// Falling edge: scales from 1 to -1 over the second half
buf.Data[i] = int(amplitude * (3.0 - phase*4.0))
}
}
Visualizing waveforms using SVG
Save the next program as waveform-generator/cmd/visualize/main.go. It accepts classic RIFF/WAVE
integer PCM files with format tag 1 and 8-, 16-, 24-, or 32-bit samples. Floating-point and
WAVE_FORMAT_EXTENSIBLE inputs are rejected. Samples are
unsigned at 8 bits and signed at the other supported depths.
For stereo input, it selects the first channel without mixing.
The plot samples 800 frames evenly from the first to the last frame. It can miss peaks between
those frames; use per-pixel minimum/maximum aggregation when you need an amplitude envelope.
FullPCMBuffer() loads the entire recording into memory, so keep this exercise to short local
files. The length checks catch a payload shorter than its declared PCM size, but do not establish
that the recording or all of its metadata is intact. The explicit byte limit excludes
RIFF padding
from the samples decoded by the pinned library.
package main
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"log"
"math"
"os"
"github.com/ajstarks/svgo"
"github.com/go-audio/wav"
)
func visualizeWaveform() error {
// Open the WAV file
file, err := os.Open("sine.wav") // Or any other .wav file
if err != nil {
return fmt.Errorf("open audio file: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
log.Printf("Warning: Failed to close audio file: %v", err)
}
}()
// Create a new decoder
decoder := wav.NewDecoder(file)
if !decoder.IsValidFile() {
return fmt.Errorf("invalid WAV file")
}
if decoder.WavAudioFormat != 1 {
return fmt.Errorf("only integer PCM WAV files are supported")
}
if decoder.BitDepth != 8 && decoder.BitDepth != 16 && decoder.BitDepth != 24 && decoder.BitDepth != 32 {
return fmt.Errorf("unsupported PCM bit depth: %d", decoder.BitDepth)
}
if err := decoder.FwdToPCM(); err != nil {
return fmt.Errorf("find PCM data: %w", err)
}
dataStart, err := file.Seek(0, io.SeekCurrent)
if err != nil {
return err
}
// v1.1.0 includes RIFF padding in PCMSize; read the unpadded data length.
var sizeBytes [4]byte
if _, err := file.ReadAt(sizeBytes[:], dataStart-4); err != nil {
return fmt.Errorf("read PCM length: %w", err)
}
pcmBytes := int64(binary.LittleEndian.Uint32(sizeBytes[:]))
bytesPerSample := int64(decoder.BitDepth / 8)
frameBytes := bytesPerSample * int64(decoder.NumChans)
if pcmBytes == 0 || pcmBytes%frameBytes != 0 {
return fmt.Errorf("empty or incomplete PCM frames")
}
info, err := file.Stat()
if err != nil {
return err
}
if dataStart+pcmBytes > info.Size() {
return fmt.Errorf("truncated PCM data")
}
// Decode samples only, excluding a possible RIFF padding byte.
decoder.PCMChunk.R = io.LimitReader(file, pcmBytes)
// Read the full PCM buffer
// Note: For large audio files, consider processing the audio in chunks
// rather than loading the entire file into memory with FullPCMBuffer().
buf, err := decoder.FullPCMBuffer()
if err != nil {
return fmt.Errorf("read PCM data: %w", err)
}
if int64(len(buf.Data))*bytesPerSample != pcmBytes {
return fmt.Errorf("truncated PCM data")
}
channels := buf.Format.NumChannels
if channels <= 0 || len(buf.Data) == 0 || len(buf.Data)%channels != 0 {
return fmt.Errorf("empty or incomplete PCM frames")
}
// Define SVG dimensions
width := 800
height := 200
centerY := height / 2
// Create SVG file
svgFile, err := os.OpenFile("waveform.svg", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return fmt.Errorf("create waveform.svg: %w", err)
}
completed := false
defer func() {
svgFile.Close()
if !completed {
os.Remove(svgFile.Name())
}
}()
// Initialize SVG canvas
writer := bufio.NewWriter(svgFile)
canvas := svg.New(writer)
canvas.Start(width, height)
canvas.Rect(0, 0, width, height, "fill:white") // Background
// Draw center line
canvas.Line(0, centerY, width, centerY, "stroke:#cccccc;stroke-width:1")
// WAV samples are interleaved by channel; choose the first channel in each frame.
numFrames := len(buf.Data) / channels
sampleAt := func(x int) float64 {
frame := x * (numFrames - 1) / (width - 1)
sample := float64(buf.Data[frame*channels])
// 8-bit WAV PCM is unsigned.
if buf.SourceBitDepth == 8 {
sample -= 128
}
return sample
}
// Draw waveform lines from center to amplitude
maxAmplitude := math.Ldexp(1, buf.SourceBitDepth-1)
scale := float64(centerY) / maxAmplitude // Scale factor for amplitude to fit height
for x := 0; x < width; x++ {
sampleValue := sampleAt(x)
// Calculate y coordinate, inverting for SVG (0 is top)
y := centerY - int(sampleValue*scale)
canvas.Line(x, centerY, x, y, "stroke:#0066cc;stroke-width:1")
}
canvas.End()
if err := writer.Flush(); err != nil {
return err
}
if err := svgFile.Close(); err != nil {
return err
}
completed = true
return nil
}
func main() {
if err := visualizeWaveform(); err != nil {
log.Fatal(err)
}
log.Println("Successfully generated waveform.svg")
}
After generating sine.wav, run the visualizer from the same parent directory:
(cd waveform-generator && GOWORK=off go run ./cmd/visualize)
Open waveform-generator/waveform.svg in a browser or image viewer. It contains a white background,
a center line at y = 100, and 800 vertical amplitude lines. Positive samples extend upward and
negative samples extend downward. A two-second tone contains 880 cycles, so the 800 sampled columns
form an aliased overview rather than a readable trace of every cycle.
Advanced waveform customization techniques
You can enhance your SVG visualizations with these techniques:
Adjusting colors and line thickness
Edit the three style strings in cmd/visualize/main.go: change fill:white to fill:#f5f5f5,
stroke:#cccccc;stroke-width:1 to stroke:#e76f51;stroke-width:1, and
stroke:#0066cc;stroke-width:1 to stroke:#2a9d8f;stroke-width:2. These are SVG CSS styles passed
to svgo, so they change the drawing
without moving the sampling code. Move the previous waveform.svg aside, then rerun the visualizer.
Creating a filled waveform
In cmd/visualize/main.go, replace only the final for x := 0; x < width; x++ loop with the
following block. Keep sampleAt, maxAmplitude, and scale above it, and canvas.End() below it.
Move the previous SVG aside and rerun the same visualizer command.
// Draw a filled waveform (area chart style)
fillColor := "#2a9d8f"
fillOpacity := 0.5
// Follow the sampled amplitudes, then close the polygon at the center line.
// Allocate slice capacity for efficiency
xs := make([]int, 0, width+2)
ys := make([]int, 0, width+2)
xs = append(xs, 0)
ys = append(ys, centerY)
lastX := 0
for x := 0; x < width; x++ {
sampleValue := sampleAt(x)
y := centerY - int(sampleValue*scale)
xs = append(xs, x)
ys = append(ys, y)
lastX = x
}
// Add the final point on the center line to close the shape
xs = append(xs, lastX)
ys = append(ys, centerY)
// Draw the polygon
style := fmt.Sprintf("fill:%s;fill-opacity:%.2f;stroke:none", fillColor, fillOpacity)
canvas.Polygon(xs, ys, style)
Practical use cases and project ideas
Try a short speech recording by changing os.Open("sine.wav") to your file’s path. Keep the input
separate from waveform.svg, and move an existing SVG before rerunning. For a podcast timeline or
audio editor, the next useful change is an envelope: calculate the minimum and maximum amplitude
in each pixel’s time interval. That preserves peaks the single-frame sampling here can miss.
Conclusion and further resources
Keep the WAV alongside its SVG while experimenting, so you can compare the sound with the sampled plot. For the library APIs used here, see:
For a hosted workflow, see the supported inputs and output options in Transloadit’s 🤖 /audio/waveform Robot documentation and Audio Encoding service.
