Skip to content

게임 오디오를 위한 Tone.js

Tone.js는 Web Audio API 위에서 동작하며 합성 오디오를 실용적으로 만들어 줍니다. 오디오 파일을 불러오는 대신, 프로그램으로 소리를 생성합니다. 덕분에 로딩 시간이 없고, 변형이 무한하며, 게임플레이에 실시간으로 반응하는 소리를 만들 수 있습니다.

직접 해보기 (버튼을 눌러 합성된 게임 사운드를 들어보세요):

오디오 파일 대신 Tone.js를 쓰는 이유

오디오 파일은 음악이나 복잡한 소리에 잘 맞습니다. 하지만 게임 음향 효과에는 합성이 진짜 장점이 있습니다. 매번 재생할 때마다 음높이와 타이밍을 바꿔서 소리가 반복적으로 느껴지지 않게 할 수 있습니다. 게임 상태에 따라 즉석에서 소리를 만들어 낼 수도 있습니다. 그리고 네트워크 요청을 아예 건너뜁니다.

단점은 합성 오디오가 파일을 재생하는 것보다 CPU를 더 쓴다는 점입니다. 간단한 게임이라면 문제가 안 됩니다. 복잡한 게임이라면 두 방식을 섞어 쓰면 됩니다.

설정

CDN이나 npm으로 Tone.js를 불러옵니다:

html
<script src="https://unpkg.com/tone@15/build/Tone.js"></script>

또는 npm으로:

bash
npm install tone
js
import * as Tone from 'tone'

자동 재생 문제

브라우저는 사용자 상호작용이 있기 전까지 오디오를 막습니다. Tone.js가 이를 처리하지만, 사용자 제스처에서 Tone.start()를 호출해야 합니다:

js
document.addEventListener('click', async () => {
  await Tone.start()
  console.log('Audio ready')
}, { once: true })

이걸 하지 않으면 AudioContext가 계속 일시 중단 상태로 남아 아무 소리도 나지 않습니다.

첫 신디사이저

신디사이저는 소리를 생성합니다. 가장 간단한 것은 Tone.Synth입니다:

js
const synth = new Tone.Synth().toDestination()

// 음 하나 재생
synth.triggerAttackRelease('C4', '8n')

triggerAttackRelease는 지정한 길이만큼 음을 재생합니다. 'C4'는 가운데 도입니다. '8n'은 현재 템포에서 8분음표입니다.

신디사이저는 오실레이터(소리의 원천)와 엔벨로프(소리가 어떻게 페이드인, 페이드아웃 되는지)를 가집니다. 둘 다 직접 설정할 수 있습니다:

js
const synth = new Tone.Synth({
  oscillator: { type: 'triangle' },
  envelope: {
    attack: 0.02,
    decay: 0.2,
    sustain: 0.3,
    release: 0.4,
  },
}).toDestination()

오실레이터 타입에는 sine(순수하고 부드러움), triangle(소프트), sawtooth(거친 음), square(공허하고 레트로한 느낌)가 있습니다.

음향 효과 시스템 만들기

게임에서는 서로 다른 소리 유형을 위해 여러 신디사이저가 필요합니다. 잘 동작하는 패턴은 다음과 같습니다:

js
let isInitialized = false
let softSynth = null
let subSynth = null
let noiseSynth = null
let sfxGain = null

