| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- 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 = """
- <?xml version="1.0" encoding="UTF-8"?>
- <Project version="1.0">
- <Application name="MixBoard" version="1.0" />
- <Transport>
- <Tempo max="200" min="60" value="\(playlist.targetBPM ?? 120)" />
- <TimeSignature denominator="4" numerator="4" />
- </Transport>
- <Structure>
- <Track contentType="audio" name="\(escapeXML(playlist.name))" color="#4A90D9">
- <Channel role="regular" audioChannels="2">
- <Volume max="2" min="0" value="1" />
- <Pan max="1" min="-1" value="0" />
- </Channel>
- </Track>
- </Structure>
- <Arrangement>
- <Lanes>
- <Lanes track="0">
- """
- 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 += """
- <Clip time="\(clipStart)" duration="\(clipDuration)" name="\(escapeXML(track.title))">
- <Audio>
- <File path="\(escapeXML(relativePath))" />
- <Playback offset="\(startOffset)" duration="\(clipDuration)" />
- </Audio>
- """
- // Gain adjustment
- if entry.gainAdjustment != 0 {
- xml += """
- <Gain value="\(entry.gainAdjustment)" />
- """
- }
- // Crossfades
- if options.includeCrossfades {
- if entry.crossfadeDuration > 0 {
- xml += """
- <FadeIn duration="\(entry.crossfadeDuration)" curve="linear" />
- """
- }
- if index + 1 < entries.count, entries[index + 1].crossfadeDuration > 0 {
- xml += """
- <FadeOut duration="\(entries[index + 1].crossfadeDuration)" curve="linear" />
- """
- }
- }
- xml += """
- </Clip>
- """
- timelinePosition = clipStart + clipDuration
- }
- xml += """
- </Lanes>
- </Lanes>
- </Arrangement>
- """
- // Markers
- if options.includeCuePoints {
- xml += " <Markers>\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 += """
- <Marker time="\(markerTime)" name="\(escapeXML(name))" color="\(cuePoint.color)" />
- """
- }
- markerTimeline += entry.effectiveDuration
- }
- xml += " </Markers>\n"
- }
- xml += """
- </Project>
- """
- return xml
- }
- // MARK: - Metadata XML
- private static func buildMetadataXML(playlist: Playlist) -> String {
- """
- <?xml version="1.0" encoding="UTF-8"?>
- <MetaData>
- <Title>\(escapeXML(playlist.name))</Title>
- <Artist></Artist>
- <Comment>Exported from MixBoard</Comment>
- <CreatedAt>\(ISO8601DateFormatter().string(from: Date()))</CreatedAt>
- </MetaData>
- """
- }
- private static func escapeXML(_ string: String) -> String {
- string
- .replacingOccurrences(of: "&", with: "&")
- .replacingOccurrences(of: "<", with: "<")
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: "\"", with: """)
- }
- }
|