Tone.js로 브라우저에서 음악 만들기

EN

들어가며

사내 프로젝트에서 인터랙티브 사운드 기능을 붙이면서 Tone.js를 처음 써봤습니다.

브라우저의 Web Audio API는 강력하지만, 직접 다루려면 AudioNode 연결, 주파수 계산, 타이밍 관리를 모두 수동으로 해야 합니다. Tone.js는 이 복잡성을 추상화하여, 음악적 개념(음표, BPM, 이펙트 체인)으로 바로 작업할 수 있게 해주는 프레임워크입니다.

Google의 Chrome Music Lab, Ableton의 Learning Music 같은 프로젝트에서도 Tone.js를 사용하고 있습니다.

시작하기 전에: Autoplay 정책

현대 브라우저는 페이지 로드 시 오디오 자동 재생을 차단합니다. AudioContext는 "suspended" 상태로 시작되며, 사용자 제스처(클릭 등) 이후에만 활성화됩니다.

document.getElementById('start-btn')?.addEventListener('click', async () => {
  await Tone.start();
  // 이제부터 소리를 낼 수 있습니다
});

Tone.start()를 반드시 사용자 인터랙션 핸들러 안에서 호출해야 합니다. 이것을 빠뜨리면 소리가 아예 나지 않습니다.

핵심 구조: Source → Effect → Destination

Tone.js의 오디오 신호 흐름은 단순합니다.

  1. Source: 소리를 생성 (Synth, Player, Sampler 등)
  2. Effect: 소리를 가공 (Reverb, Delay, Filter 등)
  3. Destination: 스피커로 출력
const synth = new Tone.Synth();
const reverb = new Tone.Reverb(1.5);
const delay = new Tone.FeedbackDelay('8n', 0.3);
 
// chain으로 연결: Synth → Delay → Reverb → 스피커
synth.chain(delay, reverb, Tone.Destination);
 
synth.triggerAttackRelease('C4', '8n');

.chain()으로 이펙트를 순서대로 연결하고, 마지막에 Tone.getDestination()으로 스피커에 연결합니다. 단일 이펙트만 쓸 때는 .toDestination()이 더 간결합니다.

시간 표현

Tone.js가 편리한 이유 중 하나는 음악적 시간 표기를 지원한다는 점입니다.

형식예시설명
Notation"4n", "8n", "2n."4분음표, 8분음표, 점2분음표
Transport Time"1:2:3"마디:박:16분음표
Seconds1.5숫자는 초 단위
Measures"2m"마디 단위
Now-relative"+0.5"현재 시간 기준 상대값

BPM이 바뀌면 notation 기반 시간도 자동으로 조정됩니다. 120 BPM에서 "4n"은 0.5초이고, 60 BPM에서는 1초입니다.

Sources: 소리 생성

신시사이저

// 기본 신시사이저
const synth = new Tone.Synth().toDestination();
synth.triggerAttackRelease('C4', '8n'); // C4 음을 8분음표 길이로
 
// FM 합성
const fmSynth = new Tone.FMSynth({
  harmonicity: 3,
  modulationIndex: 10,
}).toDestination();
fmSynth.triggerAttackRelease('A3', '4n');
 
// 뜯는 현악기 소리
const pluck = new Tone.PluckSynth().toDestination();
pluck.triggerAttack('E4');

기본 신시사이저는 모두 **모노포닉(단음)**입니다. 화음을 연주하려면 PolySynth로 감싸야 합니다.

const polySynth = new Tone.PolySynth(Tone.Synth).toDestination();
polySynth.triggerAttackRelease(['C4', 'E4', 'G4'], '2n'); // C 메이저 코드

오디오 파일 재생

// 단일 파일 재생
const player = new Tone.Player('/samples/kick.wav').toDestination();
await Tone.loaded(); // 모든 버퍼 로드 대기
player.start();
 
// Sampler: 음 높이별 파일 매핑 → 악기처럼 사용
const sampler = new Tone.Sampler({
  urls: {
    A3: 'A3.mp3',
    C4: 'C4.mp3',
    E4: 'E4.mp3',
  },
  baseUrl: '/samples/piano/',
}).toDestination();
 
await Tone.loaded();
sampler.triggerAttackRelease('D4', '4n'); // 매핑된 샘플에서 자동 리피칭

Sampler는 매핑되지 않은 음도 가장 가까운 샘플에서 피치를 조절하여 재생합니다. 모든 건반을 녹음하지 않아도 됩니다.

