WAV is uncompressed (10x bigger than MP3 typically); MP3 is the most compatible compressed audio format. The encoding itself is well-understood — every tool uses libmp3lame underneath. Choice is mostly about deployment: ship FFmpeg, ship cgo + libmp3lame, or call the API.
Method 1: FFmpeg via os/exec
FFmpeg with libmp3lame is the standard. Handles every WAV variant in the wild (PCM 8/16/24/32-bit, IEEE float, ADPCM, A-law, μ-law) and gives precise bitrate/quality control.
apt install ffmpeg # includes libmp3lame in standard builds
package main
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"time"
)
func wavToMP3(ctx context.Context, inPath, outPath string, bitrateKbps int) error {
cmd := exec.CommandContext(ctx,
"ffmpeg", "-y",
"-i", inPath,
"-codec:a", "libmp3lame",
"-b:a", strconv.Itoa(bitrateKbps)+"k",
"-ar", "44100", // standard sample rate
"-ac", "2", // stereo
outPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg: %w (%s)", err, out)
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 192k is high-quality, 128k is web-standard, 320k is max MP3 quality
if err := wavToMP3(ctx, "recording.wav", "recording.mp3", 192); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Bitrate guide:
- 320k — max MP3 quality. Files ~2.4 MB/min stereo.
- 192k — high quality, transparent for most listeners. Files ~1.4 MB/min.
- 128k — web-standard, mediocre for music, fine for voice. Files ~0.95 MB/min.
- 96k — voice/podcast quality. Files ~0.7 MB/min.
For variable bitrate (better quality at the same average size), use -q:a 2 instead of -b:a 192k (q ranges 0-9, lower = better quality).
Method 2: viert/go-lame (cgo wrapper around libmp3lame)
If you want MP3 encoding directly from Go without spawning a process, go-lame wraps libmp3lame via cgo. You read WAV samples and write MP3 samples in your Go code.
apt install libmp3lame-dev
go get github.com/viert/go-lame
package main
import (
"encoding/binary"
"fmt"
"io"
"os"
"github.com/viert/go-lame"
)
func wavToMP3(inPath, outPath string, bitrateKbps int) error {
in, err := os.Open(inPath)
if err != nil {
return err
}
defer in.Close()
// Skip the 44-byte WAV header (assumes standard PCM WAV)
var header [44]byte
if _, err := io.ReadFull(in, header[:]); err != nil {
return err
}
sampleRate := int(binary.LittleEndian.Uint32(header[24:28]))
channels := int(binary.LittleEndian.Uint16(header[22:24]))
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
enc := lame.NewEncoder(out)
defer enc.Close()
enc.SetInSamplerate(sampleRate)
enc.SetNumChannels(channels)
enc.SetBrate(bitrateKbps)
enc.SetQuality(2) // 0=best, 9=worst
if _, err := io.Copy(enc, in); err != nil {
return fmt.Errorf("encode: %w", err)
}
return nil
}
func main() {
if err := wavToMP3("recording.wav", "recording.mp3", 192); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Two caveats:
- Manual WAV header parsing. The example above only handles the simplest 44-byte header. Real WAVs may have RIFF chunks (LIST, fact) before the data — for production, use a WAV decoder like
github.com/go-audio/wav. - cgo + libmp3lame at build and runtime. Less portable than FFmpeg's all-in-one binary.
Method 3: ChangeThisFile API (no FFmpeg)
If you don't want FFmpeg in your container or libmp3lame in your binary, the API runs FFmpeg server-side. Free tier covers 1,000 conversions/month.
package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
)
const apiKey = "ctf_sk_your_key_here"
func wavToMP3(inPath, outPath string) error {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
f, err := os.Open(inPath)
if err != nil {
return err
}
defer f.Close()
fw, err := w.CreateFormFile("file", "input.wav")
if err != nil {
return err
}
if _, err := io.Copy(fw, f); err != nil {
return err
}
_ = w.WriteField("source", "wav")
_ = w.WriteField("target", "mp3")
_ = w.Close()
req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", w.FormDataContentType())
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func main() {
if err := wavToMP3("recording.wav", "recording.mp3"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The API uses FFmpeg + libmp3lame at 192k VBR by default. Pass bitrate=128 in form data for smaller files or bitrate=320 for max quality.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| FFmpeg via os/exec | Self-hosted services, broad WAV variant support | ~150MB FFmpeg install |
| viert/go-lame | Single-binary deploys, in-process encoding | cgo + manual WAV parsing, simple WAVs only |
| ChangeThisFile API | Lambda, distroless, multi-tenant SaaS | Network call, file size limit (25MB free) |
CLI alternative: lame, sox, ffmpeg
For shell pipelines:
apt install lame
# Direct lame (smallest install)
lame -b 192 recording.wav recording.mp3
# Via FFmpeg (broader format support)
ffmpeg -i recording.wav -codec:a libmp3lame -b:a 192k recording.mp3
# Batch with find:
find . -name "*.wav" -exec sh -c \
'lame -b 192 "$1" "${1%.wav}.mp3"' _ {} \;
// From Go, lame is the smallest and fastest dep:
cmd := exec.Command("lame", "-b", "192", "recording.wav", "recording.mp3")
out, err := cmd.CombinedOutput()
If you only need WAV-to-MP3 (no other formats), lame alone is a much smaller install (~5MB) than FFmpeg.
Production tips
- Use VBR for music, CBR for streaming. Variable bitrate (FFmpeg
-q:a 2) gives better quality at the same average size for music. Constant bitrate (CBR,-b:a 192k) is required for some streaming protocols and easier to seek. - Keep sample rate at 44.1kHz or 48kHz. Other rates (e.g., 22050) work but are unusual. iTunes and Spotify expect 44.1kHz; YouTube and pro audio usually use 48kHz.
- For voice-only content, consider Opus or AAC. Opus at 64k often sounds better than MP3 at 128k for speech. AAC has better compatibility with iOS apps. MP3 is the universal fallback.
- Always set a CommandContext timeout. A pathological WAV file can stall FFmpeg. 60s is typical for files under 100MB.
- Watch for clipping. If your WAV has samples above 0dBFS (rare in pro recordings), MP3 encoding can introduce distortion. Pre-normalize with
ffmpeg -af loudnormfor user uploads.
For most Go services, FFmpeg via os/exec is the right answer — broad format support, fast, well-tested. For single-binary deploys, go-lame is a workable alternative. For environments without FFmpeg, the API. Free tier covers 1,000 conversions/month.