Measurement

Seventeen chart tracks, measured after the codec

One week of the chart, one track per artist, bought as lossless downloads and run through six encodes at the rates and bitrates the platforms serve. Ninety-four of the hundred and two passes came back with a higher true peak than they started with. How much higher turned out to depend on the bitrate and on which encoder did the work, more than on the codec. Below: what was bought, the method and the control it rests on, the encoder command lines, and the data.

August 2026~15 min read

What the measurements say

  • Same codec, same bitrate, different encoder, 4.8 dB apart. AAC-LC at 256 kbps sat a median of 4.8 dB further from the source through ffmpeg than through Apple's own encoder, on the same seventeen files, and lifted true peak by a median of 2.27 dB against 0.35.
  • Bitrate moved it too. Vorbis at 96 kbps lifted true peak by a median of 2.13 dB; the same encoder at 320 lifted it by 0.10.
  • 94 of 102 encoder passes returned a higher true peak than they started with.
  • 8 of 17 were delivered above 0 dBTP, and 16 of 17 above −1 dBTP.
  • 16 of 17 are turned down by Spotify, by a median of 5.9 dB. One is turned up.

Plenty of people have measured how loud the charts are. Ian Shepherd has been publishing loudness measurements of released records for years, and MeterPlugs' Loudness Penalty will tell you the gain each platform applies to a file you drop into it. Those measurements are of the file the label delivered. Nobody streaming it receives that file. What arrives is the output of an encoder, at a rate and a bitrate the platform chose, and that is a different file with different numbers on it.

So I bought a chart week and measured both ends.

What was bought

Seventeen tracks, taken in chart order from Spotify Global for the week of 6 August 2026, one per artist. The one-per-artist rule exists because two artists had released albums that week and held eleven of the top twenty-five between them. Without it this would be a measurement of two records rather than of a chart. Nothing was selected by genre, year or sound, and the rule was written down before anything was bought.

Each was bought as a lossless download on 10 August 2026. Buying rather than capturing matters: pulling audio out of a streaming app breaks the terms you agreed to, and a measurement you cannot describe honestly is not worth publishing.

Purchase has its own limits. Nine of the seventeen are sold at 16-bit/44.1 kHz and nothing else. You can only buy the high-resolution tier where a shop stocks one. The resolution of every purchase is in the first table.

What is on sale is also regional. The same seventeen are listed at different resolutions in different countries, and one of them has no single page for the original in the store these were bought from, only remixes and the album it came from.

The method

Two sets of numbers. The master as sold is measured at its own rate, because that is the file that was delivered. The codec pass is run at the rate the platform serves, which is 44.1 kHz for Vorbis and AAC and 48 kHz for Opus, and nulled against the same master resampled to that rate. Keeping those separate is the point: encoding a 192 kHz file at 192 kHz would measure something no listener receives, and nulling a 44.1 kHz decode against a 192 kHz source would put the resample inside the codec figure.

Codec listening tests have their own long tradition at Hydrogenaudio, and if you have not seen Monty's Digital Show and Tell from Xiph, it is the clearest demonstration that sampled audio reconstructs to something other than the staircase people draw.

The null test itself is subtraction. Decode, align, level-match, subtract, and look at what is left. Alignment is first because encoders introduce delay, and subtracting without correcting for it compares a file with a shifted copy of itself. Every row here reports an offset of zero, because ffmpeg honors priming and pre-skip when it decodes. A column of zeros is also exactly what a dead aligner looks like, so the aligner is given signals shifted by known amounts and has to find them before the run starts.

Level is second: a codec can come back a fraction of a decibel off, which would otherwise read as damage, so we solve for the best-fit gain and report it next to the residual.

Then the control, which matters more than any result. The same pipeline runs a lossless float round trip on every track, and it returns a perfect null on all seventeen. A method that cannot say identical when the answer is identical is not measuring anything.

That control is why the reference files here are 32-bit float rather than 24-bit integer: several of these masters land above full scale once they are resampled to 44.1 kHz, and writing them to a fixed-point file clips them. The first version of this run used FLAC, which has no float mode, and the control came back at −97 dB. That figure was the clipping, not the method, and it took a while to believe it.

Which encoders. These are the codecs and bitrates the platforms publish. Five of the six passes run through ffmpeg; the sixth is AAC-LC at 256 kbps through afconvert, which is Apple's own encoder, shipped with macOS. Neither Spotify's Vorbis build nor the exact settings Apple encodes the store with are public, so the Vorbis columns are libvorbis at Spotify's three published bitrates and the two AAC columns are two implementations at the bitrate Apple Music serves. Every command line and every library version is in the data file and printed at the foot of this page.

Carrying two AAC columns costs a table column and settles a question that would otherwise sit under the whole page: how much of what is being measured is the codec and how much is the software. On this set, quite a lot of it is the software.

True peak is quoted from our own four-times oversampler. If you would rather not trust a filter we wrote: it agrees with ffmpeg's ebur128 filter (peak=true) to within 0.11 dB on every track at every rate from 44.1 to 192 kHz. Both columns are in the data.

As delivered

ArtistTrackSample rate (kHz)Integrated (LUFS)LRA (LU)True peak (dBTP)DR (dB)
Ariana Grandehate that i made you love me48.0−8.55.9−0.208.6
Shakira, Burna BoyDai Dai44.1−9.46.9−0.028.5
KATSEYEAnimal48.0−7.55.0−0.108.4
Temper CitySelf Aware44.1−5.44.3+0.605.8
sombrback to friends44.1−5.14.5+0.086.2
Ella LangleyChoosin' Texas44.1−7.93.8+0.608.9
DjoEnd of Beginning96.0−9.66.0−0.179.0
Olivia DeanMan I Need44.1−8.23.3−0.298.5
BTSSWIM44.1−7.97.0+0.737.5
Justin BieberBeauty And A Beat (Album Version)44.1−7.43.1+1.688.4
Malcolm ToddEarrings44.1−8.92.8+0.219.5
Michael JacksonBillie Jean (Single Version)44.1−17.32.7−1.2116.3
Dominic FikeBabydoll44.1−8.64.6−0.259.1
OasisWonderwall (Remastered)96.0−6.79.6−0.737.5
Katy PerryThe One That Got Away44.1−8.13.6+1.329.0
Olivia Rodrigostupid song48.0−7.913.8+0.747.8
Goo Goo DollsIris192.0−11.111.6−0.1411.5

Sixteen of the seventeen sit between −5.1 and −11.1 LUFS integrated, with a median of −8.1. The seventeenth is Billie Jean at −17.3, a 1982 master in a 2026 chart, and the only one of the seventeen that sits below the level the platforms normalize to.

