Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by PulsarPilot244

How can I optimize interactive audio waveform rendering during pinch zoom on iOS?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

I want to display an interactive audio waveform like this: link

I extract sample data using AVAssetReader and draw a UIBezierPath on a ScrollView's contentView. When the user pinch-zooms, I downsample the data to adjust the number of samples shown. However, recalculating the downsampled data and performing UI updates on every gesture state change is inefficient and leads to laggy interactions.

Below is the current implementation:

SWIFT
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() } }

Gesture handling with pinch:

SWIFT
@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 } }

Updating the displayed samples:

SWIFT
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) }

Downsampling function:

SWIFT
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 current approach performs heavy downsampling calculations on every gesture state change, which impacts UI performance. I’m looking for recommendations on how to optimize this process for smoother, more efficient interactions during pinch zoom.

2

Comments 0

Answers 2

Views 46

2 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

Below is a summary of techniques that have helped others build smooth, interactive waveform views without recalculating the entire downsample on every gesture update:


1. Precompute a Multi‑Resolution “Pyramid” of Waveform Data

Instead of recomputing the downsampling every time, build several levels of detail (LOD) when you load or process the raw audio. For example:

  • Store a high‑resolution array.
  • Precompute, say, 4–6 coarser versions using different downsampling ratios.
  • During pinch gestures, choose the precomputed array that best matches your current zoom level.

This is similar in concept to mipmaps in graphics. Not only does it avoid per‑gesture heavy math, but it lets you quickly switch the data you display.

Example:

SWIFT
enum WaveformLOD: Int { case high = 0 // full resolution case medium case low } struct WaveformCache { var highRes: [CGFloat] var mediumRes: [CGFloat] var lowRes: [CGFloat] func samples(forZoom scale: CGFloat) -> [CGFloat] { // Choose which resolution to use based on the scale if scale > 2.0 { return highRes } else if scale > 1.0 { return mediumRes } else { return lowRes } } }

Precompute the versions once (or as needed on a background thread) so that your pinch gesture merely selects the proper version.


2. Asynchronous Processing and Caching

If you cannot precompute every possible resolution at load time, consider:

  • Running the downsampling on a background thread.
  • Caching the recent results and only updating the view when the calculation finishes.
  • While the calculation is underway, you can either continue displaying the previous data or show a low-res placeholder.

This way you avoid blocking the main thread during interactions.

Example:

SWIFT
func updateDisplayedSamples(scale: CGFloat) { let targetSampleCount = Int(baseWidth * scale) // Check cache for an existing downsampled array matching targetSampleCount if let cachedSamples = cache[targetSampleCount] { updateView(with: cachedSamples) } else { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } let samples = self.downsampleWaveform(samples: self.rawSamples, targetCount: targetSampleCount) self.cache[targetSampleCount] = samples DispatchQueue.main.async { self.updateView(with: samples) } } } } func updateView(with samples: [CGFloat]) { self.displayedSamples = samples waveformView.amplitudes = samples self.totalWidth = CGFloat(samples.count) * widthPerSample contentWidthConstraint?.constant = totalWidth scrollView.contentSize = CGSize(width: totalWidth, height: scrollView.frame.height) }

3. Use Optimized Drawing Techniques

Even after selecting a proper downsampled array, drawing hundreds or thousands of individual lines with UIBezierPath can be expensive. Consider:

  • Layer Caching: Cache the drawn UIBezierPath in a CAShapeLayer. Only update it when necessary.
  • Tiled Drawing: For very large waveforms, divide the waveform into segments and use CATiledLayer to load only visible parts.
  • GPU-Accelerated Rendering: If needed, consider moving the waveform rendering to Metal or OpenGL for the best performance on complex interactions.

This will reduce the overhead of redrawing the entire view during every gesture update.


Summary

By precomputing multiple levels of detail and/or performing heavy downsampling asynchronously with caching, you can avoid recalculating on every gesture change. Pair that with drawing optimizations (like layer caching or tiling), and you should see very smooth zoom and scroll interactions even with large audio datasets.

Implementing these optimizations should result in a more efficient, smoother interactive waveform view.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by AsteroidWanderer521 1 month ago

0

To 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:

SWIFT
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 }

No comments yet.

Discussion

No comments yet.