Your iPhone video has two audio tracks
TL;DR — iPhone recordings contain two audio tracks: a 2-channel AAC track that is enabled, and a 4-channel APAC (spatial audio) track that is disabled. If your
AVAssetWriterexport passes only the first track, you silently drop spatial audio. If it passes both and leaves them both enabled, players play both at once and the result sounds like an echo — louder, but harder to understand. Pass every track, and copy each source track’sisEnabledonto its output:input.marksOutputTrackAsEnabled = try await track.load(.isEnabled)
What I was building
VertiCut takes a horizontal video and crops it to vertical, following a person. The video is re-encoded frame by frame with AVAssetReader → Core Image → AVAssetWriter. The audio doesn’t need processing at all, so it’s passed through untouched.
Passing audio through sounds like the one part you can’t get wrong. I got it wrong twice.
What’s actually in the file
I inspected the audio tracks of all seven test recordings from an iPhone. Every one had the same layout:
| Track | Codec | Channels | isEnabled |
What it is |
|---|---|---|---|---|
| 0 | aac |
2 | true | Stereo track for compatibility |
| 1 | apac |
4 | false | Apple Positional Audio Codec — spatial audio |
The spatial-audio track is present but disabled. When you play the original, the player plays only the AAC track.
That one false is the whole story. There are two ways to mishandle it, and I did both.
Mistake 1: pass only the first track
My first implementation did what most sample code does:
let audioTrack = try await asset.loadTracks(withMediaType: .audio).first
That picks up the stereo AAC track and throws away spatial audio entirely. Nothing fails. The output plays fine. It just quietly has less in it than the input.
I’d written this down as a known limitation — “audio passes only the first track; degrades on spatial-audio (4ch) recordings” — without knowing yet that every iPhone recording in my set was a spatial-audio recording.
Mistake 2: pass every track, all enabled
So I fixed it the obvious way: loop over every audio track and pass each one through.
On device, the output sounded like it had an echo. Quieter, muddier, harder to follow speech.
What had happened: an AVAssetWriterInput creates an enabled track by default. The APAC track, disabled in the source, came out enabled. Now the player played both tracks at the same time — the stereo mix and the 4-channel spatial mix downmixed to stereo.
My reading of why that sounds the way it does (an explanation of what I heard and measured, not something I verified against Apple’s decoder):
- The 4-channel mix carries more of the room — surround components, mostly reverb. Downmixing it on top of the stereo track raises the ratio of reverb to direct sound.
- The same sound reconstructed through two different codecs and added together produces a comb filter.
The measured level went up. On one recording, the enabled audio in the output was 3.2 dB louder than the original. Yet it sounded quieter and less clear — louder but muddier. The effect was strongest on recordings with low input gain.
The fix
Pass every track, and copy each source track’s enabled flag onto the output track:
for track in try await asset.loadTracks(withMediaType: .audio) {
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
input.marksOutputTrackAsEnabled = try await track.load(.isEnabled)
writer.add(input)
// ...
}
Note the form. Not “enable the first track, disable the rest.” Copy what the source says. That way the flags match the original by construction, not by the accident of track order.
This is also what Apple’s own export does. I checked:
| Method | Result |
|---|---|
AVAssetExportPresetPassthrough (.mov and .mp4) |
Both tracks kept, APAC enabled = false |
Re-encoding preset (HEVCHighestQuality) |
AAC only, APAC dropped |
So the passthrough preset preserves the layout exactly, and a re-encoding preset makes Mistake 1 for you.
Verifying it
RMS level of the enabled audio, in dB, original vs. output, across all seven recordings:
| s1 | s2 | s3 | s4 | s5 | s6 | s7 | |
|---|---|---|---|---|---|---|---|
| Original | −49.9 | −40.0 | −48.9 | −50.3 | −53.5 | −46.4 | −40.3 |
| Output | −49.9 | −40.0 | −48.9 | −50.3 | −53.5 | −46.4 | −40.3 |
Identical on all seven. s1 was −46.7 before the fix — that’s the 3.2 dB excess.
A test now checks on device, every run, that the output’s list of enabled flags matches the source’s. A level comparison would catch this bug too, but the flag list is the thing that was actually wrong, so that’s what the test asserts.
One difference I left in on purpose: the source has an audible selection group with one option, and my output has none. Only one track is enabled, so what plays is the same — the RMS table confirms it. Matching the group exactly would mean AVAssetWriterInputGroup, which produces two options and would differ from the source in a different way.
Two more traps nearby
One AVAssetReader per audio track. Attaching several outputs to a single reader stalls unless you drain all of them in lockstep. Video and audio — and two audio tracks — drain at different rates. A separate reader per track is the only arrangement I’ve found that can’t stall.
The preview was silent, and the file wasn’t. The app never configured AVAudioSession, so it ran under the default soloAmbient category, which obeys the ring/silent switch. With the phone on silent, the in-app preview played nothing, while the Photos app played the same file fine — Photos sets .playback. The fix was to borrow .playback only while the preview is playing, and hand it back in three places: on teardown, on scene-phase changes, and on interruption. Release with .notifyOthersOnDeactivation, or music the user was listening to stays stopped after they leave.
That second trap matters more than it looks. The preview exists so the user can check the export — and the echo bug above is precisely the kind of thing they’d need to hear.
Takeaway
“Pass the audio through unchanged” includes the track flags. A track’s samples and a track’s isEnabled are both part of the file, and AVAssetWriterInput defaults the second one to true. Copy it from the source rather than deciding it yourself.
And for anything that handles iPhone video: check how many audio tracks your test files actually have. Mine all had two. I’d been building for one.
I'm Kou (日々野 考), an indie iOS developer in Japan building on-device ML apps under kouworks. I write up the things that cost me days, so they cost you minutes.
Apps:
- VoiceKit — filler-word removal, subtitles, and summarization, entirely on-device.
- KidsMask — automatic face mosaic for photos and video; no face data ever leaves the device.
- VlogMask — real-time face mosaic camera for vlogging.
- VertiCut — turns horizontal video into vertical 9:16, following a person automatically.