M4A is what iTunes and Apple Music use — AAC audio in an MP4 container. Converting to MP3 is always lossy-to-lossy (you can't recover what AAC encoding lost), so the goal is to match the source bitrate or go slightly higher to minimize additional loss.

Method 1: pydub (FFmpeg-backed)

pydub is the easiest Python audio library. Wraps FFmpeg, supports M4A reading natively.

pip install pydub
apt install ffmpeg  # required for M4A decoding
from pydub import AudioSegment

def m4a_to_mp3(in_path: str, out_path: str, bitrate: str = "192k") -> None:
    audio = AudioSegment.from_file(in_path, format="m4a")
    audio.export(out_path, format="mp3", bitrate=bitrate)

m4a_to_mp3("podcast_episode.m4a", "podcast_episode.mp3")

Bitrate matching matters because M4A-to-MP3 is lossy-to-lossy:

  • If source M4A is 256k AAC → use 256k MP3 (Apple Music original quality).
  • If source M4A is 128k AAC → use 192k MP3 (slight headroom for additional encoding loss).
  • For voice content → 96k mono MP3 is fine (podcasts).

To detect the source bitrate first:

import subprocess
import json

def get_bitrate_kbps(in_path: str) -> int:
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_streams", "-of", "json", in_path],
        capture_output=True, text=True
    )
    streams = json.loads(out.stdout)["streams"]
    audio = next(s for s in streams if s["codec_type"] == "audio")
    return int(audio.get("bit_rate", 192000)) // 1000

rate = get_bitrate_kbps("podcast_episode.m4a")
m4a_to_mp3("podcast_episode.m4a", "podcast_episode.mp3", bitrate=f"{max(rate, 192)}k")

Method 2: With ID3 tag preservation (mutagen)

M4A uses MP4-style atoms for tags (©nam, ©ART, ©alb, etc.). MP3 needs ID3v2. mutagen reads both:

pip install pydub mutagen
apt install ffmpeg
from pydub import AudioSegment
from mutagen.mp4 import MP4
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, APIC
from mutagen.mp3 import MP3

MP4_TO_ID3 = {
    "\xa9nam": TIT2,
    "\xa9ART": TPE1,
    "\xa9alb": TALB,
    "\xa9day": TDRC,
}

def m4a_to_mp3_with_tags(in_path: str, out_path: str, bitrate: str = "192k") -> None:
    # Convert audio
    audio = AudioSegment.from_file(in_path, format="m4a")
    audio.export(out_path, format="mp3", bitrate=bitrate)

    # Copy tags
    src = MP4(in_path)
    mp3 = MP3(out_path, ID3=ID3)
    if mp3.tags is None:
        mp3.add_tags()

    for mp4_key, id3_class in MP4_TO_ID3.items():
        if mp4_key in src.tags:
            mp3.tags.add(id3_class(encoding=3, text=str(src.tags[mp4_key][0])))

    # Track number is special: stored as (track, total) tuple
    if "trkn" in src.tags:
        track, total = src.tags["trkn"][0]
        mp3.tags.add(TRCK(encoding=3, text=f"{track}/{total}"))

    # Embedded cover art (covr atom)
    if "covr" in src.tags:
        cover = src.tags["covr"][0]
        mime = "image/png" if cover.imageformat == cover.FORMAT_PNG else "image/jpeg"
        mp3.tags.add(APIC(
            encoding=3,
            mime=mime,
            type=3,  # cover (front)
            desc="Cover",
            data=bytes(cover),
        ))

    mp3.save()

m4a_to_mp3_with_tags("album_track.m4a", "album_track.mp3")

Method 3: ChangeThisFile API (no FFmpeg, tags included)

The API runs FFmpeg server-side and copies tags + cover art automatically. Free tier covers 1,000 conversions/month.

import requests

API_KEY = "ctf_sk_your_key_here"

def m4a_to_mp3(in_path: str, out_path: str, bitrate: int = 192) -> None:
    with open(in_path, "rb") as f:
        response = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": f},
            data={
                "source": "m4a",
                "target": "mp3",
                "bitrate": bitrate,
            },
            timeout=60,
        )
    response.raise_for_status()
    with open(out_path, "wb") as out:
        out.write(response.content)

m4a_to_mp3("podcast_episode.m4a", "podcast_episode.mp3")

The API automatically detects source bitrate and matches the MP3 output. Tags (artist, album, title, year, track number, cover art) are preserved.

When to use each

ApproachBest forTradeoff
pydubMost cases — easiest APIDoesn't preserve tags (use mutagen)
pydub + mutagenMusic libraries with tag preservationTwo libraries, manual tag mapping
ChangeThisFile APINo FFmpeg, automatic tag transferNetwork call, file size limit (25MB free)

Production tips

  • Match or exceed source bitrate. M4A-to-MP3 is lossy-to-lossy — re-encoding at lower bitrate compounds quality loss. Always match (256k → 256k) or exceed (128k → 192k).
  • For voice content, mono is fine. Many podcasts are mono-mixed already. Pass parameters=['-ac', '1'] to pydub for mono output (~50% smaller).
  • Always preserve tags for music. Without tags, MP3 shows as 'Unknown Artist' in players. mutagen + the MP4_TO_ID3 mapping above handles the conversion.
  • Watch for DRM-protected M4A (M4P). Old iTunes purchases may be DRM-locked — pydub/FFmpeg can't decode. Check with ffprobe; if you see encrypted, the file can't be converted without removing DRM (which requires the original purchase account).
  • Consider Opus instead. If your audience can play Opus, target=opus gives better quality than MP3 at the same bitrate. Most modern players support it.

For podcast episodes, pydub is the right answer. For music libraries, add mutagen for tag preservation. For environments without FFmpeg, the API. Free tier covers 1,000 conversions/month.