Effects: 소리 가공

주요 이펙트

// 리버브 (공간감)
const reverb = new Tone.Reverb({ decay: 2.5, wet: 0.6 });
 
// 딜레이 (에코)
const delay = new Tone.FeedbackDelay({
  delayTime: '8n',
  feedback: 0.4,
  wet: 0.3,
});
 
// 디스토션 (왜곡)
const dist = new Tone.Distortion(0.8);
 
// 필터 (주파수 대역 제거)
const filter = new Tone.Filter({
  frequency: 1000,
  type: 'lowpass',
  rolloff: -24,
});
 
// 코러스 (풍성한 소리)
const chorus = new Tone.Chorus(4, 2.5, 0.5).start();

Wet/Dry 제어

모든 이펙트는 wet 속성으로 원본/가공 신호 비율을 조절합니다.

const reverb = new Tone.Reverb(2);
reverb.wet.value = 0.5; // 50% 원본 + 50% 리버브
 
// 부드럽게 전환
reverb.wet.rampTo(1, 3); // 3초에 걸쳐 100% 리버브로

Transport: 타이밍의 핵심

Transport는 앱 전체의 마스터 타임키퍼입니다. BPM 기반의 음악적 타이밍을 관리하며, JavaScript의 setTimeout과는 정확도가 다른 차원입니다.

setTimeout은 ~100ms 오차가 흔하지만, Transport는 콜백에 정확한 시간 파라미터를 전달하여 서브밀리초 정확도를 달성합니다.

Tone.Transport.bpm.value = 120;
 
// 특정 시점에 콜백 등록
Tone.Transport.schedule((time) => {
  synth.triggerAttackRelease('C4', '8n', time);
}, '0:0:0'); // 0마디 0박
 
// 일정 간격 반복
Tone.Transport.scheduleRepeat((time) => {
  synth.triggerAttackRelease('E4', '16n', time);
}, '4n'); // 4분음표마다 반복
 
Tone.Transport.start();

중요한 포인트가 있었습니다. Transport 콜백은 오디오 스케줄링 기준으로 호출되므로, DOM 업데이트 타이밍과 그대로 섞어 쓰면 시각적 타이밍이 어긋날 수 있습니다. UI 업데이트가 필요하면 Tone.Draw.schedule()을 사용해 브라우저 렌더링 타이밍에 맞춰 넘겨야 합니다.

Tone.Transport.scheduleRepeat((time) => {
  synth.triggerAttackRelease('C4', '8n', time);
 
  // UI 업데이트는 Draw를 통해
  Tone.Draw.schedule(() => {
    highlightCurrentBeat();
  }, time);
}, '4n');

Sequence와 Part

반복되는 패턴을 만들 때는 SequencePart를 사용합니다.

// Sequence: 균일한 간격의 순차 이벤트
const seq = new Tone.Sequence(
  (time, note) => {
    synth.triggerAttackRelease(note, '8n', time);
  },
  ['C4', 'E4', 'G4', null, 'B4', 'G4', 'E4', null], // null = 쉼표
  '8n', // 8분음표 간격
).start(0);
 
// Part: 개별 타이밍이 있는 이벤트
const part = new Tone.Part(
  (time, event) => {
    synth.triggerAttackRelease(event.note, event.dur, time);
  },
  [
    { time: '0:0:0', note: 'C4', dur: '4n' },
    { time: '0:1:0', note: 'E4', dur: '4n' },
    { time: '0:2:0', note: 'G4', dur: '2n' },
  ],
).start(0);
 
Tone.Transport.start();

Sequence는 드럼 패턴처럼 균일한 간격이 필요할 때, Part는 멜로디처럼 각 음의 타이밍이 다를 때 적합합니다.

Loop

단순 반복에는 Loop이 가장 간결합니다.

const loop = new Tone.Loop((time) => {
  synth.triggerAttackRelease('C4', '8n', time);
}, '4n').start(0);

오디오 시각화

Tone.js는 분석 도구도 제공합니다.

// FFT: 주파수 도메인 분석
const fft = new Tone.FFT(256);
synth.connect(fft);
synth.toDestination();
 
function draw() {
  const values = fft.getValue(); // Float32Array (데시벨 값)
  renderFrequencyBars(values);
  requestAnimationFrame(draw);
}
draw();
 
// Waveform: 시간 도메인 파형
const waveform = new Tone.Waveform(1024);
synth.connect(waveform);
synth.toDestination();
 
