0

I need to render moving audio waveform like iOS voice memo app. Here I maintain waveform:[Int] rms amplitude of waves. Now when new waves come add it in waveform[Int] and I add new UIBezierPath line at right of CAShapeLayer and translate whole CAShapeLayer by 5 points.

But translation animation is not so smooth. Could you please suggest any better approach for it?

My current implementation:

override func draw(_ rect: CGRect) {

    shiftWaveform()
    let path: UIBezierPath!
    
    if let ppath = caLayer.path {
        path = UIBezierPath(cgPath: ppath)
    } else {
        path = UIBezierPath()
    }

    guard var wave = waveforms.last else { return }
    
    if (Int(wave) <= 2) {
        wave = 2
    }
    
    count += 1
    let startX = Int(self.bounds.width) + 5*count
    let startY = Int(self.bounds.origin.y) + Int(self.bounds.height)/2
    
    path.move(to: CGPoint(x: startX, y: startY + min(wave, Int(bounds.height/2))))
    path.addLine(to: CGPoint(x: startX, y: startY - min(wave, Int(bounds.height/2))))
    caLayer.path = path.cgPath
}
TylerP
  • 9,600
  • 4
  • 39
  • 43
Amit Singh
  • 63
  • 8

1 Answers1

4

Don't try to do your animation in draw(rect:). That puts all the work on the CPU, not the GPU, and does not take advantage of the hardware-accelerated animation in iOS.

I would suggest instead using a CAShapeLayer and a CABasicAnimation to animate your path.

Install the CGPath from your UIBezierPath into the CAShapeLayer, and then create a CABasicAnimation that changes the path property of the shape layer.

The trick to getting smooth shape animations is to have the same number of control points for every step in the animation. Thus you should not add more and more points to your path, but rather create a new path that contains a graph of the last n points of your waveform.

I would suggest keeping a ring buffer of the n points you want to graph, and building a GCPath/UIBezierPath out of that ring buffer. As you add more points, the older points would "age out" of the ring buffer and you'd always graph the same number of points.

Edit:

Ok, you need something simpler than a ring buffer: Let's call it a lastNElementsBuffer. It should let you add items, discarding the oldest element, and then always return the most recent elements added.

Here is a simple implementation:

public struct LastNItemsBuffer<T> {
    fileprivate var array: [T?]
    fileprivate var index = 0

    public init(count: Int) {
        array = [T?](repeating: nil, count: count)
    }

    public mutating func clear() {
        forceToValue(value: nil)
    }

    public mutating func forceToValue(value: T?) {
        let count = array.count
        array = [T?](repeating: value, count: count)
    }

    public mutating func write(_ element: T) {
        array[index % array.count] = element
        index += 1
    }
    public func lastNItems() -> [T] {
        var result = [T?]()
        for loop in 0..<array.count {
            result.append(array[(loop+index) % array.count])
        }
        return result.compactMap { $0 }
    }
}

If you create such a buffer of CGFloat values, and populate it with all zeros, you could then start saving new waveform values to it as they are read.

Then you'd create an animation that would create a path using the buffer of values, plus a new value, and then create an animation that shifts the new path to the left, revealing the new point.

I created a demo project on Github that shows the technique. You can download it here: https://github.com/DuncanMC/LastNItemsBufferGraph.git

Here is a sample animation:

enter image description here

Edit #2:

It sounds like you need a slightly different style of animation that what I did in my sample app. You should modify the method buildPath(plusValue:) in GraphView.swift to draw the style of graph you desire from the array of sample values (plus an optional new value). The rest of the sample app should work as written.

I updated the app to also offer a bar graph style similar to Apple's Voice memo app:

enter image description here

Edit #3:

In another thread you said you wanted to be able to allow the user to scroll back and forth through the graph, and proposed using a scroll view to manage that process.

