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

42 Views Asked by At

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.

1

There are 1 best solutions below

1
DonMag On BEST ANSWER

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

}