function drawWave() {
  const values = waveform.getValue(); // Float32Array (-1 ~ 1)
  renderWaveform(values);
  requestAnimationFrame(drawWave);
}
drawWave();
 
// Meter: 실시간 볼륨
const meter = new Tone.Meter();
synth.connect(meter);
synth.toDestination();
 
// meter.getValue()로 현재 dB 값을 읽을 수 있습니다

Canvas나 WebGL과 결합하면 오디오 비주얼라이저를 만들 수 있습니다.

React/Next.js에서 사용하기

React 통합 패턴

Tone.js 인스턴스는 useRef에 저장하고, useEffect에서 초기화해야 합니다. 리렌더링 시 재생성을 방지하기 위함입니다.

function SynthPad() {
  const synthRef = useRef<Tone.Synth | null>(null);
  const [isReady, setIsReady] = useState(false);
 
  useEffect(() => {
    synthRef.current = new Tone.Synth().toDestination();
    return () => {
      synthRef.current?.dispose();
    };
  }, []);
 
  const handleStart = async () => {
    await Tone.start();
    setIsReady(true);
  };
 
  const playNote = (note: string) => {
    if (!isReady) return;
    synthRef.current?.triggerAttackRelease(note, '8n');
  };
 
  return (
    <div>
      {!isReady && <button onClick={handleStart}>Start Audio</button>}
      {isReady && (
        <div>
          {['C4', 'D4', 'E4', 'F4', 'G4'].map((note) => (
            <button key={note} onPointerDown={() => playNote(note)}>
              {note}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Next.js SSR 주의점

서버 사이드에는 windowAudioContext가 없습니다. Tone.js 객체를 컴포넌트 최상위에서 생성하면 SSR 에러가 발생합니다. 반드시 useEffect 안에서만 초기화해야 합니다.

메모리 관리

.dispose()를 호출하면 해당 인스턴스의 모든 Web Audio 노드가 연결 해제되고 GC 대상이 됩니다. React의 useEffect cleanup에서 반드시 처리해야 합니다.

AudioBuffer는 원본 MP3/WAV보다 훨씬 크기 때문에, Player를 반복 생성하는 경우 이전 인스턴스를 확실히 dispose하지 않으면 메모리가 빠르게 채워집니다.

주의점 정리

1) 모바일 브라우저 문제

iOS Safari에서 전화 수신, 이어폰 분리 등이 발생하면 AudioContext가 "interrupted" 상태가 됩니다. 이는 W3C 스펙에 없는 Apple 고유 상태이므로, Tone.context.resume()으로 복구해야 합니다.

2) 번들 사이즈

Tone.js는 풀 기능 음악 프레임워크이므로 단순 사운드 재생 라이브러리(Howler.js ~7KB)보다 큽니다. 단순 알림음만 필요하다면 과한 선택일 수 있습니다. 다만 ESM을 지원하므로 tree shaking으로 사용하지 않는 코드를 제거할 수 있습니다.

3) CPU 소모가 큰 노드

Tone.Reverb(내부적으로 ConvolverNode)와 Tone.Panner3D가 가장 CPU를 많이 소모합니다. 모바일에서는 이펙트 수를 제한하는 것이 좋습니다.

4) 레이턴시 설정

기본 lookAhead는 0.1초입니다. 안정성을 위한 값이지만, 실시간 인터랙션에서 지연이 느껴질 수 있습니다. 필요하다면 줄일 수 있지만, 너무 낮추면 오디오 글리치가 발생할 수 있습니다.

Tone.setContext(new Tone.Context({ latencyHint: 'interactive' }));

느낀점

Tone.js는 브라우저에서 음악을 다루는 작업을 현실적으로 만들어주는 프레임워크였습니다. Web Audio API를 직접 쓸 때의 노드 관리, 타이밍 계산, 크로스 브라우저 호환성 문제를 대부분 흡수해줍니다.

제가 느낀 핵심 포인트는 세 가지입니다.

  • Sequence/Part/Loop 패턴을 이해하면 복잡한 리듬도 선언적으로 표현할 수 있었습니다.
  • React와 통합할 때 useRef + useEffect + dispose 패턴을 지켜 메모리 문제를 피해야 되었습니다.

단순 효과음이 아니라 음악적 인터랙션이 필요한 프로젝트라면 Tone.js가 가장 합리적인 선택이었습니다.

읽어주셔서 감사합니다.

참고 자료