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