Signal-iOS/SignalServiceKit/Util/AudioWaveform.swift
Jordan Rose 2727a70b90 Make AtomicValue's lock-sharing explicit
Also:
- Remove AtomicUInt's @objc, it's no longer used from ObjC
- Remove Codable conformances, they would implicitly use shared locks
  and weren't used in practice
2024-03-27 10:57:32 -07:00

623 lines
23 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Accelerate
import AVFoundation
import Foundation
import SignalCoreKit
@objc
public protocol AudioWaveformSamplingObserver: AnyObject {
func audioWaveformDidFinishSampling(_ audioWaveform: AudioWaveform)
}
// MARK: -
@objc
public class AudioWaveformManager: NSObject {
private static let unfairLock = UnfairLock()
private typealias AttachmentId = String
private static var cache = LRUCache<AttachmentId, Weak<AudioWaveform>>(maxSize: 64)
private static var observerMap = [String: SamplingObserver]()
@available(*, unavailable, message: "Do not instantiate this class.")
private override init() {}
@objc
public static func audioWaveform(forAttachment attachment: TSAttachmentStream, highPriority: Bool) -> AudioWaveform? {
unfairLock.withLock {
let attachmentId = attachment.uniqueId
guard attachment.isAudioMimeType else {
owsFailDebug("Not audio.")
return nil
}
guard let audioWaveformPath = attachment.audioWaveformPath else {
owsFailDebug("Missing audioWaveformPath.")
return nil
}
guard let originalFilePath = attachment.originalFilePath else {
owsFailDebug("Missing originalFilePath.")
return nil
}
if let cacheBox = cache[attachmentId],
let cachedValue = cacheBox.value {
return cachedValue
}
guard let value = buildAudioWaveForm(
forAudioPath: originalFilePath,
waveformPath: audioWaveformPath,
identifier: attachmentId,
highPriority: highPriority
) else {
return nil
}
cache[attachmentId] = Weak(value: value)
return value
}
}
@objc
public static func audioWaveform(forAudioPath audioPath: String, waveformPath: String) -> AudioWaveform? {
unfairLock.withLock {
guard let value = buildAudioWaveForm(
forAudioPath: audioPath,
waveformPath: waveformPath,
identifier: UUID().uuidString,
highPriority: false
) else {
return nil
}
return value
}
}
// This method should only be called with unfairLock acquired.
private static func buildAudioWaveForm(
forAudioPath audioPath: String,
waveformPath: String,
identifier: String,
highPriority: Bool
) -> AudioWaveform? {
if FileManager.default.fileExists(atPath: waveformPath) {
// We have a cached waveform on disk, read it into memory.
do {
return try AudioWaveform(contentsOfFile: waveformPath)
} catch {
owsFailDebug("Error: \(error)")
// Remove the file from disk and create a new one.
OWSFileSystem.deleteFileIfExists(waveformPath)
}
}
let audioUrl = URL(fileURLWithPath: audioPath)
var asset = AVURLAsset(url: audioUrl)
if !asset.isReadable {
// In some cases, Android sends audio messages with the "audio/mpeg" content type. This
// makes our choice of file extension ambiguous`.mp3` or `.m4a`? AVFoundation uses the
// extension to read the file, and if the extension is wrong, it won't be readable.
//
// We "lie" about the extension to generate the waveform so that AVFoundation may read
// it. This is brittle but necessary to work around the buggy marriage of Android's
// content type and AVFoundation's behavior.
//
// Note that we probably still want this code even if Android updates theirs, because
// iOS users might have existing attachments.
//
// See a similar comment in `AudioPlayer` and
// <https://github.com/signalapp/Signal-iOS/issues/3590>.
let extensionOverride: String?
switch audioUrl.pathExtension {
case "m4a": extensionOverride = "aac"
case "mp3": extensionOverride = "m4a"
default: extensionOverride = nil
}
if let extensionOverride {
let symlinkPath = OWSFileSystem.temporaryFilePath(
fileExtension: extensionOverride,
isAvailableWhileDeviceLocked: true
)
do {
try FileManager.default.createSymbolicLink(atPath: symlinkPath,
withDestinationPath: audioPath)
} catch {
owsFailDebug("Failed to create voice memo symlink: \(error)")
return nil
}
asset = AVURLAsset(url: URL(fileURLWithPath: symlinkPath))
}
}
guard asset.isReadable else {
owsFailDebug("unexpectedly encountered unreadable audio file.")
return nil
}
guard CMTimeGetSeconds(asset.duration) <= AudioWaveform.maximumDuration else {
return nil
}
let waveform = AudioWaveform()
// Listen for sampling completion so we can cache the final waveform to disk.
let observer = SamplingObserver(waveform: waveform,
identifier: identifier,
audioWaveformPath: waveformPath)
observerMap[identifier] = observer
waveform.addSamplingObserver(observer)
waveform.beginSampling(for: asset, highPriority: highPriority)
return waveform
}
private class SamplingObserver: AudioWaveformSamplingObserver {
// Retain waveform until sampling is complete.
let waveform: AudioWaveform
let identifier: String
let audioWaveformPath: String
init(waveform: AudioWaveform,
identifier: String,
audioWaveformPath: String) {
self.waveform = waveform
self.identifier = identifier
self.audioWaveformPath = audioWaveformPath
}
func audioWaveformDidFinishSampling(_ audioWaveform: AudioWaveform) {
let identifier = self.identifier
let audioWaveformPath = self.audioWaveformPath
DispatchQueue.global().async {
AudioWaveformManager.unfairLock.withLock {
do {
let parentDirectoryPath = (audioWaveformPath as NSString).deletingLastPathComponent
if OWSFileSystem.ensureDirectoryExists(parentDirectoryPath) {
try audioWaveform.write(toFile: audioWaveformPath, atomically: true)
} else {
owsFailDebug("Could not create parent directory.")
}
} catch {
owsFailDebug("Error: \(error)")
}
// Discard observer.
owsAssertDebug(observerMap[identifier] != nil)
observerMap[identifier] = nil
}
}
}
}
}
// MARK: -
@objc
public class AudioWaveform: NSObject {
@objc
public var isSamplingComplete: Bool {
return decibelSamples != nil
}
@objc
public override init() {}
deinit {
sampleOperation?.cancel()
}
// MARK: - Caching
@objc
public init(contentsOfFile filePath: String) throws {
guard let unarchivedSamples = NSKeyedUnarchiver.unarchiveObject(withFile: filePath) as? [Float] else {
throw OWSAssertionError("Failed to unarchive decibel samples")
}
super.init()
decibelSamples = unarchivedSamples
}
@objc
public func write(toFile filePath: String, atomically: Bool) throws {
guard isSamplingComplete, let decibelSamples = decibelSamples else {
throw OWSAssertionError("can't write incomplete waveform to file \(filePath)")
}
let archivedData = try NSKeyedArchiver.archivedData(withRootObject: decibelSamples, requiringSecureCoding: false)
try archivedData.write(to: URL(fileURLWithPath: filePath), options: atomically ? .atomicWrite : .init())
}
// MARK: -
@objc
public func normalizedLevelsToDisplay(sampleCount: Int) -> [Float]? {
guard isSamplingComplete else { return nil }
// Do nothing if the number of requested samples is less than 1
guard sampleCount > 0 else { return [] }
// Do nothing if we don't yet have any samples.
guard let decibelSamples = decibelSamples else {
owsFailDebug("unexpectedly missing sample data")
return nil
}
// Normalize to a range of 0-1 with 0 being silence and
// 1 being the loudest value we render.
func normalize(_ float: Float) -> Float {
float.inverseLerp(
AudioWaveform.silenceThreshold,
AudioWaveform.clippingThreshold,
shouldClamp: true
)
}
// If we're trying to downsample to more samples than exist, just return what we have.
guard decibelSamples.count > sampleCount else {
return decibelSamples.map(normalize)
}
let downSampledData = downsample(samples: decibelSamples, toSampleCount: sampleCount)
return downSampledData.map(normalize)
}
// MARK: - Sampling
/// The recorded samples for this waveform.
private var _decibelSamples = AtomicOptional<[Float]>(nil, lock: .sharedGlobal)
private var decibelSamples: [Float]? {
get { _decibelSamples.get() }
set { _decibelSamples.set(newValue) }
}
/// Anything below this decibel level is considered silent and clipped.
fileprivate static let silenceThreshold: Float = -50
fileprivate static let clippingThreshold: Float = -20
/// The number of samples to collect for the given audio file.
/// We limit this to restrict the memory space an individual audio
/// file can consume.
///
/// If rendering waveforms at a higher resolution, this value may
/// need to be adjusted appropriately.
///
/// Currently, these samples are cached to disk in `TSAttachmentStream`,
/// so we need to make sure that sample count produces a reasonably file size.
fileprivate static let sampleCount = 100
/// The maximum duration asset that we will display waveforms for.
/// It's too intensive to sample a waveform for really long audio files.
fileprivate static let maximumDuration: TimeInterval = 15 * kMinuteInterval
private weak var sampleOperation: Operation?
private static var operationDequeue = AtomicArray<AudioWaveformSamplingOperation>(lock: .sharedGlobal)
fileprivate func beginSampling(for asset: AVAsset, highPriority: Bool) {
owsAssertDebug(sampleOperation == nil)
let innerOperation = AudioWaveformSamplingOperation(asset: asset) { [weak self] samples in
guard let self = self else { return }
self.decibelSamples = samples
self.notifyObserversOfSamplingCompletion()
}
class WrapperOperation: Operation {
static let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.name = "AudioWaveformSamplingWrapper"
operationQueue.maxConcurrentOperationCount = 1
operationQueue.qualityOfService = .utility
return operationQueue
}()
private let closure: () -> Void
init(_ closure: @escaping () -> Void) {
self.closure = closure
}
override func main() {
closure()
}
}
let outerOperation = WrapperOperation {
guard let operation = Self.operationDequeue.popHead() else {
return
}
guard !operation.isCancelled else {
return
}
AudioWaveformSamplingOperation.operationQueue.addOperation(operation)
operation.waitUntilFinished()
}
if highPriority {
Self.operationDequeue.pushHead(innerOperation)
} else {
Self.operationDequeue.pushTail(innerOperation)
}
WrapperOperation.operationQueue.addOperation(outerOperation)
sampleOperation = innerOperation
}
private func downsample(samples: [Float], toSampleCount sampleCount: Int) -> [Float] {
// Do nothing if the number of requested samples is less than 1
guard sampleCount > 0 else { return [] }
// If the requested sample count is equal to the sample count, just return the samples
guard samples.count != sampleCount else { return samples }
// Calculate the number of samples each downsampled value should take into account
let sampleDistribution = Float(samples.count) / Float(sampleCount)
// Calculate the number of samples we need to factor in when downsampling. Since there
// is no such thing as a fractional sample, we need to round this up to the nearest Int.
// When we calculated the distribution later, it will factor in that some of these samples
// are weighted differently than others in the resulting output.
let sampleLength = Int(ceil(sampleDistribution))
// Calculate the weight of each sample in the downsampled group.
// For whole number `sampleDistribution` the distribution is always
// equivalent across all of the samples. If the sampleDistribution
// is _not_ a whole number, we factor the bookending values in
// relative to remainder proportion
let distribution: [Float] = {
let averageProportion = 1 / sampleDistribution
var array = [Float](repeating: averageProportion, count: sampleLength)
if samples.count % sampleCount != 0 {
// Calculate the proportion that the partial sample should be weighted at
let remainderProportion = (sampleDistribution.truncatingRemainder(dividingBy: 1)) * averageProportion
// The partial sample is factored into the "bookends" (first and last element of the distribution)
// by averaging the average distribution and the remainder distribution together.
// This provides a lightweight "anti-aliasing" effect.
let bookEndProportions = (averageProportion + remainderProportion) / 2
array[0] = bookEndProportions
array[sampleLength - 1] = bookEndProportions
}
return array
}()
// If we can ever guarantee that `samples.count` is always a multiple of `sampleCount`, we should
// switch to using the faster `vDSP_desamp`. For now, we can't use it since it only supports the
// integer stride lengths. This should be okay, since this should only be operating on already
// downsampled data (~100 points) rather than the original millions of points.
let result: [Float] = (0..<sampleCount).map { downsampledIndex in
let sampleStart = Int(floor(Float(downsampledIndex) * sampleDistribution))
return samples[sampleStart..<sampleStart + sampleLength].enumerated().reduce(0) { result, value in
return result + distribution[value.offset] * value.element
}
}
return result
}
// MARK: - Observation
private var observers = [Weak<AudioWaveformSamplingObserver>]()
private func notifyObserversOfSamplingCompletion() {
observers.forEach {
$0.value?.audioWaveformDidFinishSampling(self)
}
}
@objc
public func addSamplingObserver(_ observer: AudioWaveformSamplingObserver) {
observers.append(Weak(value: observer))
// If sampling is already complete, notify the observer immediately.
guard isSamplingComplete else { return }
observer.audioWaveformDidFinishSampling(self)
}
}
// MARK: -
private class AudioWaveformSamplingOperation: Operation {
let asset: AVAsset
let completionCallback: ([Float]) -> Void
static let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.name = "AudioWaveformSampling"
operationQueue.maxConcurrentOperationCount = 1
operationQueue.qualityOfService = .utility
return operationQueue
}()
init(asset: AVAsset, completionCallback: @escaping ([Float]) -> Void) {
self.asset = asset
self.completionCallback = completionCallback
super.init()
}
override func main() {
var decibelSamples: [Float]?
defer {
if let samples = decibelSamples { completionCallback(samples) }
}
guard !isCancelled else { return }
guard let assetReader = try? AVAssetReader(asset: asset) else {
return owsFailDebug("Unexpectedly failed to initialize asset reader")
}
// We just draw the waveform based on the first track.
guard let audioTrack = assetReader.asset.tracks.first else {
return owsFailDebug("audio file has no tracks")
}
let trackOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsNonInterleaved: false
]
)
assetReader.add(trackOutput)
decibelSamples = readDecibels(from: assetReader)
// If the operation was cancelled, return nothing as the samples may be incomplete.
guard !isCancelled else { return decibelSamples = nil }
}
private func readDecibels(from assetReader: AVAssetReader) -> [Float] {
var outputSamples = [Float]()
var readBuffer = Data()
let samplesToGroup = max(1, sampleCount(from: assetReader) / AudioWaveform.sampleCount)
assetReader.startReading()
while assetReader.status == .reading {
// Stop reading if the operation is cancelled.
guard !isCancelled else { break }
guard let trackOutput = assetReader.outputs.first else {
owsFailDebug("track output unexpectedly missing")
break
}
// Process any newly read data.
guard let nextSampleBuffer = trackOutput.copyNextSampleBuffer(),
let blockBuffer = CMSampleBufferGetDataBuffer(nextSampleBuffer) else {
// There is no more data to read, break
break
}
var readBufferLength = 0
var readBufferPointer: UnsafeMutablePointer<Int8>?
CMBlockBufferGetDataPointer(
blockBuffer,
atOffset: 0,
lengthAtOffsetOut: &readBufferLength,
totalLengthOut: nil,
dataPointerOut: &readBufferPointer
)
readBuffer.append(UnsafeBufferPointer(start: readBufferPointer, count: readBufferLength))
CMSampleBufferInvalidate(nextSampleBuffer)
// Try and process any pending samples, we may not have read enough data yet to do this.
let processedSamples = convertToDecibels(fromAmplitudes: readBuffer, from: assetReader, groupSize: samplesToGroup)
outputSamples += processedSamples
// If we successfully processed samples, remove any processed samples
// from the read buffer.
if processedSamples.count > 0 {
readBuffer.removeFirst(processedSamples.count * samplesToGroup * MemoryLayout<Int16>.size)
}
}
return outputSamples
}
private func sampleCount(from assetReader: AVAssetReader) -> Int {
let samplesPerChannel = Int(assetReader.asset.duration.value)
// We will read in the samples from each channel, interleaved since
// we only draw one waveform. This gives us an average of the channels
// if it is, for example, a stereo audio file.
return samplesPerChannel * channelCount(from: assetReader)
}
private func channelCount(from assetReader: AVAssetReader) -> Int {
guard let output = assetReader.outputs.first as? AVAssetReaderTrackOutput,
let formatDescriptions = output.track.formatDescriptions as? [CMFormatDescription] else { return 0 }
var channelCount = 0
for description in formatDescriptions {
guard let basicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(description) else { continue }
channelCount = Int(basicDescription.pointee.mChannelsPerFrame)
}
return channelCount
}
private func convertToDecibels(fromAmplitudes sampleBuffer: Data, from assetReader: AVAssetReader, groupSize: Int) -> [Float] {
var downSampledData = [Float]()
sampleBuffer.withUnsafeBytes { samples in
let sampleLength = sampleBuffer.count / MemoryLayout<Int16>.size
var decibelSamples = [Float](repeating: 0.0, count: sampleLength)
// maximum amplitude storable in Int16 = 0 dB (loudest)
var zeroDecibelEquivalent: Float = Float(Int16.max)
var loudestClipValue: Float = 0.0
var quietestClipValue = AudioWaveform.silenceThreshold
let samplesToProcess = vDSP_Length(sampleLength)
// convert 16bit int amplitudes to float representation
vDSP_vflt16([Int16](samples.bindMemory(to: Int16.self)), 1, &decibelSamples, 1, samplesToProcess)
// take the absolute amplitude value
vDSP_vabs(decibelSamples, 1, &decibelSamples, 1, samplesToProcess)
// convert to dB
vDSP_vdbcon(decibelSamples, 1, &zeroDecibelEquivalent, &decibelSamples, 1, samplesToProcess, 1)
// clip between loudest + quietest
vDSP_vclip(decibelSamples, 1, &quietestClipValue, &loudestClipValue, &decibelSamples, 1, samplesToProcess)
let sampleCount = sampleLength / groupSize
downSampledData = downsample(samples: decibelSamples, toSampleCount: sampleCount)
}
return downSampledData
}
private func downsample(samples: [Float], toSampleCount sampleCount: Int) -> [Float] {
// Do nothing if the number of requested samples is less than 1
guard sampleCount > 0 else { return [] }
// Calculate the number of samples each resulting sample should span.
// If samples.count % sampleCount is > 0, that many samples will
// be omitted from the resulting array. This is okay, because we don't
// remove any unprocessed samples from the read buffer and will include
// them in the next group to downsample.
let strideLength = samples.count / sampleCount
// This filter indicates that we should evaluate each sample equally when downsampling.
let filter = [Float](repeating: 1.0 / Float(strideLength), count: strideLength)
var downSampledData = [Float](repeating: 0.0, count: sampleCount)
vDSP_desamp(
samples,
vDSP_Stride(strideLength),
filter,
&downSampledData,
vDSP_Length(sampleCount),
vDSP_Length(strideLength)
)
return downSampledData
}
}