Web Audio API에 대한 이해

이영섭·2025년 9월 7일

서론

Sorisoop 프로젝트를 개인 프로젝트로 돌리면서 소음 측정 방식에 대한 이해 필요성을 강하게 느꼈다. 이전 팀 프로젝트 당시 일정이 급하였다보니 당장 음성을 측정하는 것에 집중하였지만 사용된 web audio api에 대한 이해도 부족하였고, 무엇보다 소음 측정시 decibel 숫자로 변환되는데 정확도가 부족하다고 느꼈다. 이에 따라 직접 Web audio api를 간단하게 실습해보면서 이해해보기로 하였다.

참고
해당 내용은 MDN 문서를 참조하였고, 일반적으로 사용되는 audio play보다는 음성의 decibel을 측정하는데 집중하였다.

Web Audio API

오디오 데이터를 처리하고 조작하기 위해서는 Audio Context가 필요하고, Web audio API를 보다 잘 활용하기 위해서는 Audio Context노드에 대한 기본 개념을 이해할 필요가 있다.

Audio Context란?

웹 브라우저에서 오디오 데이터를 처리하고 조작하기 위한 모든 노드와 오디오 처리를 담는 컨테이너 객체를 말한다. 쉽게 말해 오디오 데이터를 처리하거나, 오디오 데이터 관련 작업들에 대한 생명주기 관리 등을 담당한다. Audio Context는 오디오 작업을 위한 일종의 진입점이라 이해하였다.

노드란?

이 Web audio API에 있어서 노드란 오디오 신호가 흐르는 전자 회로의 구성 요소와 같고, 각 노드는 특정 작업을 수행한다. 이들을 서로 연결하여 복잡한 오디오 처리 파이프라인을 구축할 수 있다.

각 주요 노드란 다음과 같다.

노드 종류 역할 예시
소스 노드 (Source Node) 오디오 신호를 생성합니다. MediaStreamSourceNode(마이크 입력), AudioBufferSourceNode(오디오 파일)
처리 노드 (Processing Node) 오디오 신호를 변형하거나 분석합니다. AnalyserNode(소리 분석), GainNode(볼륨 조절), BiquadFilterNode(주파수 필터링)
목적지 노드 (Destination Node) 처리된 오디오 신호를 최종적으로 받습니다. AudioDestinationNode(스피커나 헤드폰으로 소리 출력)

Web Audio의 일반적인 작업 흐름

아래 내용은 MDN 문서에 있는 일반적인 작업 흐름을 내가 이해한 바대로 풀어서 설명한것이다.

  1. 오디오 작업을 처리하기 위해 진입점인 AudioContext를 생성한다.

  2. 오디오 신호를 생성하기 위해 컨텍스트내 소스를 생성한다.

  3. 출력될 audio 음향 효과를 위해 이펙트 노드를 생성한다.

  4. 오디오 데이터가 출력될 최종 목적지 노드에 연결한다.

  5. 사운드를 이펙트 효과를 위한 이펙트 노드에 연결하고, 해당 이펙트가 연결된 사운드를 목적지에 연결함으로써 오디오를 출력한다.

MDN 문서 내 audio context 작업 흐름
인용 자료: MDN 문서 내 AudioContext 일반적 작업 흐름

Web Audio API와 소음측정 간의 상관 관계

그렇다면 여지껏 이해한 Web Audio API를 소음 측정에 연결하기 위해서는 어떻게 해야할까?
이를 이해하기 위해 위에서 설명한 AudioContext의 작업 흐름과 함께 아래 간단한 html 코드를 활용하여 실습하였다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Understanding Web Audio API</title>
  <link rel="stylesheet" href="app.css">
</head>
<body>
  <div id="audio">
    <p>Audio</p>
  </div>
  <form>
    <button type="submit" id="startButton">
      Audio test 시작!
    </button>
  </form>
</body>
<script>

</script>
</html>

1. AudioContext 생성

제일 먼저 생각할 수 있는것은 오디오 작업 처리를 위해 AudioContext를 생성한다. 모든 오디오 작업의 진입점은 AudioContext이기 때문이다.

이를 위해 html 코드의 'Audio test 시작!' 버튼을 클릭하면 audioContext를 생성할 수 있도록 아래 js 코드를 준비하였다.

	const startButton = document.getElementById('startButton');
    let audioContext = null;

    startButton.addEventListener('click', (event) => {
        event.preventDefault(); // 폼 제출을 방지합니다.

        // 사용자의 첫 클릭 시점에 AudioContext를 생성
        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
            console.log("AudioContext가 생성되었습니다.", audioContext);
        }
    });

