import SwiftUI import AVFoundation import CryptoKit import CoreImage import AudioToolbox import CoreAudio /// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. @MainActor final class Player: ObservableObject { @Published var image: CGImage? var onWillStartPlayback: (() -> Double)? var onPlaybackReady: (() -> Void)? private var pendingPlayback: Task? @Published private(set) var waitingForBackground = false var audioPlaybackStarted: Bool { node.isPlaying } var onGlobalMixChanged: ((GlobalMix) -> Void)? var jogSecondsPerTurn: Double = 2 var nudgeStep: Double = 0.05 @Published private(set) var cuePosition: Double = 0 @Published private(set) var hotCues: [Double?] = [nil,nil,nil,nil] @Published private(set) var loopIn: Double? @Published private(set) var loopOut: Double? @Published var loopEnabled = false var isShowScreen: Bool { slideImage != nil } var canJog: Bool { currentSongID != nil && duration > 0 } func stopDeck() { guard canJog else { return } pauseDeck();loopEnabled = false;seek(0) } func setCue(at seconds: Double? = nil) { guard canJog else { return } if let seconds { guard seconds.isFinite else { return };cuePosition = min(duration,max(0,seconds)) } else { tick();cuePosition = time } } func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) } private func pauseDeck() { cancelPendingPlayback() if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time } else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,anchor+Double(current.sampleTime)/file.processingFormat.sampleRate)) } node.pause();playing = false;timer?.invalidate();timer = nil } func hotCue(_ index: Int,clear: Bool = false) { guard hotCues.indices.contains(index),canJog else { return } if clear { hotCues[index] = nil } else if let value = hotCues[index] { seek(value) } else { hotCues[index] = time } } func markLoopIn() { guard canJog else { return };loopIn = time;loopOut = nil;loopEnabled = false } func markLoopOut() { guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true } func defineLoop(start: Double,end: Double) { guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1-1e-9 else { return } loopIn = start;loopOut = end;loopEnabled = true } func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) } private var graphicsGeneration = UUID() private var graphicsTask: Task? private var checkpointTask: Task? private var checkpoints = [Int:CDGDecoder]() private var lastGraphicsPacket = -1 @Published private(set) var outputOpacity: Double = 1 @Published private(set) var fading = false var fadeSeconds: Double = 3 var autoFade = true private var fadeTask: Task? private var slideImage: CGImage? private var slideStart = ProcessInfo.processInfo.systemUptime private var slideAnchor: Double = 0 func showScreen(_ image: CGImage,seconds: Double,title: String,songID: Int64) { stop();slideImage = image;self.image = image;duration = max(3,seconds);self.title = title;currentSongID = songID slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() } func fadeOutAndStop() { guard duration > 0,!fading else { return };fading = true let token = generation,start = ProcessInfo.processInfo.systemUptime,seconds = max(0.05,fadeSeconds),initial = outputOpacity fadeTask = Task { [weak self] in while !Task.isCancelled { guard let self,self.generation == token else { return } let fraction = min(1,(ProcessInfo.processInfo.systemUptime-start)/seconds) self.setFade(initial*(1-fraction)) if fraction >= 1 { self.stop();return } try? await Task.sleep(nanoseconds:16_666_667) } } } private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;node.volume = Float(next) } } private var videoPlayer: AVPlayer? private var videoOutput: AVPlayerItemVideoOutput? private let videoContext = CIContext() private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") } private var restoringMix = true private var persistsMix = true func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws { guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") } var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel) var previousDevice = AudioDeviceID(0),bytes = UInt32(MemoryLayout.size) _ = AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,&bytes) func setMap(_ values: inout [Int32]) -> OSStatus { values.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) } } // Channel-only changes can be applied by HAL without interrupting the player. if previousDevice == device,setMap(&map) == noErr { return } tick() let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground var previousMap = outputChannelMap() cancelPendingPlayback();generation = UUID();node.stop();engine.stop() do { var value = device let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value))) guard result == noErr else { throw LibraryError.message("Audio routing failed (\(result))") } let mapResult = setMap(&map) guard mapResult == noErr else { throw LibraryError.message("Audio channel routing failed (\(mapResult))") } if running || resume { try engine.start() } if file != nil { schedule(at:position,play:resume,notifyPlayback:wasWaiting) } } catch { if previousDevice != 0 { _ = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,UInt32(MemoryLayout.size)) if !previousMap.isEmpty { _ = setMap(&previousMap) } } if running || resume { try? engine.start() } if file != nil { schedule(at:position,play:resume && engine.isRunning,notifyPlayback:wasWaiting) } throw error } } func outputChannelMap() -> [Int32] { guard let unit = engine.outputNode.audioUnit else { return [] } var size: UInt32 = 0 guard AudioUnitGetPropertyInfo(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,&size,nil) == noErr,size > 0 else { return [] } var values = [Int32](repeating:-1,count:Int(size)/4) let result = values.withUnsafeMutableBytes { AudioUnitGetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,&size) } return result == noErr ? values : [] } struct GlobalMix: Codable, Equatable { var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool } var globalMix: GlobalMix { .init(volume:volume,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) } private func saveMix() { guard persistsMix,!restoringMix, let data = try? JSONEncoder().encode(globalMix) else { return } try? FileManager.default.createDirectory(at:mixURL.deletingLastPathComponent(),withIntermediateDirectories:true) try? data.write(to:mixURL,options:.atomic) onGlobalMixChanged?(globalMix) } @Published private(set) var waveform = [Float]() @Published private(set) var waveformBands = [WaveformBands]() @Published var inputGain: Double = 0 { didSet { tone.globalGain = Float(min(12,max(-12,inputGain))) } } @Published var lowGain: Double = 0 { didSet { updateTone() } } @Published var midGain: Double = 0 { didSet { updateTone() } } @Published var highGain: Double = 0 { didSet { updateTone() } } @Published var filter: Double = 0 { didSet { updateTone() } } @Published var toneEnabled = true { didSet { updateTone() } } @Published private(set) var leftLevel: Float = 0 @Published private(set) var rightLevel: Float = 0 private let tone = AVAudioUnitEQ(numberOfBands:4) private let meter = AudioLevelMeter() private var metering = false @Published private(set) var waveformLoading = false @Published private(set) var waveformUnavailable = false @Published private(set) var waveformFromCache = false @Published private(set) var waveformSaved = false private let waveformCacheDirectory: URL private var audioURL: URL? private var waveformTask: Task? private var waveformGeneration = UUID() @Published var playing = false @Published var time: Double = 0 @Published var duration: Double = 0 @Published var title = "Select a song" @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume); saveMix() } } @Published var repeating = false @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } @Published var graphicsOffset: Double = 0 { didSet { if graphicsOffset != oldValue { invalidateGraphics();render() } } } private(set) var currentSongID: Int64? var onFinished: (() -> Void)? private let engine = AVAudioEngine() private let node = AVAudioPlayerNode() private let effect = AVAudioUnitTimePitch() private var file: AVAudioFile? private var anchor: Double = 0 private var generation = UUID() private var cdg = Data() private var decoder = CDGDecoder() private var timer: Timer? init(waveformCacheDirectory: URL? = nil, persistsMix: Bool = true) { self.persistsMix = persistsMix self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") engine.attach(node);engine.attach(effect);engine.attach(tone) if persistsMix,let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) { volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled } restoringMix = false;updateTone() } func refreshCachedWaveform() { if let audioURL { analyseWaveform(audioURL) } } func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } } func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } private func updateTone() { AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) saveMix() } func load(_ song: Song, preference: SongPreference = .init(), startPlaying: Bool = true) throws { stop() do { guard MediaByteCopy.nonempty(URL(fileURLWithPath:song.audio)) else { throw LibraryError.message("Audio file is empty or unavailable: \(song.title). Reimport the original media to repair the library copy.") } let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } engine.connect(node,to:effect,format:loaded.processingFormat) engine.connect(effect,to:tone,format:loaded.processingFormat) engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) file = loaded; cdg = graphics; decoder = CDGDecoder();prepareGraphicsCheckpoints() duration = Double(loaded.length)/loaded.processingFormat.sampleRate title = song.title; currentSongID = song.id pitch = preference.pitch; tempo = preference.tempo updateTone() let meter = self.meter engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } metering = true engine.mainMixerNode.outputVolume = Float(volume) engine.prepare(); try engine.start() schedule(at:0,play:startPlaying); startTimer(); tick() if ["mp4","m4v","mov"].contains(URL(fileURLWithPath:song.audio).pathExtension.lowercased()) { let item = AVPlayerItem(url:URL(fileURLWithPath:song.audio)) let output = AVPlayerItemVideoOutput(pixelBufferAttributes:[kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA]) item.add(output);videoOutput = output;videoPlayer = AVPlayer(playerItem:item);videoPlayer?.isMuted = true } audioURL = URL(fileURLWithPath:song.audio) analyseWaveform(audioURL!) } catch { stop(); throw error } } private func analyseWaveform(_ url: URL, force: Bool = false) { waveformTask?.cancel() let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false let directory = waveformCacheDirectory waveformTask = Task.detached(priority:.utility) { [weak self] in let result: Result var cached = false,saved = false do { if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { result = .success(analysis);cached = true;saved = true } else { let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } result = .success(analysis) } } catch { result = .failure(error) } guard !Task.isCancelled else { return } let loadedFromCache = cached,analysisSaved = saved await MainActor.run { [weak self] in guard let self,self.waveformGeneration == token else { return } self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved switch result { case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands case .failure:self.waveformUnavailable = true } } } } private func cancelPendingPlayback() { pendingPlayback?.cancel();pendingPlayback = nil;waitingForBackground = false } private func startNodePlayback() { cancelPendingPlayback() let delay = onWillStartPlayback?() ?? 0 guard delay.isFinite,delay > 0 else { onPlaybackReady?();node.play();return } waitingForBackground = true;let token = generation pendingPlayback = Task { @MainActor [weak self] in do { try await Task.sleep(nanoseconds:UInt64(min(15,delay)*1_000_000_000)+20_000_000) } catch { return } guard let self,self.generation == token,self.playing else { return } self.waitingForBackground = false;self.pendingPlayback = nil self.onPlaybackReady?();self.node.play() } } private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) { guard let file else { return } cancelPendingPlayback();generation = UUID(); let token = generation node.stop();if !fading { setFade(1) } let rate = file.processingFormat.sampleRate let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) anchor = Double(start)/rate; time = anchor guard start < file.length else { playing = false; render(); return } node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in Task { @MainActor in guard let self, self.generation == token else { return } if self.fading { return } if self.loopEnabled,let start = self.loopIn { self.schedule(at:start,play:true) } else if self.repeating { self.schedule(at:0,play:true) } else { self.playing = false; self.time = self.duration; self.render() self.timer?.invalidate(); self.timer = nil; self.onFinished?() } } } if play { if notifyPlayback { startNodePlayback() } else { node.play() } }; playing = play; render() } private func startTimer() { timer?.invalidate() timer = Timer(timeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } if let timer { RunLoop.main.add(timer,forMode:.common) } } func toggle() { if slideImage != nil { if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return } guard file != nil else { return } if playing { cancelPendingPlayback();tick(); node.pause(); playing = false } else if time >= duration { schedule(at:0,play:true); startTimer() } else { startNodePlayback(); playing = true; startTimer() } } func stop() { cancelPendingPlayback() graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1 cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop() fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1);slideImage = nil videoPlayer?.pause();videoPlayer = nil;videoOutput = nil waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } meter.reset(); leftLevel = 0; rightLevel = 0 engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" } func seek(_ value: Double) { guard value.isFinite else { return } invalidateGraphics() if loopEnabled,let loopIn,let loopOut,(value < loopIn || value > loopOut) { loopEnabled = false } if fading { fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1) } if slideImage != nil,value.isFinite { time = min(duration,max(0,value));slideAnchor = time;slideStart = ProcessInfo.processInfo.systemUptime;return } guard file != nil, value.isFinite else { return } schedule(at:min(max(value,0),duration),play:playing); startTimer() } private func render() { if let slideImage { image = slideImage;return } if let videoPlayer, let videoOutput { let target = max(0,time+graphicsOffset) if abs(videoPlayer.currentTime().seconds-target) > 0.08 { videoPlayer.seek(to:CMTime(seconds:target,preferredTimescale:600),toleranceBefore:.zero,toleranceAfter:.zero) } if playing { videoPlayer.rate = Float(tempo) } else { videoPlayer.pause() } if let pixels = videoOutput.copyPixelBuffer(forItemTime:videoPlayer.currentTime(),itemTimeForDisplay:nil) { let frame = CIImage(cvPixelBuffer:pixels);image = videoContext.createCGImage(frame,from:frame.extent) } } else { guard !cdg.isEmpty else { if image != nil { image = nil };return } let seconds = max(0,time+graphicsOffset),target = min(cdg.count/24,Int(seconds*300)) guard target != lastGraphicsPacket || image == nil else { return } guard graphicsTask == nil else { return } if target < decoder.packetIndex || target-decoder.packetIndex > 900 { var start = decoder if target < decoder.packetIndex { start = checkpoints.keys.filter{$0 <= target}.max().flatMap{checkpoints[$0]} ?? CDGDecoder() } else if let key = checkpoints.keys.filter({$0 <= target && $0 > decoder.packetIndex}).max(),let saved = checkpoints[key] { start = saved } let initial = start,data = cdg,token = graphicsGeneration graphicsTask = Task.detached(priority:.userInitiated) { [weak self] in var decoded = initial;decoded.render(data,at:seconds,cancelled:{Task.isCancelled}) guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded await MainActor.run { guard let self,self.graphicsGeneration == token else { return } self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil } } } else { decoder.render(cdg,at:seconds);lastGraphicsPacket = target;image = decoder.image() } } } private func tick() { if slideImage != nil { if playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart) } if playing,time >= duration,!fading { if repeating { time = 0;slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime } else { playing = false;timer?.invalidate();timer = nil;onFinished?() } };return } let levels = meter.snapshot() let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0 if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right } if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { let next = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) if abs(time-next) > 0.0001 { time = next } if loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return } } if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) } render() } private func invalidateGraphics() { graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;lastGraphicsPacket = -1 } private func prepareGraphicsCheckpoints() { guard !cdg.isEmpty else { return } let data = cdg,interval = max(3000,(cdg.count/24)/180) checkpointTask = Task.detached(priority:.utility) { [weak self] in var decoder = CDGDecoder(),saved = [Int:CDGDecoder]() for packet in stride(from:0,through:data.count/24,by:interval) { guard !Task.isCancelled else { return };decoder.render(data,at:Double(packet)/300,cancelled:{Task.isCancelled});saved[decoder.packetIndex] = decoder } guard !Task.isCancelled else { return };let completed = saved await MainActor.run { [weak self] in guard let self,self.cdg == data else { return };self.checkpoints = completed } } } } /// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. /// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. enum AudioWaveform { struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] } static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] { try analyse(url:url,bins:bins,cancelled:cancelled).levels } static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis { guard bins > 0 else { return .init(levels:[],bands:[]) } let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } var energy = [Double](repeating: 0, count: bins) var counts = [Int](repeating: 0, count: bins) var position: Int64 = 0 let channels = Int(file.processingFormat.channelCount) let rate = file.processingFormat.sampleRate let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) var lowState = [Double](repeating:0,count:channels), highState = lowState var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows while position < file.length { if cancelled() { throw CancellationError() } try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) let frames = Int(buffer.frameLength) guard frames > 0, let data = buffer.floatChannelData else { break } for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } let peak = rms.max() ?? 0 let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } func reset() { lock.lock();levels = (0,0);lock.unlock() } } /// Persistent analysis cache: file path, size, modification time and analyser version form its key. /// Invalid or obsolete entries are treated as misses, never as playback errors. enum WaveformCache { static func location(audio: URL,directory: URL) throws -> URL { let values = try FileManager.default.attributesOfItem(atPath:audio.path) let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 let size = (values[.size] as? NSNumber)?.int64Value ?? 0 let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)" let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined() return directory.appendingPathComponent(key+".waveform") } static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis { let url = try location(audio:audio,directory:directory) let data = try Data(contentsOf:url) guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") } let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data) guard analysis.levels.count == 4096,analysis.bands.count == 4096, analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }), analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") } return analysis } static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws { try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true) let encoder = PropertyListEncoder();encoder.outputFormat = .binary try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic) } }