AudioParamでオートメーション

AudioParam として定義されているノードのパラメータは、次の関数を組み合わせてオートメーションができます。

オートメーションの開始・終了時刻を指定するときは AudioContext.currentTime を使います。

var ctx = new AudioContext()
ctx.currentTime // AudioContext のインスタンスが生成されてからの経過時間 [秒] 。

ここではオートメーションを使ってエンベロープを作り、簡単なシーケンスを演奏します。

シンセサイザのエンベロープ

シンセサイザで使われるエンベロープは、トリガされると low から high に向かって増加したあと high から low に向かって減少するような信号を出力します。エンベロープの出力をオシレータの出力やサンプルデータと掛け合わせることで音量の変化を作ることができます。

よくあるシンセサイザのエンベロープはAttack、Decay、Sustain、Releaseの4つの区間が組み合わさっています。4つの区間をそれぞれ1文字に略してADSRと言うことがあります。次の図はADSRエンベロープを表しています。

Image of ADOsc audio graph.

Attack は low から high までの立ち上がりにかかる時間、 Decay は high から Sustain の値までの減衰にかかる時間です。

Sustainは、Decayの終わりから指が鍵盤が離される(ノートオフ)までの区間ですが、ユーザが操作するパラメータとしてはその区間でエンベロープが出力する値の大きさになっています。

Releaseはノートオフの後、エンベロープが low まで減衰するのにかかる時間です。AttackやDecayの途中でノートオフされたときもReleaseに移行します。

実装

次の図のようなルーティングのシンセサイザを実装します。

Image of ADOsc audio graph.
function createOscillator(audioContext, parentNode, type, frequency, detune) {
  var osc = audioContext.createOscillator()
  osc.type = type
  osc.frequency.value = frequency
  osc.detune.value = detune
  osc.connect(parentNode)
  return osc
}

function createGain(audioContext, parentNode, gain) {
  var gainNode = audioContext.createGain()
  gainNode.gain.value = gain
  gainNode.connect(parentNode)
  return gainNode
}

class Synth {
  constructor(
    ctx,
    parent,
    gainPeak = 0.1,
    gainAttack = 0.02,
    gainDecay = 0.4,
    gainSustain = 0.01,
    gainRelease = 0.8,
    numOsc = 4,
    detuneDelta = 200,
    type = "sine",
    frequency = 440,
    detune = 0,
  ) {
    this.ctx = ctx

    this.gainNode = createGain(ctx, parent, 0)

    this.osc = new Array(numOsc)
    for (var i = 0; i < this.osc.length; ++i) {
      this.osc[i] = createOscillator(
        ctx, this.gainNode, type, frequency, detune + detuneDelta * i)
      this.osc[i].start()
    }

    this.gainPeak = gainPeak / numOsc
    this.gainAttack = gainAttack
    this.gainDecay = gainDecay
    this.gainSustain = gainSustain
    this.gainRelease = gainRelease
  }

  scheduleLinADS(audioParam, peak, attack, decay, sustain) {
    var currentTime = this.ctx.currentTime
    audioParam.cancelScheduledValues(currentTime)
    audioParam.linearRampToValueAtTime(0, 0.002 + currentTime)
    audioParam.linearRampToValueAtTime(peak, attack + currentTime)
    audioParam.linearRampToValueAtTime(sustain, attack + decay + currentTime)
  }

  scheduleLinRelease(audioParam, release) {
    var currentTime = this.ctx.currentTime
    audioParam.linearRampToValueAtTime(0, release + currentTime)
  }

  trigger() {
    this.scheduleLinADS(this.gainNode.gain, this.gainPeak,
      this.gainAttack, this.gainDecay, this.gainSustain)
  }

  release() {
    this.scheduleLinRelease(this.gainNode.gain, this.gainRelease)
  }
}

var ctx = new AudioContext()
var synth = new Synth(ctx, ctx.destination,
  0.1, 0.3, 0.6, 0.01, 1.2, 8, 500.01, "square", 440, 0)