버튼을 클릭하였을 때 .audioContext가 객체로 생성됨을 확인할 수 있다.
audio context consolelog
초기 console창을 보고 listener 속성이 소스가 입력되는 곳인 줄 알았는데 오디오를 듣는 가상의 청자(listener)를 의미하며, 듣는 청자로 하여금 왼쪽에서 오른쪽으로 이동하는 음성처럼 들리게 할 수도 있다. 또한 currentTime은 측정 시간이 아닌 audioContext가 생성된 이후의 시간(초)을 나타내는 상대적인 값임을 이해하자

그 다음으로 생각할 것은 소스를 어디서 받아오는가이다. 사용되는 기기는 노트북, 데스크탑, 휴대폰이므로 각 기기의 마이크로부터 데이터를 받아와야한다.

2. 소스 생성

결국 소음도 음성 데이터이기 때문에 어딘가에서 입력을 받아서 처리해야하며, 이때 사용할 수 있는 AudioContext의 메서드가 createMediaStreamSource() 메서드 이다. 이 createMediaStreamSource() 메서드는 외부 미디어 스트림을 입력으로 받아 오디오 그래프에 연결할 수 있는 새로운 노드 객체를 만들어 반환하는 역할을 한다. 이 미디어 스트림은 마이크가 받아올 수도 있고 이미 녹화된 비디오와 결합된 음성 데이터일 수도 있다.

  • 스트림(stream)이란?
    시간에 따라 연속적으로 발생하는 데이터의 흐름을 의미

이제 위에 실습한 javascript 코드에 소스를 입력받을 수 있도록 처리할 것이다.

const startButton = document.getElementById('startButton');
    let audioContext = null;

    startButton.addEventListener('click', (event) => {
        event.preventDefault(); // 폼 제출을 방지합니다.

        // 사용자의 첫 클릭 시점에 AudioContext를 생성
        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
            
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then(stream => {
                    const source = audioContext.createMediaStreamSource(stream);
                    console.log("마이크 소스 노드가 생성되었습니다.", source);
                })
                .catch(err => {
                    console.error("마이크 접근에 실패했습니다:", err);
                });
        }
    });

createMediaStreamSource 메서드는 그 자체로 오디오를 생성하는 것이 아니라 MediaStream 객체를 입력으로 받아서 Web Audio API의 노드 그래프에 연결할 수 있는 소스 노드를 만들어주는 역할을 하기에 navigator.mediaDevices.getUserMedia()라는 메서드를 통해 먼저 MediaStream 객체를 얻어야 한다.

이 getUserMedia 메서드로 인해 유저에게 마이크를 허용할 것인지 묻게 된다.

허용을 해주면 source는 다음과 같이 console에 출력된다.

사실 여기까지는 유의미한 console 출력이 없다.
단지 getUserMedia를 통해 마이크에 접근권한을 얻어 오디오를 생성하고, 나중에 해당 음향데이터를 분석하기 위해 createMediaStreamSource 메서드를 통해 소스 노드를 만들어주는 AnaylserNode로 건네주기 전 중간 역할이라고 보면 된다.

3. 음향 데이터 분석

결국 이 음향 데이터를 decibel이라는 숫자로 변환하기 위해 분석해야하고, 이때 사용하는 것이 AnaylserNode이다.
실습 코드를 통해 자세히 살펴보자

const startButton = document.getElementById('startButton');
    let audioContext = null;

    startButton.addEventListener('click', (event) => {
        event.preventDefault();

        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
            
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then(stream => {
                    const source = audioContext.createMediaStreamSource(stream);
              
                    // AnalyserNode를 생성합니다.
                    const analyser = audioContext.createAnalyser();
                    
                    // 소스 노드를 AnalyserNode에 연결합니다.
                    source.connect(analyser);

                    console.log("AnalyserNode가 생성되어 소스에 연결되었습니다.", analyser);
                })
                .catch(err => {
                    console.error("마이크 접근에 실패했습니다:", err);
                });
        }
    });

audioContext의 메서드인 createAnalyser를 통해 AnalyserNode를 생성하고, source.connet를 통해 source 노드와 analyserNode를 연결하여 source 노드에서 나오는 오디오 데이터를 analyser 노드로 전달한다.

해당 analyserNode를 콘솔에 출력해보면 다음과 같다
해당 콘솔에서도 decibel로 변환하기 위한 값들은 존재하지 않는다.
단, 음향 측정을 위한 노드의 설정값이 존재할 뿐이다.
  1. fftSize
    FFT(Fast Fourier Transform) 크기를 설정하는 속성으로 오디오 신호를 주파수 구성 요소로 분해하는 데 사용되는 알고리즘이다.
  • 값이 클수록: 주파수 분석의 정밀도가 높아진다.
  • 값이 작을수록: 시간 변화에 대한 반응 속도가 빨라진다.
  1. minDecibels / maxDecibels
    최소 및 최대 데시벨 범위를 설정

  2. smoothingTimeConstant
    오디오 데이터의 부드러움을 조절하는 상수로 0에서 1 사이의 값을 가진다.

