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 오디오 제한과 터치로 잠금 해제하는 패턴을 다룹니다.