import Foundation /// Exports a playlist as an Adobe Audition multitrack session (.sesx) file. /// Based on the real Audition .sesx XML schema (version 1.9). /// Each original track becomes a separate clip on the timeline, referencing /// the original audio files by absolute path. struct AuditionExporter: DAWExporter { static let formatID = "audition" static let formatName = "Adobe Audition Session" static let fileExtension = "sesx" static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws { let entries = playlist.sortedEntries let sampleRate = options.targetSampleRate ?? 44100 let totalSamples = timeToSamples(playlist.totalDuration, sampleRate: sampleRate) let sessionDir = url.deletingLastPathComponent().path // Collect unique file references var fileRefs: [(id: Int, absolutePath: String, relativePath: String, mediaHandler: String)] = [] var fileIDMap = [String: Int]() for entry in entries { guard let track = entry.track else { continue } let path = track.filePath if fileIDMap[path] == nil { let fid = fileRefs.count fileIDMap[path] = fid let rel = makeRelativePath(from: sessionDir, to: path) let handler = mediaHandler(for: track.fileFormat) fileRefs.append((id: fid, absolutePath: path, relativePath: rel, mediaHandler: handler)) } } // --- Build XML matching real Audition format --- var xml = "\n" xml += "\n" xml += "\n\n" // Session xml += " \n" // Tracks xml += " \n" // Audio track xml += " \n" xml += " \n" xml += " Mix\n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" // Audio clips on timeline var timelinePos: TimeInterval = 0 for (index, entry) in entries.enumerated() { guard let track = entry.track else { continue } guard let fid = fileIDMap[track.filePath] else { continue } // Apply crossfade overlap if index > 0 && entry.crossfadeDuration > 0 { timelinePos -= entry.crossfadeDuration } let clipStart = timelinePos let startOffset = entry.startOffset let effectiveDuration = entry.effectiveDuration let startSample = timeToSamples(clipStart, sampleRate: sampleRate) let endSample = timeToSamples(clipStart + effectiveDuration, sampleRate: sampleRate) let sourceIn = timeToSamples(startOffset, sampleRate: sampleRate) let sourceOut = timeToSamples(startOffset + effectiveDuration, sampleRate: sampleRate) xml += " \n" xml += " \n" timelinePos = clipStart + effectiveDuration } xml += " \n" // Master track xml += " \n" xml += " \n" xml += " Master\n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n" xml += " \n\n" // Files section — AFTER , before xml += " \n" for ref in fileRefs { let uuid = UUID().uuidString.lowercased() xml += " \n" } xml += " \n\n" xml += "\n" try xml.write(to: url, atomically: true, encoding: .utf8) } // MARK: - Helpers private static func timeToSamples(_ time: TimeInterval, sampleRate: Double) -> Int64 { Int64(time * sampleRate) } private static func esc(_ string: String) -> String { string .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) .replacingOccurrences(of: "'", with: "'") } private static func makeRelativePath(from sessionDir: String, to filePath: String) -> String { if filePath.hasPrefix(sessionDir) { let relative = String(filePath.dropFirst(sessionDir.count)) return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative } return filePath } /// Map file extension to Audition media handler identifier. private static func mediaHandler(for format: String) -> String { switch format.lowercased() { case "mp3": return "AmioMP3" case "wav": return "AmioWav" case "flac": return "AmioLSF" case "aif", "aiff": return "AmioAIFF" case "m4a", "aac", "alac": return "AmioQuickTime" case "ogg": return "AmioOGG" default: return "AmioGeneric" } } }