mirror of https://github.com/oxen-io/session-ios
commit
ffddca5021
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Pause.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
%PDF-1.7
|
||||
|
||||
1 0 obj
|
||||
<< >>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<< /Length 3 0 R >>
|
||||
stream
|
||||
/DeviceRGB CS
|
||||
/DeviceRGB cs
|
||||
q
|
||||
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
|
||||
0.000000 0.000000 0.000000 scn
|
||||
5.142858 0.000000 m
|
||||
1.714286 0.000000 l
|
||||
0.767857 0.000000 0.000000 0.767857 0.000000 1.714285 c
|
||||
0.000000 14.285714 l
|
||||
0.000000 15.232142 0.767857 16.000000 1.714286 16.000000 c
|
||||
5.142858 16.000000 l
|
||||
6.089286 16.000000 6.857143 15.232142 6.857143 14.285714 c
|
||||
6.857143 1.714285 l
|
||||
6.857143 0.767857 6.089286 0.000000 5.142858 0.000000 c
|
||||
h
|
||||
16.000000 1.714285 m
|
||||
16.000000 14.285714 l
|
||||
16.000000 15.232142 15.232143 16.000000 14.285715 16.000000 c
|
||||
10.857143 16.000000 l
|
||||
9.910715 16.000000 9.142858 15.232142 9.142858 14.285714 c
|
||||
9.142858 1.714285 l
|
||||
9.142858 0.767857 9.910715 0.000000 10.857143 0.000000 c
|
||||
14.285715 0.000000 l
|
||||
15.232143 0.000000 16.000000 0.767857 16.000000 1.714285 c
|
||||
h
|
||||
f
|
||||
n
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
804
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<< /Annots []
|
||||
/Type /Page
|
||||
/MediaBox [ 0.000000 0.000000 16.000000 16.000000 ]
|
||||
/Resources 1 0 R
|
||||
/Contents 2 0 R
|
||||
/Parent 5 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Kids [ 4 0 R ]
|
||||
/Count 1
|
||||
/Type /Pages
|
||||
>>
|
||||
endobj
|
||||
|
||||
6 0 obj
|
||||
<< /Type /Catalog
|
||||
/Pages 5 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000034 00000 n
|
||||
0000000894 00000 n
|
||||
0000000916 00000 n
|
||||
0000001089 00000 n
|
||||
0000001163 00000 n
|
||||
trailer
|
||||
<< /ID [ (some) (id) ]
|
||||
/Root 6 0 R
|
||||
/Size 7
|
||||
>>
|
||||
startxref
|
||||
1222
|
||||
%%EOF
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,214 @@
|
||||
import Accelerate
|
||||
import NVActivityIndicatorView
|
||||
|
||||
@objc(LKVoiceMessageView)
|
||||
final class VoiceMessageView : UIView {
|
||||
private let voiceMessage: TSAttachment
|
||||
private let isOutgoing: Bool
|
||||
private var isLoading = false
|
||||
private var isForcedAnimation = false
|
||||
private var volumeSamples: [Float] = [] { didSet { updateShapeLayers() } }
|
||||
@objc var progress: CGFloat = 0 { didSet { updateShapeLayers() } }
|
||||
@objc var duration: Int = 0 { didSet { updateDurationLabel() } }
|
||||
@objc var isPlaying = false { didSet { updateToggleImageView() } }
|
||||
|
||||
// MARK: Components
|
||||
private lazy var toggleImageView = UIImageView(image: #imageLiteral(resourceName: "Play"))
|
||||
|
||||
private lazy var spinner = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: .black, padding: nil)
|
||||
|
||||
private lazy var durationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var backgroundShapeLayer: CAShapeLayer = {
|
||||
let result = CAShapeLayer()
|
||||
result.fillColor = Colors.text.cgColor
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var foregroundShapeLayer: CAShapeLayer = {
|
||||
let result = CAShapeLayer()
|
||||
result.fillColor = (isLightMode && isOutgoing) ? UIColor.white.cgColor : Colors.accent.cgColor
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private let leadingInset: CGFloat = 0
|
||||
private let sampleSpacing: CGFloat = 1
|
||||
private let targetSampleCount = 48
|
||||
private let toggleContainerSize: CGFloat = 32
|
||||
private let vMargin: CGFloat = 0
|
||||
|
||||
@objc public static let contentHeight: CGFloat = 40
|
||||
|
||||
// MARK: Initialization
|
||||
@objc(initWithVoiceMessage:isOutgoing:)
|
||||
init(voiceMessage: TSAttachment, isOutgoing: Bool) {
|
||||
self.voiceMessage = voiceMessage
|
||||
self.isOutgoing = isOutgoing
|
||||
super.init(frame: CGRect.zero)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(voiceMessage:associatedWith:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(voiceMessage:associatedWith:) instead.")
|
||||
}
|
||||
|
||||
@objc func initialize() {
|
||||
setUpViewHierarchy()
|
||||
if voiceMessage.isDownloaded {
|
||||
guard let url = (voiceMessage as? TSAttachmentStream)?.originalMediaURL else {
|
||||
return print("[Loki] Couldn't get URL for voice message.")
|
||||
}
|
||||
if let cachedVolumeSamples = Storage.getVolumeSamples(for: voiceMessage.uniqueId!), cachedVolumeSamples.count == targetSampleCount {
|
||||
self.hideLoader()
|
||||
self.volumeSamples = cachedVolumeSamples
|
||||
} else {
|
||||
let voiceMessageID = voiceMessage.uniqueId!
|
||||
AudioUtilities.getVolumeSamples(for: url, targetSampleCount: targetSampleCount).done(on: DispatchQueue.main) { [weak self] volumeSamples in
|
||||
guard let self = self else { return }
|
||||
self.hideLoader()
|
||||
self.isForcedAnimation = true
|
||||
self.volumeSamples = volumeSamples
|
||||
Storage.write { transaction in
|
||||
Storage.setVolumeSamples(for: voiceMessageID, to: volumeSamples, using: transaction)
|
||||
}
|
||||
}.catch(on: DispatchQueue.main) { error in
|
||||
print("[Loki] Couldn't sample audio file due to error: \(error).")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showLoader()
|
||||
}
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
set(.width, to: 200)
|
||||
set(.height, to: VoiceMessageView.contentHeight)
|
||||
layer.insertSublayer(backgroundShapeLayer, at: 0)
|
||||
layer.insertSublayer(foregroundShapeLayer, at: 1)
|
||||
let toggleContainer = UIView()
|
||||
toggleContainer.clipsToBounds = false
|
||||
toggleContainer.addSubview(toggleImageView)
|
||||
toggleImageView.set(.width, to: 12)
|
||||
toggleImageView.set(.height, to: 12)
|
||||
toggleImageView.center(in: toggleContainer)
|
||||
toggleContainer.addSubview(spinner)
|
||||
spinner.set(.width, to: 24)
|
||||
spinner.set(.height, to: 24)
|
||||
spinner.center(in: toggleContainer)
|
||||
toggleContainer.set(.width, to: toggleContainerSize)
|
||||
toggleContainer.set(.height, to: toggleContainerSize)
|
||||
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
|
||||
toggleContainer.backgroundColor = UIColor.white
|
||||
let glowRadius: CGFloat = isLightMode ? 1 : 2
|
||||
let glowColor = isLightMode ? UIColor.black.withAlphaComponent(0.4) : UIColor.black
|
||||
let glowConfiguration = UIView.CircularGlowConfiguration(size: toggleContainerSize, color: glowColor, radius: glowRadius)
|
||||
toggleContainer.setCircularGlow(with: glowConfiguration)
|
||||
addSubview(toggleContainer)
|
||||
toggleContainer.center(.vertical, in: self)
|
||||
toggleContainer.pin(.leading, to: .leading, of: self, withInset: leadingInset)
|
||||
addSubview(durationLabel)
|
||||
durationLabel.center(.vertical, in: self)
|
||||
durationLabel.pin(.trailing, to: .trailing, of: self)
|
||||
}
|
||||
|
||||
// MARK: UI & Updating
|
||||
private func showLoader() {
|
||||
isLoading = true
|
||||
toggleImageView.isHidden = true
|
||||
spinner.startAnimating()
|
||||
spinner.isHidden = false
|
||||
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] timer in
|
||||
guard let self = self else { return timer.invalidate() }
|
||||
if self.isLoading {
|
||||
self.updateFakeVolumeSamples()
|
||||
} else {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
updateFakeVolumeSamples()
|
||||
}
|
||||
|
||||
private func updateFakeVolumeSamples() {
|
||||
let fakeVolumeSamples = (0..<targetSampleCount).map { _ in Float.random(in: 0...1) }
|
||||
volumeSamples = fakeVolumeSamples
|
||||
}
|
||||
|
||||
private func hideLoader() {
|
||||
isLoading = false
|
||||
toggleImageView.isHidden = false
|
||||
spinner.stopAnimating()
|
||||
spinner.isHidden = true
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateShapeLayers()
|
||||
}
|
||||
|
||||
private func updateShapeLayers() {
|
||||
clipsToBounds = false // Bit of a hack to do this here, but the containing stack view turns this off all the time
|
||||
guard !volumeSamples.isEmpty else { return }
|
||||
let sMin = CGFloat(volumeSamples.min()!)
|
||||
let sMax = CGFloat(volumeSamples.max()!)
|
||||
let w = width() - leadingInset - toggleContainerSize - durationLabel.width() - 2 * Values.smallSpacing
|
||||
let h = height() - 2 * vMargin
|
||||
let sW = (w - sampleSpacing * CGFloat(volumeSamples.count - 1)) / CGFloat(volumeSamples.count)
|
||||
let backgroundPath = UIBezierPath()
|
||||
let foregroundPath = UIBezierPath()
|
||||
for (i, value) in volumeSamples.enumerated() {
|
||||
let x = leadingInset + toggleContainerSize + Values.smallSpacing + CGFloat(i) * (sW + sampleSpacing)
|
||||
let fraction = (CGFloat(value) - sMin) / (sMax - sMin)
|
||||
let sH = max(8, h * fraction)
|
||||
let y = vMargin + (h - sH) / 2
|
||||
let subPath = UIBezierPath(roundedRect: CGRect(x: x, y: y, width: sW, height: sH), cornerRadius: sW / 2)
|
||||
backgroundPath.append(subPath)
|
||||
if progress > CGFloat(i) / CGFloat(volumeSamples.count) { foregroundPath.append(subPath) }
|
||||
}
|
||||
backgroundPath.close()
|
||||
foregroundPath.close()
|
||||
if isLoading || isForcedAnimation {
|
||||
let animation = CABasicAnimation(keyPath: "path")
|
||||
animation.duration = 0.25
|
||||
animation.toValue = backgroundPath
|
||||
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
|
||||
backgroundShapeLayer.add(animation, forKey: "path")
|
||||
backgroundShapeLayer.path = backgroundPath.cgPath
|
||||
} else {
|
||||
backgroundShapeLayer.path = backgroundPath.cgPath
|
||||
}
|
||||
foregroundShapeLayer.path = foregroundPath.cgPath
|
||||
isForcedAnimation = false
|
||||
}
|
||||
|
||||
private func updateDurationLabel() {
|
||||
durationLabel.text = OWSFormat.formatDurationSeconds(duration)
|
||||
updateShapeLayers()
|
||||
}
|
||||
|
||||
private func updateToggleImageView() {
|
||||
toggleImageView.image = isPlaying ? #imageLiteral(resourceName: "Pause") : #imageLiteral(resourceName: "Play")
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc(getCurrentTime:)
|
||||
func getCurrentTime(for panGestureRecognizer: UIPanGestureRecognizer) -> TimeInterval {
|
||||
guard voiceMessage.isDownloaded else { return 0 }
|
||||
let locationInSelf = panGestureRecognizer.location(in: self)
|
||||
let waveformFrameOrigin = CGPoint(x: leadingInset + toggleContainerSize + Values.smallSpacing, y: vMargin)
|
||||
let waveformFrameSize = CGSize(width: width() - leadingInset - toggleContainerSize - durationLabel.width() - 2 * Values.smallSpacing,
|
||||
height: height() - 2 * vMargin)
|
||||
let waveformFrame = CGRect(origin: waveformFrameOrigin, size: waveformFrameSize)
|
||||
guard waveformFrame.contains(locationInSelf) else { return 0 }
|
||||
let fraction = (locationInSelf.x - waveformFrame.minX) / (waveformFrame.maxX - waveformFrame.minX)
|
||||
return Double(fraction) * Double(duration)
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
|
||||
extension Storage {
|
||||
|
||||
static let volumeSamplesCollection = "LokiVolumeSamplesCollection"
|
||||
|
||||
static func getVolumeSamples(for attachment: String) -> [Float]? {
|
||||
var result: [Float]?
|
||||
read { transaction in
|
||||
result = transaction.object(forKey: attachment, inCollection: volumeSamplesCollection) as? [Float]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func setVolumeSamples(for attachment: String, to volumeSamples: [Float], using transaction: YapDatabaseReadWriteTransaction) {
|
||||
transaction.setObject(volumeSamples, forKey: attachment, inCollection: volumeSamplesCollection)
|
||||
}
|
||||
}
|
@ -0,0 +1,190 @@
|
||||
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))
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class ConversationStyle;
|
||||
@class TSAttachment;
|
||||
|
||||
@protocol ConversationViewItem;
|
||||
|
||||
@interface OWSAudioMessageView : UIStackView
|
||||
|
||||
- (instancetype)initWithAttachment:(TSAttachment *)attachment
|
||||
isIncoming:(BOOL)isIncoming
|
||||
viewItem:(id<ConversationViewItem>)viewItem
|
||||
conversationStyle:(ConversationStyle *)conversationStyle;
|
||||
|
||||
- (void)createContents;
|
||||
|
||||
+ (CGFloat)bubbleHeight;
|
||||
|
||||
- (void)updateContents;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
@ -1,305 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSAudioMessageView.h"
|
||||
#import "ConversationViewItem.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "UIColor+OWS.h"
|
||||
#import "ViewControllerUtils.h"
|
||||
#import <SignalMessaging/OWSFormat.h>
|
||||
#import <SignalMessaging/UIColor+OWS.h>
|
||||
#import <SessionServiceKit/MIMETypeUtil.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSAudioMessageView ()
|
||||
|
||||
@property (nonatomic) TSAttachment *attachment;
|
||||
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic) BOOL isIncoming;
|
||||
@property (nonatomic, weak) id<ConversationViewItem> viewItem;
|
||||
@property (nonatomic, readonly) ConversationStyle *conversationStyle;
|
||||
|
||||
@property (nonatomic, nullable) UIButton *audioPlayPauseButton;
|
||||
@property (nonatomic, nullable) UILabel *audioBottomLabel;
|
||||
@property (nonatomic, nullable) AudioProgressView *audioProgressView;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSAudioMessageView
|
||||
|
||||
- (instancetype)initWithAttachment:(TSAttachment *)attachment
|
||||
isIncoming:(BOOL)isIncoming
|
||||
viewItem:(id<ConversationViewItem>)viewItem
|
||||
conversationStyle:(ConversationStyle *)conversationStyle
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self) {
|
||||
_attachment = attachment;
|
||||
if ([attachment isKindOfClass:[TSAttachmentStream class]]) {
|
||||
_attachmentStream = (TSAttachmentStream *)attachment;
|
||||
}
|
||||
_isIncoming = isIncoming;
|
||||
_viewItem = viewItem;
|
||||
_conversationStyle = conversationStyle;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)updateContents
|
||||
{
|
||||
[self updateAudioProgressView];
|
||||
[self updateAudioBottomLabel];
|
||||
|
||||
if (self.audioPlaybackState == AudioPlaybackState_Playing) {
|
||||
[self setAudioIconToPause];
|
||||
} else {
|
||||
[self setAudioIconToPlay];
|
||||
}
|
||||
}
|
||||
|
||||
- (CGFloat)audioProgressSeconds
|
||||
{
|
||||
return [self.viewItem audioProgressSeconds];
|
||||
}
|
||||
|
||||
- (CGFloat)audioDurationSeconds
|
||||
{
|
||||
return self.viewItem.audioDurationSeconds;
|
||||
}
|
||||
|
||||
- (AudioPlaybackState)audioPlaybackState
|
||||
{
|
||||
return [self.viewItem audioPlaybackState];
|
||||
}
|
||||
|
||||
- (BOOL)isAudioPlaying
|
||||
{
|
||||
return self.audioPlaybackState == AudioPlaybackState_Playing;
|
||||
}
|
||||
|
||||
- (void)updateAudioBottomLabel
|
||||
{
|
||||
if (self.isAudioPlaying && self.audioProgressSeconds > 0 && self.audioDurationSeconds > 0) {
|
||||
self.audioBottomLabel.text =
|
||||
[NSString stringWithFormat:@"%@ / %@",
|
||||
[OWSFormat formatDurationSeconds:(long)round(self.audioProgressSeconds)],
|
||||
[OWSFormat formatDurationSeconds:(long)round(self.audioDurationSeconds)]];
|
||||
} else {
|
||||
self.audioBottomLabel.text =
|
||||
[NSString stringWithFormat:@"%@", [OWSFormat formatDurationSeconds:(long)round(self.audioDurationSeconds)]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setAudioIcon:(UIImage *)icon
|
||||
{
|
||||
icon = [icon resizedImageToSize:CGSizeMake(self.iconSize, self.iconSize)];
|
||||
[_audioPlayPauseButton setImage:icon forState:UIControlStateNormal];
|
||||
[_audioPlayPauseButton setImage:icon forState:UIControlStateDisabled];
|
||||
}
|
||||
|
||||
- (void)setAudioIconToPlay
|
||||
{
|
||||
[self setAudioIcon:[UIImage imageNamed:@"CirclePlay"]];
|
||||
}
|
||||
|
||||
- (void)setAudioIconToPause
|
||||
{
|
||||
[self setAudioIcon:[UIImage imageNamed:@"CirclePause"]];
|
||||
}
|
||||
|
||||
- (void)updateAudioProgressView
|
||||
{
|
||||
[self.audioProgressView
|
||||
setProgress:(self.audioDurationSeconds > 0 ? self.audioProgressSeconds / self.audioDurationSeconds : 0.f)];
|
||||
|
||||
UIColor *progressColor = [self.conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming];
|
||||
self.audioProgressView.horizontalBarColor = progressColor;
|
||||
self.audioProgressView.progressColor = progressColor;
|
||||
}
|
||||
|
||||
- (void)replaceIconWithDownloadProgressIfNecessary:(UIView *)iconView
|
||||
{
|
||||
if (!self.viewItem.attachmentPointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (self.viewItem.attachmentPointer.state) {
|
||||
case TSAttachmentPointerStateFailed:
|
||||
// We don't need to handle the "tap to retry" state here,
|
||||
// only download progress.
|
||||
return;
|
||||
case TSAttachmentPointerStateEnqueued:
|
||||
case TSAttachmentPointerStateDownloading:
|
||||
break;
|
||||
}
|
||||
switch (self.viewItem.attachmentPointer.pointerType) {
|
||||
case TSAttachmentPointerTypeRestoring:
|
||||
// TODO: Show "restoring" indicator and possibly progress.
|
||||
return;
|
||||
case TSAttachmentPointerTypeUnknown:
|
||||
case TSAttachmentPointerTypeIncoming:
|
||||
break;
|
||||
}
|
||||
NSString *_Nullable uniqueId = self.viewItem.attachmentPointer.uniqueId;
|
||||
if (uniqueId.length < 1) {
|
||||
OWSFailDebug(@"Missing uniqueId.");
|
||||
return;
|
||||
}
|
||||
|
||||
CGFloat downloadViewSize = self.iconSize;
|
||||
MediaDownloadView *downloadView =
|
||||
[[MediaDownloadView alloc] initWithAttachmentId:uniqueId radius:downloadViewSize * 0.5f];
|
||||
iconView.layer.opacity = 0.01f;
|
||||
[self addSubview:downloadView];
|
||||
[downloadView autoSetDimensionsToSize:CGSizeMake(downloadViewSize, downloadViewSize)];
|
||||
[downloadView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:iconView];
|
||||
[downloadView autoAlignAxis:ALAxisVertical toSameAxisOfView:iconView];
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (CGFloat)hMargin
|
||||
{
|
||||
return 0.f;
|
||||
}
|
||||
|
||||
- (CGFloat)hSpacing
|
||||
{
|
||||
return 8.f;
|
||||
}
|
||||
|
||||
+ (CGFloat)vMargin
|
||||
{
|
||||
return 0.f;
|
||||
}
|
||||
|
||||
- (CGFloat)vMargin
|
||||
{
|
||||
return [OWSAudioMessageView vMargin];
|
||||
}
|
||||
|
||||
+ (CGFloat)bubbleHeight
|
||||
{
|
||||
CGFloat iconHeight = self.iconSize;
|
||||
CGFloat labelsHeight = ([OWSAudioMessageView labelFont].lineHeight * 2 +
|
||||
[OWSAudioMessageView audioProgressViewHeight] + [OWSAudioMessageView labelVSpacing] * 2);
|
||||
CGFloat contentHeight = MAX(iconHeight, labelsHeight);
|
||||
return contentHeight + self.vMargin * 2;
|
||||
}
|
||||
|
||||
- (CGFloat)bubbleHeight
|
||||
{
|
||||
return [OWSAudioMessageView bubbleHeight];
|
||||
}
|
||||
|
||||
+ (CGFloat)iconSize
|
||||
{
|
||||
return 72.f;
|
||||
}
|
||||
|
||||
- (CGFloat)iconSize
|
||||
{
|
||||
return [OWSAudioMessageView iconSize];
|
||||
}
|
||||
|
||||
- (BOOL)isVoiceMessage
|
||||
{
|
||||
return self.attachment.isVoiceMessage;
|
||||
}
|
||||
|
||||
- (void)createContents
|
||||
{
|
||||
self.axis = UILayoutConstraintAxisHorizontal;
|
||||
self.alignment = UIStackViewAlignmentCenter;
|
||||
self.spacing = self.hSpacing;
|
||||
self.layoutMarginsRelativeArrangement = YES;
|
||||
self.layoutMargins = UIEdgeInsetsMake(self.vMargin, 0, self.vMargin, 0);
|
||||
|
||||
_audioPlayPauseButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
self.audioPlayPauseButton.enabled = NO;
|
||||
[self addArrangedSubview:self.audioPlayPauseButton];
|
||||
self.audioPlayPauseButton.imageView.contentMode = UIViewContentModeCenter;
|
||||
[self.audioPlayPauseButton autoSetDimension:ALDimensionWidth toSize:56.f];
|
||||
[self.audioPlayPauseButton autoSetDimension:ALDimensionHeight toSize:56.f];
|
||||
self.audioPlayPauseButton.imageView.clipsToBounds = NO;
|
||||
self.audioPlayPauseButton.clipsToBounds = NO;
|
||||
self.clipsToBounds = NO;
|
||||
|
||||
[self replaceIconWithDownloadProgressIfNecessary:self.audioPlayPauseButton];
|
||||
|
||||
NSString *_Nullable filename = self.attachment.sourceFilename;
|
||||
if (filename.length < 1) {
|
||||
filename = [self.attachmentStream.originalFilePath lastPathComponent];
|
||||
}
|
||||
NSString *topText = [[filename stringByDeletingPathExtension] ows_stripped];
|
||||
if (topText.length < 1) {
|
||||
topText = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType].localizedUppercaseString;
|
||||
}
|
||||
if (topText.length < 1) {
|
||||
topText = NSLocalizedString(@"GENERIC_ATTACHMENT_LABEL", @"A label for generic attachments.");
|
||||
}
|
||||
if (self.isVoiceMessage) {
|
||||
topText = nil;
|
||||
}
|
||||
UILabel *topLabel = [UILabel new];
|
||||
topLabel.text = topText;
|
||||
topLabel.textColor = [self.conversationStyle bubbleTextColorWithIsIncoming:self.isIncoming];
|
||||
topLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
||||
topLabel.font = [OWSAudioMessageView labelFont];
|
||||
|
||||
AudioProgressView *audioProgressView = [AudioProgressView new];
|
||||
self.audioProgressView = audioProgressView;
|
||||
[self updateAudioProgressView];
|
||||
[audioProgressView autoSetDimension:ALDimensionHeight toSize:[OWSAudioMessageView audioProgressViewHeight]];
|
||||
|
||||
UILabel *bottomLabel = [UILabel new];
|
||||
self.audioBottomLabel = bottomLabel;
|
||||
[self updateAudioBottomLabel];
|
||||
bottomLabel.textColor = [self.conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming];
|
||||
bottomLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
||||
bottomLabel.font = [OWSAudioMessageView labelFont];
|
||||
|
||||
UIStackView *labelsView = [UIStackView new];
|
||||
labelsView.axis = UILayoutConstraintAxisVertical;
|
||||
labelsView.spacing = [OWSAudioMessageView labelVSpacing];
|
||||
[labelsView addArrangedSubview:topLabel];
|
||||
[labelsView addArrangedSubview:audioProgressView];
|
||||
[labelsView addArrangedSubview:bottomLabel];
|
||||
|
||||
// Ensure the "audio progress" and "play button" are v-center-aligned using a container.
|
||||
UIView *labelsContainerView = [UIView containerView];
|
||||
[self addArrangedSubview:labelsContainerView];
|
||||
[labelsContainerView addSubview:labelsView];
|
||||
[labelsView autoPinWidthToSuperview];
|
||||
[labelsView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual];
|
||||
[labelsView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual];
|
||||
|
||||
[audioProgressView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.audioPlayPauseButton];
|
||||
|
||||
[self updateContents];
|
||||
}
|
||||
|
||||
+ (CGFloat)audioProgressViewHeight
|
||||
{
|
||||
return 12.f;
|
||||
}
|
||||
|
||||
+ (UIFont *)labelFont
|
||||
{
|
||||
return [UIFont ows_dynamicTypeCaption2Font];
|
||||
}
|
||||
|
||||
+ (CGFloat)labelVSpacing
|
||||
{
|
||||
return 2.f;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
Loading…
Reference in New Issue