Rendering Waveforms in SwiftUI

Episode #447 | 28 minutes | published on July 9, 2020 | Uses iOS-14.0-beta2, Xcode-12-beta2, Swift-5.3
Subscribers Only
I've been working on rendering waveforms using mathematical functions and have found the experience to be both fun and enlightening. In this episode we will develop a method to render arbitrary functions using a Shape, then explore some mathematical concepts that can help us render a nice looking waveform that could be use to indicate activity in sound, speech, or other effects.

How I built the SiriWaveJS library - The math included in this episode was inspired by this blog post, where Flavio De Stafano renders the original Siri waveform using JavaScript. I found this post to be incredibly helpful in understanding the concepts.

You can download the Grapher file I used if you want to follow along.

Defining the WaveForm Shape

First we define a shape we can use which takes a function that will define the line we'll plot.

struct WaveFormShape: Shape {

    let fn: (Double) -> Double = { _ in 0 }
    let range: ClosedRange<Double> = -1...1
    let steps: Int = 3

    func path(in rect: CGRect) -> Path {
       ...
    }
}

Next we define a computed property to render each point:

    var points: [CGPoint] {
        var points = [CGPoint]()
        let xStride = (range.upperBound-range.lowerBound) / Double(steps-1)
        for x in stride(from: range.lowerBound, through: range.upperBound, by: xStride) {
            let y = fn(x)
            let p = CGPoint(x: x, y: y)
            points.append(p)
        }

        return points
    }

This gives us an array of graph points. Next we need to apply this to our view space, and for that we'll need the rect to transform these points into:

    private func normalizedPoints(in rect: CGRect) -> [CGPoint] {
        let points = self.points
        return points.enumerated().map { (offset, p) in
            let screenX = CGFloat(offset) * rect.width/CGFloat(points.count - 1)
            let screenY = rect.midY - (p.y * rect.height/2)
            return CGPoint(x: screenX, y: screenY)
        }
    }

Here we define the y range with 0 being in the middle of the graph, 1 at the top, and -1 at the bottom.

Finally we can implement our path function:

    func path(in rect: CGRect) -> Path {
        Path { p in
            let points = normalizedPoints(in: rect)
            p.addLines(points)
        }
    }

Graphing a Tapered Sine Curve

We start with a SwiftUI view that takes a few parameters:

struct GraphView: View {

    var amplitude: Double = 2
    var frequency: Double = 4
    var phase: Double = 0

    var body: some View {
        ...
    }
}

Then we define the function that we want to use. For this I'll define a configurable sine function:

    private func sineFunc(_ x: Double) -> Double {
        amplitude * sin(frequency * x - phase)
    }

As well as a windowing or "tapering" function we can use to filter out values on the edges:

    private func taperFunc(_ x: Double) -> Double {
        let K: Double = 1
        return pow(K/(K + pow(x, 4)), K)
    }

Now we can define the body of our view, rendering the waveform shape with the above functions:

        WaveForm(
            fn: { sineFunc($0) * taperFunc($0) },
            steps: 300,
            range: (-2 * .pi)...(2 * .pi)
        )
        .stroke(Color.white, style:
                    StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [], dashPhase: 0)
                )

Displaying a tweakable interface

To show this graph with some configurable sliders, we'll create some bindings we can pass in and control with sliders.

struct ContentView: View {   
    @State var amplitude: Double = 1.0
    @State var frequency: Double = 4.0
    @State var phase: Double = 0

    var body: some View {
        ZStack {
            LinearGradient.pinkToBlack
            VStack {

                GraphView(amplitude: amplitude, frequency: frequency, phase: phase)
                    .frame(height: 200)
                    .blendMode(.overlay)


                VStack {
                    ParamSlider(label: "A", value: $amplitude, range: 0...2.0)
                    ParamSlider(label: "k", value: $frequency, range: 1...20)
                    ParamSlider(label: "t", value: $phase, range: 0...(.pi * 40))
                }.padding()
            }
        }.edgesIgnoringSafeArea(.all)
    }
}

Here we use a simple extension on LinearGradient to give it a nice background:

extension LinearGradient {
    static var pinkToBlack = LinearGradient(gradient: Gradient(colors: [Color.pink, Color.black]), startPoint: .top, endPoint: .bottom)
}

And we also use a simple ParamSlider component to simplify the label, slider, and range for each of our parameters:

struct ParamSlider: View {
    var label: String
    var value: Binding<Double>
    var range: ClosedRange<Double>

    var body: some View {
        HStack {
            Text(label)
            Slider(value: value, in: range)
        }
    }
}
blog comments powered by Disqus