iPhone16で空間オーディオを使った動画から再エンコードせずに空間オーディオを削除したい

# 概要
iPhone16シリーズから、ビデオを撮影する時は自動的に空間オーディオがオンになっている。

空間オーディオを使うことで臨場感が増したり、後から編集で映像の画角に収まっていない音を除去したりとiPhone単体でかなりリッチな録音ができるようになっているらしい。

これを実現するために、Appleは動画ファイルに既存からあるAACに加えて「Apple Positional Audio Codec」(APAC)という新しい音声データを追加した。

しかしこのAPACの含まれた動画を取り込めないことがあったため、除外する方法を検討した。

本来は、PHAssetからAVAssetを取得する際に空間オーディオを除外するオプションがあれば望ましいが、Appleは空間オーディオが含まれるファイルとそうでないファイルをあえて同一に扱う方針をとっている。

そのため、外部から空間オーディオのファイルかどうかを識別する手段が用意されておらず、基本的には動画ファイル自体を再構築する方法が求められる。

# 解決方法
基本的にはAPACを除去した新たなAVAssetを作成していく。

#### 1. 問題の動画をAVAsset形式で取得する
今回はAVFoundationの機能を使うため、まず対象の動画を`AVAsset`形式で取得する。
（この手順の詳細は趣旨と異なるため省略する）

#### 2. AVAssetから空間オーディオ(APAC)ではない音声トラックを取り出す
動画ファイル内の音声は、通常のAAC音声と空間オーディオ（APAC）の音声の2種類が`AVAssetTrack`として格納されている。

現状、iPhone16で撮影された動画ではこの2種類のみが含まれているため、空間オーディオを除去したい場合は「APACではない音声トラック」を抽出すればよい。

この時音声トラックからフォーマットの情報を取得して、そのトラックが空間オーディオなのか調べる必要があるが、Cで実装されているCoreMediaの仕組みを使う必要があったため以下のようにextensionでラップしてみた。
    
```Swift
extension AVAssetTrack {
    func isSpatialAudioTrack() async throws -> Bool {
        let formatDescriptions = try await self.load(.formatDescriptions)
        for formatDescription in formatDescriptions {
            if CMFormatDescriptionGetMediaSubType(formatDescription) == FourCharCode("apac") {
                return true
            }
        }
        return  false
    }
}
```

このextensionを使い、`AVAsset`からAPACでない音声トラックを抽出する。
```Swift
let audioTracks = try await asset.loadTracks(withMediaType: .audio)

var normalAudioTrack: AVAssetTrack?
for audioTrack in audioTracks {
    guard try await !audioTrack.isSpatialAudioTrack() else { continue }
    normalAudioTrack = audioTrack
}
```
#### 3. 映像トラックを取得する
映像トラックはAPACやAACといった音声トラックと違い、特に区別がないため、単純に取得する。
```Swift
let videoTrack = try await asset.loadTracks(withMediaType: .video).first
```

#### 3. `AVMutableComposition`を使って新しいAVAssetを作成する
AVFoudationには`AVMutableComposition`という`AVAssetTrack`を切って貼ってして新しく`AVAsset`を作れるクラスが用意されているためそれを使って、取り出した音声トラックと映像トラックを組み合わせた`AVAsset`を作成する。
```Swift
let composition = AVMutableComposition()

let newAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
try newAudioTrack?.insertTimeRange(CMTimeRange.init(start: .zero, duration: duration), of: normalAudioTrack, at: .zero)

let newVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
try newVideoTrack?.insertTimeRange(CMTimeRange.init(start: .zero, duration: duration), of: videoTrack, at: .zero)
```

# 完成コード
無駄な処理を減らすため、実際に使う場合は手前で動画ファイルにAPACが含まれているか確認を行った方が良い。
```Swift
private extension AVAssetTrack {
    func isSpatialAudioTrack() async throws -> Bool {
        let formatDescriptions = try await self.load(.formatDescriptions)
        for formatDescription in formatDescriptions {
            if CMFormatDescriptionGetMediaSubType(formatDescription) == FourCharCode("apac") {
                return true
            }
        }
        return  false
    }
}

func removeSpatialAudioTracks(from asset: AVAsset) async throws -> AVAsset {
    let audioTracks = try await asset.loadTracks(withMediaType: .audio)

    var normalAudioTrack: AVAssetTrack?
    for audioTrack in audioTracks {
        guard try await !audioTrack.isSpatialAudioTrack() else { continue }
        normalAudioTrack = audioTrack
    }

    guard let normalAudioTrack = normalAudioTrack else { return asset }

    guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else { return asset }
    let duration = try await asset.load(.duration)

    let composition = AVMutableComposition()

    let newAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
    try newAudioTrack?.insertTimeRange(CMTimeRange.init(start: .zero, duration: duration), of: normalAudioTrack, at: .zero)

    let newVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
    try newVideoTrack?.insertTimeRange(CMTimeRange.init(start: .zero, duration: duration), of: videoTrack, at: .zero)

    return composition
}
```