Eight of the seventeen were delivered above 0 dBTP, and sixteen of the seventeen above −1. Apple asks for −1 dBTP in its delivery notes and Amazon for −2. This table is not an argument with those numbers. It is what these records were delivered at, by people who had meters in front of them.

What the codec hands back

True peak (dBTP) after each encode, with the change from what went in.
ArtistTrackIn (dBTP)Vorbis 96kVorbis 160kVorbis 320kAAC 256k (ffmpeg)AAC 256k (Apple)Opus 128k
Ariana Grandehate that i made you love me−0.19+1.45 (+1.64)+0.73 (+0.91)+0.10 (+0.29)+0.41 (+0.60)+0.16 (+0.35)+0.98 (+1.18)
Shakira, Burna BoyDai Dai−0.02+3.53 (+3.55)+2.28 (+2.30)+0.40 (+0.42)+2.85 (+2.87)+0.93 (+0.95)+2.23 (+2.22)
KATSEYEAnimal−0.07+2.73 (+2.80)+1.69 (+1.76)+0.24 (+0.31)+2.66 (+2.73)+0.72 (+0.79)+2.47 (+2.57)
Temper CitySelf Aware+0.60+3.73 (+3.13)+2.18 (+1.58)+0.70 (+0.10)+4.15 (+3.55)+0.99 (+0.39)+2.84 (+2.26)
sombrback to friends+0.08+3.13 (+3.05)+1.94 (+1.86)+0.40 (+0.32)+3.25 (+3.17)+0.74 (+0.66)+2.39 (+2.38)
Ella LangleyChoosin' Texas+0.60+2.01 (+1.41)+1.27 (+0.67)+0.62 (+0.02)+0.67 (+0.07)+0.59 (−0.01)+1.22 (+0.61)
DjoEnd of Beginning+0.08+1.90 (+1.81)+1.36 (+1.28)+0.11 (+0.03)+1.96 (+1.88)+0.19 (+0.11)+1.33 (+1.25)
Olivia DeanMan I Need−0.29+1.70 (+1.99)+0.83 (+1.12)−0.01 (+0.29)+2.57 (+2.87)+0.08 (+0.38)+1.47 (+1.76)
BTSSWIM+0.73+3.18 (+2.45)+2.09 (+1.36)+0.85 (+0.11)+3.00 (+2.27)+1.87 (+1.14)+2.33 (+1.54)
Justin BieberBeauty And A Beat (Album Version)+1.68+2.84 (+1.16)+1.68 (+0.00)+1.67 (−0.01)+3.42 (+1.74)+1.64 (−0.04)+2.30 (+0.61)
Malcolm ToddEarrings+0.21+3.99 (+3.78)+1.67 (+1.46)+0.37 (+0.16)+2.02 (+1.80)+0.43 (+0.22)+2.05 (+1.90)
Michael JacksonBillie Jean (Single Version)−1.21−0.23 (+0.98)−0.90 (+0.31)−1.33 (−0.12)−0.79 (+0.42)−1.20 (+0.01)−0.68 (+0.51)
Dominic FikeBabydoll−0.25+1.88 (+2.13)+1.17 (+1.42)+0.10 (+0.35)+3.86 (+4.11)+0.27 (+0.52)+1.45 (+1.63)
OasisWonderwall (Remastered)−0.31+2.84 (+3.14)+1.42 (+1.73)−0.21 (+0.09)+3.78 (+4.09)−0.09 (+0.22)+1.72 (+2.03)
Katy PerryThe One That Got Away+1.32+2.54 (+1.22)+1.60 (+0.28)+1.29 (−0.03)+4.48 (+3.16)+0.73 (−0.59)+2.26 (+0.93)
Olivia Rodrigostupid song+0.79+3.29 (+2.50)+1.97 (+1.18)+0.76 (−0.02)+0.46 (−0.33)+1.14 (+0.35)+2.83 (+2.09)
Goo Goo DollsIris+0.43+1.88 (+1.45)+1.50 (+1.07)+0.49 (+0.07)+1.58 (+1.15)+0.51 (+0.08)+1.52 (+1.16)
Residual against the source (dB) after alignment and level matching. Lower is further from the source.
ArtistTrackVorbis 96kVorbis 160kVorbis 320kAAC 256k (ffmpeg)AAC 256k (Apple)Opus 128k
Ariana Grandehate that i made you love me−18.8−24.4−33.9−29.2−34.0−21.0
Shakira, Burna BoyDai Dai−16.3−21.4−34.2−25.7−32.0−20.1
KATSEYEAnimal−15.6−20.6−32.3−24.1−29.3−18.8
Temper CitySelf Aware−16.2−22.3−34.6−24.5−30.7−19.4
sombrback to friends−14.8−20.4−33.9−25.2−29.1−18.8
Ella LangleyChoosin' Texas−18.0−23.6−34.6−28.6−34.6−21.2
DjoEnd of Beginning−17.4−22.3−32.7−27.7−31.9−19.9
Olivia DeanMan I Need−18.8−24.1−34.9−29.3−34.0−21.4
BTSSWIM−17.4−21.6−33.2−25.8−30.3−19.3
Justin BieberBeauty And A Beat (Album Version)−15.8−21.0−32.3−23.6−29.5−19.4
Malcolm ToddEarrings−15.5−20.9−31.7−25.0−30.0−18.9
Michael JacksonBillie Jean (Single Version)−12.9−17.0−28.7−21.1−25.7−16.0
Dominic FikeBabydoll−17.6−21.9−33.7−27.7−33.1−20.9
OasisWonderwall (Remastered)−15.1−20.2−32.7−25.1−29.4−18.9
Katy PerryThe One That Got Away−16.6−21.7−32.9−25.6−30.3−19.9
Olivia Rodrigostupid song−15.2−20.1−30.6−24.2−29.1−18.4
Goo Goo DollsIris−14.1−19.0−29.2−23.7−27.0−16.8

Each codec cell is the true peak that came back, then the change from what went in and the residual against the source. The Opus column went in at 48 kHz. Its true peak in is a fraction different from the column shown.

Ninety-four of the hundred and two passes returned a higher true peak than they started with. Vorbis at 96 kbps raised it on all seventeen, median 2.13 dB, as much as 3.78. Vorbis at 160 raised it on all seventeen, median 1.28. Opus at 128 raised it on all seventeen, median 1.63.

Two of the six barely moved it. Vorbis at 320 raised it on thirteen of the seventeen by a median of 0.10 dB, and Apple's AAC encoder at 256 raised it on fourteen by a median of 0.35. The tracks they did not raise came back between 0.01 and 0.59 dB lower.

The two things that moved the numbers

