import Accelerate import AVFoundation import Foundation /// Generates waveform visualization data from audio files. /// Produces downsampled min/max pairs suitable for drawing waveforms. struct WaveformGenerator { /// A single column of waveform data (min and max sample values). struct WaveformSample: Codable { let min: Float let max: Float } /// Default number of samples (columns) for the waveform display. static let defaultResolution = 800 // MARK: - Public API /// Generate waveform data for a track and cache it. @MainActor static func generateWaveform(for track: Track, resolution: Int = defaultResolution) async throws -> [WaveformSample] { guard track.hasLocalFile else { throw WaveformError.fileNotFound } let samples = try await generateWaveform(fileURL: track.fileURL, resolution: resolution) // Cache on main actor (SwiftData model) let encoded = try JSONEncoder().encode(samples) track.waveformData = encoded return samples } /// Generate waveform data from a file URL. static func generateWaveform(fileURL: URL, resolution: Int = defaultResolution) async throws -> [WaveformSample] { try await Task.detached(priority: .userInitiated) { if OGGDecoder.isOGGFile(fileURL) { let (mono, _) = try OGGDecoder.readMonoSamples(url: fileURL) guard !mono.isEmpty else { return [WaveformSample]() } return downsample(mono, to: resolution) } let audioFile = try AVAudioFile(forReading: fileURL) let totalFrames = audioFile.length guard totalFrames > 0 else { return [WaveformSample]() } let mono = try readMonoSamples(from: audioFile) guard !mono.isEmpty else { return [WaveformSample]() } return downsample(mono, to: resolution) }.value } /// Decode cached waveform data. static func decodeCachedWaveform(from data: Data) -> [WaveformSample]? { try? JSONDecoder().decode([WaveformSample].self, from: data) } // MARK: - Audio Reading private static func readMonoSamples(from audioFile: AVAudioFile) throws -> [Float] { let processingFormat = audioFile.processingFormat let channelCount = Int(processingFormat.channelCount) let totalFrames = audioFile.length // Read in chunks using the file's processingFormat (auto-decompresses to PCM Float32) let chunkSize: AVAudioFrameCount = 65536 var allSamples = [Float]() allSamples.reserveCapacity(Int(totalFrames)) audioFile.framePosition = 0 guard let buffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: chunkSize) else { throw WaveformError.formatError } while audioFile.framePosition < totalFrames { let remaining = AVAudioFrameCount(totalFrames - audioFile.framePosition) let framesToRead = min(chunkSize, remaining) try audioFile.read(into: buffer, frameCount: framesToRead) guard let channelData = buffer.floatChannelData, buffer.frameLength > 0 else { break } let frameCount = Int(buffer.frameLength) if channelCount == 1 { let ptr = UnsafeBufferPointer(start: channelData[0], count: frameCount) allSamples.append(contentsOf: ptr) } else { // Mix down to mono by averaging channels var mono = [Float](repeating: 0, count: frameCount) for ch in 0.. [WaveformSample] { let count = samples.count guard count > 0, resolution > 0 else { return [] } var result = [WaveformSample]() result.reserveCapacity(resolution) for i in 0.. 0 else { result.append(WaveformSample(min: 0, max: 0)) continue } var minVal: Float = 0 var maxVal: Float = 0 samples.withUnsafeBufferPointer { buf in vDSP_minv(buf.baseAddress!.advanced(by: start), 1, &minVal, length) vDSP_maxv(buf.baseAddress!.advanced(by: start), 1, &maxVal, length) } result.append(WaveformSample(min: minVal, max: maxVal)) } return result } } // MARK: - Errors enum WaveformError: Error, LocalizedError { case fileNotFound case formatError case noAudioData var errorDescription: String? { switch self { case .fileNotFound: return "Audio file not found (cloud tracks don't support waveforms)" case .formatError: return "Unable to read audio format for waveform generation" case .noAudioData: return "No audio data found for waveform generation" } } }