WaveformGenerator.swift 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import Accelerate
  2. import AVFoundation
  3. import Foundation
  4. /// Generates waveform visualization data from audio files.
  5. /// Produces downsampled min/max pairs suitable for drawing waveforms.
  6. struct WaveformGenerator {
  7. /// A single column of waveform data (min and max sample values).
  8. struct WaveformSample: Codable {
  9. let min: Float
  10. let max: Float
  11. }
  12. /// Default number of samples (columns) for the waveform display.
  13. static let defaultResolution = 800
  14. // MARK: - Public API
  15. /// Generate waveform data for a track and cache it.
  16. @MainActor
  17. static func generateWaveform(for track: Track, resolution: Int = defaultResolution) async throws -> [WaveformSample] {
  18. guard track.hasLocalFile else {
  19. throw WaveformError.fileNotFound
  20. }
  21. let samples = try await generateWaveform(fileURL: track.fileURL, resolution: resolution)
  22. // Cache on main actor (SwiftData model)
  23. let encoded = try JSONEncoder().encode(samples)
  24. track.waveformData = encoded
  25. return samples
  26. }
  27. /// Generate waveform data from a file URL.
  28. static func generateWaveform(fileURL: URL, resolution: Int = defaultResolution) async throws -> [WaveformSample] {
  29. try await Task.detached(priority: .userInitiated) {
  30. if OGGDecoder.isOGGFile(fileURL) {
  31. let (mono, _) = try OGGDecoder.readMonoSamples(url: fileURL)
  32. guard !mono.isEmpty else { return [WaveformSample]() }
  33. return downsample(mono, to: resolution)
  34. }
  35. let audioFile = try AVAudioFile(forReading: fileURL)
  36. let totalFrames = audioFile.length
  37. guard totalFrames > 0 else { return [WaveformSample]() }
  38. let mono = try readMonoSamples(from: audioFile)
  39. guard !mono.isEmpty else { return [WaveformSample]() }
  40. return downsample(mono, to: resolution)
  41. }.value
  42. }
  43. /// Decode cached waveform data.
  44. static func decodeCachedWaveform(from data: Data) -> [WaveformSample]? {
  45. try? JSONDecoder().decode([WaveformSample].self, from: data)
  46. }
  47. // MARK: - Audio Reading
  48. private static func readMonoSamples(from audioFile: AVAudioFile) throws -> [Float] {
  49. let processingFormat = audioFile.processingFormat
  50. let channelCount = Int(processingFormat.channelCount)
  51. let totalFrames = audioFile.length
  52. // Read in chunks using the file's processingFormat (auto-decompresses to PCM Float32)
  53. let chunkSize: AVAudioFrameCount = 65536
  54. var allSamples = [Float]()
  55. allSamples.reserveCapacity(Int(totalFrames))
  56. audioFile.framePosition = 0
  57. guard let buffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: chunkSize) else {
  58. throw WaveformError.formatError
  59. }
  60. while audioFile.framePosition < totalFrames {
  61. let remaining = AVAudioFrameCount(totalFrames - audioFile.framePosition)
  62. let framesToRead = min(chunkSize, remaining)
  63. try audioFile.read(into: buffer, frameCount: framesToRead)
  64. guard let channelData = buffer.floatChannelData, buffer.frameLength > 0 else { break }
  65. let frameCount = Int(buffer.frameLength)
  66. if channelCount == 1 {
  67. let ptr = UnsafeBufferPointer(start: channelData[0], count: frameCount)
  68. allSamples.append(contentsOf: ptr)
  69. } else {
  70. // Mix down to mono by averaging channels
  71. var mono = [Float](repeating: 0, count: frameCount)
  72. for ch in 0..<channelCount {
  73. let chPtr = channelData[ch]
  74. for i in 0..<frameCount {
  75. mono[i] += chPtr[i]
  76. }
  77. }
  78. let divisor = Float(channelCount)
  79. for i in 0..<frameCount {
  80. mono[i] /= divisor
  81. }
  82. allSamples.append(contentsOf: mono)
  83. }
  84. }
  85. return allSamples
  86. }
  87. // MARK: - Downsampling
  88. private static func downsample(_ samples: [Float], to resolution: Int) -> [WaveformSample] {
  89. let count = samples.count
  90. guard count > 0, resolution > 0 else { return [] }
  91. var result = [WaveformSample]()
  92. result.reserveCapacity(resolution)
  93. for i in 0..<resolution {
  94. let start = i * count / resolution
  95. let end = min((i + 1) * count / resolution, count)
  96. let length = vDSP_Length(end - start)
  97. guard length > 0 else {
  98. result.append(WaveformSample(min: 0, max: 0))
  99. continue
  100. }
  101. var minVal: Float = 0
  102. var maxVal: Float = 0
  103. samples.withUnsafeBufferPointer { buf in
  104. vDSP_minv(buf.baseAddress!.advanced(by: start), 1, &minVal, length)
  105. vDSP_maxv(buf.baseAddress!.advanced(by: start), 1, &maxVal, length)
  106. }
  107. result.append(WaveformSample(min: minVal, max: maxVal))
  108. }
  109. return result
  110. }
  111. }
  112. // MARK: - Errors
  113. enum WaveformError: Error, LocalizedError {
  114. case fileNotFound
  115. case formatError
  116. case noAudioData
  117. var errorDescription: String? {
  118. switch self {
  119. case .fileNotFound: return "Audio file not found (cloud tracks don't support waveforms)"
  120. case .formatError: return "Unable to read audio format for waveform generation"
  121. case .noAudioData: return "No audio data found for waveform generation"
  122. }
  123. }
  124. }