Bitrate, inside one codec. Between Vorbis at 96 kbps and Vorbis at 320, on the same encoder and the same files, the residual improves by a median of 16.2 dB and the peak lift falls by a median of 1.78 dB. On one track that is the difference between coming back at +3.53 dBTP and coming back at +0.40. Both bitrates are Spotify. Which one a listener gets depends on their tier and their connection.

Same codec. Same bitrate. Two encoders. 0 dBFS +4.48 +0.73 Katy Perry as delivered ffmpeg Apple’s encoder True peak after AAC-LC at 256 kbps, seventeen chart tracks soneam.com

The encoder, at one bitrate. The two AAC columns are the same codec at the same 256 kbps on the same files, encoded once by ffmpeg and once by Apple's own encoder. Apple's is closer to the source on all seventeen, by a median of 4.8 dB, and lifts true peak less on sixteen, by a median of 1.78 dB. On one track ffmpeg returned +4.48 dBTP where Apple returned +0.73.

That second comparison is the reason both columns are here rather than one. Run this measurement with whatever encoder is on your machine and part of what you write down is your machine. It is also a correction: an earlier draft of this page, with only ffmpeg's AAC, reported that AAC-LC at 256 lifted peaks more than Vorbis at 96 did. Through Apple's encoder it does not, and the sentence was describing an implementation rather than a codec.

Before anyone else says it: ffmpeg's built-in AAC encoder is not the strongest AAC encoder in circulation, and it is not meant to be. Fraunhofer's libfdk_aac is generally held to be better, and this run does not include it, because its license keeps it out of the ffmpeg builds that Debian and Homebrew ship. So the honest reading of the 4.8 dB is an upper bound on how far implementations can sit apart, not a claim that every pair of encoders does.

It is also the pair most people will actually meet. A stock ffmpeg is what is installed on the machine of anyone who sets out to check this. Someone repeating the measurement with the encoder they already have would get the ffmpeg column, and would be measuring something real about their own pipeline rather than something wrong.

Ranked by residual across all six, Vorbis at 320 is closest on fifteen of the seventeen and Apple's AAC at 256 on the other two. Ranked by peak lift, they are the two gentlest by a wide margin. Which codec family wins is not a question this set answers; which bitrate and which encoder is.

The resample happens before the encoder

ArtistTrackSold at (kHz)Sample peak (dBFS)At 44.1 kHz (dBFS)Change (dB)
Ariana Grandehate that i made you love me48.0−0.20−0.19+0.01
KATSEYEAnimal48.0−0.20−0.19+0.01
DjoEnd of Beginning96.0−0.30+0.08+0.38
OasisWonderwall (Remastered)96.0−1.10−0.47+0.63
Olivia Rodrigostupid song48.0−0.20+0.74+0.94
Goo Goo DollsIris192.0−0.15+0.29+0.44

Six of the seventeen are sold above 44.1 kHz. On three of them the sample peak crossed 0 dBFS during the resample, before any codec had run. That step belongs to the platform, it is not visible on the meter in front of the person who made the file, and it moves the number the encoder starts from.

What each platform then does with it

ArtistTrackIntegrated (LUFS)Spotify gain (dB)Apple Music gain (dB)
Ariana Grandehate that i made you love me−8.5−5.5−7.5
Shakira, Burna BoyDai Dai−9.4−4.6−6.6
KATSEYEAnimal−7.5−6.5−8.5
Temper CitySelf Aware−5.4−8.6−10.6
sombrback to friends−5.1−8.9−10.9
Ella LangleyChoosin' Texas−7.9−6.1−8.1
DjoEnd of Beginning−9.6−4.4−6.4
Olivia DeanMan I Need−8.2−5.8−7.8
BTSSWIM−7.9−6.1−8.1
Justin BieberBeauty And A Beat (Album Version)−7.4−6.6−8.6
Malcolm ToddEarrings−8.9−5.1−7.1
Michael JacksonBillie Jean (Single Version)−17.3+3.3+1.3
Dominic FikeBabydoll−8.6−5.4−7.4
OasisWonderwall (Remastered)−6.7−7.3−9.3
Katy PerryThe One That Got Away−8.1−5.9−7.9
Olivia Rodrigostupid song−7.9−6.1−8.1
Goo Goo DollsIris−11.1−2.9−4.9

Gain here is arithmetic: the platform's target minus the master. Sixteen of the seventeen are turned down by Spotify, by a median of 5.9 dB. The one that is turned up is Billie Jean, by 3.3. Spotify runs a limiter on anything it turns up, so what a listener gets on that track is not the peak in the table plus the gain, and the limiter is not modeled here.

The two tracks sold in two tiers

ArtistTrackTierIntegrated (LUFS)LRA (LU)True peak (dBTP)DR (dB)
Goo Goo DollsIris192.0 kHz−11.1011.60−0.14011.52
Goo Goo DollsIrisCD−11.1011.60+0.34011.65
DjoEnd of Beginning96.0 kHz−9.606.00−0.1708.99
DjoEnd of BeginningCD−9.606.00+0.1199.10

Two of the seventeen were sold in both a high-resolution and a CD tier. Both were bought. Iris is the question, being a 1998 catalog title that could plausibly have been remastered for its high-resolution release. The Djo track is the control: a recent release, where the two tiers ought to be one master, and a single pair on its own cannot separate a real difference from the error in the comparison.

Integrated loudness and loudness range are identical to two decimal places in both pairs. The only figure that differs is true peak, and it differs by the amount the 44.1 kHz resample accounts for. For these two, the high-resolution download is the same master.

What this does not say

  • Seventeen tracks from one week of one chart. Every figure above is that sample and no more.
  • The dynamic range column is our own peak-to-RMS approximation over the loudest blocks, not a strict TT-DR reading.
  • Spotify's playback limiter is not applied. Where a track is turned up, the figures are what the signal would reach without one.
  • Spotify publishes its bitrates but not its container. Ogg Vorbis is the long-reported assumption rather than a documented fact, so everything here is stated as the codec rather than the platform.
  • Apple's encoder is on the page, but the settings Apple encodes the store with are not published. The Apple column is that encoder at 256 kbps in constrained VBR with quality at maximum, which is a narrower claim than what Apple Music does.
  • Six encodes per track is not a survey. Vorbis at 96, 160 and 320 are the three bitrates Spotify publishes; the Opus column is a single point, and the difference between the two AAC columns is one comparison of two implementations, not a study of encoders.
  • Nothing here says what to deliver. Level is part of how a record is written, and clipping is a tool people reach for on purpose. A table cannot tell you whether a peak matters on your record.

What you can take from it

Two things this data supports. A master delivered close to full scale is likely to be handed to a listener above it, and how far above depends on a bitrate you do not control. And the number your meter shows at the end of a session is not the number that arrives; if you want to know what arrives, the only way to find out is to encode it and look.

