DAWProjectExporter.swift 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import Foundation
  2. /// Exports a playlist in the DAWproject open exchange format.
  3. /// DAWproject is an open standard for DAW session interchange developed by
  4. /// Bitwig and PreSonus. It's supported by Bitwig Studio, Studio One, REAPER, and others.
  5. ///
  6. /// A .dawproject file is a ZIP archive containing:
  7. /// - project.xml (session metadata, tracks, clips, markers)
  8. /// - metadata.xml (basic project info)
  9. /// - Audio Files/ (referenced audio files)
  10. struct DAWProjectExporter: DAWExporter {
  11. static let formatID = "dawproject"
  12. static let formatName = "DAWproject"
  13. static let fileExtension = "dawproject"
  14. static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
  15. let entries = playlist.sortedEntries
  16. let sampleRate = options.targetSampleRate ?? 44100
  17. // DAWproject is a ZIP, but for simplicity we'll write the XML and provide
  18. // instructions for bundling. For a complete implementation, use ZIPFoundation.
  19. // Here we export the project.xml which is the core of the format.
  20. let xmlURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
  21. let projectXML = buildProjectXML(
  22. playlist: playlist,
  23. entries: entries,
  24. sampleRate: sampleRate,
  25. options: options
  26. )
  27. let metadataXML = buildMetadataXML(playlist: playlist)
  28. // Write project XML
  29. try projectXML.write(to: xmlURL, atomically: true, encoding: .utf8)
  30. // Write metadata alongside
  31. let metadataURL = url.deletingLastPathComponent()
  32. .appendingPathComponent("metadata.xml")
  33. try metadataXML.write(to: metadataURL, atomically: true, encoding: .utf8)
  34. }
  35. // MARK: - Project XML
  36. private static func buildProjectXML(
  37. playlist: Playlist,
  38. entries: [PlaylistEntry],
  39. sampleRate: Double,
  40. options: ExportOptions
  41. ) -> String {
  42. var xml = """
  43. <?xml version="1.0" encoding="UTF-8"?>
  44. <Project version="1.0">
  45. <Application name="MixBoard" version="1.0" />
  46. <Transport>
  47. <Tempo max="200" min="60" value="\(playlist.targetBPM ?? 120)" />
  48. <TimeSignature denominator="4" numerator="4" />
  49. </Transport>
  50. <Structure>
  51. <Track contentType="audio" name="\(escapeXML(playlist.name))" color="#4A90D9">
  52. <Channel role="regular" audioChannels="2">
  53. <Volume max="2" min="0" value="1" />
  54. <Pan max="1" min="-1" value="0" />
  55. </Channel>
  56. </Track>
  57. </Structure>
  58. <Arrangement>
  59. <Lanes>
  60. <Lanes track="0">
  61. """
  62. var timelinePosition: TimeInterval = 0
  63. for (index, entry) in entries.enumerated() {
  64. guard let track = entry.track,
  65. let fileURL = options.effectiveFileURL(for: track) else { continue }
  66. let clipStart = timelinePosition
  67. let startOffset = entry.startOffset
  68. let clipDuration = entry.effectiveDuration
  69. let relativePath = "\(options.audioFilesRelativePath)/\(fileURL.lastPathComponent)"
  70. // Apply crossfade overlap
  71. if index > 0 {
  72. timelinePosition -= entry.crossfadeDuration
  73. }
  74. xml += """
  75. <Clip time="\(clipStart)" duration="\(clipDuration)" name="\(escapeXML(track.title))">
  76. <Audio>
  77. <File path="\(escapeXML(relativePath))" />
  78. <Playback offset="\(startOffset)" duration="\(clipDuration)" />
  79. </Audio>
  80. """
  81. // Gain adjustment
  82. if entry.gainAdjustment != 0 {
  83. xml += """
  84. <Gain value="\(entry.gainAdjustment)" />
  85. """
  86. }
  87. // Crossfades
  88. if options.includeCrossfades {
  89. if entry.crossfadeDuration > 0 {
  90. xml += """
  91. <FadeIn duration="\(entry.crossfadeDuration)" curve="linear" />
  92. """
  93. }
  94. if index + 1 < entries.count, entries[index + 1].crossfadeDuration > 0 {
  95. xml += """
  96. <FadeOut duration="\(entries[index + 1].crossfadeDuration)" curve="linear" />
  97. """
  98. }
  99. }
  100. xml += """
  101. </Clip>
  102. """
  103. timelinePosition = clipStart + clipDuration
  104. }
  105. xml += """
  106. </Lanes>
  107. </Lanes>
  108. </Arrangement>
  109. """
  110. // Markers
  111. if options.includeCuePoints {
  112. xml += " <Markers>\n"
  113. var markerTimeline: TimeInterval = 0
  114. for (index, entry) in entries.enumerated() {
  115. guard let track = entry.track else { continue }
  116. if index > 0 {
  117. markerTimeline -= entry.crossfadeDuration
  118. }
  119. for cuePoint in track.cuePoints.sorted() {
  120. let markerTime = markerTimeline + cuePoint.timestamp - entry.startOffset
  121. let name = cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name
  122. xml += """
  123. <Marker time="\(markerTime)" name="\(escapeXML(name))" color="\(cuePoint.color)" />
  124. """
  125. }
  126. markerTimeline += entry.effectiveDuration
  127. }
  128. xml += " </Markers>\n"
  129. }
  130. xml += """
  131. </Project>
  132. """
  133. return xml
  134. }
  135. // MARK: - Metadata XML
  136. private static func buildMetadataXML(playlist: Playlist) -> String {
  137. """
  138. <?xml version="1.0" encoding="UTF-8"?>
  139. <MetaData>
  140. <Title>\(escapeXML(playlist.name))</Title>
  141. <Artist></Artist>
  142. <Comment>Exported from MixBoard</Comment>
  143. <CreatedAt>\(ISO8601DateFormatter().string(from: Date()))</CreatedAt>
  144. </MetaData>
  145. """
  146. }
  147. private static func escapeXML(_ string: String) -> String {
  148. string
  149. .replacingOccurrences(of: "&", with: "&amp;")
  150. .replacingOccurrences(of: "<", with: "&lt;")
  151. .replacingOccurrences(of: ">", with: "&gt;")
  152. .replacingOccurrences(of: "\"", with: "&quot;")
  153. }
  154. }