import AVFoundation import Foundation enum DubbingCompositorError: LocalizedError { case noAudioTracks case cannotReadDuration var errorDescription: String? { switch self { case .noAudioTracks: return "录音没有音频轨道" case .cannotReadDuration: return "无法读取录音时长" } } } enum DubbingCompositor { static func merge(urls: [URL], outputURL: URL) async throws { try await mergeSync(urls: urls, outputURL: outputURL) } private static func mergeSync(urls: [URL], outputURL: URL) async throws { try? FileManager.default.removeItem(at: outputURL) let composition = AVMutableComposition() var currentTime = CMTime.zero for url in urls { let source = AVURLAsset(url: url) let tracks = try await source.loadTracks(withMediaType: .audio) guard let track = tracks.first else { throw DubbingCompositorError.noAudioTracks } let duration = try await source.load(.duration) guard duration.seconds.isFinite, duration.seconds > 0 else { throw DubbingCompositorError.cannotReadDuration } let timeRange = CMTimeRange(start: .zero, duration: duration) let compositionTrack = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid ) guard let compositionTrack else { throw DubbingCompositorError.noAudioTracks } try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime) currentTime = currentTime + duration } guard let exporter = AVAssetExportSession( asset: composition, presetName: AVAssetExportPresetAppleM4A ) else { throw DubbingCompositorError.noAudioTracks } exporter.outputURL = outputURL exporter.outputFileType = .m4a await exporter.export() guard exporter.status == .completed else { throw exporter.error ?? DubbingCompositorError.noAudioTracks } } }