What it does not support is a recommendation. Sixteen of these seventeen were delivered above −1 dBTP and they are, by the only measure anyone applies to a chart, working records. The engineers who made them had the same meters and made a call. If you want a number to aim at, the platforms publish theirs, and this table is what happens on the way to a listener either way.

Run it on your own work

The numbers on this page are rendered straight from the raw data, which the measuring script wrote. Every figure in every table is in there, along with the ones that did not make the page: per-band residuals, the level correction on each pass, and the gain each of the ten platforms applies.

The earlier report on three of my own masters carries the null-test script in full, and the same primitives produced these figures. If you want to see what happens to one file rather than seventeen, the loudness tool measures it in the browser and plays it back through each platform's codec. The file is analyzed and deleted; the encodes it makes so you can hear them go thirty minutes later.

What did the encoding

Recorded by the script at run time, so it describes this run rather than what was installed when the page was written.

ffmpeg version 7.1.5-0+deb13u1 Copyright (c) 2000-2026 the FFmpeg developers

libvorbis: Encoder libvorbis [libvorbis]:
libopus: Encoder libopus [libopus Opus]:
aac: Encoder aac [AAC (Advanced Audio Coding)]:
wavpack: Encoder wavpack [WavPack]:

The exact command line for every pass is in the data file, under each track's codecs entry. They are all the same shape:

ffmpeg -y -i master_44100.wav -map 0:a:0 -c:a libvorbis -b:a 320k out.ogg
ffmpeg -y -i master_44100.wav -map 0:a:0 -c:a aac -profile:a aac_low -b:a 256k out.m4a
ffmpeg -y -i master_48000.wav -map 0:a:0 -c:a libopus -b:a 128k out.opus
afconvert -f m4af -d aac -b 256000 -q 127 -s 2 master_44100.wav out.m4a

The script

Printed rather than summarised, because a measurement described in prose is a measurement you have to take on trust. The first file walks the seventeen tracks and runs the encodes; the second is the Apple pass, which has to run on macOS because that is where Apple's encoder lives. The null-test core they both import is on the earlier page, in full.

scripts/chart_codec_study.py
"""Measure a week's chart through the codecs the platforms actually serve.

Seventeen tracks bought as lossless downloads (Qobuz, Japanese store, 2026-08-10),
one per artist, taken in chart order from Spotify Global for the week of 6 August.
Nothing here is chosen by genre, year or sound: the selection rule is written down
in docs/MEASUREMENT_STUDY.md and was fixed before anything was measured.

What this answers that the existing chart measurements do not: those measure the
file the label delivered. This measures what comes back out of the encoder the
listener is actually served, which is a different file.

Run it in the container: Homebrew's ffmpeg ships without libvorbis, and the
container's is the build the product actually serves.

    docker compose run --rm --no-deps \\
      -v "/Volumes/Extreme SSD/003_music/Download:/audio:ro" \\
      -v "$PWD/docs:/app/docs" \\
      web uv run --directory /app python /app/scripts/chart_codec_study.py \\
      --root /audio

Mounting an external drive that way is slow to start - a couple of minutes
before the first line appears, on a cold disk. It is not stuck. On 2026-08-10
that wait got read as a failure, the audio was copied under media/ to work
around it, and the copy was never needed.

Writes docs/data/chart_codec_study.json. Only numbers are written; the audio
stays where it is and no encode is kept. Measuring a recording and reporting
the numbers is one thing, redistributing it is another.

The null-test primitives are imported from scripts/codec_null_test.py, which is
the script published with the guide, so these figures come out of the code we
already showed people rather than a second private copy of it.
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time

import numpy as np
import soundfile as sf

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, '..', 'src'))

from codec_null_test import (                                    # noqa: E402
    align, band_residual, check_alignment, db, rms, specs, to_wav,
    true_peak_ebur128,
)
from projects.audio import (                                     # noqa: E402
    analyze_dynamic_range, analyze_loudness, generate_track_peaks,
)

DEFAULT_ROOT = '/Volumes/Extreme SSD/003_music/Download'
OUT_DIR = os.path.join(HERE, '..', 'docs', 'data')

# Titles are the seller's, verbatim. Where a shop's metadata is odd - Thriller's
# sixth track is sold as "(Single Version)" while its neighbours say "(Album
# Version)" - the oddity is carried through rather than tidied, because the table
# has to describe what was bought.
MANIFEST = [
    # key            artist                    title as sold                         path
    ('ariana',      'Ariana Grande',          'hate that i made you love me',
     'アリアナ・グランデ/hate that i made you love me/01 - アリアナ・グランデ - hate that i made you love me.wav'),
    ('shakira',     'Shakira, Burna Boy',     'Dai Dai',
     'シャキーラ, バーナ・ボーイ/Dai Dai/01 - シャキーラ, バーナ・ボーイ - Dai Dai.wav'),
    ('katseye',     'KATSEYE',                'Animal',
     'KATSEYE/Animal/01 - KATSEYE - Animal.wav'),
    ('tempercity',  'Temper City',            'Self Aware',
     'Temper City/Self Aware/01 - Temper City - Self Aware.wav'),
    ('sombr',       'sombr',                  'back to friends',
     'Sombr/back to friends/01 - Sombr - back to friends.wav'),
    ('langley',     'Ella Langley',           "Choosin' Texas",
     "ELLA LANGLEY/Choosin' Texas/01 - ELLA LANGLEY - Choosin' Texas.wav"),
    ('djo',         'Djo',                    'End of Beginning',
     'Djo/End of Beginning/01 - Djo - End of Beginning.wav'),
    ('oliviadean',  'Olivia Dean',            'Man I Need',
     'オリヴィア・ディーン/Man I Need/01 - オリヴィア・ディーン - マン・アイ・ニード.wav'),
    ('bts',         'BTS',                    'SWIM',
     'BTS (防弾少年団)/ARIRANG/07 - BTS (防弾少年団) - SWIM.wav'),
    ('bieber',      'Justin Bieber',          'Beauty And A Beat (Album Version)',
     'Justin Bieber/Believe/10 - Justin Bieber - ビューティー・アンド・ア・ビート (Album Version).wav'),
    ('malcolmtodd', 'Malcolm Todd',           'Earrings',
     'Malcolm Todd/Sweet Boy/01 - Malcolm Todd - Earrings.wav'),
    ('mj',          'Michael Jackson',        'Billie Jean (Single Version)',
     'Michael Jackson/Thriller/06 - Michael Jackson - ビリー・ジーン (Single Version).wav'),
    ('fike',        'Dominic Fike',           'Babydoll',
     "ドミニク・ファイク/Don't Forget About Me, Demos/03 - ドミニク・ファイク - Babydoll.wav"),
    ('oasis',       'Oasis',                  'Wonderwall (Remastered)',
     'オアシス/(What’s The Story) Morning Glory (20th Anniversary Standard Edition)/03 - オアシス - Wonderwall (Remastered).wav'),
    ('katyperry',   'Katy Perry',             'The One That Got Away',
     'ケイティ・ペリー/Teenage Dream/07 - ケイティ・ペリー - ワン・ザット・ゴット・アウェイ.wav'),
    ('rodrigo',     'Olivia Rodrigo',         'stupid song',
     'オリヴィア・ロドリゴ/you seem pretty sad for a girl so in love/02 - オリヴィア・ロドリゴ - stupid song.wav'),
    ('iris',        'Goo Goo Dolls',          'Iris',
     'Goo Goo Dolls/Dizzy up the Girl/11 - Goo Goo Dolls - Iris.wav'),
]

# Two tracks were bought twice, in both tiers the shop sold. Iris is the question
# (a 1998 catalog title: is the high-res a different master?) and Djo is the
# control (a recent release, where the two tiers should be the same master). One
# pair on its own cannot tell a real difference from the error in the comparison,
# which is what the second pair is for.
PAIRS = [
    ('iris', 'Goo Goo Dolls', 'Iris (CD tier)',
     'Goo Goo Dolls/Dizzy up the Girl/01-11 Iris CD.flac'),
    ('djo', 'Djo', 'End of Beginning (CD tier)',
     'Djo/End of Beginning/01-01 End of Beginning.flac'),
]

BAND_WINDOW_S = 60      # centred excerpt for the per-band FFT, see band_window()

# Spotify publishes three Vorbis bitrates and most listeners are not on the
# lowest one. Measuring only 96 kbps would be measuring the worst case and
# calling it the platform, which is the first thing anyone would say about it,
# and they would be right. 96 stays in as the floor.
EXTRA_SPECS = {
    'spotify_160': {'encoder': 'libvorbis', 'bitrate': '160k', 'ext': 'ogg',
                    'label': 'Ogg Vorbis 160 kbps'},
    'spotify_320': {'encoder': 'libvorbis', 'bitrate': '320k', 'ext': 'ogg',
                    'label': 'Ogg Vorbis 320 kbps'},
}


def all_specs():
    """The product's three, plus the two extra Vorbis bitrates."""
    out = dict(specs())
    out.update(EXTRA_SPECS)
    return out