이러한 속성들을 제어함으로써 더 정밀한 음성 측정이 가능하나, gemini나 chatgpt를 활용했을 때 일반적으로 소음 측정시 기본값으로 많이 사용된다고 답변이 돌아오기도 했고, 우선 decibel 값을 받아온 이후 조정이 필요하다면 거치는 것으로 결정했다.

이 음향을 decibel로 변환하기 위해서는 2가지 방법이 존재하고, 이때 analyserNode 메소드를 활용하는데 이 2가지 메소드는 getByteFrequencyData()getByteTimeDomainData() 다.

  • getByteFrequencyData()
    오디오 신호를 주파수별 진폭으로 분해하는 메소드이고, 특정 주파수 대역의 소리 크기를 나타내며 비교적 복잡하게 연산해야 한다는 특징을 지니고 있다.

  • getByteTimeDomainData()
    오디오 신호의 파형(시간에 따른 진폭 변화)을 나타내는 메소드이고, 순간적인 소리의 크기(음량) 변화를 나타내며 순간적인 소리 크기 변화가 심해 측정값이 불안정할 수 있다.

이 메소드들 중 소음 측정의 정확성을 높이기 위해 소음 측정에 활용할 메소드는 getByteFrequencyData()메소드이다.

getByteFrequencyData()메소드의 작업흐름은 다음과 같다.
① AnalyserNode에 의해 실시간 오디오 스트림을 계속해서 분석한다.
소리에는 여러 주파수가 섞여 있으며 FFT 설정에 의해 그 안에 어떤 주파수가 얼마나 강하게 포함되어 있는지 분석하여 준다.

② getByteFrequencyData 메소드는 dataArray를 변수로 받는다. 이 dataArray는 Unit8Array로 구성되어져 있는데, 이 dataArray는 일종의 빈 그릇이라 생각하면 된다. getByteFrequencyData 메소드는 이 dataArray에 AnalyserNode가 가장 최근에 분석한 주파수 데이터(각 주파수 대역의 음량)를 채워넣는다.

참고 사항
getByteFrequencyData 메소드가 AnalyserNode가 가장 최근에 분석한 주파수 데이터(각 주파수 대역의 음량)를 채워넣는데 모든 주파수를 넣는 것은 아니다. 정확히는 dataArray를 구성할 때 이 그릇의 크기가 analyserNode의 frequencyBinCount에 의해 결정되는데 frequencyBinCount는 FFT(고속 푸리에 변환)를 수행한 후 얻게 되는 주파수 데이터 배열의 길이를 나타내며, 이를 초과하는 주파수데이터는 담지 않는다.

이제 실습을 통해 주파수를 확인해보자

    const startButton = document.getElementById('startButton');
    let audioContext = null;
    let analyser = null;
    let dataArray = null;

    startButton.addEventListener('click', (event) => {
        event.preventDefault();

        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
            
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then(stream => {
                    const source = audioContext.createMediaStreamSource(stream);
                    
                    // AnalyserNode를 생성하고 설정합니다.
                    analyser = audioContext.createAnalyser();
                    analyser.fftSize = 2048;
                    
                    // 데이터를 담을 배열을 생성합니다.
                    dataArray = new Uint8Array(analyser.frequencyBinCount);

                    // 소스 노드를 AnalyserNode에 연결합니다.
                    source.connect(analyser);

              
              		// 1초 후 측정을 중지하도록 설정
                    setTimeout(() => {
                        stopMeasurement(stream);
                    }, 1000); // 1000밀리초 = 1초
              
                    console.log("마이크 입력으로부터 주파수 데이터 분석을 시작합니다.");
                    // 주파수 데이터 분석을 시작합니다.
                    analyzeFrequency();
                })
                .catch(err => {
                    console.error("마이크 접근에 실패했습니다:", err);
                });
        }
    });

    // 주파수 데이터를 실시간으로 가져와 콘솔에 출력하는 함수
    function analyzeFrequency() {
        // AnalyserNode로부터 주파수 데이터를 가져옵니다.
        analyser.getByteFrequencyData(dataArray);

        // 콘솔에 데이터 배열을 출력하여 실시간 변화를 확인합니다.
        console.log(dataArray);
    }

	function stopMeasurement(stream) {
        if (audioContext) {
            audioContext.close();
            audioContext = null;
            console.log("측정을 중지합니다.");
        }
    }

