DEV Community

Cover image for How I built the SiriWaveJS library: a look at the math and the code
Flavio Maria De Stefano
Flavio Maria De Stefano

Posted on

How I built the SiriWaveJS library: a look at the math and the code

It was 4 years ago when I had the idea to replicate the Apple® Siri wave-form (introduced with the iPhone 4S) in the browser using pure Javascript.

During the last month, I updated this library by doing a lot of refactoring using ES6 features and reviewed the build process using RollupJS. Now I’ve decided to share what I've learned during this process and the math behind this library.

To get an idea of what the output will be, visit the live example; the whole codebase is here.

Additionally, you can download all plots drawn in this article in GCX (OSX Grapher format): default.gcx and ios9.gcx

The classic wave style

Classic style

Initially, this library only had the classic wave-form style that all of you remember using in iOS 7 and iOS 8.

It’s no hard task to replicate this simple wave-form, only a bit of math and basic concepts of the Canvas API.

Siri wave-form in iOS 7/8

You’re probably thinking that the wave-form is a modification of the Sine math equation, and you're right... well, almost right.

Before starting to code, we’ve got to find our linear equation that will be simply applied afterward. My favorite plot editor is Grapher; you can find it in any OSX installation under Applications > Utilities > Grapher.app

We start by drawing the well known sin(x):

Plot for y = sin(x)