def toolchain():
    """Exactly what did the encoding, so the run can be repeated or disputed.

    None of these are the encoders the platforms run. Apple uses its own AAC
    implementation and Spotify builds its own Vorbis; a page that reports what
    an encoder did has to say which encoder.
    """
    import platform
    out = {'platform': platform.platform()}
    try:
        r = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True)
        lines = (r.stdout or '').splitlines()
        out['ffmpeg'] = lines[0] if lines else ''
        for ln in lines:
            if ln.startswith('configuration:'):
                out['ffmpeg_configuration'] = ln[len('configuration:'):].strip()
    except OSError:
        out['ffmpeg'] = 'unavailable'
    for lib in ('libvorbis', 'libopus', 'aac', 'wavpack'):
        r = subprocess.run(['ffmpeg', '-hide_banner', '-h', 'encoder=' + lib],
                           capture_output=True, text=True)
        head = (r.stdout or '').splitlines()
        out.setdefault('encoders', {})[lib] = head[0].strip() if head else 'missing'
    return out


def platforms():
    """Read the platform table out of the shipped tool rather than retyping it.

    Four copies of these numbers exist in the product and a test keeps them in
    step; a fifth copy living in a study script is exactly how an article ends up
    quoting a figure the product stopped using.
    """
    import re
    path = os.path.join(HERE, '..', 'src', 'theme', 'static', 'js',
                        'sn-loudness-tool.js')
    src = open(path, encoding='utf-8').read()
    m = re.search(r'var PLATFORMS = \[(.*?)\n    \];', src, re.DOTALL)
    if not m:
        sys.exit('could not find PLATFORMS in sn-loudness-tool.js')
    out = []
    for line in m.group(1).splitlines():
        line = line.strip()
        if not line.startswith('{'):
            continue
        key = re.search(r"key:\s*'([^']+)'", line)
        name = re.search(r"name:\s*'([^']+)'", line)
        lufs = re.search(r'lufs:\s*(-?[\d.]+|null)', line)
        tp = re.search(r'tp:\s*(-?[\d.]+)', line)
        if not (key and name and lufs):
            continue
        out.append({
            'key': key.group(1),
            'name': name.group(1),
            'lufs': None if lufs.group(1) == 'null' else float(lufs.group(1)),
            'tp': float(tp.group(1)) if tp else None,
            'down_only': 'down: true' in line,
            'normalizes': 'norm: false' not in line,
        })
    return out


def platform_gain(master_lufs, p):
    """The gain a platform applies. Arithmetic, not a model: target minus master,
    floored at zero where the platform only turns things down."""
    if not p['normalizes'] or p['lufs'] is None or master_lufs is None:
        return 0.0
    delta = p['lufs'] - master_lufs
    return min(0.0, delta) if p['down_only'] else delta


def band_window(x, sr):
    """A centred excerpt for the per-band FFT.

    The residual itself is computed on the whole track. The band breakdown is
    not, because a 192 kHz five-minute file is a 55-million-point transform and
    the same excerpt length on every track is more comparable than a full
    transform whose resolution changes with the length of the song.
    """
    n = int(BAND_WINDOW_S * sr)
    if len(x) <= n:
        return 0, len(x)
    start = (len(x) - n) // 2
    return start, start + n


def our_true_peak(path):
    """Our 4x FFT-upsampled true peak, plus the sample peak it sat above.

    Both this and ffmpeg's ebur128 are recorded for every file. Across the
    seventeen they agree within 0.11 dB at every rate from 44.1 to 192 kHz,
    which is the answer to anyone who would rather not trust a filter we wrote.

    An earlier version of this comment claimed ebur128 stops oversampling at
    high rates. It does not. That was inferred from one 192 kHz file where the
    true peak sits on the sample peak - which is what a densely sampled file
    looks like, not what a broken meter looks like.
    """
    try:
        _peaks, sample_peak, tp, _tp_data = generate_track_peaks(path)
        return tp, sample_peak
    except Exception as e:                                   # pragma: no cover
        print('    true peak failed: %s' % e)
        return None, None


