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,
let fileURL = options.effectiveFileURL(for: track) else { continue }
let path = fileURL.path
if fileIDMap[path] == nil {
let fid = fileRefs.count
fileIDMap[path] = fid
let rel = makeRelativePath(from: sessionDir, to: path)
let format = fileURL.pathExtension.isEmpty ? track.fileFormat : fileURL.pathExtension
let handler = mediaHandler(for: format)
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,
let fileURL = options.effectiveFileURL(for: track),
let fid = fileIDMap[fileURL.path] 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"
}
}
}