function initSFX() {
  if (isInitialized) return
  
  // 마스터 볼륨 제어
  sfxGain = new Tone.Gain(0.7).toDestination()
  
  // 멜로디성 소리를 위한 부드러운 신디사이저
  softSynth = new Tone.PolySynth(Tone.Synth, {
    maxPolyphony: 6,
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.2,
      sustain: 0.3,
      release: 0.4,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // 깊은 충격음을 위한 서브 베이스
  subSynth = new Tone.Synth({
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.3,
      sustain: 0.1,
      release: 0.3,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // 질감을 위한 노이즈
  noiseSynth = new Tone.NoiseSynth({
    noise: { type: 'pink' },
    envelope: {
      attack: 0.02,
      decay: 0.15,
      sustain: 0,
      release: 0.15,
    },
    volume: -20,
  }).connect(sfxGain)
  
  isInitialized = true
}

PolySynth는 여러 음을 동시에 재생할 수 있게 해줍니다. 일반 Synth는 단음입니다.

게임 사운드 예시

자주 쓰이는 게임 사운드 몇 가지와 만드는 방법입니다:

코인 획득 - 상행 음표는 보상감을 줍니다:

js
function coinCollect() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('C4', '8n', now, 0.3)
  softSynth.triggerAttackRelease('E4', '8n', now + 0.1, 0.35)
  softSynth.triggerAttackRelease('G4', '4n', now + 0.2, 0.4)
}

점프 - 빠른 상행 스윕:

js
function jump() {
  const synth = new Tone.Synth({
    oscillator: { type: 'square' },
    envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.1 },
  }).toDestination()
  
  const now = Tone.now()
  synth.frequency.setValueAtTime(150, now)
  synth.frequency.exponentialRampToValueAtTime(400, now + 0.1)
  synth.triggerAttackRelease('C4', '8n', now, 0.2)
}

타격/피해 - 노이즈를 곁들인 하행:

js
function hit() {
  const now = Tone.now()
  subSynth.triggerAttackRelease('E2', '4n', now, 0.5)
  noiseSynth.triggerAttackRelease('4n', now, 0.25)
}

사망 - 슬픈 하행 음표:

js
function death() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('E4', '4n', now, 0.4)
  softSynth.triggerAttackRelease('C4', '4n', now + 0.2, 0.35)
  softSynth.triggerAttackRelease('A3', '4n', now + 0.4, 0.3)
}

이펙트 추가하기

이펙트는 소리를 더 풍부하게 만듭니다. 신디사이저와 출력(destination) 사이에 체인으로 연결합니다:

js
const filter = new Tone.Filter({
  frequency: 3000,
  type: 'lowpass',
  rolloff: -12,
})

const reverb = new Tone.Reverb({
  decay: 1.2,
  wet: 0.25,
})

const gain = new Tone.Gain(0.7)

// 체인: synth -> filter -> reverb -> gain -> destination
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()

const synth = new Tone.Synth().connect(filter)

로우패스 필터는 거슬리는 고음역을 잘라냅니다. 리버브는 공간감을 더합니다. Gain은 볼륨을 제어합니다.

비율 제한 (Rate Limiting)

게임이 짧은 시간에 많은 소리를 트리거하면, 소리가 겹쳐서 엉망이 될 수 있습니다. 비율 제한을 추가하세요:

js
const MIN_INTERVAL_MS = 35
let lastNoteTime = 0

function canPlayNote() {
  const now = performance.now()
  if (now - lastNoteTime < MIN_INTERVAL_MS) {
    return false
  }
  lastNoteTime = now
  return true
}

function playSound() {
  if (!canPlayNote()) return
  synth.triggerAttackRelease('C4', '8n')
}

동적 배경 음악

게임플레이에 반응하는 음악을 위해 비트 기반 시스템을 사용합니다:

js
const BPM = 140
const BEAT_MS = (60 / BPM) * 1000

let bassSynth = null
let kickDrum = null
let beatInterval = null
let beatCount = 0
let energy = 0.3

const PROGRESSION = [
  { root: 'E2', chord: ['E3', 'G3', 'B3'] },
  { root: 'C2', chord: ['C3', 'E3', 'G3'] },
  { root: 'D2', chord: ['D3', 'F#3', 'A3'] },
  { root: 'A1', chord: ['A2', 'C3', 'E3'] },
]

function playBeat() {
  const beatInMeasure = beatCount % 8
  const chordIndex = Math.floor(beatCount / 8) % PROGRESSION.length
  const chord = PROGRESSION[chordIndex]
  const now = Tone.now()
  
  // 1번째 비트의 베이스
  if (beatInMeasure === 0) {
    bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
  }
  
  // 킥 드럼 - 에너지가 높을 때만
  if (beatInMeasure === 0 && energy > 0.3) {
    kickDrum.triggerAttackRelease('C1', '8n', now, 0.4)
  }
  
  beatCount++
}

function startMusic() {
  beatInterval = setInterval(playBeat, BEAT_MS)
}

function stopMusic() {
  clearInterval(beatInterval)
}

// 게임 이벤트가 에너지에 영향을 줌
function onEnemyKill() {
  energy = Math.min(1, energy + 0.1)
}

음악의 강도는 게임에서 벌어지는 일에 따라 바뀝니다.

MembraneSynth와 NoiseSynth로 드럼 만들기

타악기에는 전용 신디사이저를 사용합니다:

js
// 킥 드럼
const kick = new Tone.MembraneSynth({
  pitchDecay: 0.05,
  octaves: 4,
  envelope: {
    attack: 0.001,
    decay: 0.2,
    sustain: 0,
    release: 0.1,
  },
  volume: -14,
}).toDestination()

kick.triggerAttackRelease('C1', '8n')

// 스네어
const snare = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.002,
    decay: 0.15,
    sustain: 0,
    release: 0.1,
  },
  volume: -12,
}).toDestination()

snare.triggerAttackRelease('8n')

// 하이햇
const hihat = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.001,
    decay: 0.04,
    sustain: 0,
    release: 0.03,
  },
  volume: -22,
}).toDestination()

hihat.triggerAttackRelease('32n')

정리 (Cleanup)

작업이 끝나면 신디사이저를 dispose해서 리소스를 해제하세요:

js
function cleanup() {
  if (softSynth) softSynth.dispose()
  if (subSynth) subSynth.dispose()
  if (noiseSynth) noiseSynth.dispose()
  if (sfxGain) sfxGain.dispose()
}

흔한 실수

Tone.start()를 기다리지 않기 - 사용자 상호작용에서 컨텍스트가 시작되기 전까지는 오디오가 재생되지 않습니다.

소리마다 신디사이저를 새로 만들기 - 신디사이저는 init에서 한 번 만들고 재사용하세요. 매번 새로 만들면 메모리가 샙니다.

겹치는 소리가 너무 많기 - 비율 제한과 중복 제거를 써서 오디오가 뭉개지는 걸 막으세요.

거슬리는 주파수 - 로우패스 필터와 더 부드러운 오실레이터 타입(sine, triangle)을 써서 플레이어가 거슬려하지 않을 소리를 만드세요.

더 많은 자료

게임을 위한 Web Audio API는 기반이 되는 API와 오디오 파일 로딩을 다룹니다.

Tone.js 문서에 전체 API 레퍼런스가 있습니다.

무료 게임 에셋을 찾을 수 있는 곳에는 Freesound 같은 오디오 및 음향 효과 제공처가 포함됩니다.

모바일 친화적인 웹 게임은 iOS 오디오 제한과 터치로 잠금 해제하는 패턴을 다룹니다.