The problem there is that your audio sample could be over a long time interval, and so the image of the whole waveform graph could be too large to hold in memory. (Imagine a graph of a 3 minute recording with a data-point for every 1/20th of a second, and each data-point is graphed 10 points wide by 200 points tall. Thats 3 * 60 * 20 = 3600 data points. If you use 10 points horizontally, on a 3X Retina display, that's 30 pixels wide per data point or 108,000 pixels wide, • 200 points • 3X = 600 pixels tall, or 64.8 MILLION pixels. At 3 bytes/pixel, (8 bits/color with no alpha) that's 194.4 million bytes of data, just for a 3 minute recording. Now let's say it's a 2 hour long recording. Time to run out of memory and crash.)

Instead I would say you should save a buffer of data points for your entire recording, scaled down to the smallest data type that would give you one point precision. You could probably use a single byte per data point. Save those points in a struct that also includes the samples/second.

Write a function that takes the graph data struct and time offset as input, and generates a CGPath for that time offset, plus or minus enough data points to make the graph wider than your display window on either side. Then you could animate the graph in either direction for forward or reverse playback. You could implement a tap gesture recognizer for letting the user drag the graph back and forth, or a slider, or whatever you needed. When you get to the end of the current graph, you'd just call your function to generate a new portion of the graph and display that new portion offset to the right screen location.

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • Thanks Dunac for your input. lets assume I add new UIBezierPath line on right of CAShapelayer and I have added four UIBezierPath lines on layer, so if new UIBezierPath line comes to add on right then before adding I need to shift existing four lines on left .. So my concern is I am not getting effective way to translate existing UIBezierPath lines on same CAShapeLayer and add new line at right .. – Amit Singh May 23 '21 at 15:14
  • No, don't add new `UIBezierPath` points on the right. Throw away the Bezier path and rebuild it again for each animation step. Because old values "age off" and new values come in from the right, the graph will smoothly animate from right to left. See the edit to my answer. – Duncan C May 23 '21 at 15:36
  • lets assume my buffer max size is 5, so do you mean if new entry comes then I should remove CAShapeLayer from parent layer and create new CAShapelayer with added latest five UBezierPath on it ? or Could you please provide bit more explanation on animation point? I am new in Core Animation so might not be getting you exactly .. – Amit Singh May 23 '21 at 17:15
  • My approach doesn't work quite right as described. Let me think about it. – Duncan C May 23 '21 at 20:25
  • Ok, I wrote a sample project illustrating how to do this sort of animation and put it up on Github. See the edits to my answer. – Duncan C May 23 '21 at 22:51
  • Thanks Duncan I really appreciate your response ... In your example waves are connected (looks like analog signal) but I am looking to create discrete signal representation like iPhone Voice memo app, its my bad if could not make clear requirement . To get discrete line , I do moveto operation for each new point and then addline. I am working on speech recognition project, so I need to display it on screen as well. Adding my sample code git hub link : https://github.com/singham2012/audio.git – Amit Singh May 24 '21 at 01:22
  • 90% of the demo app I uploaded to Github should still apply to your use. You'd just need to modify the method `buildPath(plusValue:)` in `GraphView.swift`. Make that method return a path that looks like a series of bars rather than a line graph. The rest of the logic should work as written. – Duncan C May 24 '21 at 02:26
  • In another thread you said you wanted to enable the user to scroll back and forth through your graph. You could certainly render the graph onto a scroll view and then animate scrolling it into view. You could also modify the code I wrote to instead render a portion of a larger array of data, and animate scrolling that view, rather than animating new vales onto the screen one at a time from a single direction. – Duncan C May 27 '21 at 18:00
  • @AmitSingh see the newest edit to my answer. If you want the user to be able to scrub back and forth through the audio you will probably need to generate the graph in sections and then create code to assemble/animate the sections seamlessly. It isn't that hard. – Duncan C May 29 '21 at 13:12
  • Hi Duncan .. you are right if we store all paths in scroll view then it will bump the memory and app will crash. So we need to come up with some optimisation. If you also check voice memo app then I think you will also convince that it is based on uiscrollview , plz confirm – Amit Singh Jun 05 '21 at 07:28