Manipulacja Audio w HTML5

Cześć! Cieszę się, że mnie odwiedziłeś/aś. Zanim przejdziesz do artykułu chciałbym zwrocić Ci uwagę na to, że ten artykuł był pisany kilka lat temu (2016-02-19) miej więc proszę na uwadzę że rozwiązania i przemyślenia które tu znajdziesz nie muszą być aktualne. Niemniej jednak zachęcam do przeczytania.

Cześć,
HTML 5 poza rzeczami znanymi, takimi jak WebRTC, localStorage czy sessionStorage daje możliwość manipulacji elementami Audio. Pokażę krótki kawałek kodu którym możemy łatwo przeanalizować dźwięk który „słyszy” mikrofon ;)

Jak zwykle kod pisany jest pod Chrome, ale łatwo możecie go sobie przepisać min. na FireFoxa.
Zacznijmy od uzyskania dostępu do mikrofonu. UserMedia w Chrome wymagają aby strona żądająca do nich dostępu była serwowana przez HTTPs, stąd demo które zamieszczę, ma darmowy, zaufany certyfikat, wygenerowany zgodnie z poprzednim wpisem na tym blogu – Darmowy certyfikat TLS (HTTPS) od Lets Encrypt

Więc owy dostęp możemy uzyskać dzięki linijce takiego kodu:

1
2
3
4
5
navigator.webkitGetUserMedia(
  {audio:true, video:false},
  callbackWithMedia,
  function(){}
);

Gdzie jako parametry podajemy, kolejno, o jaki dostęp prosimy, callback do funkcji która ma się wykonać gdy uzyskamy dostęp i callback do funkcji gdy pojawi się błąd.

Funkcja przekazana jako drugi argument przyjmuje jeden argument którym jest strumień, w tym przypadku audio.
Aby móc manipulować i analizować dźwięk musimy skorzystać z klasy AudioContext która reprezentuje całościowy kontekst dźwiękowy komputera. Poza tym dostarcza kilka metod fabrykujących inne obiekty klas które mogą operować na dźwięku, My użyjemy klasy Analyser.

1
2
3
4
5
var callbackWithMedia = function (stream) {
  var context = new AudioContext();
  var microphone = context.createMediaStreamSource(stream);
  var analyser = context.createAnalyser();
};

Tak jak napisałem wyżej, pierw tworzymy kontekst. Następnie funkcją createMediaStreamSource tworzymy, przekazując w jej argumencie nasz surowy strumień, obiekt klasy MediaStreamAudioSourceNode który może być np. odtwarzany. Następnie używając aktualnego kontekstu tworzymy nasz Analyser.

Klasa Analyser pozwala analizować strumień audio w dziedzinie częstotliwości lub czasu. My będziemy opierać się o częstotliwość dźwięku. Aby uzyskać takie dane od Analyser’a należy podać mu argument FFT – czyli Szybka Transformata Fouriera.

Transformacja Fouriera rozkłada funkcję okresową na szereg funkcji okresowych tak, że uzyskana transformata podaje w jaki sposób poszczególne częstotliwości składają się na pierwotną funkcję.

A sam argument który podajemy do Analyser’a to rozmiar bloku danych – jest to zwykle liczba która jest potęgą liczby 2.

Rozbudujmy nasz kod:

1
2
3
4
5
6
7
8
var callbackWithMedia = function (stream) {
  var context = new AudioContext();
  var microphone = context.createMediaStreamSource(stream);
  var analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      microphone.connect(analyser);
      element.src = URL.createObjectURL(stream);
};

Poza ustawieniem fftSize łączymy jeszcze naszego Analyser’a z naszym strumieniem mediów i przypisujemy do pola src zmiennej element wartość naszego surowego strumienia przepuszczonego przez funkcję URL.createObjectURL. Zmienna element została zdefiniowana jako:

1
var element = document.getElementById("audioElement");

A w pliku HTML w którym osadzamy nasz plik JS mamy element audio:

1
<audio controls="controls" style="width:100%;height:100px" id="audioElement"></audio>

który renderuje się do takiego odtwarzacza:

Uzyskujemy dzięki temu efekt w którym jeśli otworzymy naszą stronę i klikniemy na odtwarzaczu „Play” to usłyszymy dźwięk z mikrofonu.

No i fajnie – ale nadal nie korzystamy z Analyser’a. Napiszmy kawałek kodu który jeśli wykryje „przemowę” mówioną do mikrofonu to odtworzy nam inny plik audio – niech to będą oklaski ;) czyli nasza przemowa będzie nagradzana oklaskami. A więc zaczynajmy:

1
2
3
4
5
6
7
8
9
10
11
12
var callbackWithMedia = function (stream) {
  var context = new AudioContext();
  var microphone = context.createMediaStreamSource(stream);
  var analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      microphone.connect(analyser);
      window.setInterval(function() {
      var freqByteData = new Uint8Array(analyser.frequencyBinCount);
      analyser.getByteFrequencyData(freqByteData);
  }, 50);
      element.src = URL.createObjectURL(stream);
};

Dodaliśmy tutaj setInterval – czyli oznaczyliśmy funkcję przekazaną jako pierwszy argument jako taką która ma się uruchamiać co tyle ms ile jest przekazane w drugim argumencie. Czyli u nas co 50ms.

W niej natomiast tworzymy obiekt klasy Uint8Array, czyli tak naprawdę tablicy o długości podanej w argumencie – frequencyBinCount. I wypełniamy ją danymi od Analyser’a.

