11

I really hope this question stays a programming question and do not end up an Sound Mechanics question... Here goes...

I am doing some experiments in order to figure out how the Web Audio API works. What I am trying to do is a simple "Hang up phone" sound playing in a loop. The problem is that when the sound ends, you can hear a quite annoying 'clicking' sound. I cannot explain it better, but you can hear it if you test the code.

Is there some way I could avoid this? Some filter I could apply or anything?

var audioContext = new (AudioContext || webkitAudioContext)();
    
    var frequencyOffset = 0
    function boop(){
      // Our sound source is a simple triangle oscillator
      var oscillator = audioContext.createOscillator(); // Create sound source  
      oscillator.type = 'triangle';
      
      // Adding a gain node just to lower the volume a bit and to make the
      // sound less ear-piercing
      var gain = audioContext.createGain();
      oscillator.connect(gain);
      gain.connect(audioContext.destination);
      
      gain.gain.value = 0.1;
      // Just for fun let the frequency increase on each itteration
      oscillator.frequency.value = 200 + frequencyOffset;
      oscillator.start(0);
      
      // The sound should last for 250ms
      setTimeout(function(){
        oscillator.disconnect(); 
        oscillator.stop();
        gain.disconnect();
      }, 250);
      frequencyOffset += 1;
    }

    setInterval(boop, 500);
Loupax
  • 4,728
  • 6
  • 41
  • 68
  • Nowadays you can use a gain node and ramp down its value with exponentialRampToValueAtTime. I wrote a bit more about it here if that interests you: http://marcgg.com/blog/2016/11/01/javascript-audio/ – marcgg Nov 01 '16 at 17:23
  • @marcgg `exponentialRampToValueAtTime` method or `setTargetAtTime` method doesn't work in Firefox as of now (Windows 7, version 54.0.1 (32-bit)) – Long Nguyen Jul 06 '17 at 05:08

4 Answers4

14

This is an audio issue, not a programming problem. The click you hear occurs when a waveform is stopped/cut in the middle of a wave, rather than at a zero-crossing.

The best simple solution from a audio paradigm is to very quickly fade-out, instead of just stopping playback.

A slightly more complex solution is to find the next zero-crossing and stop playback at precisely that point.

Silas Reinagel
  • 4,155
  • 1
  • 21
  • 28
  • Is Mozilla aware of this bug? – Lajos Mészáros Dec 15 '15 at 11:58
  • 1
    @LajosMeszaros -- This is no bug, but a fundamental and well understood problem in audio processing and synthesis. A web audio API implementation may provide an easy, off-the-shelf solution to it, but it's not a priority issue. – John Weisz Feb 15 '17 at 15:16
  • @JohnWeisz - Let me rephrase the question. Is Mozilla aware of this implementation inconsistency with Chrome? By the way, the answer is yes, but they are waiting for a solution, until the WebAudio API provides a proper solution on how to handle this case. – Lajos Mészáros Feb 16 '17 at 14:59
  • Adding link to the WebAudioAPI discussion, which targets this topic: https://github.com/WebAudio/web-audio-api/issues/541 – Lajos Mészáros Feb 16 '17 at 15:02
11

There's a brief explanation of why we hear the clicking sound (it's a human ear thing) and good examples of how to get around that using the Web audio API here: http://alemangui.github.io/blog//2015/12/26/ramp-to-value.html

The main takeaway from the article is that the exponential methods to remove the click work better; exponentialRampToValueAtTime and setTargetAtTime.

Using setTargetAtTime to remove the click

var context = new AudioContext();
var oscillator = context.createOscillator();
var gainNode = context.createGain();

oscillator.connect(gainNode);
gainNode.connect(context.destination)
oscillator.start();

stopButton.addEventListener('click', function() {
    gainNode.gain.setTargetAtTime(0, context.currentTime, 0.015);
});

Using exponentialRampToValueAtTime to remove the click

var context = new AudioContext();
var oscillator = context.createOscillator();
var gainNode = context.createGain();

oscillator.connect(gainNode);
gainNode.connect(context.destination)

oscillator.start();

stopButton.addEventListener('click', function() {
    // Important! Setting a scheduled parameter value
    gainNode.gain.setValueAtTime(gainNode.gain.value, context.currentTime); 

    gainNode.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.03);
});

Both of these worked for me in my use case, with exponentialRampToValueAtTime working slightly better. I could still hear a faint click using setTargetAtTime.

Sheraff
  • 5,730
  • 3
  • 28
  • 53
timmcliu
  • 1,769
  • 1
  • 13
  • 12
4

Looks like the Web Audio API gives the developer an easy way of stopping a sound source from playing without abruptly stopping the waveform and avoid any noise and sound artifacts.

  1. Create your sound source (in my example an oscillator)
  2. Create a gain node and connect it with the sound source.
  3. Start the sound source and set the gain value to 0. That way, you won't listen to the sound even if it's technically playing
  4. Set the gain value to 1 when you want the source to play and to 0 when it should not play. The gain node will handle the rest, and no clicking will be heard

var audioContext = new(AudioContext || webkitAudioContext)();

var frequencyOffset = 0
  // Our sound source is a simple triangle oscillator
var oscillator = audioContext.createOscillator(); // Create sound source  
oscillator.type = 'triangle';

// Adding a gain node just to lower the volume a bit and to make the
// sound less ear-piercing. It will also allow us to mute and replay
// our sound on demand
var gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);

gainNode.gain.value = 0;
oscillator.frequency.value = 200;
oscillator.start(0);

function boop() {
  gainNode.gain.value = 0.1;
  // The sound should last for 250ms
  setTimeout(function() {
    gainNode.gain.value = 0;
  }, 250);
  oscillator.frequency.value++;
}

setInterval(boop, 500);
Loupax
  • 4,728
  • 6
  • 41
  • 68
  • That is exactly, what I'm doing in my app, but there is still that clicking sound, even in your snippet. I even tried gain.exponentialRampToValueAtTime() and gain.linearRampToValueAtTime(), but it did not work either. – Lajos Mészáros Oct 03 '15 at 22:18
  • 1
    Okay, in Chrome, it works fine, but it doesn't in Firefox. (win7, ff 41.0.1) – Lajos Mészáros Oct 03 '15 at 22:21
  • 1
    This just adds another layer of indirection to the problem. The gain node is the right tool, but it needs a ramp envelope so that the speaker isn't forced to jump instantly between 0 and a large nonzero value, which is physically impossible and causes the click. I'm not sure why this was accepted. See [this answer](https://stackoverflow.com/a/48467308/6243352) – ggorlen Jul 31 '21 at 20:10
0

timmcliu's answer is good but two things further reduced the clicky sound in my case:

  1. I used linearRampToValueAtTime. I suppose this makes the change more gradual so it softens the click.
  2. I've scheduled the prerequisite noop setValueAtTime into the future too. I suppose this makes the change start being applied from the next render quantum rather than the current one. I think without this the ramp down starts way too suddenly since the browser cannot edit the already pre-buffered render quantum (128 samples, IIUC).

Basically this:

stopButton.addEventListener('click', function() {
    let t = context.currentTime;
    gainNode.gain.setValueAtTime(gainNode.gain.value, t + 0.02);
    gainNode.gain.linearRampToValueAtTime(0.0001, t + 0.04);
});
ypsu
  • 1,277
  • 9
  • 7