def source_metrics(path):
    loud = analyze_loudness(path) or {}
    tp, sample_peak = our_true_peak(path)
    st_max = loud.get('short_term_max')
    info = sf.info(path)
    return {
        'sample_rate': info.samplerate,
        'channels': info.channels,
        'subtype': info.subtype,
        'duration_s': round(info.frames / info.samplerate, 3),
        'lufs': loud.get('lufs'),
        'lra': loud.get('lra'),
        'short_term_max': st_max,
        'true_peak_4x': tp,
        'sample_peak': sample_peak,
        'true_peak_ebur128': loud.get('true_peak'),
        'dynamic_range': analyze_dynamic_range(path),
        'psr': (tp - st_max) if (tp is not None and st_max is not None) else None,
    }


def delivery_rate(spec):
    """The rate the encoder is actually fed.

    Nobody hands Spotify a 192 kHz file and gets 192 kHz Ogg back: the platform
    resamples first, and the streams are 44.1 kHz for Vorbis and AAC and 48 kHz
    for Opus. Feeding the encoder the file's own rate would measure something no
    listener receives - and libvorbis refuses 96 kHz at these bitrates anyway,
    which is how this got noticed.

    Nulling happens at this rate too, against a reference resampled to it, so
    the residual is the codec and not the sample-rate conversion in front of it.
    For a 44.1 kHz source that makes this identical to the published script.
    """
    return 48000 if spec['encoder'] == 'libopus' else 44100


def encode_spec(src, dst, spec):
    """As the published script's encode(), plus an explicit sample format.

    The references here are 32-bit float so that resampling a master sitting at
    full scale cannot clip on the way in. FLAC has no float mode, so ffmpeg
    quietly picks 16-bit for the lossless control and the control then reports
    -97 dB - which is 16-bit quantisation noise wearing the costume of a broken
    method. The three real codecs take float happily and are unaffected.
    """
    cmd = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', src,
           '-map', '0:a:0', '-c:a', spec['encoder']]
    spec['_argv'] = cmd  # filled in below, recorded so the run can be repeated
    if spec.get('bitrate') and spec['bitrate'] != '0':
        cmd += ['-b:a', spec['bitrate']]
    if spec['encoder'] == 'aac':
        cmd += ['-profile:a', 'aac_low']
    if spec.get('sample_fmt'):
        cmd += ['-sample_fmt', spec['sample_fmt']]
    full = cmd + [dst]
    spec['_argv'] = ' '.join(full[:-1] + ['out.' + spec['ext']])
    run_ff(full)


def run_ff(cmd):
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError((r.stderr or 'ffmpeg failed')[:400])


def codec_pass(ref_path, spec, tmp, sr):
    """Encode, decode, align, subtract. Same steps as the published script.

    ``ref_path`` is the master already resampled to the encoder's delivery rate
    and ``sr`` is that rate.
    """
    enc = os.path.join(tmp, 'e.' + spec['ext'])
    dec = os.path.join(tmp, 'd.wav')
    for p in (enc, dec):
        if os.path.exists(p):
            os.remove(p)
    encode_spec(ref_path, enc, spec)
    to_wav(enc, dec, sr)

    ref, _ = sf.read(ref_path, always_2d=True, dtype='float64')
    ref = ref[:, 0]
    test, _ = sf.read(dec, always_2d=True, dtype='float64')
    test = test[:, 0]

    lag = align(ref, test)
    if lag >= 0:
        a, b = ref[:len(ref) - lag], test[lag:lag + len(ref) - lag]
    else:
        a, b = ref[-lag:], test[:len(ref) + lag]
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]

    # A codec can hand back a fraction of a dB off level, and uncorrected that
    # reads as damage. Both figures are reported so neither has to be trusted.
    g = float(np.dot(a, b) / np.dot(b, b)) if np.dot(b, b) else 1.0
    residual_raw = db(rms(a - b) / rms(a))
    diff_gain = a - b * g
    residual = db(rms(diff_gain) / rms(a))

    lo, hi = band_window(a, sr)
    bands = band_residual(a[lo:hi], diff_gain[lo:hi], sr)

    enc_tp_4x, enc_sample_peak = our_true_peak(dec)
    out = {
        'label': spec['label'],
        'lag_samples': int(lag),
        'gain_correction_db': db(abs(g)),
        'residual_db': None if residual == -np.inf else round(residual, 2),
        'residual_uncorrected_db': (None if residual_raw == -np.inf
                                    else round(residual_raw, 2)),
        'bands': [{'lo': lo_, 'hi': hi_,
                   'rel_db': None if r == -np.inf else round(r, 1)}
                  for (lo_, hi_), r in bands],
        'true_peak_4x': enc_tp_4x,
        'sample_peak': enc_sample_peak,
        'true_peak_ebur128': true_peak_ebur128(dec),
        'encoded_bytes': os.path.getsize(enc),
        'command': spec.get('_argv'),
    }
    del ref, test, a, b, diff_gain
    return out


