| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- 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..<channelCount {
- let chPtr = channelData[ch]
- for i in 0..<frameCount {
- mono[i] += chPtr[i]
- }
- }
- let divisor = Float(channelCount)
- for i in 0..<frameCount {
- mono[i] /= divisor
- }
- allSamples.append(contentsOf: mono)
- }
- }
- return allSamples
- }
- // MARK: - Downsampling
- private static func downsample(_ samples: [Float], to resolution: Int) -> [WaveformSample] {
- let count = samples.count
- guard count > 0, resolution > 0 else { return [] }
- var result = [WaveformSample]()
- result.reserveCapacity(resolution)
- for i in 0..<resolution {
- let start = i * count / resolution
- let end = min((i + 1) * count / resolution, count)
- let length = vDSP_Length(end - start)
- guard length > 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"
- }
- }
- }
|