Skip to content

Audiocontext demo. Volume is halved on Chrome/Android, crackles on Firefox

I did a pure CSS/JS piano keyboard using the AudioContext object but I have two problems related to the playTone function:

  1. On Chrome/android (v.89.0.4389.105/Android 10) it seems that the volume is halved at every key pressed: after a few notes played the volume is not audible anymore.

  2. On Firefox (v.88/MacOS 10.15.7) I hear a crackle at the end of every key pressed.

On latest Chrome/MacOS, for comparison, it sounds good.

const noteFrequencies = {
   'C1' : 261.63,
   'C1#': 277.18,
   'D1' : 293.66,
   'D1#': 311.13,
   'E1' : 329.63,
   'F1' : 349.23,
   'F1#': 369.99,
   'G1' : 392.00,
   'G1#': 415.30,
   'A1' : 440.00,
   'A1#': 466.16,
   'B1' : 493.88
}


function playTone(note, duration = 1) {

   let ac = new AudioContext();
   let oscl = ac.createOscillator();
   let gain = ac.createGain();

   gain.connect(ac.destination);
   oscl.connect(gain);
   
   oscl.frequency.value = noteFrequencies[note];
   oscl.type = "sine"
   gain.gain.linearRampToValueAtTime(.0001, ac.currentTime + duration);
   oscl.start();
   oscl.stop(ac.currentTime + duration);
}


let notelist = document.querySelector('.piano');

notelist.addEventListener('click', (ev) => {
  let tgt = ev.target;
  let tone;
  if (tgt.matches('[data-tone]')) {
     tone = tgt.getAttribute('data-tone');
     playTone(tone);   
  }
});
* {
   box-sizing: border-box;
   font-family: "Lobster Two";
   color: #555666;
}

h1 {
   text-align: center;
   font-size: 5rem;
   margin-bottom: 4rem;
}

.piano {
   position   : relative;
   display    : flex;
   width      : max-content;
   list-style : none;
   margin     : 0 auto;
   padding    : 0;
   align-items: flex-start;
}

.piano li {      
   --ar   : .2;   
   display: inherit;
   cursor : pointer;
   color  : transparent;
   user-select : none;
   aspect-ratio: var(--ar);
}

@supports (not (aspect-ratio: 1)) {
  .piano li::before {
      content: "";
      padding-top: calc(100% / var(--ar));
   }
}


li:not(.diesis) {
  width         : max(50px, 4vw);
  border-left   : 1px solid #c4c4c8;
  border-bottom : 1px solid #c4c4c8;
  border-radius : 0 0 4px 4px;
  background    : linear-gradient(to bottom, #f2f2f5, #fff);
  box-shadow    : 0 0 5px #ccc inset;
}


li:not(.diesis):active {
  border-top    : 1px solid #bbb;
  border-left   : 1px solid #ccc;
  border-bottom : 1px solid #ccc;
  background    : linear-gradient(to bottom, #fff, #f2f2f5);
  box-shadow    :
     2px 0 3px rgba(0,0,0,0.1) inset,
     -4px 2px 10px rgba(0,0,0,.02) inset; 
}


li.diesis {   
  position      : relative;
  z-index       : 1;
   
  --w           : max(30px, 2.5vw);
  width         : var(--w);
  margin        : 0 calc(var(--w) / -2);
  border        : 1px solid #131313;
  border-radius : 0 0 4px 4px;
  background    : linear-gradient(40deg, #222, #555);
  box-shadow    :
     0 -1px 1px 2px rgba(0,0,0, .5) inset,
     0 1px 2px rgba(0,0,0, .5);
   
}

li.diesis:active {
  background: linear-gradient(100deg, #505050, #131313);
  box-shadow: 
     -1px -1px 2px rgba(255,255,255,0.2) inset,
     0 -2px 2px 3px rgba(0,0,0,0.6) inset,
     0 1px 2px rgba(0,0,0,0.5);
}
<ul class="piano">
   <li data-tone="C1">C1</li>
   <li data-tone="C1#" class="diesis">C1#</li>
   <li data-tone="D1">D1</li>
   <li data-tone="D1#" class="diesis">D1#</li>
   <li data-tone="E1">E1</li>
   <li data-tone="F1">F1</li>
   <li data-tone="F1#" class="diesis">F1#</li>
   <li data-tone="G1">G1</li>
   <li data-tone="G1#" class="diesis">G1#</li>
   <li data-tone="A1">A1</li>
   <li data-tone="A1#" class="diesis">A1#</li>
   <li data-tone="B1">B1</li>
</ul>

A full demo on codepen is available too, if it can help the debug.

Thank you

Answer

The problem with the volume in Chrome can be solved by using only one global AudioContext which then needs to be resumed in the click handler.

The crackling in Firefox can be removed by adding an explicit value to the automation timeline to start with.

const currentTime = ac.currentTime;

gain.gain.setValueAtTime(1, currentTime);
gain.gain.linearRampToValueAtTime(.0001, currentTime + duration);

The updated JavaScript code of your CodePen would then look like this:

const ac = new AudioContext();
const noteFrequencies = {
   'C1' : 261.63,
   'C1#': 277.18,
   'D1' : 293.66,
   'D1#': 311.13,
   'E1' : 329.63,
   'F1' : 349.23,
   'F1#': 369.99,
   'G1' : 392.00,
   'G1#': 415.30,
   'A1' : 440.00,
   'A1#': 466.16,
   'B1' : 493.88,
   'C2' : 523.25,
   'C2#': 554.37,
   'D2' : 587.33,
   'D2#': 622.25,
   'E2' : 659.25
}


function playTone(note, duration = 1) {
   const currentTime = ac.currentTime;

   let oscl = ac.createOscillator();
   let gain = ac.createGain();

   gain.connect(ac.destination);
   oscl.connect(gain);

   oscl.frequency.value = noteFrequencies[note];
   oscl.type = "sine"
   gain.gain.setValueAtTime(1, currentTime);
   gain.gain.linearRampToValueAtTime(.0001, currentTime + duration);
   oscl.start();
   oscl.stop(currentTime + duration);
}


let notelist = document.querySelector('.piano');

notelist.addEventListener('click', (ev) => {
  ac.resume();

  let tgt = ev.target;
  let tone;
  if (tgt.matches('[data-tone]')) {
     tone = tgt.getAttribute('data-tone');
     playTone(tone);   
  }
});