| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- 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 = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n"
- xml += "<!DOCTYPE sesx>\n"
- xml += "<sesx version=\"1.9\">\n\n"
- // Session
- xml += " <session appBuild=\"MixBoard\" appVersion=\"1.0\" "
- xml += "audioChannelType=\"stereo\" bitDepth=\"32\" "
- xml += "duration=\"\(totalSamples)\" sampleRate=\"\(Int(sampleRate))\">\n"
- // Tracks
- xml += " <tracks>\n"
- // Audio track
- xml += " <audioTrack automationLaneOpenState=\"false\" id=\"10001\" index=\"1\" select=\"false\" visible=\"true\">\n"
- xml += " <trackParameters trackHeight=\"140\" trackHue=\"160\" trackMinimized=\"false\">\n"
- xml += " <name>Mix</name>\n"
- xml += " </trackParameters>\n"
- xml += " <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
- xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
- xml += " <trackOutput outputID=\"10000\" type=\"trackID\"/>\n"
- xml += " <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
- xml += " <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
- xml += " <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
- xml += " </component>\n"
- xml += " <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
- xml += " <parameter index=\"0\" parameterValue=\"0\"/>\n"
- xml += " <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
- xml += " </component>\n"
- xml += " <component componentID=\"Audition.StereoPanner\" id=\"trackPan\" name=\"StereoPanner\" powered=\"true\">\n"
- xml += " <parameter index=\"0\" name=\"Pan\" parameterValue=\"0\"/>\n"
- xml += " </component>\n"
- xml += " </trackAudioParameters>\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 += " <audioClip clipAutoCrossfade=\"true\" "
- xml += "crossFadeHeadClipID=\"-1\" crossFadeTailClipID=\"-1\" "
- xml += "endPoint=\"\(endSample)\" fileID=\"\(fid)\" hue=\"-1\" "
- xml += "id=\"\(index)\" lockedInTime=\"false\" looped=\"false\" "
- xml += "name=\"\(esc(track.title))\" offline=\"false\" select=\"false\" "
- xml += "sourceInPoint=\"\(sourceIn)\" sourceOutPoint=\"\(sourceOut)\" "
- xml += "startPoint=\"\(startSample)\" zOrder=\"\(127 + index)\">\n"
- xml += " </audioClip>\n"
- timelinePos = clipStart + effectiveDuration
- }
- xml += " </audioTrack>\n"
- // Master track
- xml += " <masterTrack automationLaneOpenState=\"false\" id=\"10000\" index=\"0\" select=\"false\" visible=\"true\">\n"
- xml += " <trackParameters trackHeight=\"70\" trackHue=\"-1\" trackMinimized=\"true\">\n"
- xml += " <name>Master</name>\n"
- xml += " </trackParameters>\n"
- xml += " <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
- xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
- xml += " <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
- xml += " <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
- xml += " <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
- xml += " </component>\n"
- xml += " <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
- xml += " <parameter index=\"0\" parameterValue=\"0\"/>\n"
- xml += " <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
- xml += " </component>\n"
- xml += " </trackAudioParameters>\n"
- xml += " </masterTrack>\n"
- xml += " </tracks>\n"
- xml += " </session>\n\n"
- // Files section — AFTER </session>, before </sesx>
- xml += " <files>\n"
- for ref in fileRefs {
- let uuid = UUID().uuidString.lowercased()
- xml += " <file absolutePath=\"\(esc(ref.absolutePath))\" "
- xml += "id=\"\(ref.id)\" "
- xml += "mediaHandler=\"\(ref.mediaHandler)\" "
- xml += "recoveryID=\"\(uuid)\" "
- xml += "relativePath=\"\(esc(ref.relativePath))\"/>\n"
- }
- xml += " </files>\n\n"
- xml += "</sesx>\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"
- }
- }
- }
|