iosuser-interfaceavaudioenginesoundeffecthighperformance

What is the best approach for rapid sound playback on iOS?


I have a UI that allows someone to move a dial and the dial 'snaps' to each 'mark' on the dial.

I want to add sound to this and I've made a very short 'click' sound that is a fraction of a second.

I don't want to restrict how fast the user can rotate the dial, but I want the sound to play as the dial goes to each mark.

So I need a fast and responsive Audio library to use, however I also know I need to limit how many times it's played in case they spin it so quickly that otherwise the sound would become a constant noise, rather than distinct clicks.

I've seen comments that AVFoundation is too slow and that AVAudioEngine was going to give a better performance, but I'm still not sure if that's the best approach and how to tackle limiting the 'repetitive sound' so it's not just a horrendous noise.

I realise this is kind of something that games programmers deal with more than non-game iOS app developers deal with but I'm still stuck for an approach.


Solution

  • One approach...

    Play the "click" sound every time the "current tick mark" changes.

    This will be slightly different, depending on how you are animating the "dial" -- but the concept is the same. Let's use a scroll view for example.

    For the scrollable content, we'll use a view and draw a vertical "tick mark" every 20-points, taller on even 100-points positions. We'll also overlay a view with a single vertical line near the horizontal center - so we want to play a "click" when a tick hits that line. And we'll size things so we can only scroll horizontally.

    It will look like this:

    enter image description here

    and after scrolling a little:

    enter image description here

    When implementing scrollViewDidScroll(...) with a typical scroll view, it is very easy to scroll quickly... so quickly, that the .contentOffset.x can change 200+ points between calls.

    If we try to play the tick sound for every 20-points of change, we could be playing it 10 times at essentially the same time.

    So, we could create a class property:

    var prevTickMark: Int = 0
    

    then calculate the current tick mark in scrollViewDidScroll(...). If the values are different, play a tick sound:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        var cx = Int(scrollView.contentOffset.x)
        
        // offset to the first tick-mark
        cx += Int(scrollView.contentInset.left)
        
        let curTick: Int = cx / 20
        if prevTickMark != curTick {
            // we just passed, or we are on, a new "tick"
            //  so play the tick sound
            AudioServicesPlayAlertSound(SystemSoundID(1057))
            prevTickMark = curTick
        }
    }
    

    If we are scrolling / dragging very, very quickly, we don't need a click for every tick mark... because we are not seeing every tick mark cross the center-line.

    As the scrolling decelerates -- or when dragging slowly -- we'll get a click on every tick.

    Here's some quick example code to try out...

    TickView - ticks every 20-points

    class TickView: UIView {
        
        lazy var tickLayer: CAShapeLayer = self.layer as! CAShapeLayer
        override class var layerClass: AnyClass {
            return CAShapeLayer.self
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder:aDecoder)
            commonInit()
        }
        
        private func commonInit() {
            tickLayer.fillColor = nil
            tickLayer.strokeColor = UIColor.red.cgColor
            backgroundColor = .yellow
        }
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let y: CGFloat = bounds.maxY * 0.75
            let shortTick: CGFloat = bounds.maxY * 0.25
            let tallTick: CGFloat = bounds.maxY * 0.5
    
            let bez = UIBezierPath()
            
            var pt: CGPoint = .init(x: bounds.minX, y: y)
    
            // horizontal line full width of view
            bez.move(to: pt)
            bez.addLine(to: .init(x: bounds.maxX, y: pt.y))
    
            // add vertical "tick" lines every 20-points
            //  with a taller line every 100-points
            bez.move(to: pt)
    
            while pt.x <= bounds.maxX {
                bez.move(to: pt)
                if Int(pt.x) % 100 == 0 {
                    bez.addLine(to: .init(x: pt.x, y: pt.y - tallTick))
                } else {
                    bez.addLine(to: .init(x: pt.x, y: pt.y - shortTick))
                }
                pt.x += 20.0
            }
    
            tickLayer.path = bez.cgPath
        }
        
    }
    

    MidLineView - vertical line to overlay on the scroll view

    class MidLineView: UIView {
        
        lazy var midLineLayer: CAShapeLayer = self.layer as! CAShapeLayer
        override class var layerClass: AnyClass {
            return CAShapeLayer.self
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder:aDecoder)
            commonInit()
        }
        
        private func commonInit() {
            midLineLayer.fillColor = nil
            midLineLayer.strokeColor = UIColor.blue.cgColor
            backgroundColor = .clear
        }
        override func layoutSubviews() {
            super.layoutSubviews()
            
            let bez = UIBezierPath()
            
            // we want the mid line to be *about* at the horizontal center
            //  but at an even 20-points
            var x: Int = Int(bounds.midX)
            x -= x % 20
    
            bez.move(to: .init(x: CGFloat(x), y: bounds.minY))
            bez.addLine(to: .init(x: CGFloat(x), y: bounds.maxY))
            
            midLineLayer.path = bez.cgPath
        }
        
    }
    

    ViewController - example controller

    class ViewController: UIViewController, UIScrollViewDelegate {
        
        let scrollView = UIScrollView()
        
        // view with "tick-mark" lines every 20-points
        let tickView = TickView()
        
        // view with single vertical line
        //  overlay on the scroll view so we have a
        //  "center-line"
        let midLineView = MidLineView()
        
        // track the previous "tick"
        var prevTickMark: Int = 0
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            tickView.translatesAutoresizingMaskIntoConstraints = false
            midLineView.translatesAutoresizingMaskIntoConstraints = false
            
            scrollView.addSubview(tickView)
            view.addSubview(scrollView)
            view.addSubview(midLineView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                scrollView.heightAnchor.constraint(equalToConstant: 120.0),
                scrollView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                tickView.topAnchor.constraint(equalTo: cg.topAnchor),
                tickView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
                tickView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
                tickView.bottomAnchor.constraint(equalTo: cg.bottomAnchor),
                
                // let's make the "tick" view 2000-points wide
                //  so we have a good amount of scrolling distance
                tickView.widthAnchor.constraint(equalToConstant: 2000.0),
                tickView.heightAnchor.constraint(equalTo: fg.heightAnchor, multiplier: 1.0),
                
                midLineView.topAnchor.constraint(equalTo: scrollView.topAnchor),
                midLineView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
                midLineView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
                midLineView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
                
            ])
            
            scrollView.delegate = self
            
            // disable interaction on the overlaid view
            midLineView.isUserInteractionEnabled = false
            
            // so we can see the framing of the scroll view
            scrollView.backgroundColor = .lightGray
            
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            
            // offsets so the "ticks" start and end near the horiztonal center
            //  on even 20-points
            var x: Int = Int(scrollView.frame.width * 0.5)
            x -= x % 20
            scrollView.contentInset = .init(top: 0.0, left: CGFloat(x), bottom: 0.0, right: scrollView.frame.width - CGFloat(x))
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            var cx: Int = Int(scrollView.contentOffset.x)
    
            // offset to the first tick-mark
            cx += Int(scrollView.contentInset.left)
    
            let curTick: Int = cx / 20
            if prevTickMark != curTick {
                // we just passed, or we are on, a new "tick"
                //  so play the tick sound
                AudioServicesPlayAlertSound(SystemSoundID(1057))
                prevTickMark = curTick
            }
        }
    
    }