I want to show an interactive audio waveform like this.
I've extracted the sample data using AVAssetReader. Using this data, I'm drawing a UIBezierPath in a Scrollview's contentView. Currently, when I pinch zoom-in or zoom-out the scrollView, I'm downsampling the sample data to determine how many samples are to be shown.
class WaveformView: UIView {
var amplitudes: [CGFloat] = [] {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), !amplitudes.isEmpty else { return }
// Set up drawing parameters
context.setStrokeColor(UIColor.black.cgColor)
context.setLineWidth(1.0)
context.setLineCap(.round)
let midY = rect.height / 2
let widthPerSample = rect.width / CGFloat(amplitudes.count)
// Draw waveform
let path = UIBezierPath()
for (index, amplitude) in amplitudes.enumerated() {
let x = CGFloat(index) * widthPerSample
let height = amplitude * rect.height * 0.8
// Draw vertical line for each sample
path.move(to: CGPoint(x: x, y: midY - height))
path.addLine(to: CGPoint(x: x, y: midY + height))
}
path.stroke()
}
}
Added gesture handle
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
initialPinchDistance = gesture.scale
case .changed:
let scaleFactor = gesture.scale / initialPinchDistance
var newScale = currentScale * scaleFactor
newScale = min(max(newScale, minScale), maxScale)
// Update displayed samples with new scale
updateDisplayedSamples(scale: newScale)
print(newScale)
// Maintain zoom center point
let pinchCenter = gesture.location(in: scrollView)
let offsetX = (pinchCenter.x - scrollView.bounds.origin.x) / scrollView.bounds.width
let newOffsetX = (totalWidth * offsetX) - (pinchCenter.x - scrollView.bounds.origin.x)
scrollView.contentOffset.x = max(0, min(newOffsetX, totalWidth - scrollView.bounds.width))
view.layoutIfNeeded()
case .ended, .cancelled:
currentScale = scrollView.contentSize.width / (baseWidth * widthPerSample)
default:
break
}
}
private func updateDisplayedSamples(scale: CGFloat) {
let targetSampleCount = Int(baseWidth * scale)
displayedSamples = downsampleWaveform(samples: rawSamples, targetCount: targetSampleCount)
waveformView.amplitudes = displayedSamples
totalWidth = CGFloat(displayedSamples.count) * widthPerSample
contentWidthConstraint?.constant = totalWidth
scrollView.contentSize = CGSize(width: totalWidth, height: 300)
}
private func downsampleWaveform(samples: [CGFloat], targetCount: Int) -> [CGFloat] {
guard samples.count > 0, targetCount > 0 else { return [] }
if samples.count <= targetCount {
return samples
}
var downsampled: [CGFloat] = []
let sampleSize = samples.count / targetCount
for i in 0..<targetCount {
let startIndex = i * sampleSize
let endIndex = min(startIndex + sampleSize, samples.count)
let slice = samples[startIndex..<endIndex]
// For each window, take the maximum value to preserve peaks
if let maxValue = slice.max() {
downsampled.append(maxValue)
}
}
return downsampled
}
The following approach works very inefficiently as everytime gesture.state is changed I'm calculating the downsampled data and performs UI operation based on that. How can I implement this functionality more efficiently for smooth interaction?
I want to show an interactive audio waveform like this.
I've extracted the sample data using AVAssetReader. Using this data, I'm drawing a UIBezierPath in a Scrollview's contentView. Currently, when I pinch zoom-in or zoom-out the scrollView, I'm downsampling the sample data to determine how many samples are to be shown.
class WaveformView: UIView {
var amplitudes: [CGFloat] = [] {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), !amplitudes.isEmpty else { return }
// Set up drawing parameters
context.setStrokeColor(UIColor.black.cgColor)
context.setLineWidth(1.0)
context.setLineCap(.round)
let midY = rect.height / 2
let widthPerSample = rect.width / CGFloat(amplitudes.count)
// Draw waveform
let path = UIBezierPath()
for (index, amplitude) in amplitudes.enumerated() {
let x = CGFloat(index) * widthPerSample
let height = amplitude * rect.height * 0.8
// Draw vertical line for each sample
path.move(to: CGPoint(x: x, y: midY - height))
path.addLine(to: CGPoint(x: x, y: midY + height))
}
path.stroke()
}
}
Added gesture handle
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
initialPinchDistance = gesture.scale
case .changed:
let scaleFactor = gesture.scale / initialPinchDistance
var newScale = currentScale * scaleFactor
newScale = min(max(newScale, minScale), maxScale)
// Update displayed samples with new scale
updateDisplayedSamples(scale: newScale)
print(newScale)
// Maintain zoom center point
let pinchCenter = gesture.location(in: scrollView)
let offsetX = (pinchCenter.x - scrollView.bounds.origin.x) / scrollView.bounds.width
let newOffsetX = (totalWidth * offsetX) - (pinchCenter.x - scrollView.bounds.origin.x)
scrollView.contentOffset.x = max(0, min(newOffsetX, totalWidth - scrollView.bounds.width))
view.layoutIfNeeded()
case .ended, .cancelled:
currentScale = scrollView.contentSize.width / (baseWidth * widthPerSample)
default:
break
}
}
private func updateDisplayedSamples(scale: CGFloat) {
let targetSampleCount = Int(baseWidth * scale)
displayedSamples = downsampleWaveform(samples: rawSamples, targetCount: targetSampleCount)
waveformView.amplitudes = displayedSamples
totalWidth = CGFloat(displayedSamples.count) * widthPerSample
contentWidthConstraint?.constant = totalWidth
scrollView.contentSize = CGSize(width: totalWidth, height: 300)
}
private func downsampleWaveform(samples: [CGFloat], targetCount: Int) -> [CGFloat] {
guard samples.count > 0, targetCount > 0 else { return [] }
if samples.count <= targetCount {
return samples
}
var downsampled: [CGFloat] = []
let sampleSize = samples.count / targetCount
for i in 0..<targetCount {
let startIndex = i * sampleSize
let endIndex = min(startIndex + sampleSize, samples.count)
let slice = samples[startIndex..<endIndex]
// For each window, take the maximum value to preserve peaks
if let maxValue = slice.max() {
downsampled.append(maxValue)
}
}
return downsampled
}
The following approach works very inefficiently as everytime gesture.state is changed I'm calculating the downsampled data and performs UI operation based on that. How can I implement this functionality more efficiently for smooth interaction?
Share Improve this question asked Jan 30 at 4:14 Ahsan Habib SwassowAhsan Habib Swassow 214 bronze badges1 Answer
Reset to default 0To speed up calculations on large vectors of samples you can make use of a dedicated vDSP component of Accelerate framework.
Take a look at vDSP.downsample(_:decimationFactor:filter:result:)
function.
In your case:
import Accelerate
func downsample(samples: [Float], targetCount: Int) -> [Float] {
// Calculate the decimation factor
let decimationFactor = max(1, samples.count / targetCount)
// Create a result array for the downsampled data
let downsampledSize = (samples.count + decimationFactor - 1) / decimationFactor
var downsampledData = [Float](repeating: 0.0, count: downsampledSize)
// Create an identity filter (not really used, but required by the function)
var filter = [Float](repeating: 1.0, count: 1) // Identity filter
// Use vDSP to downsample the data
vDSP.downsample(samples, decimationFactor, filter, &downsampledData)
return downsampledData
}