Symptom
Swishy/watery 'phasing' artifact around the voice on short-form talking-head video, noticeably worse after uploading to TikTok than on local playback.
Root cause (measured)
- A DJI Mic 3 with one TX into an iPhone records a MONO signal stored as stereo AAC (~100 kbps): L and R are bit-identical (L-R = -inf dB on the raw file).
- Every stereo AAC re-encode quantizes in M/S (joint stereo). Rounding noise lands independently in M and S, so each generation adds L/R-DECORRELATED noise. Measured stack-up across one pipeline: gen 2 (160k) L-R peak -32 dB; gen 4 (192k) L-R peak -25.6 dB. Noise ~20 dB under the voice is audible as swish.
- TikTok adds generation 5, AND normalizes playback loudness to roughly -14..-16 LUFS. A quiet upload (-21.6 LUFS measured) gets ~+7 dB of platform gain applied to voice and noise floor alike — this is why the artifact was much worse in-app than locally.
Fix (three rules)
- Go mono at the first re-encode, but probe first. Cheap sampled check: decode 3 x 10s windows (10/50/90% of duration), filter
aeval=val(0)-val(1)|val(0)-val(1),astats, require L-R RMS < -40 dB in EVERY window. Any loud window vetoes (protects genuine two-TX split-stereo recordings). Then encode-ac 1(0.5/0.5 average is level-exact for dual-mono). A mono track gives later encoders no channel pair to decorrelate — worst case a platform upmixes to dual-mono and M/S-codes a digital-zero side channel once, instead of stacking noise across N generations. - Stop burning generations. Intermediate prep re-encodes that only exist for video (e.g. dense-keyframe passes) should use
-c:a copy, not a bare-c:a aac(which silently means 128k stereo). Bump kept encodes to 192k for headroom. - Master to platform loudness before upload: high-pass 70 Hz, linear gain to -14 LUFS integrated, true-peak limiter at ~-1.5 dBTP (
alimiter=limit=0.84:level=false), video stream-copied. At target loudness the platform applies ~0 dB gain, so it never amplifies the noise floor.
Gotchas learned the hard way
- EBU R128 sums energy across channels: dual-mono STEREO measures ~+3 LU hotter than the identical signal downmixed to mono. If you measure loudness pre-downmix and apply gain post-downmix, you land ~3 LU under target (we hit -17.1 instead of -14). Measure with the pan filter as a prefilter in the analysis pass.
- ffmpeg loudnorm JSON parsing: the JSON block in stderr is followed by trailing lines;
json.loadsonstderr[stderr.rfind('{'):]throws 'Extra data'. Usejson.JSONDecoder().raw_decodeinstead. - Prefer linear gain + limiter over loudnorm for speech: dynamic loudnorm pumps on speech pauses; linear-mode loudnorm silently undershoots when TP-capped.
- Expect the limiter to shave ~1 LU when applying large gains (+10 dB); -15 LUFS integrated against a -14 target is normal and fine.
- 'Sounds fine on my Mac' does not clear the upload: local playback skips the platform's transcode AND its normalization gain. Definitive check is round-tripping the platform's own output file and measuring L-R and LUFS on it.