CueSheetExporter.swift 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. import Foundation
  2. /// Exports a playlist as a standard CUE sheet (.cue).
  3. /// CUE sheets are widely supported by CD burning software, virtual CD drives,
  4. /// and many audio tools including Audacity, foobar2000, and various DAWs.
  5. struct CueSheetExporter: DAWExporter {
  6. static let formatID = "cue"
  7. static let formatName = "Cue Sheet"
  8. static let fileExtension = "cue"
  9. static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
  10. let entries = playlist.sortedEntries
  11. var lines: [String] = []
  12. lines.append("REM Generated by MixBoard")
  13. lines.append("REM Date: \(ISO8601DateFormatter().string(from: Date()))")
  14. lines.append("TITLE \"\(playlist.name)\"")
  15. var timelinePosition: TimeInterval = 0
  16. for (index, entry) in entries.enumerated() {
  17. guard let track = entry.track, track.hasLocalFile else { continue }
  18. let trackNumber = String(format: "%02d", index + 1)
  19. let fileName = track.fileURL.lastPathComponent
  20. let relativePath = "\(options.audioFilesRelativePath)/\(fileName)"
  21. lines.append("FILE \"\(relativePath)\" WAVE")
  22. lines.append(" TRACK \(trackNumber) AUDIO")
  23. lines.append(" TITLE \"\(track.title)\"")
  24. if !track.artist.isEmpty {
  25. lines.append(" PERFORMER \"\(track.artist)\"")
  26. }
  27. // INDEX 01 is the start time in MM:SS:FF format (frames = 1/75 second)
  28. let indexTime = formatCueTime(timelinePosition)
  29. lines.append(" INDEX 01 \(indexTime)")
  30. // Add cue points as INDEX entries
  31. if options.includeCuePoints {
  32. for (cpIndex, cuePoint) in track.cuePoints.sorted().enumerated() {
  33. // INDEX 02+ for additional cue points
  34. let cpTime = formatCueTime(timelinePosition + cuePoint.timestamp - entry.startOffset)
  35. lines.append(" REM CUE \(cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name)")
  36. if cpIndex < 98 { // CUE format supports INDEX 00-99
  37. lines.append(" INDEX \(String(format: "%02d", cpIndex + 2)) \(cpTime)")
  38. }
  39. }
  40. }
  41. // Add BPM as a remark
  42. if let bpm = track.bpm {
  43. lines.append(" REM BPM \(String(format: "%.1f", bpm))")
  44. }
  45. if let key = track.musicalKey {
  46. lines.append(" REM KEY \(key)")
  47. }
  48. timelinePosition += entry.effectiveDuration
  49. if index + 1 < entries.count {
  50. timelinePosition -= entries[index + 1].crossfadeDuration
  51. }
  52. }
  53. let content = lines.joined(separator: "\n") + "\n"
  54. try content.write(to: url, atomically: true, encoding: .utf8)
  55. }
  56. // MARK: - CUE Time Format
  57. /// Convert seconds to CUE time format MM:SS:FF where FF = frames (1/75 second).
  58. private static func formatCueTime(_ seconds: TimeInterval) -> String {
  59. let totalSeconds = max(0, seconds)
  60. let minutes = Int(totalSeconds) / 60
  61. let secs = Int(totalSeconds) % 60
  62. let frames = Int((totalSeconds - Double(Int(totalSeconds))) * 75)
  63. return String(format: "%02d:%02d:%02d", minutes, secs, frames)
  64. }
  65. }