次のボタンを押すと Synth.trigger 、離すと Synth.release を呼び出します。

scheduleLinADS の次の行は、リリースの途中で再度トリガされたときにエンベロープが 0 にリセットされるようにしています。

    audioParam.linearRampToValueAtTime(0, 0.002 + currentTime)

次の図はリセットありとリセットなしを比べています。エンベロープの緑で強調した部分が違いです。AとBの時間の長さは同じです。

Image of ADOsc audio graph.

0.002 という値は適当に決めた値です。この値が小さすぎるとエンベロープがリセットされたときにプチノイズが聞こえることがあります。

linearRampToValueAtTimeexponentialRampToValueAtTime に変えてみます。

// Synth のメソッド
  scheduleExpADS(audioParam, peak, attack, decay, sustain) {
    var currentTime = this.ctx.currentTime
    audioParam.cancelScheduledValues(currentTime)
    audioParam.linearRampToValueAtTime(1e-4, 0.002 + currentTime)
    audioParam.exponentialRampToValueAtTime(peak, attack + currentTime)
    audioParam.exponentialRampToValueAtTime(sustain, attack + decay + currentTime)
  }

  scheduleExpRelease(audioParam, release) {
    var currentTime = this.ctx.currentTime
    audioParam.exponentialRampToValueAtTime(1e-4, release + currentTime)
    audioParam.linearRampToValueAtTime(0, release + 0.001 + currentTime)
  }

exponentialRampToValueAtTime は一つ目の引数 value に 0 が指定できないので、代わりに 1e-4 という適当に決めた値を使っています。リリースの終了後に 0 にリセットするため linearRampToValueAtTime を一つ加えています。

次のボタンで exponentialRampToValueAtTime を使ったエンベロープを試聴できます。

シーケンスの演奏

とりあえず Synth を拡張してシーケンスを演奏させてみます。

class Synth {

  // 途中を省略

  scheduleNoteEnv(
    startTime,
    duration,
    audioParam,
    peak,
    attack,
    decay,
    sustain,
    release
  ) {
    audioParam.setValueAtTime(1e-4, startTime)
    audioParam.linearRampToValueAtTime(1e-4, 0.002 + startTime)

    var attackTime = attack + startTime
    audioParam.exponentialRampToValueAtTime(peak, attackTime)
    audioParam.exponentialRampToValueAtTime(sustain, decay + attackTime)

    var releaseTime = duration + release + startTime
    audioParam.exponentialRampToValueAtTime(1e-4, releaseTime)
    audioParam.linearRampToValueAtTime(0, 0.001 + releaseTime)
  }

  scheduleNote(startTime, duration, frequency) {
    for (var i = 0; i < this.osc.length; ++i) {
      this.osc[i].frequency.setValueAtTime(frequency, startTime)
    }

    this.scheduleNoteEnv(
      startTime,
      duration,
      this.gainNode.gain,
      this.gainPeak,
      this.gainAttack,
      this.gainDecay,
      this.gainSustain,
      this.gainRelease
    )
  }
}

var synths = []
for (var i = 0; i < 8; ++i) {
  synths.push(new Synth(ctx, ctx.destination,
    0.1, 0.03, 0.2, 0.01, 1.2, 3, 200 + 200 * Math.random(), "square", 440, 0))
}

var seq = []
var startTime = 0

// 適当なシーケンスを作成。
for (var i = 0; i < 64; ++i) {
  seq.push({
    frequency: (Math.floor(Math.random() * 23 + 1) * 60)
      * (1 + 0.01 * Math.random()),
    duration: Math.random() + 0.01,
    startTime: startTime,
  })
  startTime += 0.1 + Math.random() * 0.3
}

var divSequence = document.getElementById("divSequence")

// Button は <input type="button"> を簡単に作るためのクラス。
// 三つ目の引数は click イベントに応じて呼び出される。
var buttonSeq = new Button(divSequence, "Start Sequence", () => {
  for (var i = 0; i < seq.length; ++i) {
    synths[i % synths.length].scheduleNote(
      ctx.currentTime + seq[i].startTime, seq[i].duration, seq[i].frequency)
  }
})