def study_track(key, artist, title, path, tmp, quick=False):
    print('  %s' % os.path.basename(path))
    t0 = time.time()
    row = {'key': key, 'artist': artist, 'title': title,
           'file': os.path.basename(path)}
    row['source'] = source_metrics(path)

    # One resampled reference per delivery rate, reused by every encoder that
    # feeds off it. Measured as well as encoded: the platform's own downsample
    # moves the peaks before the codec has touched anything, and separating
    # those two steps is most of what makes the codec figure mean something.
    refs = {}
    for rate in sorted({delivery_rate(s) for s in all_specs().values()} | {44100}):
        rp = os.path.join(tmp, 'ref%d.wav' % rate)
        to_wav(path, rp, rate)
        tp, sp = our_true_peak(rp)
        refs[rate] = rp
        row.setdefault('resampled', {})[str(rate)] = {
            'true_peak_4x': tp, 'sample_peak': sp,
        }

    # The control runs first and prints first. A pipeline that cannot return
    # "identical" when the answer is identical is not measuring anything, so if
    # this is not far below the codec rows the rest of the row is decoration.
    # WavPack rather than FLAC, because FLAC has no float mode and several of
    # these masters land above full scale once they are resampled to 44.1 kHz.
    # Writing those to a fixed-point file clips them, and the control then
    # reports the clipping as if the method were broken. WavPack round-trips
    # float bit-identically, so the control measures only what it is for.
    row['control'] = codec_pass(refs[44100],
                                {'encoder': 'wavpack', 'bitrate': '0',
                                 'ext': 'wv',
                                 'label': 'WavPack (lossless float)'},
                                tmp, 44100)
    print('    control %s' % (row['control']['residual_db'] if
                              row['control']['residual_db'] is not None
                              else 'perfect null'))

    row['codecs'] = {}
    if not quick:
        for pkey, spec in all_specs().items():
            rate = delivery_rate(spec)
            c = codec_pass(refs[rate], spec, tmp, rate)
            c['encoder_input_rate'] = rate
            row['codecs'][pkey] = c
            print('    %-18s residual %6s dB   TP %+.2f -> %+.2f (4x, %d Hz)'
                  % (spec['label'], c['residual_db'],
                     row['resampled'][str(rate)]['true_peak_4x'] or 0.0,
                     c['true_peak_4x'] or 0.0, rate))

    row['platform_gain_db'] = {
        p['key']: round(platform_gain(row['source']['lufs'], p), 2)
        for p in platforms()
    }
    row['seconds'] = round(time.time() - t0, 1)
    return row


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--root', default=DEFAULT_ROOT)
    ap.add_argument('--only', help='one manifest key, for iterating')
    ap.add_argument('--quick', action='store_true',
                    help='source metrics and control only, no codec passes')
    # Only ./src, ./media and ./scripts are mounted into the running web
    # container, so `docker compose exec` needs somewhere else for the results
    # to land. `docker compose run -v` (see the module docstring) can mount
    # docs/ directly and this can be left alone.
    ap.add_argument('--out', default=OUT_DIR)
    args = ap.parse_args()
    out_dir = args.out

    if not shutil.which('ffmpeg'):
        sys.exit('ffmpeg not found')

    # Every row in this study reports "aligned +0 samples", because ffmpeg
    # compensates encoder delay and Opus pre-skip when it decodes. That is also
    # precisely what a dead aligner looks like, so it has to prove itself on
    # known shifts before any of those zeros are believed.
    check_alignment()

    entries = [e for e in MANIFEST if not args.only or e[0] == args.only]
    pairs = [e for e in PAIRS if not args.only or e[0] == args.only]

    missing = [p for _, _, _, p in entries + pairs
               if not os.path.exists(os.path.join(args.root, p))]
    if missing:
        sys.exit('missing:\n  ' + '\n  '.join(missing))

    tmp = tempfile.mkdtemp(prefix='chartstudy-')
    result = {
        'measured_at': time.strftime('%Y-%m-%d'),
        'chart': 'Spotify Global, week of 2026-08-06',
        'selection': 'one track per artist, in chart order',
        'source': 'purchased lossless downloads (Qobuz, Japanese store)',
        'band_window_s': BAND_WINDOW_S,
        'toolchain': toolchain(),
        'tracks': [],
        'pairs': [],
    }

    print('tracks')
    for key, artist, title, rel in entries:
        result['tracks'].append(
            study_track(key, artist, title, os.path.join(args.root, rel),
                        tmp, args.quick))

    # The second tier of the two tracks sold in two. Source metrics only: the
    # question is whether the master differs, which loudness and range answer,
    # and running the codecs on both tiers would double the table for nothing.
    print('second tiers')
    for key, artist, title, rel in pairs:
        p = os.path.join(args.root, rel)
        print('  %s' % os.path.basename(p))
        result['pairs'].append({'key': key, 'artist': artist, 'title': title,
                                'file': os.path.basename(p),
                                'source': source_metrics(p)})

    os.makedirs(out_dir, exist_ok=True)
    out_json = os.path.join(out_dir, 'chart_codec_study.json')
    with open(out_json, 'w', encoding='utf-8') as f:
        json.dump(result, f, indent=2, ensure_ascii=False)
    print('\nwrote %s' % out_json)

    bad = [t['key'] for t in result['tracks']
           if t['control']['residual_db'] is not None
           and t['control']['residual_db'] > -120]
    if bad:
        print('WARNING: the lossless control did not null on: %s' % ', '.join(bad))
        print('Nothing else in this file means anything until it does.')


if __name__ == '__main__':
    main()
scripts/chart_codec_apple.py
"""Add an Apple-encoder AAC column to the chart study, measured on macOS.

The main run happens in the container, where ffmpeg has libvorbis. Its AAC
column is ffmpeg's own encoder, which is not what Apple Music is served with,
and saying so in the copy is honest but weaker than measuring the real one.
macOS ships Apple's AAC encoder as afconvert, so this pass runs on the host and
merges a second AAC column into the same JSON.

    scripts/chart_codec_apple.py [--root DIR]

Requires afconvert (macOS), ffmpeg for decoding, and numpy/soundfile. It reads
docs/data/chart_codec_study.json, adds codecs['apple_native'] to every track and
a second toolchain entry, and writes the file back. Run it after the main study,
never before: it will not create rows it does not find.
"""

import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile

import numpy as np
import soundfile as sf

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, '..', 'src'))

from codec_null_test import align, band_residual, db, rms, to_wav   # noqa: E402
from chart_codec_study import (                                     # noqa: E402
    BAND_WINDOW_S, DEFAULT_ROOT, MANIFEST, band_window, our_true_peak,
    true_peak_ebur128,
)

DATA = os.path.join(HERE, '..', 'docs', 'data', 'chart_codec_study.json')

# Constrained VBR at 256 kbps with the codec's quality dial at maximum. Apple
# Music is served as 256 kbps VBR AAC; this is that bitrate through Apple's
# encoder rather than a guess at Apple's exact internal settings, and the
# command line goes into the data so the choice is visible.
AFCONVERT = ['-f', 'm4af', '-d', 'aac', '-b', '256000', '-q', '127', '-s', '2']


def run(cmd):
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(' '.join(cmd[:4]) + ': ' + (r.stderr or '')[:300])


def apple_version():
    r = subprocess.run(['afconvert', '--help'], capture_output=True, text=True)
    first = [ln.strip() for ln in (r.stdout or '').splitlines() if ln.strip()]
    return {
        'tool': 'afconvert (Apple CoreAudio)',
        'version': next((ln for ln in first if ln.startswith('Version')), ''),
        'host': platform.platform(),
    }


