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

ApproachBest forTradeoff
FFmpeg via os/execSelf-hosted services, broad WAV variant support~150MB FFmpeg install
viert/go-lameSingle-binary deploys, in-process encodingcgo + manual WAV parsing, simple WAVs only
ChangeThisFile APILambda, distroless, multi-tenant SaaSNetwork 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 loudnorm for 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.