次のボタンで演奏を開始します。

複数のノートが同時に演奏される場合のために、複数の Synth を作っています。 i 番目のノートを synths[i % synth.length] にスケジュールしています。上のボタンで演奏するとところどころ正しく再生されていない部分があるように聞こえます。

ノートごとに新しいノードを作る方法で演奏してみます。

class Synth2 {
  constructor(
    ctx,
    parent,
    gainPeak = 0.1,
    gainAttack = 0.02,
    gainDecay = 0.4,
    gainSustain = 0.01,
    gainRelease = 0.8,
    type = "sine",
    detune = 0,
  ) {
    this.ctx = ctx

    this.parent = parent

    this.type = type
    this.detune = detune

    this.gainPeak = gainPeak
    this.gainAttack = gainAttack
    this.gainDecay = gainDecay
    this.gainSustain = gainSustain
    this.gainRelease = gainRelease

    this.usedNode = []
  }

  createNode(frequency) {
    var gain = createGain(this.ctx, this.parent, 0)
    var osc = createOscillator(
      this.ctx, gain, this.type, frequency, this.detune)
    return { gain: gain, osc: osc }
  }

  scheduleNoteEnv(
    startTime,
    duration,
    audioParam,
    peak,
    attack,
    decay,
    sustain,
    release
  ) {
    audioParam.setValueAtTime(0, startTime)
    audioParam.linearRampToValueAtTime(1e-4, 0.002 + startTime)

    var attackTime = attack + startTime
    audioParam.exponentialRampToValueAtTime(peak, attackTime)
    audioParam.exponentialRampToValueAtTime(sustain, decay + attackTime)

    var releaseTime = duration + release + startTime
    audioParam.exponentialRampToValueAtTime(1e-4, releaseTime)
    audioParam.linearRampToValueAtTime(0, 0.001 + releaseTime)
  }

  scheduleNote(startTime, duration, frequency) {
    var node = this.createNode(frequency)

    node.osc.start(startTime)

    this.scheduleNoteEnv(
      startTime,
      duration,
      node.gain.gain,
      this.gainPeak,
      this.gainAttack,
      this.gainDecay,
      this.gainSustain,
      this.gainRelease
    )

    this.usedNode.push(node)
  }

  midiNoteNumberToFrequency(number) {
    return 440 * Math.pow(2, (number - 69) / 12)
  }

  cleanup() {
    for (var node of this.usedNode) {
      node.gain.disconnect()
      node.osc.stop()
    }
    this.usedNode = []
  }

  // sequence = [{startTime, duration, number}, ...]
  scheduleSequence(sequence) {
    this.cleanup()

    var currentTime = this.ctx.currentTime
    for (var note of sequence) {
      this.scheduleNote(
        currentTime + note.startTime,
        note.duration,
        this.midiNoteNumberToFrequency(note.number)
      )
    }
  }
}

var scale = [0, 3, 5, 7, 9, 14, 17]
var seq2 = []
var startTime = 0
for (var i = 0; i < 64; ++i) {
  seq2.push({
    startTime: startTime,
    duration: Math.random() + 0.01,
    number: 42 + scale[Math.floor(Math.random() * scale.length)],
  })
  startTime += 0.14 * (1 + Math.floor(Math.random() + 0.5))
}
var synth2 = new Synth2(ctx, ctx.destination,
  0.1, 0.15, 0.4, 0.01, 1.8, "sine", 0)

var divSequence2 = document.getElementById("divSequence2")
var buttonSeq = new Button(divSequence2, "Start Sequence", () => {
  synth2.scheduleSequence(seq2)
})

次のボタンで演奏を開始します。

毎回ノードを作り直す方が滑らかに演奏されます。使い終わったノードは disconnect した上で、どこからも参照されないようにすることでガベージコレクタに回収されるようです。