import Foundation /// Exports a playlist as a standard CUE sheet (.cue). /// CUE sheets are widely supported by CD burning software, virtual CD drives, /// and many audio tools including Audacity, foobar2000, and various DAWs. struct CueSheetExporter: DAWExporter { static let formatID = "cue" static let formatName = "Cue Sheet" static let fileExtension = "cue" static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws { let entries = playlist.sortedEntries var lines: [String] = [] lines.append("REM Generated by MixBoard") lines.append("REM Date: \(ISO8601DateFormatter().string(from: Date()))") lines.append("TITLE \"\(playlist.name)\"") var timelinePosition: TimeInterval = 0 for (index, entry) in entries.enumerated() { guard let track = entry.track, track.hasLocalFile else { continue } let trackNumber = String(format: "%02d", index + 1) let fileName = track.fileURL.lastPathComponent let relativePath = "\(options.audioFilesRelativePath)/\(fileName)" lines.append("FILE \"\(relativePath)\" WAVE") lines.append(" TRACK \(trackNumber) AUDIO") lines.append(" TITLE \"\(track.title)\"") if !track.artist.isEmpty { lines.append(" PERFORMER \"\(track.artist)\"") } // INDEX 01 is the start time in MM:SS:FF format (frames = 1/75 second) let indexTime = formatCueTime(timelinePosition) lines.append(" INDEX 01 \(indexTime)") // Add cue points as INDEX entries if options.includeCuePoints { for (cpIndex, cuePoint) in track.cuePoints.sorted().enumerated() { // INDEX 02+ for additional cue points let cpTime = formatCueTime(timelinePosition + cuePoint.timestamp - entry.startOffset) lines.append(" REM CUE \(cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name)") if cpIndex < 98 { // CUE format supports INDEX 00-99 lines.append(" INDEX \(String(format: "%02d", cpIndex + 2)) \(cpTime)") } } } // Add BPM as a remark if let bpm = track.bpm { lines.append(" REM BPM \(String(format: "%.1f", bpm))") } if let key = track.musicalKey { lines.append(" REM KEY \(key)") } timelinePosition += entry.effectiveDuration if index + 1 < entries.count { timelinePosition -= entries[index + 1].crossfadeDuration } } let content = lines.joined(separator: "\n") + "\n" try content.write(to: url, atomically: true, encoding: .utf8) } // MARK: - CUE Time Format /// Convert seconds to CUE time format MM:SS:FF where FF = frames (1/75 second). private static func formatCueTime(_ seconds: TimeInterval) -> String { let totalSeconds = max(0, seconds) let minutes = Int(totalSeconds) / 60 let secs = Int(totalSeconds) % 60 let frames = Int((totalSeconds - Double(Int(totalSeconds))) * 75) return String(format: "%02d:%02d:%02d", minutes, secs, frames) } }