Perfecto! Now, let’s add some parameters (Amplitude [A], Time coordinate [t] and Spatial frequency [k]) that will be useful later (Read more at: https://en.wikipedia.org/wiki/Wave).

Now we have to “attenuate” this function on plot boundaries, so that for |x| > 2, the y values tends to 0. Let’s draw separately an equation g(x) that has these characteristics.

This seems to be a good equation to start with. Let’s add some parameters here too to smooth the curve for our purposes:

Now, by multiplying our f(x, …) and g(x, …), and by setting precise parameters to the other static values, we obtain something like this.

  • A = 0.9 set the amplitude of the wave to max Y = A

  • k = 8 set the spatial frequency and we obtain “more peaks” in the range [-2, 2]

  • t = -π/2 set the phase translation so that f(0, …) = 1

  • K = 4 set the factor for the “attenuation equation” so that the final equation is y = 0 when |x| ≥ 2

It looks good! 😍

Now, if you notice on the original wave we have other sub-waves that will give a lower value for the amplitude. Let’s draw them for A = {0.8, 0.6, 0.4, 0.2, -0.2, -0.4, -0.6, -0.8}

In the final canvas composition, the sub-waves will be drawn with a decreasing opacity tending to 0.

Basic code concepts

What do we do now with this equation?

We use the equation to obtain the Y value for an input X.

Basically, by using a simple for loop from -2 to 2 (the plot boundaries in this case), we have to draw point by point the equation on the canvas using the beginPath and lineTo API.

const ctx = canvas.getContext('2d');

ctx.beginPath();
ctx.strokeStyle = 'white';

for (let i = -2; i <= 2; i += 0.01) {
   const x = _xpos(i);
   const y = _ypos(i);
   ctx.lineTo(x, y);
}

ctx.stroke();
Enter fullscreen mode Exit fullscreen mode

Probably this pseudo-code will clear up these ideas. We still have to implement our _xpos and _ypos functions.

But… hey, what is 0.01???

That value represents how many pixels you move forward in each iteration before reaching the right plot boundary... but why 0.0.1, and what is the correct value?

If you use a really small value (< 0.01), you’ll get an insanely precise rendering of the graph but your performance will decrease because you’ll get too many iterations.

Instead, if you use a really big value (> 0.1) your graph will lose precision and you’ll notice this instantly.

Plot drawn with precision = 0.2

You can see that the final code is actually similar to the pseudo-code: https://github.com/kopiro/siriwave/blob/master/src/curve.ts

Implement _xpos(i)

You may argue that if we’re drawing the plot by incrementing the x, then _xpos may simply return the input argument.

This is almost correct, but our plot is always drawn from -B to B (B = Boundary = 2).

So, to draw on the canvas via pixel coordinates, we must translate -B to 0 and B to 1 (simple transposition of [-B, B] to [0,1]); then multiply [0,1] and the canvas width (w).

_xpos(i) = w * [ (i + B) / 2B ]
Enter fullscreen mode Exit fullscreen mode

Implement _ypos

To implement _ypos, we should simply write our equation obtained before (closely).

const K = 4;
const FREQ = 6;

function _attFn(x) {
    return Math.pow(K / (K + Math.pow(x, K)), K);
}

function _ypos(i) {
    return Math.sin(FREQ * i - phase) * 
        _attFn(i) * 
        canvasHeight *
        globalAmplitude * 
        (1 / attenuation);
}
Enter fullscreen mode Exit fullscreen mode

Let’s clarify some parameters.

  • canvasHeight is Canvas height expressed in PX

  • i is our input value (the X)

  • phase is the most important parameter, let’s discuss it later

  • globalAmplitude is a static parameter that represents the amplitude of the total wave (composed by sub-waves)

  • attenuation is a static parameter that changes for each line and represents the amplitude of a wave

Phase

Now let’s discuss the phase variable: it is the first changing variable over time because it simulates the wave movement.

What does it mean? It means that for each animation frame, our base controller should increment this value. But to avoid this value throwing a buffer overflow, let’s modulo it with 2π (since Math.sin dominio is already modulo 2π).

phase = (phase + (Math.PI / 2) * speed) % (2 * Math.PI);
Enter fullscreen mode Exit fullscreen mode

We multiply speed and Math.PI so that with speed = 1 we have the maximum speed (why? because sin(0) = 0, sin(π/2) = 1, sin(π) = 0, ....

Finalizing

Now that we have all code to draw a single line, we define a configuration array to draw all sub-waves and then cycle over them.

return [
    { attenuation: -2, lineWidth: 1.0, opacity: 0.1 },
    { attenuation: -6, lineWidth: 1.0, opacity: 0.2 },
    { attenuation: 4, lineWidth: 1.0, opacity: 0.4 },
    { attenuation: 2, lineWidth: 1.0, opacity: 0.6},

    // basic line
    { attenuation: 1, lineWidth: 1.5, opacity: 1.0},
];
Enter fullscreen mode Exit fullscreen mode

The iOS 9+ style

GIF of SiriwaveJS iOS9+

Now things start to get complicated. The style introduced with iOS 9 is really complex and reverse engineering to simulate it’s not easy at all! I’m not fully satisfied with the final result, but I’ll continue to improve it until I get the desired result.

As previously done, let’s start to obtain the linear equations of the waves.

Original Siri iOS 9+ wave-form

As you can notice:

  • We have three different specular equations with different colors (green, blue, red)

  • A single wave seems to be a sum of sine equations with different parameters

  • All other colors are a composition of these three base colors

  • There is a **straight line **at the plot boundaries

By picking again our previous equations, let’s define a more complex equation that involves translation. We start by defining again our attenuation equation:

Now, define h(x, A, k, t) function, that is the sine function multiplied for **attenuation function, in its absolute value:

We now have a powerful tool.

With h(x), we can now create the final wave-form by summing different h(x) with different parameters involving different amplitudes, frequency, and translations. For example, let’s define the *red curve * by putting random values.

If we do the same with a green and blue curve, this is the result:

This is not quite perfect, but it could work.

To obtain the specular version, just multiply everything by -1.

In the coding side, the approach is the same, we have only a more complex equation for _ypos.

const K = 4;
const NO_OF_CURVES = 3;

// This parameters should be generated randomly
const widths = [ 0.4, 0.6, 0.3 ];
const offsets = [ 1, 4, -3 ];
const amplitudes = [ 0.5, 0.7, 0.2 ];
const phases = [ 0, 0, 0 ];

function _globalAttFn(x) {
    return Math.pow(K / (K + Math.pow(x, 2)), K);
}

function _ypos(i) {
    let y = 0;
    for (let ci = 0; ci < NO_OF_CURVES; ci++) {
        const t = offsets[ci];
        const k = 1 / widths[ci];
        const x = (i * k) - t;

        y += Math.abs(
            amplitudes[ci] * 
            Math.sin(x - phases[ci]) * 
            _globalAttFn(x)
        );
    }

    y = y / NO_OF_CURVES;
    return canvasHeightMax * globalAmplitude * y;
}
Enter fullscreen mode Exit fullscreen mode

There’s nothing complex here. The only thing that changed is that we cycle NO_OF_CURVES times overall pseudo-random parameters and we sum all y values.

Before multiplying it for canvasHeightMax and globalAmplitude that give us the absolute PX coordinate of the canvas, we divide it for NO_OF_CURVES so that y is always ≤ 1.

Composite operation

One thing that actually matters here is the globalCompositeOperation mode to set in the Canvas. If you notice, in the original controller, when there’s an overlap of 2+ colors, they’re actually mixed in a standard way.

The default is set to source-over, but the result is poor, even with an opacity set.

composite operation: source-over

You can see all examples of vary **globalCompositeOperation **here: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation

By setting globalCompositeOperation to ligther, you notice that the intersection of the colors is nearest to the original.

Composite operation: lighter

Build with RollupJS

Before refactoring everything, I wasn’t satisfied at all with the codebase: old prototype-like classes, a single Javascript file for everything, no uglify/minify and no build at all.

Using the new ES6 feature like native classes, spread operators and lambda functions, I was able to clean everything, split files, and decrease lines of unnecessary code.

Furthermore, I used RollupJS to create a transpiled and minified build in various formats.

Since this is a browser-only library, I decided to create two builds: a UMD (Universal Module Definition) build that you can use directly by importing the script or by using CDN, and another one as an ESM module.

The UMD module is built with this configuration:

{
    input: 'src/siriwave.js',
    output: {
        file: pkg.unpkg,
        name: pkg.amdName,
        format: 'umd'
    },
    plugins: [
        resolve(),
        commonjs(),
        babel({ exclude: 'node_modules/**' }),
    ]
}
Enter fullscreen mode Exit fullscreen mode

An additional **minified UMD module **is built with this configuration:

{
    input: 'src/siriwave.js',
    output: {
        file: pkg.unpkg**.replace('.js', '.min.js')**,
        name: pkg.amdName,
        format: 'umd'
    },
    plugins: [
        resolve(),
        commonjs(),
        babel({ exclude: 'node_modules/**' }),
        **uglify()**
    ]
}
Enter fullscreen mode Exit fullscreen mode

Benefiting of UnPKG service, you can find the final build on this URL served by a CDN: https://unpkg.com/siriwave/dist/siriwave.min.js

This is the “old style Javascript way” — you can just import your script and then refer in your code by using SiriWave global object.

To provide a more elegant and modern way, I also built an ESM module with this configuration:

{ 
    input: 'src/siriwave.js',
    output: { 
        file: pkg.module, 
        format: 'esm'
    }, 
    plugins: [ 
        babel({ exclude: node_modules/**’ })
    ]
}
Enter fullscreen mode Exit fullscreen mode

We clearly don’t want the resolve **or **commonjs RollupJS plugins because the developer transplier will resolve dependencies for us.

You can find the final RollupJS configuration here: https://github.com/kopiro/siriwave/blob/master/rollup.config.js

Watch and Hot code reload

Using RollupJS, you can also take advantage of rollup-plugin-livereload and rollup-plugin-serve plugins to provide a better way to work on scripts.

Basically, you just add these plugins when you’re in “developer” mode:

import livereload from 'rollup-plugin-livereload';
import serve from 'rollup-plugin-serve';

if (process.env.NODE_ENV !== 'production') {
    additional_plugins.push(serve({ open: true, contentBase: '.' }));
    additional_plugins.push(livereload({ watch: 'dist' }));
}
Enter fullscreen mode Exit fullscreen mode

We finish by adding these lines into the package.json:

"module": "dist/siriwave.m.js",
"jsnext:main": "dist/siriwave.m.js",
"unpkg": "dist/siriwave.js",
"amdName": "SiriWave",
"scripts": {
    "build": "NODE_ENV=production rollup -c",
    "dev": "rollup -c -w"
},
Enter fullscreen mode Exit fullscreen mode

Let’s clarify some parameters:

  • module / jsnext:main - path of dist ESM module

  • unpkg - path of dist UMD module

  • amdName name of the global object in UMD module

Thanks a lot RollupJS!

Hope that you find this article interesting, see you soon! 😎

Top comments (0)