AuditionExporter.swift 8.5 KB

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