mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			191 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			191 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			Swift
		
	
| import Accelerate
 | |
| import PromiseKit
 | |
| 
 | |
| enum AudioUtilities {
 | |
|     private static let noiseFloor: Float = -80
 | |
| 
 | |
|     private struct FileInfo {
 | |
|         let sampleCount: Int
 | |
|         let asset: AVAsset
 | |
|         let track: AVAssetTrack
 | |
|     }
 | |
| 
 | |
|     enum Error : LocalizedError {
 | |
|         case noAudioTrack
 | |
|         case noAudioFormatDescription
 | |
|         case loadingFailed
 | |
|         case parsingFailed
 | |
| 
 | |
|         var errorDescription: String? {
 | |
|             switch self {
 | |
|             case .noAudioTrack: return "No audio track."
 | |
|             case .noAudioFormatDescription: return "No audio format description."
 | |
|             case .loadingFailed: return "Couldn't load asset."
 | |
|             case .parsingFailed: return "Couldn't parse asset."
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     static func getVolumeSamples(for audioFileURL: URL, targetSampleCount: Int) -> Promise<[Float]> {
 | |
|         return loadFile(audioFileURL).then { fileInfo in
 | |
|             AudioUtilities.parseSamples(from: fileInfo, with: targetSampleCount)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private static func loadFile(_ audioFileURL: URL, isRetry: Bool = false) -> Promise<FileInfo> {
 | |
|         let asset = AVURLAsset(url: audioFileURL)
 | |
|         guard let track = asset.tracks(withMediaType: AVMediaType.audio).first else {
 | |
|             if isRetry {
 | |
|                 return Promise(error: Error.loadingFailed)
 | |
|             } else {
 | |
|                 // Workaround for issue where MP3 files sent by Android get saved as M4A
 | |
|                 var newAudioFileURL = audioFileURL.deletingPathExtension()
 | |
|                 let fileName = newAudioFileURL.lastPathComponent
 | |
|                 newAudioFileURL = newAudioFileURL.deletingLastPathComponent()
 | |
|                 newAudioFileURL = newAudioFileURL.appendingPathComponent("\(fileName).mp3")
 | |
|                 let fileManager = FileManager.default
 | |
|                 if fileManager.fileExists(atPath: newAudioFileURL.path) {
 | |
|                     return loadFile(newAudioFileURL, isRetry: true)
 | |
|                 } else {
 | |
|                     do {
 | |
|                         try FileManager.default.copyItem(at: audioFileURL, to: newAudioFileURL)
 | |
|                     } catch {
 | |
|                         return Promise(error: Error.loadingFailed)
 | |
|                     }
 | |
|                     return loadFile(newAudioFileURL, isRetry: true)
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         let (promise, seal) = Promise<FileInfo>.pending()
 | |
|         asset.loadValuesAsynchronously(forKeys: [ #keyPath(AVAsset.duration) ]) {
 | |
|             var nsError: NSError?
 | |
|             let status = asset.statusOfValue(forKey: #keyPath(AVAsset.duration), error: &nsError)
 | |
|             switch status {
 | |
|             case .loaded:
 | |
|                 guard let formatDescriptions = track.formatDescriptions as? [CMAudioFormatDescription],
 | |
|                     let audioFormatDescription = formatDescriptions.first,
 | |
|                     let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatDescription)
 | |
|                     else { return seal.reject(Error.noAudioFormatDescription) }
 | |
|                 let sampleCount = Int((asbd.pointee.mSampleRate) * Float64(asset.duration.value) / Float64(asset.duration.timescale))
 | |
|                 let fileInfo = FileInfo(sampleCount: sampleCount, asset: asset, track: track)
 | |
|                 seal.fulfill(fileInfo)
 | |
|             default:
 | |
|                 print("Couldn't load asset due to error: \(nsError?.localizedDescription ?? "no description provided").")
 | |
|                 seal.reject(Error.loadingFailed)
 | |
|             }
 | |
|         }
 | |
|         return promise
 | |
|     }
 | |
| 
 | |
|     private static func parseSamples(from fileInfo: FileInfo, with targetSampleCount: Int) -> Promise<[Float]> {
 | |
|         // Prepare the reader
 | |
|         guard let reader = try? AVAssetReader(asset: fileInfo.asset) else { return Promise(error: Error.parsingFailed) }
 | |
|         let range = 0..<fileInfo.sampleCount
 | |
|         reader.timeRange = CMTimeRange(start: CMTime(value: Int64(range.lowerBound), timescale: fileInfo.asset.duration.timescale),
 | |
|             duration: CMTime(value: Int64(range.count), timescale: fileInfo.asset.duration.timescale))
 | |
|         let outputSettings: [String:Any] = [
 | |
|             AVFormatIDKey : Int(kAudioFormatLinearPCM),
 | |
|             AVLinearPCMBitDepthKey : 16,
 | |
|             AVLinearPCMIsBigEndianKey : false,
 | |
|             AVLinearPCMIsFloatKey : false,
 | |
|             AVLinearPCMIsNonInterleaved : false
 | |
|         ]
 | |
|         let output = AVAssetReaderTrackOutput(track: fileInfo.track, outputSettings: outputSettings)
 | |
|         output.alwaysCopiesSampleData = false
 | |
|         reader.add(output)
 | |
|         var channelCount = 1
 | |
|         let formatDescriptions = fileInfo.track.formatDescriptions as! [CMAudioFormatDescription]
 | |
|         for audioFormatDescription in formatDescriptions {
 | |
|             guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatDescription) else {
 | |
|                 return Promise(error: Error.parsingFailed)
 | |
|             }
 | |
|             channelCount = Int(asbd.pointee.mChannelsPerFrame)
 | |
|         }
 | |
|         let samplesPerPixel = max(1, channelCount * range.count / targetSampleCount)
 | |
|         let filter = [Float](repeating: 1 / Float(samplesPerPixel), count: samplesPerPixel)
 | |
|         var result = [Float]()
 | |
|         var sampleBuffer = Data()
 | |
|         // Read the file
 | |
|         reader.startReading()
 | |
|         defer { reader.cancelReading() }
 | |
|         while reader.status == .reading {
 | |
|             guard let readSampleBuffer = output.copyNextSampleBuffer(),
 | |
|                 let readBuffer = CMSampleBufferGetDataBuffer(readSampleBuffer) else { break }
 | |
|             var readBufferLength = 0
 | |
|             var readBufferPointer: UnsafeMutablePointer<Int8>?
 | |
|             CMBlockBufferGetDataPointer(readBuffer,
 | |
|                                         atOffset: 0,
 | |
|                                         lengthAtOffsetOut: &readBufferLength,
 | |
|                                         totalLengthOut: nil,
 | |
|                                         dataPointerOut: &readBufferPointer)
 | |
|             sampleBuffer.append(UnsafeBufferPointer(start: readBufferPointer, count: readBufferLength))
 | |
|             CMSampleBufferInvalidate(readSampleBuffer)
 | |
|             let sampleCount = sampleBuffer.count / MemoryLayout<Int16>.size
 | |
|             let downSampledLength = sampleCount / samplesPerPixel
 | |
|             let samplesToProcess = downSampledLength * samplesPerPixel
 | |
|             guard samplesToProcess > 0 else { continue }
 | |
|             processSamples(from: &sampleBuffer,
 | |
|                            outputSamples: &result,
 | |
|                            samplesToProcess: samplesToProcess,
 | |
|                            downSampledLength: downSampledLength,
 | |
|                            samplesPerPixel: samplesPerPixel,
 | |
|                            filter: filter)
 | |
|         }
 | |
|         // Process any remaining samples
 | |
|         let samplesToProcess = sampleBuffer.count / MemoryLayout<Int16>.size
 | |
|         if samplesToProcess > 0 {
 | |
|             let downSampledLength = 1
 | |
|             let samplesPerPixel = samplesToProcess
 | |
|             let filter = [Float](repeating: 1.0 / Float(samplesPerPixel), count: samplesPerPixel)
 | |
|             processSamples(from: &sampleBuffer,
 | |
|                            outputSamples: &result,
 | |
|                            samplesToProcess: samplesToProcess,
 | |
|                            downSampledLength: downSampledLength,
 | |
|                            samplesPerPixel: samplesPerPixel,
 | |
|                            filter: filter)
 | |
|         }
 | |
|         guard reader.status == .completed else { return Promise(error: Error.parsingFailed) }
 | |
|         // Return
 | |
|         return Promise { $0.fulfill(result) }
 | |
|     }
 | |
| 
 | |
|     private static func processSamples(from sampleBuffer: inout Data, outputSamples: inout [Float], samplesToProcess: Int,
 | |
|         downSampledLength: Int, samplesPerPixel: Int, filter: [Float]) {
 | |
|         sampleBuffer.withUnsafeBytes { (samples: UnsafeRawBufferPointer) in
 | |
|             var processingBuffer = [Float](repeating: 0, count: samplesToProcess)
 | |
|             let sampleCount = vDSP_Length(samplesToProcess)
 | |
|             // Create an UnsafePointer<Int16> from the samples
 | |
|             let unsafeBufferPointer = samples.bindMemory(to: Int16.self)
 | |
|             let unsafePointer = unsafeBufferPointer.baseAddress!
 | |
|             // Convert 16 bit int samples to floats
 | |
|             vDSP_vflt16(unsafePointer, 1, &processingBuffer, 1, sampleCount)
 | |
|             // Take the absolute values to get the amplitude
 | |
|             vDSP_vabs(processingBuffer, 1, &processingBuffer, 1, sampleCount)
 | |
|             // Get the corresponding dB values and clip the results
 | |
|             getdB(from: &processingBuffer)
 | |
|             // Downsample and average
 | |
|             var downSampledData = [Float](repeating: 0, count: downSampledLength)
 | |
|             vDSP_desamp(processingBuffer,
 | |
|                         vDSP_Stride(samplesPerPixel),
 | |
|                         filter,
 | |
|                         &downSampledData,
 | |
|                         vDSP_Length(downSampledLength),
 | |
|                         vDSP_Length(samplesPerPixel))
 | |
|             // Remove the processed samples
 | |
|             sampleBuffer.removeFirst(samplesToProcess * MemoryLayout<Int16>.size)
 | |
|             // Update the output samples
 | |
|             outputSamples += downSampledData
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     static func getdB(from normalizedSamples: inout [Float]) {
 | |
|         // Convert samples to a log scale
 | |
|         var zero: Float = 32768.0
 | |
|         vDSP_vdbcon(normalizedSamples, 1, &zero, &normalizedSamples, 1, vDSP_Length(normalizedSamples.count), 1)
 | |
|         // Clip to [noiseFloor, 0]
 | |
|         var ceil: Float = 0.0
 | |
|         var noiseFloorMutable = AudioUtilities.noiseFloor
 | |
|         vDSP_vclip(normalizedSamples, 1, &noiseFloorMutable, &ceil, &normalizedSamples, 1, vDSP_Length(normalizedSamples.count))
 | |
|     }
 | |
| }
 |