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, track.hasLocalFile else { continue }
let clipStart = timelinePosition
let startOffset = entry.startOffset
let clipDuration = entry.effectiveDuration
let relativePath = "\(options.audioFilesRelativePath)/\(track.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: """)
}
}