def measure(src_path, tmp):
    ref_path = os.path.join(tmp, 'ref.wav')
    enc = os.path.join(tmp, 'a.m4a')
    dec = os.path.join(tmp, 'a.wav')
    for p in (ref_path, enc, dec):
        if os.path.exists(p):
            os.remove(p)

    to_wav(src_path, ref_path, 44100)
    cmd = ['afconvert'] + AFCONVERT + [ref_path, enc]
    run(cmd)
    to_wav(enc, dec, 44100)

    ref, _ = sf.read(ref_path, always_2d=True, dtype='float64')
    ref = ref[:, 0]
    test, _ = sf.read(dec, always_2d=True, dtype='float64')
    test = test[:, 0]

    lag = align(ref, test)
    if lag >= 0:
        a, b = ref[:len(ref) - lag], test[lag:lag + len(ref) - lag]
    else:
        a, b = ref[-lag:], test[:len(ref) + lag]
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]

    g = float(np.dot(a, b) / np.dot(b, b)) if np.dot(b, b) else 1.0
    diff = a - b * g
    residual = db(rms(diff) / rms(a))
    lo, hi = band_window(a, 44100)
    tp, sample_peak = our_true_peak(dec)
    return {
        'label': 'AAC-LC 256 kbps (Apple encoder)',
        'lag_samples': int(lag),
        'gain_correction_db': db(abs(g)),
        'residual_db': None if residual == -np.inf else round(residual, 2),
        'bands': [{'lo': l_, 'hi': h_,
                   'rel_db': None if r == -np.inf else round(r, 1)}
                  for (l_, h_), r in band_residual(a[lo:hi], diff[lo:hi], 44100)],
        'true_peak_4x': tp,
        'sample_peak': sample_peak,
        'true_peak_ebur128': true_peak_ebur128(dec),
        'encoded_bytes': os.path.getsize(enc),
        'encoder_input_rate': 44100,
        'command': ' '.join(['afconvert'] + AFCONVERT + ['in.wav', 'out.m4a']),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--root', default=DEFAULT_ROOT)
    args = ap.parse_args()
    for tool in ('afconvert', 'ffmpeg'):
        if not shutil.which(tool):
            sys.exit('%s not found; this pass only runs on macOS' % tool)

    d = json.load(open(DATA, encoding='utf-8'))
    by_key = {t['key']: t for t in d['tracks']}
    paths = {k: os.path.join(args.root, rel) for k, _a, _t, rel in MANIFEST}

    tmp = tempfile.mkdtemp(prefix='apple-aac-')
    for key, track in by_key.items():
        src = paths.get(key)
        if not src or not os.path.exists(src):
            sys.exit('missing audio for %s' % key)
        r = measure(src, tmp)
        track['codecs']['apple_native'] = r
        ffm = track['codecs']['apple']
        print('%-22s ffmpeg %6.1f dB / TP %+.2f    Apple %6.1f dB / TP %+.2f'
              % (track['artist'], ffm['residual_db'], ffm['true_peak_4x'],
                 r['residual_db'], r['true_peak_4x']))

    d.setdefault('toolchain', {})['apple'] = apple_version()
    with open(DATA, 'w', encoding='utf-8') as f:
        json.dump(d, f, indent=2, ensure_ascii=False)
    print('\nmerged apple_native into %s' % DATA)


if __name__ == '__main__':
    main()

All three download as plain .py: chart_codec_study.py, chart_codec_apple.py and codec_null_test.py, which the other two import. They expect ffmpeg, numpy and soundfile, and a directory of audio you own.

Use this

The measurements are published under CC BY 4.0. Take the numbers, redraw the charts, disagree with the conclusions in public. Credit soneam.com and link back so a reader can check what you took.

Cite this

Maeda, T. (2026). Seventeen chart tracks, measured after the codec. Soneam. https://soneam.com/guides/streaming-codec-true-peak-measured/

@misc{soneam2026codec,
  author = {Maeda, Takahiro},
  title  = {Seventeen chart tracks, measured after the codec},
  year   = {2026},
  url    = {https://soneam.com/guides/streaming-codec-true-peak-measured/},
  note   = {Dataset: https://soneam.com/guides/streaming-codec-true-peak-measured/data.json}
}

About the author

I am Takahiro Maeda. I write software for a living and master records on the side, which is the order those two things go in and the reason this page is a measurement rather than an opinion. Soneam is the software: it is for sending finished masters to clients and getting them signed off, and one of its features plays a master through the codecs measured here.

So there is an interest to declare, and it is declared: the measurement code, the raw data and every encoder command line are on this page, and the conclusions are checkable without taking my word for any of it. Corrections are welcome at the contact address, and if you find something wrong here I would rather hear it than not.

Common questions

How loud are chart tracks in 2026?

Sixteen of these seventeen sit between −5.1 and −11.1 LUFS integrated, with a median of −8.1. The seventeenth is Billie Jean at −17.3, a 1982 master in a 2026 chart. Eight of the seventeen were delivered above 0 dBTP and sixteen above −1 dBTP.

Does a streaming codec raise true peak on real chart masters?

On this set it did on ninety-four of the hundred and two passes. Vorbis at 96 kbps raised it on all seventeen by a median of 2.13 dB, Vorbis at 160 by 1.28, and Opus at 128 by 1.63. The two gentlest were Vorbis at 320, which raised it by a median of 0.10 dB, and Apple's AAC encoder at 256, by 0.35.

Does the bitrate a listener streams at change what reaches them?

On this set it changed it more than the choice of codec did. Between Vorbis at 96 kbps and Vorbis at 320, on the same encoder and the same files, the residual against the source improves by a median of 16.2 dB and the true peak lift falls by a median of 1.78 dB. Both bitrates are Spotify, and which one a listener gets depends on their tier and their connection.

Does the AAC encoder you use change the result?

On this set, more than the choice between AAC and Vorbis did. The same codec at the same 256 kbps on the same files, through ffmpeg and through Apple's own encoder, gives a median residual of −25.2 dB against −30.3 and a median true peak lift of 2.27 dB against 0.35. Apple's is closer to the source on all seventeen and lifts the peak less on sixteen. Measuring codecs with whatever encoder is installed measures the installation as well as the codec.

Does the platform resample before it encodes, and does that matter?

Six of the seventeen are sold above 44.1 kHz, and the streams are 44.1 kHz for Vorbis and AAC and 48 kHz for Opus, so the file is resampled first. The sample peak moves when it is. On three of those six it moved above 0 dBFS before any codec ran: from −0.30 to +0.08, from −0.15 to +0.29, and from −0.20 to +0.74.

Is a high-resolution download a different master from the CD version?

For the two tracks here that were sold in both tiers, no. Integrated loudness and loudness range are identical to two decimal places in both pairs. One of them is a 1998 catalog title, the other a recent release included as a control, and both behaved the same way. The only difference is true peak, and it is the size the 44.1 kHz resample accounts for.

How do you know the measurement is not measuring itself?

A lossless float round trip runs through the same align-and-subtract pipeline on every track and returns a perfect null on all seventeen. The aligner is separately asked to find known shifts before the run starts, because every row reports zero offset and a column of zeros is also what a dead aligner looks like. True peak is quoted from our own oversampler, which agrees with ffmpeg's ebur128 within 0.11 dB on every track at every rate from 44.1 to 192 kHz.

Share this guide

Get sign-off without the back-and-forth

Soneam turns a master into a shareable review link. Your client hears a lossless preview at streaming loudness, leaves feedback pinned to the waveform, and approves, with no account, no MP3, and a link that expires when you decide, not when a free plan does.