0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サンプル音ゼロで「ドラムらしい音」を作る Web Audio 合成レシピ — kick / snare / hi-hat を 60 行で書く

0
Posted at

Web で動くドラムマシンを作るとき、たいていは .wav.mp3 のサンプル音をロードして再生します。動くのは動くのですが、サンプルファイルなしで「ドラムらしい音」を OscillatorNode と BiquadFilter だけで合成することができます。各ボイス 20 行、合計 60 行。サンプル不要なので fetch 待ちもなく、即起動で叩けます。

16 ステップシーケンサ + サンプルゼロ合成の実装と、合成レシピを音響学的にどう設計したかの話です。

drum-machine の UI。Four-on-the-floor プリセットが選ばれていて、Kick が 1/5/9/13、Snare が 5/13、Hi-hat が偶数ステップ、Open hat が 7/15 に配置されている。中央の step 7 にプレイヘッドのアウトラインが乗っている。ダークテーマ。

🥁 Demo: https://sen.ltd/portfolio/drum-machine/
📦 GitHub: https://github.com/sen-ltd/drum-machine

ドラムらしさの正体 — 3 つの要素

ドラム音を合成するときに見る要素は以下の 3 つ:

要素 何を決めるか 実装でどうするか
エネルギーカーブ アタックの鋭さ + 減衰の速さ GainNode.gainsetValueAtTime + exponentialRampToValueAtTime
スペクトル 「音の色」 — 鈍いか、鋭いか、金属的か OscillatorNode.type (sine/triangle/square) + BiquadFilter
ピッチ動作 アタックで高く、すぐ低くなる (Kick の本質) frequency.exponentialRampToValueAtTime

サンプル再生だと全部「録音されたとおり」しか出せませんが、合成だとこの 3 つを独立に調整できる。「もう少しアタックを鋭く」「胴体を厚く」みたいな調整がパラメータで効きます。

Kick — sine sweep + 急速 amp decay

実物のキックドラムは「コツン」というアタックの後に「ボーン」という胴体が残る音。これを 1 個の正弦波で合成するレシピ:

osc.frequency.setValueAtTime(150, time);                       // アタック (click 寄り)
osc.frequency.exponentialRampToValueAtTime(48, time + 0.05);   // 50ms で急降下
ampEnv: 1.0  0.0001  450ms (exponential)

ポイントは:

  • 150 Hz → 48 Hz の急降下 が「コツン → ボーン」を作る。150 Hz だけだと音程が立ちすぎ、48 Hz だけだと胴体しかない
  • ピッチ降下は 50ms と速い (人間の耳は 30ms 以下のイベントを連続音として聞く)
  • amp 減衰は 450ms ぐらいでちょうど良い。短すぎると「ピッ」、長すぎると「ブーン」が間延びする
  • 波形は sine 一択。triangle や square だと倍音が乗って「電子音」感が出てキック感が薄れる
export function playKick(ctx, time, dest, gain = 1) {
  const osc = ctx.createOscillator();
  osc.type = "sine";
  osc.frequency.setValueAtTime(150, time);
  osc.frequency.exponentialRampToValueAtTime(48, time + 0.05);

  const ampEnv = ctx.createGain();
  ampEnv.gain.setValueAtTime(0, time);
  ampEnv.gain.linearRampToValueAtTime(gain, time + 0.001);
  ampEnv.gain.exponentialRampToValueAtTime(0.0001, time + 0.45);

  osc.connect(ampEnv).connect(dest);
  osc.start(time);
  osc.stop(time + 0.5);
}

exponentialRampToValueAtTime0 に到達できない (内部的に対数領域で計算するため) のが地味なハマりどころ。0.0001 などの十分小さい値を指定するか、最後に setValueAtTime(0, end) を打って明示的に切る必要があります。

Snare — pitched body + bandpassed noise の二層

