AVFoundationで動画に音声を追加する

# [Swift]AVFoundationで動画に音声を追加する

関わっているアプリ開発のプロジェクトで無音動画に音声を結合する必要がありました。AVFoundationまわりの情報が少なく特にSwiftのサンプルコードなどがあまりないなと思い備忘録をかねてまとめます。
サンプルコードでは無音声動画と音声の結合をしていますが、音声ありでも処理は変わりません。その場合は差し替えの処理も可能です。

前提としてAVAsset/AVMutableCompositionをある程度理解しておく必要はあります。
[AVFoundation Programming Guide](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/AVFoundationPG/Articles/03_Editing.html)

以下サンプルコードです。（必要箇所だけ抜き出しているのでコンパイルが通るかは？）

```swift:MovieMakerViewController.swift


import UIKit
import Foundation
import AVFoundation
import AVKit


class MovieMakerViewController: UIViewController {
    
    
    let dispatchQueue = DispatchQueue(label: "queue")
    
    let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
    let videoUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("videotmp.mp4")
    let soundUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("soundtmp.caf")
    let movieUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("sample.mp4")//output
  
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
  

        let composition = AVMutableComposition.init()


        //--------------------        
        //source video
        let asset = AVURLAsset.init(url: self.videoUrl)
        let range = CMTimeRangeMake(start: CMTime.zero, duration: asset.duration)
        let videoTrack = asset.tracks(withMediaType: .video).first
        //        let audioTrack = asset.tracks(withMediaType: .audio).first//無音性動画の場合エラーになる
        
        let compositionVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
        //        let compositionAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
        
        
        try? compositionVideoTrack?.insertTimeRange(range, of: videoTrack!, at: CMTime.zero)
        //        try? compositionAudioTrack?.insertTimeRange(range, of: audioTrack!, at: CMTime.zero)
        let instruction = AVMutableVideoCompositionInstruction.init()
        instruction.timeRange = range
        let layerInstruction = AVMutableVideoCompositionLayerInstruction.init(assetTrack: compositionVideoTrack!)
        

        //-------------------- 
        //source sound
        let soundAsset = AVURLAsset.init(url: self.soundUrl)
        let soundTrack = soundAsset.tracks(withMediaType: .audio).first
        let compositionSoundTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
        try? compositionSoundTrack?.insertTimeRange(range, of: soundTrack!, at: CMTime.zero)
        

        //-------------------- 
        //composite
        let transform = videoTrack!.preferredTransform
        
        layerInstruction.setTransform(transform, at: CMTime.zero)
        instruction.layerInstructions = [layerInstruction]
        
        let videoComposition = AVMutableVideoComposition.init()
        videoComposition.renderSize = videoTrack!.naturalSize
        videoComposition.instructions = [instruction]
        videoComposition.frameDuration = CMTime.init(value: 1, timescale: 60)
        
        
        if FileManager.default.fileExists(atPath: self.movieUrl.path) {
            try? FileManager.default.removeItem(at: self.movieUrl)
        }
        
        let session = AVAssetExportSession.init(asset: composition, presetName: AVAssetExportPresetHighestQuality)
        session?.outputURL = self.movieUrl
        session?.outputFileType = .mp4
        session?.videoComposition = videoComposition
        
        session?.exportAsynchronously(completionHandler: {
            if session?.status == AVAssetExportSession.Status.completed {
                DispatchQueue.global(qos: .default).async {
                    DispatchQueue.main.async {
                        print("finished")
                    }
                }
            }
        })
        
        DispatchQueue.global(qos: .default).async {
            DispatchQueue.main.async {
                Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer: Timer) in
                    print("\(Int((session?.progress ?? 0) * 100.0))%")
                    //完了したらtimer.invalidate()を実行
                })
            }
        }
    }
    
}

```


もともとの無音声動画と音声は同じdurationという前提なので再生時間はそのまま抜き出しています。

**AVの時間情報**

```swift:MovieMakerViewController.swift
let asset = AVURLAsset.init(url: self.videoUrl)
let range = CMTimeRangeMake(start: CMTime.zero, duration: asset.duration)
```
AVAssetで動画でも音声でも時間情報を取り出すことができます。CMTimeを操作すること自由に再生時間を制御できますが、動画と音声で考え方が違います。CMTimeは動画であればフレームレート、音声であればサンプルレートを時間基準を算定します。（このあたりは機会があれば）

またこのサンプルではViewがLoadされてからメインキュー内で動画が作成されていますが、本来はメインとは別で行われるべきです。実際には自分でキューを作ってその中で処理をしていますが、サンプルコードでは省略しています。以下がその名残です。
またその場合、ユーザーに処理状況をお知らせする必要があります。UIまわりはメインキューでないと反映されないのでDispatchQueue.main.async内で処理状況を知らせるUI要素をするとよいです。進行状況はsession.progressで取得できます。

```swift:MovieMakerViewController.swift
　　　　let dispatchQueue = DispatchQueue(label: "queue")
　　　　（略）

　　　　DispatchQueue.global(qos: .default).async {
            DispatchQueue.main.async {
                Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer: Timer) in
                    print("\(Int((session?.progress ?? 0) * 100.0))%")
                    //完了したらtimer.invalidate()を実行
                })
            }
        }
```

手順としては

- AVAssetで再生時間に関する情報を抜き出す
- AVMutableCompositionにVideoTrackとAudioTrackを追加する
- AVAssetExportSessionに合成(composition)のための情報を渡してエクスポートする

これを応用すれば音声を差し替えたり、動画同士を連結することもできるようになります。それはまたの機会に。