Synth Class の実装

テスト

最終的には次のようなテストとなりました。前項との大きな違いは、一定時間後に停止するという非同期処理の部分を修正したことです。async / await と Promise Object を使って、500ms 後に停止を実行しています。

https://codesandbox.io/s/wyzx356nv5?module=%2Ftest%2FSynth.spec.js

Synth.spec.js
import Synth from '/src/Synth'
import createAudioContext from '/src/WebAudioAPI/createAudioContext'
const audioContext = createAudioContext()
const nextNode = audioContext.destination
const synth = new Synth({ audioContext, nextNode })
const now = audioContext.currentTime
// 再生時に期待する動き
describe('play', () => {
const options = {
midiNoteNumber: 70,
startTime: now,
}
it('state', () => {
synth.play(options)
const state = synth.oscillatorArray[options.midiNoteNumber].state
const expectedState = {
play: true,
midiNoteNumber: 70,
}
expect(state).toEqual(expectedState)
})
it('osc', () => {
const osc = synth.oscillatorArray[options.midiNoteNumber].osc
expect(!!osc).toBe(true)
})
})
// 停止時に期待する動き
describe('stop', () => {
const options = {
midiNoteNumber: 70,
}
it('state', async () => {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
synth.stop(options)
resolve()
}, 500)
})
await promise
const state = synth.oscillatorArray[options.midiNoteNumber].state
const expectedState = {
play: false,
midiNoteNumber: null,
}
expect(state).toEqual(expectedState)
const osc = synth.oscillatorArray[options.midiNoteNumber].osc
expect(!!osc).toBe(false)
})
it('osc', () => {
const osc = synth.oscillatorArray[options.midiNoteNumber].osc
expect(!!osc).toBe(false)
})
})
// すでに停止している音を停止させる時の挙動
describe('double stop', () => {
const options = {
midiNoteNumber: 70,
}
it('state', () => {
const state = synth.oscillatorArray[options.midiNoteNumber].state
const expectedState = {
play: false,
midiNoteNumber: null,
}
expect(state).toEqual(expectedState)
})
it('osc', () => {
const osc = synth.oscillatorArray[options.midiNoteNumber].osc
expect(!!osc).toBe(false)
})
})

Synth Class 本体

https://codesandbox.io/s/wyzx356nv5?module=%2Fsrc%2FSynth.js

Synth.js
import createOscillator from '/src/WebAudioAPI/createOscillator'
class Synth {
constructor({ audioContext, nextNode }) {
this.audioContext = audioContext
this.output = nextNode
this.oscillatorArray = this.createOscillatorArray()
this.createOscillator = createOscillator({ audioContext })
}
createOscillatorArray() {
const vacantArray = Array.from({ length: 128 })
return vacantArray.map(o => ({
state: { midiNoteNumber: null, play: false },
osc: null,
}))
}
createGain(volume = 0.2) {
const gainNode = this.audioContext.createGain()
gainNode.gain.value = volume
return gainNode
}
play({ midiNoteNumber, startTime = 0 }) {
const osc = this.createOscillator({ midiNoteNumber })
const gain = this.createGain()
osc.connect(gain)
gain.connect(this.output)
osc.start(startTime)
const state = {
play: true,
midiNoteNumber,
}
const item = {
osc,
state,
}
this.oscillatorArray[midiNoteNumber] = item
return `play ${midiNoteNumber}`
}
stop({ midiNoteNumber, endTime = 0 }) {
const targetOscillator = this.oscillatorArray[midiNoteNumber].osc
if (!targetOscillator) {
return `not ${midiNoteNumber} playing`
}
targetOscillator.stop(endTime)
const state = {
play: false,
midiNoteNumber: null,
}
this.oscillatorArray[midiNoteNumber].state = state
this.oscillatorArray[midiNoteNumber].osc = null
return `stop ${midiNoteNumber}`
}
}
export default Synth

実装のポイント

audioContext を外から受け取る

Synth クラスはインスタンスを作成する際に、audioContext を受け取ります。これによって、複数のシンセを作成しても、全て同じ audioContext から作成することができます。異なる audioContext から作成してしまうと時間等が一致しません。

const synth = new Synth({ audioContext, nextNode })

次に接続する先を外から受け取る

同様に Synth クラスは、次の出力先である nextNode も受け取ります。これによって自由なルーティングを実現し、例えば直接 destination に出力するのではなく、新たに作ったリバーブモジュールに出力する、といったことが可能になります。

oscillatorArray の default 値を設定するメソッド

createOscillatorArray メソッドは、oscillatorArray の default 値を作成するための関数です。stateosc というプロパティを item として持つようにします。演奏状態が変更されるたびに、この配列の対応するプロパティの値が変更されます。

createOscillatorArray() {
const vacantArray = Array.from({ length: 128 })
return vacantArray.map(o => ({
state: { midiNoteNumber: null, play: false },
osc: null,
}))
}

createOscillator

Synth.js
import createOscillator from '/src/WebAudioAPI/createOscillator'
this.createOscillator = createOscillator({ audioContext })

import している createOscillator は、関数を返す関数、つまり高階関数になっています。この関数は、audioContext を受け取って、関数を返します。その結果、this.createOscillator() と実行することで、高階関数から返ってきて保存されていた関数が実行されます。

次のコードは、createOscillator です。先ほど述べたように、これは高階関数なので、実行すると関数を返します。

なぜこのようにする必要があるかというと、audioContext.createOscillator() で使用する audioContext は引数として一度受ければいいわけですが、midiNoteNumberwave は、音程が変更されるたびに引数として受けなくてはいけないからです。こういう場合には高階関数を利用するのがスマートです。

createOscillator.js
import convertMidiNoteToFrequency from '/src/Util/convertMidiNoteToFrequency'
const createOscillator = ({ audioContext }) => {
return ({ midiNoteNumber, wave = 'sine' }) => {
const osc = audioContext.createOscillator()
osc.type = wave
osc.frequency.value = convertMidiNoteToFrequency(midiNoteNumber)
return osc
}
}
export default createOscillator

Synth Class をインスタンス化して使用する

作成した Synth Class をインスタンス化して音を鳴らしてみましょう。きっちりできましたね。

https://codesandbox.io/s/8n7r4z0yz0

index.js
import Synth from '/src/Synth'
import createAudioContext from '/src/WebAudioAPI/createAudioContext'
const audioContext = createAudioContext()
const destination = audioContext.destination
const synth = new Synth({
audioContext,
nextNode: destination,
})
const option1 = {
midiNoteNumber: 75,
wave: 'sawtooth',
velocity: 100,
}
synth.play(option1)
const option2 = {
midiNoteNumber: 75,
}
setTimeout(() => synth.stop(option2), 2000)

では次に MIDI キーボードを接続して、このブラウザシンセサイザーを演奏できるようにしていきます。そのためには Web MIDI API を使用します。