実物のスネアは ほぼノイズです。が、それだけだと「ザーッ」としか聞こえない。「鋭い音色」を立たせる pitched body を並列で混ぜることで「スネアらしさ」が出ます:

[ triangle 200Hz → 50ms decay ] ─┐
                                  ├─→ master
[ noise → bandpass 1.5kHz Q=0.6 → 180ms decay ] ─┘

合成レシピ:

// Body: 200Hz triangle、急減衰
const body = ctx.createOscillator();
body.type = "triangle";
body.frequency.setValueAtTime(200, time);
const bodyEnv = envelopedGain(ctx, time, 0.55, 0.05);  // 50ms
body.connect(bodyEnv).connect(dest);
body.start(time);
body.stop(time + 0.07);

// Noise: white noise を bandpass、長めに減衰
const noise = ctx.createBufferSource();
noise.buffer = whiteNoiseBuffer;  // pre-generated Float32Array
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = 1500;
filter.Q.value = 0.6;
const noiseEnv = envelopedGain(ctx, time, 0.6, 0.18);  // 180ms
noise.connect(filter).connect(noiseEnv).connect(dest);
noise.start(time);

ポイント:

  • bandpass の中心周波数 1500 Hz はスネアの「シャリ」が住む帯域。ここを外すと「ザザザ…」のノイズだけになる
  • Q = 0.6 は「軽くフィルタリング」レベル。Q を上げすぎると音色が変わって金属的になる (鳴り物パーカッションっぽい)
  • body の triangle は sine より倍音が乗って音色がカチッとする。snare 系では sine だと地味すぎる
  • ホワイトノイズバッファは ctx.createBuffer(1, len, sampleRate) で 1 回作って Math.random() * 2 - 1 で埋めて使い回し。毎回作ると hit ごとに数 KB のアロケーションが発生する

Hi-hat — square wave + 鋭い bandpass

ハイハットの正体は「金属同士のカチッ」です。これは合成的には 超高い倍音が密集して、急速に減衰する で表現できます:

osc.type = "square";          // square は奇数倍音が豊富 → 金属的
osc.frequency.setValueAtTime(7800, time);

filter.type = "bandpass";
filter.frequency.value = 7500;  // 7-8kHz が cymbal の主要成分
filter.Q.value = 12;            // 狭い帯域に絞る (高 Q = ピーキー)

decay: closed = 40ms / open = 320ms

ポイント:

  • square wave (7800Hz の) は奇数倍音が豊富で、ハイハットの「カチッ」感が出る。sine だと「ピッ」になって金属感が消える
  • bandpass Q=12 は高めの Q。これで 7500Hz 中心の狭帯域だけ通す → 金属的な「鳴り」になる
  • closed (40ms) と open (320ms) は decay だけの違い。同じオシレータ + 同じフィルタで両方作れる。「open hat」を別ボイスとして用意する必要なし
export function playHat(ctx, time, dest, open = false, gain = 1) {
  const osc = ctx.createOscillator();
  osc.type = "square";
  osc.frequency.setValueAtTime(7800, time);

  const filter = ctx.createBiquadFilter();
  filter.type = "bandpass";
  filter.frequency.value = 7500;
  filter.Q.value = 12;

  const decay = open ? 0.32 : 0.04;
  const env = envelopedGain(ctx, time, 0.45 * gain, decay);

  osc.connect(filter).connect(env).connect(dest);
  osc.start(time);
  osc.stop(time + decay + 0.02);
}

Lookahead Scheduler — JS タイマーが信用できない問題

ここまでが「音作り」、次は 正確な timing で打つ 仕組み。

ナイーブな実装 setInterval(playStep, 60_000 / bpm / 4)必ずズレます。理由は JS の event loop が GC や layout で 10-50ms 平気で詰まるから。それを setInterval のコールバックで触ると音もそのままズレる。

正解は Chris Wilson の "A Tale of Two Clocks" パターン:

function tick() {
  while (state.nextStepTime < audioCtx.currentTime + 0.1) {
    onStep({ step: state.step, time: state.nextStepTime });  // ステップ予約
    state.nextStepTime += secondsPerStep();
    state.step = (state.step + 1) % stepsPerBar;
  }
}
setInterval(tick, 25);   // 25ms ごとに tick(), 100ms ahead で予約

仕組み:

  1. audioCtx.currentTime は audio スレッドが renders した正確な時刻
  2. ステップの予約は osc.start(time)未来の時刻指定 が可能
  3. JS タイマーは「予約をするだけ」、実際の発音は audio スレッドが render する
  4. JS タイマーが 50ms 詰まっても、すでに 100ms 先まで予約済みなので発音は遅れない

これで「JS の timing は信頼しない、audio クロックだけが真」という設計になります。

Chris Wilson 方式は何が嬉しいか

setTimeout(playStep, 100) ではダメな理由がもう 1 つあって、OscillatorNode.start()setTimeout の中から呼ぶと そこからオーディオ生成が始まる。 hit のタイミングが GC や layout の influence をモロに受ける。

これに対し osc.start(time)未来の時刻を指定すれば、audio スレッドはそれを sample-accurate でレンダーします。50 ms の jitter があっても、tick が動く限り次の 100ms 分は予約済みなので 耳にはズレが届かない

テスト — 偽 AudioContext で完全カバー

シンセシス部分 (kick が kick らしい音か) はもちろん耳テスト。ですが scheduler は決定論的に test できる:

function makeFakes() {
  const audio = { currentTime: 0 };  // 偽の audio clock
  const intervals = [];
  const setIntervalFn = (fn, ms) => {
    intervals.push({ fn, ms, cleared: false });
    return intervals.length - 1;
  };
  const advance = (sec) => {
    audio.currentTime += sec;
    for (const slot of intervals) if (!slot.cleared) slot.fn();
  };
  return { audio, setIntervalFn, advance, /* ... */ };
}

test("at 120 BPM, 16 steps per bar = 0.125 s per step", () => {
  const fakes = makeFakes();
  const queued = [];
  const sched = createScheduler({
    audioCtx: fakes.audio,
    bpm: 120, stepsPerBar: 16, beatsPerBar: 4,
    onStep: ({ time }) => queued.push(time),
    setIntervalFn: fakes.setIntervalFn,
    clearIntervalFn: fakes.clearIntervalFn,
  });
  sched.start();
  fakes.advance(1.0);
  // 連続するステップの間隔がきっかり 0.125 秒
  for (let i = 1; i < queued.length; i++) {
    assert.ok(Math.abs(queued[i] - queued[i - 1] - 0.125) < 1e-9);
  }
});

scheduler は audioCtxsetIntervalFn依存性注入で受け取るので、Node の node:test で AudioContext なしに完全に検証できる。17 ケースで 0.07 秒。

まとめ

  • Kick = sine 150→48 Hz exponential sweep、amp 450ms decay。三要素のうち pitch sweep が一番効く
  • Snare = triangle 200Hz body (50ms) + bandpass 1.5kHz Q=0.6 noise (180ms) を並列で混ぜる。ノイズだけでは「スネア感」がない
  • Hi-hat = square 7800Hz + bandpass 7500Hz Q=12 を closed/open で decay だけ違える。1 オシレータ + 1 フィルタで両方表現
  • exponentialRampToValueAtTime は 0 に届かない0.0001 を使う
  • Lookahead scheduler で JS タイマーの jitter を吸収。25ms tick + 100ms ahead で sample-accurate
  • 依存性注入で AudioContext を fake にすれば、scheduler のテストは Node node:test で完結

コード全文drums.js (合成 + パターン)、scheduler.js (Chris-Wilson lookahead)、tests/ (17 ケース)。MIT。

Web Audio 連作 3 件目: #206 metronome (timing) → #212 pitch-detector (analysis) → 今回 (synthesis)。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?