Search code examples
graphicsrenderingsignal-processing

Plotting a discrete-time signal shows amplitude modulation


I'm trying to render a simple discrete-time signal using a canvas element. However, the representation seems to be inaccurate. As you can see in the code snippet the signal appears to be amplitude modulated after the frequency reaches a certain threshold. Even though it's well below the Nyquist limit of <50Hz (assuming a sampling rate of 100Hz in this example). For very low frequencies like 5Hz it looks perfectly fine.

How would I go about rendering this properly? And does it work for more complex signals (say, the waveform of a song)?

window.addEventListener('load', () => {
  const canvas = document.querySelector('canvas');
  const frequencyElem = document.querySelector('#frequency');
  const ctx = canvas.getContext('2d');

  const renderFn = t => {
    const signal = new Array(100);
    const sineOfT = Math.sin(t / 1000 / 8 * Math.PI * 2) * 0.5 + 0.5;
    const frequency = sineOfT * 20 + 3;

    for (let i = 0; i < signal.length; i++) {
      signal[i] = Math.sin(i / signal.length * Math.PI * 2 * frequency);
    }

    frequencyElem.innerText = `${frequency.toFixed(3)}Hz`

    render(ctx, signal);
    requestAnimationFrame(renderFn);
  };

  requestAnimationFrame(renderFn);
});

function render(ctx, signal) {
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;

  ctx.clearRect(0, 0, w, h);

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

  signal.forEach((value, i) => {
    const x = i / (signal.length - 1) * w;
    const y = h - (value + 1) / 2 * h;

    if (i === 0) {
      ctx.moveTo(x, y);
    } else {
      ctx.lineTo(x, y);
    }
  });

  ctx.stroke();
}
@media (prefers-color-scheme: dark) {
  body {
    background-color: #333;
    color: #f6f6f6;
  }
}
<canvas></canvas>
<br/>
Frequency: <span id="frequency"></span>


Solution

  • It looks right to me. At higher frequencies, when the peak falls between two samples, the sampled points can be a lot lower than the peak.

    If the signal only has frequencies < Nyquist, then the signal can be reconstructed from its samples. That doesn't mean that the samples look like the signal.

    As long as your signal is oversampled by 2x or more (or so), you can draw it pretty accurately by using cubic interpolation between the sample points. See, for example, Catmull-Rom interpolation in here: https://en.wikipedia.org/wiki/Cubic_Hermite_spline

    You can use the bezierCurveTo method in HTML Canvas to draw these interpolated curves. If you need to use lines, then you should find any maximum or minimum points that occur between samples and include those in your path.

    I've edited your snippet to use the bezierCurveTo method with Catmull-Rom interpolation below:

    window.addEventListener('load', () => {
      const canvas = document.querySelector('canvas');
      const frequencyElem = document.querySelector('#frequency');
      const ctx = canvas.getContext('2d');
    
      const renderFn = t => {
        const signal = new Array(100);
        const sineOfT = Math.sin(t / 1000 / 8 * Math.PI * 2) * 0.5 + 0.5;
        const frequency = sineOfT * 20 + 3;
    
        for (let i = 0; i < signal.length; i++) {
          signal[i] = Math.sin(i / signal.length * Math.PI * 2 * frequency);
        }
    
        frequencyElem.innerText = `${frequency.toFixed(3)}Hz`
    
        render(ctx, signal);
        requestAnimationFrame(renderFn);
      };
    
      requestAnimationFrame(renderFn);
    });
    
    function render(ctx, signal) {
      const w = ctx.canvas.width;
      const h = ctx.canvas.height;
    
      ctx.clearRect(0, 0, w, h);
    
      ctx.strokeStyle = 'red';
      ctx.beginPath();
    
      const dx = w/(signal.length - 1);
      const dy = -(h-2)/2.0;
      const c = 1.0/2.0;
    
      for (let i=0; i < signal.length-1; ++i) {
        const x0 = i * dx;
        const y0 = h*0.5 + signal[i]*dy;
        const x3 = x0 + dx;
        const y3 = h*0.5 + signal[i+1]*dy;
        let x1,y1,x2,y2;
        if (i>0) {
          x1 = x0 + dx*c;
          y1 = y0 + (signal[i+1] - signal[i-1])*dy*c/2;
        } else {
          x1 = x0;
          y1 = y0;
          ctx.moveTo(x0, y0);
        }
        if (i < signal.length-2) {
          x2 = x3 - dx*c;
          y2 = y3 - (signal[i+2] - signal[i])*dy*c/2;
        } else {
          x2 = x3;
          y2 = y3;
        }
        ctx.bezierCurveTo(x1,y1,x2,y2,x3,y3);
      }
    
      ctx.stroke();
    }
    @media (prefers-color-scheme: dark) {
      body {
        background-color: #333;
        color: #f6f6f6;
      }
    }
    <canvas></canvas>
    <br/>
    Frequency: <span id="frequency"></span>