Sound effects in a small game usually come from many places: a pack bought years ago, a free library, a few sounds recorded at home, one made in a synthesizer at 2 a.m. Each is fine on its own. Together, the fireball drowns the dialogue, the coin pickup is louder than the explosion, and the looping wind under the level has a click every four seconds. This guide shows how to measure loudness the way broadcast and streaming do, how to apply it to short game sounds, and how to check loops. Every script here was run with Python and ffmpeg on real files.
#Why peak normalisation does not work
Most editors offer "normalise", which raises a file until its loudest sample reaches a level such as -1 dBFS. That makes every file use the full range, not sound equally loud. The ear responds to energy over time and to frequency, not to the single highest sample.
We measured two sounds from our own spell pack that were levelled to the same loudness. The lightning impact peaks at -1.6 dB, the ice cast at -7.7 dB, six dB apart, and they sound about equally loud. Peak-normalise both to -1 and the ice cast becomes nearly 7 LU louder than the lightning (LU is a loudness unit, about one decibel). That is the coin-louder-than-the-explosion effect, produced by a button that was supposed to make things consistent.
#LUFS, and which LUFS for a short sound
Loudness in LUFS (loudness units relative to full scale) comes from ITU-R BS.1770, the method behind broadcast and streaming loudness. It weights frequencies roughly the way the ear does, so deep bass counts less and the presence range counts more, and measures energy over time. The EBU meter defines three readings:
- Momentary: the last 400 milliseconds.
- Short-term: the last 3 seconds.
- Integrated: the whole file, with quiet parts gated out.
Integrated loudness is right for music and dialogue. For a one-second impact it misleads, because the tail and the silence after the hit pull the average down. In our measurements, a heavy fire impact reads -23.1 LUFS integrated but -16.0 at its loudest moment, while an ice cast with a softer tail reads -18.2 integrated and also -16.0 at its loudest. Levelled by integrated loudness, the impact would be raised by about five units and come out much louder than the cast. For one-shots, level the maximum momentary loudness: the loudest 400 milliseconds, which is roughly what the player hears as "how loud was that". For loops and ambiences that play for a long time, integrated loudness is the right reading.
#Measuring with ffmpeg
ffmpeg's ebur128 filter is a complete EBU meter, including true peak. This script prints the three numbers for each file:
import re
import subprocess
import sys
def measure(path):
"""Integrated and max momentary loudness (LUFS) and true peak (dBTP) of one file."""
log = subprocess.run(
["ffmpeg", "-nostdin", "-nostats", "-i", path,
"-af", "apad=pad_dur=0.5,ebur128=peak=true:framelog=info", "-f", "null", "-"],
capture_output=True, text=True, check=True).stderr
momentary = [float(m) for m in re.findall(r"\bM:\s*(-?[\d.]+)", log)]
summary = log[log.rfind("Summary:"):]
integrated = float(re.search(r"I:\s*(-?[\d.]+)\s*LUFS", summary).group(1))
peak = float(re.search(r"Peak:\s*(-?[\d.]+)\s*dBFS", summary).group(1))
return integrated, max(momentary), peak
def normalize(src, dst, target=-16.0, ceiling=-1.0):
"""One-shot: max momentary loudness to target, unless the true peak would pass the ceiling."""
_, momentary, peak = measure(src)
gain = min(target - momentary, ceiling - peak)
subprocess.run(["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", src,
"-af", f"volume={gain:.2f}dB", "-c:a", "pcm_s24le", dst], check=True)
return gain
if __name__ == "__main__":
for path in sys.argv[1:]:
i, m, tp = measure(path)
print(f"{path}: integrated {i:.1f} LUFS, max momentary {m:.1f} LUFS, true peak {tp:.1f} dBTP")
Two details are easy to miss. The apad filter adds half a second of silence, because the momentary meter needs a full 400 ms window and a very short click would otherwise never be measured completely. And peak=true turns on true peak measurement; without it you get sample peaks, which is the next problem.
normalize() raises or lowers a file so its loudest moment hits the target, but never so far that the true peak passes the ceiling. If the ceiling wins, the sound ends up quieter than the target, and that is a decision for your ears: a limiter can make it louder, at a cost to the attack. A pink noise burst and a 60 Hz thump we made for the test both came out at exactly -16.0 LUFS momentary; the burst needed 11.6 dB more and the thump 13.1, because the low thump counts less in the weighting. Their true peaks ended at -2.9 and -5.9 dB, safely under the ceiling.
#True peak, and what happens after you export
A digital file stores samples, but the speaker gets a smooth wave rebuilt between them, and that wave can rise above the highest sample. Such "inter-sample peaks" clip in the converter or in the next step. Lossy encoding adds to it: the Ogg version of our fire impact measures 0.1 dB higher in true peak than the WAV. Keep the true peak at or below -1 dBTP in the master files, and measure again after converting to Ogg, MP3 or your engine's compressed format.
#Pick targets and keep them
There is no single standard for game sound effect files. EBU R128 sets -23 LUFS for whole broadcast programmes, and games often mix louder, especially on phones. What matters more than the exact number is that every sound of a kind is levelled to the same target, and that the kinds have a fixed relationship. In our pack, one-shots are levelled to -16 LUFS maximum momentary, projectile loops to -20 LUFS integrated and long aura loops to -24, all with true peak at or below -1 dBTP. A spell cast, its impact and a loop running underneath then sit at sensible levels without touching a fader, and the game's mixer handles the rest.
#Loops that do not click
A loop clicks when the last sample and the first do not meet: the wave jumps, and the speaker plays the jump as a click. This check compares the jump at the loop point with the jumps inside the file:
import array
import subprocess
import sys
def samples(path):
raw = subprocess.run(["ffmpeg", "-nostdin", "-loglevel", "error", "-i", path,
"-f", "f32le", "-ac", "1", "-"], capture_output=True, check=True).stdout
return array.array("f", raw)
def seam_ratio(x):
"""Jump from the last sample to the first, against the 99.9th percentile of jumps inside."""
steps = sorted(abs(b - a) for a, b in zip(x, x[1:]))
typical = steps[int(len(steps) * 0.999)] + 1e-12
return abs(x[0] - x[-1]) / typical
if __name__ == "__main__":
for path in sys.argv[1:]:
print(f"{path}: seam {seam_ratio(samples(path)):.2f}")
A value of 1 or less means the loop point is as smooth as any other point. A 440 Hz tone exactly one second long, which holds a whole number of cycles, gives 1.00; the same tone 0.99 seconds long gives 8.54 and clicks audibly. Our fire projectile loop gives 0.03 as WAV and 0.04 as Ogg.
Know the limit of the test: in noisy material it can pass a bad cut by chance. We cut the same fire loop at three arbitrary points and got values between 0.20 and 0.74, although those cuts change the rhythm of the sound. So run the check to catch clicks, and still listen to every loop for at least three repeats.
The reliable way to make a loop is to render or record a little more than you need and fold the extra tail onto the start with an equal-power crossfade, so the end flows into the beginning by construction. Keep loops in WAV or Ogg Vorbis: our two-second Ogg loop decodes to exactly 96,000 samples, like the WAV. MP3 files carry encoder delay and padding; some decoders remove it using information in the file and some do not, so an MP3 loop that is perfect in one engine can have a gap in another.
#A pack built this way
Our sound pack Zaklinka follows these rules: 97 magic and spell sound effects in ten schools, each with casts, a charge-up, three impacts and seamless projectile and aura loops, plus 17 general spell sounds, as 48 kHz 24-bit WAV and Ogg Vorbis. Every file is levelled to the targets above and measured with the same ffmpeg meter, loops are checked at the seam, and a manifest lists each file's loudness and true peak. The sounds are made entirely in code, with no recordings and no generative audio models, and were checked by measurement and spectrograms rather than by a listening session, so listen through the preview page before you pick them. It is listed as Coming soon.
#Frequently asked questions
What loudness should my game sound effects have?
There is no single standard. Choose targets per kind of sound (for example -16 LUFS maximum momentary for one-shots and lower integrated targets for loops), level every file to them, and keep true peak at or below -1 dBTP.
Why not use integrated LUFS for short sounds?
Because the tail and silence pull the average down, so sounds with long tails get raised too much. The maximum momentary reading follows what the player hears as the hit.
Do I need to measure again after converting to Ogg?
Yes, at least the true peak. Lossy encoding changes the wave slightly and can raise inter-sample peaks; in our test the Ogg file peaked 0.1 dB higher.
Why does my loop click in the engine but not in the editor?
Often the file format: MP3 adds encoder delay and padding that not every decoder removes. Use WAV or Ogg Vorbis for loops and check the seam.
Comments
Comments
Be the first to leave a comment.
Leave a comment