Załóżmy że jako wykrywaną „przemowę” będziemy rozumieć utrzymującą się przez co najmniej 2 sekundy średnią częstotliwość na poziomie 70Hz. Dla ułatwienia będziemy rozpatrywać tylko 3 pierwsze wartości dostarczone od Analyser’a.

Możemy to zaimplementować np. tak

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var callbackWithMedia = function (stream) {
  var context = new AudioContext();
  var microphone = context.createMediaStreamSource(stream);
  var analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      microphone.connect(analyser);
      window.setInterval(function() {
      var freqByteData = new Uint8Array(analyser.frequencyBinCount);
      analyser.getByteFrequencyData(freqByteData);
      if((freqByteData[0] + freqByteData[1] +freqByteData[2])/3 >= noiseLvl){
          currentDuration++;
      }
      else {
          if(currentDuration> 0)
              currentDuration--;
      }

      if(currentDuration >= duration){
          clap();
          currentDuration= 0;
      }
      console.log(((freqByteData[0] + freqByteData[1] +freqByteData[2])/3) + '...   '+currentDuration);
  }, 50);
  element.src = URL.createObjectURL(stream);
};

Czyli jeśli średnia częstotliwość jest na poziomie większym niż noiseLvl (70), to zwiększamy zmienną currentDuration o jeden, w przeciwnym przypadku, jeśli zmienna ta jest większa niż 0, to zmniejszamy ją o 1. To zmniejszanie pozwoli nam wykrywać i korygować ewentualną sytuację w której przemowa nie jest ciągła.. Czyli jeśli np. zrobimy pauzę w mówieniu to będzie spadać wymagana długość mówienia, ale gdy zaczniemy mówić znów to nie zaczniemy zbierać jej „od zera”. Wymagana długość mówienia to zmienna duration i dla 2 sekund ma wartość 40. Wartość 40 wzięła się stąd że, skoro funkcja jest odpalana co 50ms to currentDuration ma szansę być raz na 50ms zwiększone o jeden. Jeśli przemowa będzie ciągła to currentDuration będzie miało wartość 40 po 2 sekundach, ponieważ 40 * 50m = 2000ms = 2s.

I jeśli uzyskaliśmy wymagany próg to wywołujemy clap() i zerujemy currentDuration. Funkcja clap odtworzy nam plik MP3 z oklaskami:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var clapContext = new AudioContext();
var clap = function(){
  console.log("Clap!!");
  var request = new XMLHttpRequest();
  request.open("GET", "clap.mp3", true);
  request.responseType = "arraybuffer";
  request.onload = function() {
      var audioData = request.response;
      var soundSource = clapContext.createBufferSource();
      clapContext.decodeAudioData(audioData, function(soundBuffer){
          soundSource.buffer = soundBuffer;
          soundSource.connect(clapContext.destination);
          soundSource.start(clapContext.currentTime);
      });
  };

  request.send();
}

Wysyłamy GET’a po plik, dekodujemy używając kolejnych metod z klasy AudioContext i odtwarzamy ;) zwróć uwagę na responseType oraz na sposób przekazywania pliku MP3 do kontekstu.

Cały kod wygląda tak (poza plikiem HTML):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
var element = document.getElementById("audioElement");
var duration = 40;
var currentDuration = 0;
var noiseLvl = 70;
var clapContext = new AudioContext();

var clap = function(){
  console.log("Clap!!");
  var request = new XMLHttpRequest();
  request.open("GET", "clap.mp3", true);
  request.responseType = "arraybuffer";
  request.onload = function() {
      var audioData = request.response;
      var soundSource = clapContext.createBufferSource();
      clapContext.decodeAudioData(audioData, function(soundBuffer){
          soundSource.buffer = soundBuffer;
          soundSource.connect(clapContext.destination);
          soundSource.start(clapContext.currentTime);
      });
  };

  request.send();
}


var callbackWithMedia = function (stream) {
  var context = new AudioContext();
  var microphone = context.createMediaStreamSource(stream);
  var analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      microphone.connect(analyser);
      window.setInterval(function() {
      var freqByteData = new Uint8Array(analyser.frequencyBinCount);
      analyser.getByteFrequencyData(freqByteData);
      if((freqByteData[0] + freqByteData[1] +freqByteData[2])/3 >= noiseLvl){
          currentDuration++;
      }
      else {
          if(currentDuration > 0)
              currentDuration--;
      }

      if(currentDuration >= duration){
          clap();
          currentDuration = 0;
      }
      console.log(((freqByteData[0] + freqByteData[1] +freqByteData[2])/3) + '...   '+currentDuration);
  }, 50);
  element.src = URL.createObjectURL(stream);
};

navigator.webkitGetUserMedia(
  {audio:true, video:false},
  callbackWithMedia,
  function(){}
);

DEMO

Dzięki!

Dzięki za wizytę,
Mateusz Mazurek
Mateusz M.

Ostatnie wpisy

Podsumowanie: maj, czerwiec, lipiec i sierpień 2024

Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More

2 miesiące ago

Podsumowanie: kwiecień 2024

Cześć! Zapraszam na krótkie podsumowanie kwietnia. Wyjazd do Niemiec A dokładniej pod granicę z Francją. Chrześnica miała pierwszą komunię. Po… Read More

6 miesięcy ago

Podsumowanie: luty i marzec 2024

Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More

7 miesięcy ago

Podsumowanie: styczeń 2024

Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More

9 miesięcy ago

Podsumowanie roku 2023

Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More

10 miesięcy ago

Podsumowanie: grudzień 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More

11 miesięcy ago