import Foundation /// Exports a playlist in the DAWproject open exchange format. /// DAWproject is an open standard for DAW session interchange developed by /// Bitwig and PreSonus. It's supported by Bitwig Studio, Studio One, REAPER, and others. /// /// A .dawproject file is a ZIP archive containing: /// - project.xml (session metadata, tracks, clips, markers) /// - metadata.xml (basic project info) /// - Audio Files/ (referenced audio files) struct DAWProjectExporter: DAWExporter { static let formatID = "dawproject" static let formatName = "DAWproject" static let fileExtension = "dawproject" static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws { let entries = playlist.sortedEntries let sampleRate = options.targetSampleRate ?? 44100 // DAWproject is a ZIP, but for simplicity we'll write the XML and provide // instructions for bundling. For a complete implementation, use ZIPFoundation. // Here we export the project.xml which is the core of the format. let xmlURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml") let projectXML = buildProjectXML( playlist: playlist, entries: entries, sampleRate: sampleRate, options: options ) let metadataXML = buildMetadataXML(playlist: playlist) // Write project XML try projectXML.write(to: xmlURL, atomically: true, encoding: .utf8) // Write metadata alongside let metadataURL = url.deletingLastPathComponent() .appendingPathComponent("metadata.xml") try metadataXML.write(to: metadataURL, atomically: true, encoding: .utf8) } // MARK: - Project XML private static func buildProjectXML( playlist: Playlist, entries: [PlaylistEntry], sampleRate: Double, options: ExportOptions ) -> String { var xml = """ """ var timelinePosition: TimeInterval = 0 for (index, entry) in entries.enumerated() { guard let track = entry.track, let fileURL = options.effectiveFileURL(for: track) else { continue } let clipStart = timelinePosition let startOffset = entry.startOffset let clipDuration = entry.effectiveDuration let relativePath = "\(options.audioFilesRelativePath)/\(fileURL.lastPathComponent)" // Apply crossfade overlap if index > 0 { timelinePosition -= entry.crossfadeDuration } xml += """ """ // Gain adjustment if entry.gainAdjustment != 0 { xml += """ """ } // Crossfades if options.includeCrossfades { if entry.crossfadeDuration > 0 { xml += """ """ } if index + 1 < entries.count, entries[index + 1].crossfadeDuration > 0 { xml += """ """ } } xml += """ """ timelinePosition = clipStart + clipDuration } xml += """ """ // Markers if options.includeCuePoints { xml += " \n" var markerTimeline: TimeInterval = 0 for (index, entry) in entries.enumerated() { guard let track = entry.track else { continue } if index > 0 { markerTimeline -= entry.crossfadeDuration } for cuePoint in track.cuePoints.sorted() { let markerTime = markerTimeline + cuePoint.timestamp - entry.startOffset let name = cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name xml += """ """ } markerTimeline += entry.effectiveDuration } xml += " \n" } xml += """ """ return xml } // MARK: - Metadata XML private static func buildMetadataXML(playlist: Playlist) -> String { """ \(escapeXML(playlist.name)) Exported from MixBoard \(ISO8601DateFormatter().string(from: Date())) """ } private static func escapeXML(_ string: String) -> String { string .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } }