import Foundation import AVFoundation import MediaToolbox import Accelerate // MARK: - Lock-free Ring Buffer for audio samples /// Single-producer (audio render thread), single-consumer (main thread) ring buffer. /// No locks, no allocations on the audio thread. ARM64 naturally-atomic Int writes /// ensure the single write index is safely visible across threads. final class PCMRingBuffer { let capacity: Int private let buffer: UnsafeMutablePointer // Atomic indices — render thread writes `writeIndex`, main thread writes `readIndex` private var _writeIndex: Int = 0 init(capacity: Int) { self.capacity = capacity buffer = .allocate(capacity: capacity) buffer.initialize(repeating: 0, count: capacity) } deinit { buffer.deallocate() } /// Write samples from the audio render thread. Lock-free. func write(_ samples: UnsafePointer, count: Int) { let wi = _writeIndex let space = capacity for i in 0.., count: Int) -> Int { let wi = _writeIndex let start = (wi - count + capacity) % capacity for i in 0.., CMItemCount) -> Void)? // Pre-allocated FFT resources — created once, reused every frame (30fps) private let fftSize = 1024 private let fftLog2n: vDSP_Length private let fftSetup: FFTSetup private var hannWindow: [Float] private var fftTimeDomain: [Float] private var fftRealp: [Float] private var fftImagp: [Float] private var fftMagnitudes: [Float] // Debug: save PCM to WAV file for verification — tap the share button in settings var debugDumpEnabled = false var debugDumpURL: URL? private var debugFileHandle: FileHandle? private var debugSamplesWritten: Int = 0 private var debugMaxSamplesActual = 44100 * 5 // recalculated in startDebugDump from actual rate /// Posted on main thread when debug capture completes. userInfo contains "url": URL. static let captureCompleteNotification = Notification.Name("AudioTapCaptureComplete") // Source format detected by the tap's prepare callback var sourceFormat: AVAudioFormat? private init() { let halfSize = fftSize / 2 fftLog2n = vDSP_Length(log2(Float(fftSize))) fftSetup = vDSP_create_fftsetup(fftLog2n, FFTRadix(kFFTRadix2))! hannWindow = [Float](repeating: 0, count: fftSize) vDSP_hann_window(&hannWindow, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM)) fftTimeDomain = [Float](repeating: 0, count: fftSize) fftRealp = [Float](repeating: 0, count: halfSize) fftImagp = [Float](repeating: 0, count: halfSize) fftMagnitudes = [Float](repeating: 0, count: halfSize) } deinit { vDSP_destroy_fftsetup(fftSetup) } // MARK: - Install / Remove Tap /// Install the shared tap on a player item. Returns true if successful. /// Safe to call multiple times — removes any existing tap first. @MainActor func installTap(on playerItem: AVPlayerItem) async -> Bool { // Remove existing tap removeTap(from: playerItem) // Load audio tracks — async API (non-deprecated) guard let audioTrack = try? await playerItem.asset .loadTracks(withMediaType: .audio).first else { print("[AudioTap] No audio track found on playerItem") return false } // Create the MTAudioProcessingTap with C callbacks var callbacks = MTAudioProcessingTapCallbacks( version: kMTAudioProcessingTapCallbacksVersion_0, clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), init: tapInit, finalize: nil, prepare: tapPrepare, unprepare: nil, process: tapProcess ) var tapOut: MTAudioProcessingTap? let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut ) guard status == noErr, let tap = tapOut else { print("[AudioTap] MTAudioProcessingTapCreate failed: \(status)") return false } let inputParams = AVMutableAudioMixInputParameters(track: audioTrack) inputParams.audioTapProcessor = tap let mix = AVMutableAudioMix() mix.inputParameters = [inputParams] playerItem.audioMix = mix print("[AudioTap] Tap installed successfully") // Start debug dump if enabled if debugDumpEnabled { startDebugDump() } return true } /// Remove the tap from a player item. func removeTap(from playerItem: AVPlayerItem?) { playerItem?.audioMix = nil sourceFormat = nil ringBuffer.reset() // Prevent stale samples from previous stream bleeding into next FFT stopDebugDump() } // MARK: - FFT Processing (called from main thread timer) /// Perform FFT on the most recent samples and return frequency bands (0.0-1.0). /// Uses pre-allocated buffers — minimal heap allocation per call. /// Call this at 30fps from the visualizer timer. func computeFFTBands(bandCount: Int = 30) -> [Float] { let halfSize = fftSize / 2 // Read most recent 1024 samples from ring buffer into pre-allocated array _ = fftTimeDomain.withUnsafeMutableBufferPointer { buf in ringBuffer.readMostRecent(into: buf.baseAddress!, count: fftSize) } // Apply Hann window (pre-computed) to reduce spectral leakage vDSP_vmul(fftTimeDomain, 1, hannWindow, 1, &fftTimeDomain, 1, vDSP_Length(fftSize)) // Zero the split complex buffers for i in 0..> 8) & 0xFF) p[26] = UInt8((sr >> 16) & 0xFF); p[27] = UInt8((sr >> 24) & 0xFF) // Byte rate: sampleRate * 1ch * 4 bytes let br = sr * 4 p[28] = UInt8(br & 0xFF); p[29] = UInt8((br >> 8) & 0xFF) p[30] = UInt8((br >> 16) & 0xFF); p[31] = UInt8((br >> 24) & 0xFF) // Block align: 4 p[32] = 4; p[33] = 0 // Bits per sample: 32 p[34] = 32; p[35] = 0 // "data" p[36] = 0x64; p[37] = 0x61; p[38] = 0x74; p[39] = 0x61 // Data size placeholder (patch later) } FileManager.default.createFile(atPath: url.path, contents: header) debugFileHandle = FileHandle(forWritingAtPath: url.path) debugFileHandle?.seekToEndOfFile() debugSamplesWritten = 0 debugDumpEnabled = true print("[AudioTap] Debug capture started: \(url.lastPathComponent) at \(sampleRate)Hz") } /// Stop capturing and finalize the WAV header with correct sizes. func stopDebugDump() { guard let fh = debugFileHandle else { return } debugDumpEnabled = false // Patch WAV header with correct sizes let dataSize = UInt32(debugSamplesWritten * MemoryLayout.size) let fileSize = dataSize + 36 // 44 - 8 = 36 fh.seek(toFileOffset: 4) var fs = fileSize; fh.write(Data(bytes: &fs, count: 4)) fh.seek(toFileOffset: 40) var ds = dataSize; fh.write(Data(bytes: &ds, count: 4)) fh.closeFile() debugFileHandle = nil if debugSamplesWritten > 0 { let rate = sourceFormat?.sampleRate ?? 44100 let duration = Double(debugSamplesWritten) / rate print("[AudioTap] Debug capture complete: \(String(format: "%.1f", duration))s, \(dataSize) bytes") } debugSamplesWritten = 0 } /// Called from the tap process callback to write samples to WAV file. func debugWriteSamples(_ samples: UnsafePointer, count: Int) { guard debugDumpEnabled, debugSamplesWritten < debugMaxSamplesActual else { if debugDumpEnabled && debugSamplesWritten >= debugMaxSamplesActual { // Auto-stop after 5 seconds debugDumpEnabled = false DispatchQueue.main.async { [weak self] in self?.stopDebugDump() if let url = self?.debugDumpURL { NotificationCenter.default.post( name: AudioTapProcessor.captureCompleteNotification, object: nil, userInfo: ["url": url] ) } } } return } let data = Data(bytes: samples, count: count * MemoryLayout.size) debugFileHandle?.write(data) debugSamplesWritten += count } } // MARK: - C Tap Callbacks (free functions, not methods) private func tapInit( tap: MTAudioProcessingTap, clientInfo: UnsafeMutableRawPointer?, tapStorageOut: UnsafeMutablePointer ) { tapStorageOut.pointee = clientInfo } private func tapPrepare( tap: MTAudioProcessingTap, maxFrames: CMItemCount, processingFormat: UnsafePointer ) { let processor = Unmanaged .fromOpaque(MTAudioProcessingTapGetStorage(tap)) .takeUnretainedValue() let format = AVAudioFormat(streamDescription: processingFormat) processor.sourceFormat = format print("[AudioTap] Prepared: \(processingFormat.pointee.mSampleRate)Hz, " + "\(processingFormat.pointee.mChannelsPerFrame)ch, " + "\(processingFormat.pointee.mBitsPerChannel)bit, " + "float=\(processingFormat.pointee.mFormatFlags & kAudioFormatFlagIsFloat != 0)") } private func tapProcess( tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags, bufferListInOut: UnsafeMutablePointer, numberFramesOut: UnsafeMutablePointer, flagsOut: UnsafeMutablePointer ) { // Fetch audio from the source — passes through to player unchanged let status = MTAudioProcessingTapGetSourceAudio( tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut ) guard status == noErr else { return } let processor = Unmanaged .fromOpaque(MTAudioProcessingTapGetStorage(tap)) .takeUnretainedValue() // Extract mono float samples from the first channel let abl = UnsafeMutableAudioBufferListPointer(bufferListInOut) guard let firstBuffer = abl.first, let data = firstBuffer.mData else { return } let floatPtr = data.assumingMemoryBound(to: Float.self) let frameCount = Int(numberFramesOut.pointee) // Write to ring buffer (lock-free, no allocation) processor.ringBuffer.write(floatPtr, count: frameCount) // Forward to Shazam handler if active processor.shazamHandler?(bufferListInOut, numberFramesOut.pointee) // Debug dump if enabled if processor.debugDumpEnabled { processor.debugWriteSamples(floatPtr, count: frameCount) } }