다만 위와 같이 입력할 경우 현재 decibel 측정 버튼만 존재하고 stop 버튼이 존재하지 않기 때문에 첫 시도에는 setTimeOut을 활용하여 1초동안 값이 얼마나 담기는지 확인했다.

결과는 다음과 같다.

Unit8Array에 담긴것은 주파수 데이터로 이것을 RMS로 합하고 decibel 변환 공식으로 변환한 것이 decibel이 된다. 다만 한가지 간과한 것이 코드상 1초 후에 stopMeasurement를 호출하여 멈추었지만, 1초동안 주파수의 모든 데이터를 담은 것이 아닌 1frame의 주파수 데이터를 담은 것이었다.

나중에 고민할 문제이기는 하지만, requestAnimateFrame을 호출하면 1초동안 60회의 주파수 데이터를 수집할 수 있어 정확도가 올라가지만, NextJS로 본 프로젝트를 진행하는 입장에서는 주파수 데이터를 decibel로 계산할 수 있다고 쳐도, 실시간으로 decibel chart를 보여주는 입장에서는 많은 rerendering을 야기할 수 있고, 데이터의 decibel 변환 속도에 비해 데이터 수집 속도가 더 빨라서 측정 시작 버튼을 눌렀음에도 UI 업데이트가 느려 측정이 시작되지 않는 것 같은 기분을 줄 수 있는 위험이 있다고 생각했다.

다시 본론으로 돌아와서 이 주파수 데이터를 decibel로 전환하는 공식을 적용한 실습 코드는 다음과 같다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Understanding Web Audio API</title>
  <link rel="stylesheet" href="app.css">
</head>
<body>
  <div id="audio">
    <p>현재 데시벨: <span id="current-db">0</span> dB</p>
  </div>
  <form>
    <button type="submit" id="startButton">Audio test 시작!</button>
  </form>
</body>
<script>
    const startButton = document.getElementById('startButton');
    const dbDisplay = document.getElementById('current-db');
    let audioContext = null;
    let analyser = null;
    let dataArray = null;

    startButton.addEventListener('click', (event) => {
        event.preventDefault();

        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
            
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then(stream => {
                    const source = audioContext.createMediaStreamSource(stream);
                    
                    // AnalyserNode를 생성하고 설정합니다.
                    analyser = audioContext.createAnalyser();
                    analyser.fftSize = 2048;
                    
                    // 데이터를 담을 배열을 생성합니다.
                    dataArray = new Uint8Array(analyser.frequencyBinCount);

                    // 노드 연결: 소스 -> 분석기 -> 목적지 (소리가 들리도록)
                    source.connect(analyser);
                    analyser.connect(audioContext.destination);

                    console.log("측정을 시작합니다.");
                    // 실시간 dB 측정을 시작합니다.
                    updateDb();
                })
                .catch(err => {
                    console.error("마이크 접근에 실패했습니다:", err);
                });
        }
    });

    // 실시간으로 dB 값을 계산하고 화면에 업데이트하는 함수
    function updateDb() {
        // AnalyserNode로부터 주파수 데이터를 가져옵니다.
        analyser.getByteFrequencyData(dataArray);

        let sumOfSquares = 0;
        // 배열의 모든 값에 대해 제곱의 합을 구합니다.
        for (const amplitude of dataArray) {
            sumOfSquares += amplitude * amplitude;
        }

        // RMS (Root Mean Square) 값을 계산합니다.
        const rms = Math.sqrt(sumOfSquares / dataArray.length);

        // 데시벨(dB)로 변환합니다.
        // RMS가 0이면 로그 계산이 불가능하므로 예외 처리
        const db = rms > 0 ? 20 * Math.log10(rms) : -100;
        
        // dB 값을 HTML에 표시합니다.
        dbDisplay.textContent = db.toFixed(2); // 소수점 두 자리로 표시

        // 다음 프레임에서 이 함수를 다시 호출합니다.
        requestAnimationFrame(updateDb);
    }
</script>
</html>

이렇게 하면 실시간으로 decibel을 전환하는 것을 다음과 같이 화면에서 확인할 수 있다.

여기까지 구현하여도 decibel을 표현하는데는 큰 무리가 없다.
다만, 사람의 귀가 저음과 고음보다 중음에 더 민감하게 반응하는 특성을 반영하지 않고 오직 소리의 decibel로만 표현한 것이다. 실제 소음 측정에는 decibel의 dB보다는 dBA를 많이 사용한다고 한다. 글이 길어지는 관계로 다음 글에서 decibel을 dBA로 전환하도록 하겠다.

profile
신입 개발자 지망생

0개의 댓글