AuditionExporter.swift 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import Foundation
  2. /// Exports a playlist as an Adobe Audition multitrack session (.sesx) file.
  3. /// Based on the real Audition .sesx XML schema (version 1.9).
  4. /// Each original track becomes a separate clip on the timeline, referencing
  5. /// the original audio files by absolute path.
  6. struct AuditionExporter: DAWExporter {
  7. static let formatID = "audition"
  8. static let formatName = "Adobe Audition Session"
  9. static let fileExtension = "sesx"
  10. static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
  11. let entries = playlist.sortedEntries
  12. let sampleRate = options.targetSampleRate ?? 44100
  13. let totalSamples = timeToSamples(playlist.totalDuration, sampleRate: sampleRate)
  14. let sessionDir = url.deletingLastPathComponent().path
  15. // Collect unique file references
  16. var fileRefs: [(id: Int, absolutePath: String, relativePath: String, mediaHandler: String)] = []
  17. var fileIDMap = [String: Int]()
  18. for entry in entries {
  19. guard let track = entry.track,
  20. let fileURL = options.effectiveFileURL(for: track) else { continue }
  21. let path = fileURL.path
  22. if fileIDMap[path] == nil {
  23. let fid = fileRefs.count
  24. fileIDMap[path] = fid
  25. let rel = makeRelativePath(from: sessionDir, to: path)
  26. let format = fileURL.pathExtension.isEmpty ? track.fileFormat : fileURL.pathExtension
  27. let handler = mediaHandler(for: format)
  28. fileRefs.append((id: fid, absolutePath: path, relativePath: rel, mediaHandler: handler))
  29. }
  30. }
  31. // --- Build XML matching real Audition format ---
  32. var xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n"
  33. xml += "<!DOCTYPE sesx>\n"
  34. xml += "<sesx version=\"1.9\">\n\n"
  35. // Session
  36. xml += " <session appBuild=\"MixBoard\" appVersion=\"1.0\" "
  37. xml += "audioChannelType=\"stereo\" bitDepth=\"32\" "
  38. xml += "duration=\"\(totalSamples)\" sampleRate=\"\(Int(sampleRate))\">\n"
  39. // Tracks
  40. xml += " <tracks>\n"
  41. // Audio track
  42. xml += " <audioTrack automationLaneOpenState=\"false\" id=\"10001\" index=\"1\" select=\"false\" visible=\"true\">\n"
  43. xml += " <trackParameters trackHeight=\"140\" trackHue=\"160\" trackMinimized=\"false\">\n"
  44. xml += " <name>Mix</name>\n"
  45. xml += " </trackParameters>\n"
  46. xml += " <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
  47. xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
  48. xml += " <trackOutput outputID=\"10000\" type=\"trackID\"/>\n"
  49. xml += " <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
  50. xml += " <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
  51. xml += " <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
  52. xml += " </component>\n"
  53. xml += " <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
  54. xml += " <parameter index=\"0\" parameterValue=\"0\"/>\n"
  55. xml += " <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
  56. xml += " </component>\n"
  57. xml += " <component componentID=\"Audition.StereoPanner\" id=\"trackPan\" name=\"StereoPanner\" powered=\"true\">\n"
  58. xml += " <parameter index=\"0\" name=\"Pan\" parameterValue=\"0\"/>\n"
  59. xml += " </component>\n"
  60. xml += " </trackAudioParameters>\n"
  61. // Audio clips on timeline
  62. var timelinePos: TimeInterval = 0
  63. for (index, entry) in entries.enumerated() {
  64. guard let track = entry.track,
  65. let fileURL = options.effectiveFileURL(for: track),
  66. let fid = fileIDMap[fileURL.path] else { continue }
  67. // Apply crossfade overlap
  68. if index > 0 && entry.crossfadeDuration > 0 {
  69. timelinePos -= entry.crossfadeDuration
  70. }
  71. let clipStart = timelinePos
  72. let startOffset = entry.startOffset
  73. let effectiveDuration = entry.effectiveDuration
  74. let startSample = timeToSamples(clipStart, sampleRate: sampleRate)
  75. let endSample = timeToSamples(clipStart + effectiveDuration, sampleRate: sampleRate)
  76. let sourceIn = timeToSamples(startOffset, sampleRate: sampleRate)
  77. let sourceOut = timeToSamples(startOffset + effectiveDuration, sampleRate: sampleRate)
  78. xml += " <audioClip clipAutoCrossfade=\"true\" "
  79. xml += "crossFadeHeadClipID=\"-1\" crossFadeTailClipID=\"-1\" "
  80. xml += "endPoint=\"\(endSample)\" fileID=\"\(fid)\" hue=\"-1\" "
  81. xml += "id=\"\(index)\" lockedInTime=\"false\" looped=\"false\" "
  82. xml += "name=\"\(esc(track.title))\" offline=\"false\" select=\"false\" "
  83. xml += "sourceInPoint=\"\(sourceIn)\" sourceOutPoint=\"\(sourceOut)\" "
  84. xml += "startPoint=\"\(startSample)\" zOrder=\"\(127 + index)\">\n"
  85. xml += " </audioClip>\n"
  86. timelinePos = clipStart + effectiveDuration
  87. }
  88. xml += " </audioTrack>\n"
  89. // Master track
  90. xml += " <masterTrack automationLaneOpenState=\"false\" id=\"10000\" index=\"0\" select=\"false\" visible=\"true\">\n"
  91. xml += " <trackParameters trackHeight=\"70\" trackHue=\"-1\" trackMinimized=\"true\">\n"
  92. xml += " <name>Master</name>\n"
  93. xml += " </trackParameters>\n"
  94. xml += " <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
  95. xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
  96. xml += " <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
  97. xml += " <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
  98. xml += " <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
  99. xml += " </component>\n"
  100. xml += " <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
  101. xml += " <parameter index=\"0\" parameterValue=\"0\"/>\n"
  102. xml += " <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
  103. xml += " </component>\n"
  104. xml += " </trackAudioParameters>\n"
  105. xml += " </masterTrack>\n"
  106. xml += " </tracks>\n"
  107. xml += " </session>\n\n"
  108. // Files section — AFTER </session>, before </sesx>
  109. xml += " <files>\n"
  110. for ref in fileRefs {
  111. let uuid = UUID().uuidString.lowercased()
  112. xml += " <file absolutePath=\"\(esc(ref.absolutePath))\" "
  113. xml += "id=\"\(ref.id)\" "
  114. xml += "mediaHandler=\"\(ref.mediaHandler)\" "
  115. xml += "recoveryID=\"\(uuid)\" "
  116. xml += "relativePath=\"\(esc(ref.relativePath))\"/>\n"
  117. }
  118. xml += " </files>\n\n"
  119. xml += "</sesx>\n"
  120. try xml.write(to: url, atomically: true, encoding: .utf8)
  121. }
  122. // MARK: - Helpers
  123. private static func timeToSamples(_ time: TimeInterval, sampleRate: Double) -> Int64 {
  124. Int64(time * sampleRate)
  125. }
  126. private static func esc(_ string: String) -> String {
  127. string
  128. .replacingOccurrences(of: "&", with: "&amp;")
  129. .replacingOccurrences(of: "<", with: "&lt;")
  130. .replacingOccurrences(of: ">", with: "&gt;")
  131. .replacingOccurrences(of: "\"", with: "&quot;")
  132. .replacingOccurrences(of: "'", with: "&apos;")
  133. }
  134. private static func makeRelativePath(from sessionDir: String, to filePath: String) -> String {
  135. if filePath.hasPrefix(sessionDir) {
  136. let relative = String(filePath.dropFirst(sessionDir.count))
  137. return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative
  138. }
  139. return filePath
  140. }
  141. /// Map file extension to Audition media handler identifier.
  142. private static func mediaHandler(for format: String) -> String {
  143. switch format.lowercased() {
  144. case "mp3": return "AmioMP3"
  145. case "wav": return "AmioWav"
  146. case "flac": return "AmioLSF"
  147. case "aif", "aiff": return "AmioAIFF"
  148. case "m4a", "aac", "alac": return "AmioQuickTime"
  149. case "ogg": return "AmioOGG"
  150. default: return "AmioGeneric"
  151. }
  152. }
  153. }