初めに
前回の続き
この記事ではパースしたMIDIファイルとSoundFontからWebAudioAPIを使って実際に音を出す、本体である。
実装はsrc/web_audio以下に実装されている。
クラス
まずよく使うクラスについて解説する。
Manager.ts
全体のマネージャークラスでAudioContextの管理も受け持っている。
読み込んだSoundFontファイル、MIDIファイルもこのクラスに紐づけている。
演奏自体はMIDIPlayeで管理するがfinishedで演奏が終了してればcontext.close()などを呼び演奏が無効化されるのでその管理などを行う。
volumeはmasger_gain.connect(context.destination)に紐づいた全体のボリュームと同期される。
class Manager {
context: AudioContext;
master_gain: GainNode;
midi_files: MidiFile[];
sf_files: SFFile[];
player?: MIDIPlayer;
timer?: ReturnType<typeof setInterval>; // 先読みスケジューラのタイマー
finished = false; // 曲を最後まで鳴らし終えた
volume = 1; // マスター音量(スライダーの値)。AudioContext を作り直しても引き継ぐ
...
};
MIDIPlayer.ts
演奏を管理するクラスで以下のようになっている。
class MIDIPlayer{
context: AudioContext
index = 0;
presets: Map<number, Preset>;
time_table: { ticks: number[]; secs: number[]; rates: number[] };
events: Array<Event>;
reverb: Reverb;
chorus: Chorus;
program = new Array(16).fill(0);
bank = new Array(16).fill(0);
channels: Array<Channel>;
....
};
のようになっている。
- index: 現在のイベント位置を示すindex
- presets: パースしたSoundFont
- time_table: MIDI時間であるtickを実時間に変換するためのテーブル
- events: MIDIイベントの配列
- reverb/chorus: 全Channel共通で使うエフェクター
- bank/program: 各チャンネルのpresetを指す番号
- channels: チャンネルごとのパラメーターなどを管理するためのクラス
Channel.ts
export type ChannelState = {
volume: number;
expression: number;
bend: number;
bend_range: number;
rpn_msb: number;
rpn_lsb: number;
mod: number;
pressure: number;
reverb: number;
chorus: number;
coarse_tune: number; // 半音
fine_tune: number; // セント
fine_msb: number; // ファインチューニングの生の値(14bit の上位・下位)
fine_lsb: number;
}
class Channel{
input: GainNode;
pan: ConstantSourceNode;
bend: ConstantSourceNode;
vib: ConstantSourceNode;
state: ChannelState;
...
};
チャンネルはこのようになっており、ChannelStateはチャンネルに紐づいたパラメーターである。
Chorus/Reverb
Chorus/Reverbは重い処理のため一つのプレイヤーに固定されたエフェクトとして以下で実装される。
これらはinput/outputを持ちエフェクトを中間に持つものである。
なおこれらのパラメーターは独自に決め手おり実装依存である。
class Chorus{
input: GainNode;
output: GainNode;
oscillator = Array<AudioNode>();
constructor(context: AudioContext, gain: GainNode, voices = 3, base = 0.025, rate = 0.5, depth = 0.002, wet = 0.6){
this.input = context.createGain();
this.output = context.createGain();
this.output.gain.value = wet;
this.output.connect(gain);
for( let i = 0; i < voices; i++ ){
const delay = context.createDelay(0.1);
delay.delayTime.value = base + i * 0.004; // 本ごとに少しずらす
const lfo = context.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = rate * (1 + 0.13 * i); // 周波数も少しずらして、同期を避ける
const amount = context.createGain();
amount.gain.value = depth; // 揺れ幅(秒)
lfo.connect(amount);
amount.connect(delay.delayTime); // AudioParam に足し込まれる
lfo.start(context.currentTime + i / (voices * rate)); // 開始をずらして位相をずらす
const pan = context.createStereoPanner();
pan.pan.value = voices === 1 ? 0 : -1 + 2 * i / (voices - 1); // 左→右に振り分け
this.input.connect(delay);
delay.connect(pan);
pan.connect(this.output);
this.oscillator.push(lfo);
}
}
}
export default Chorus;
class Reverb{
input: GainNode;
output: GainNode;
conv: ConvolverNode;
constructor(context: AudioContext, gain: GainNode, wet = 0.5){
this.input = context.createGain();
this.output = context.createGain();
this.output.gain.value = wet;
this.conv = context.createConvolver();
this.conv.buffer = make_impulse(context);
this.input.connect(this.conv);
this.conv.connect(this.output);
this.output.connect(gain);
}
}
export default Reverb;
// 指数減衰するステレオのノイズ。左右で別のノイズにすると広がりが出る
function make_impulse(context: AudioContext,
seconds = 2.0, rt60 = 1.8, predelay = 0.02, damping = 0.6) {
const rate = context.sampleRate, len = Math.floor(rate * seconds);
const buf = context.createBuffer(2, len, rate);
for( let ch = 0; ch < 2; ch++ ){
const d = buf.getChannelData(ch);
let lp = 0;
for( let i = 0; i < len; i++ ){
const t = i / rate;
if( t < predelay ){ d[i] = 0; continue; } // 直接音と響きの間の小さな間
const env = 10 ** (-3 * t / rt60); // rt60 秒で -60 dB
const a = 1 - damping * Math.min(1, t / rt60); // 時間とともに高域を落とす(暗くする)
lp += a * ((Math.random() * 2 - 1) - lp); // 1次ローパス
d[i] = lp * env;
}
}
return buf;
}
Noteの構成
実際に発音する前にNoteの構成について解説する。
NoteはMidiEventのOn//Offから作られる音で`key(音程)とvelocity(鍵盤を押す速さ、強さ)でパラメトライズされる。
実際の音はkeyとvelocityの範囲をPreset ZoneとInstrument Zoneを通して選ばれSampleが選ばれる、このZone選定は複数のSampleが見つかることもありその場合は全部を足し合わされる。
この一つのSampleに紐づく音声チェーンはVoiceと呼ばれそれを束ねたものがNoteになる。
Voiceの構成
Voiceは一つのSoundFontのSampleから作られる音声であり、以下のように接続されGeneratorで定義されたエフェクトがかけられる。
ADRSはAttack、Decay、Release、Sustainの4文字であるが、これに加えてDelay
とHoldの6個のパラメーターで決められる音量の調整用のGainNodeである。
cutはそれ以外の音量調整用に用いられるGainNodeである。
Note全体の構成
Chorus/Reverbエフェクトは重い処理のため全演奏中に共通でMIDIPlayerに紐づけられている。
つまり下のようなグラフでNoteは作られる。
create_note.ts
実際に音声を作っているのはsrc/web_audio/create_note.tsでやっており、主なところを切り抜いたのは以下である。
export type ZoneVoice = { exclusive_class: number; start_time: number; end_time: number; choke: (time: number) => void };
export type Voice = { output: GainNode; zones: ZoneVoice[] };
export default function create_note(context: AudioContext, note: number, vel: number,
start_time: number, duration: number, preset: Preset, channel: Channel,
reverb: Reverb, chorus: Chorus,
opts: { release_scale?: number; soft?: boolean } = {}): Voice {
const zones: ZoneVoice[] = [];
const merger = context.createGain();
let active = 0; // 鳴っているボイスの数
for( const pz of preset.zones ){
if( !is_match_zone(note, vel, pz) ) continue;
if( !pz.instrument ) continue
const inst = pz.instrument;
for( const iz of inst.zones ){
if( !is_match_zone(note, vel, iz) ) continue;
if( !iz.sample ) continue;
const p = calc_params(iz, inst, pz, preset, note, opts);
const source = create_buffer_source(context, note, iz.sample, iz, inst, p);
const { amp, end_time } = createADSREnvelope(context, start_time, vel, duration, p);
const { filter, panner } = create_filter_panner(context, p);
const cut = context.createGain();
source.connect(cut);
cut.connect(amp);
amp.connect(filter);
filter.connect(panner);
const trem = p.mod_lfo_to_volume !== 0 ? context.createGain() : null;
if( trem ){
panner.connect(trem);
trem.connect(merger);
}
else panner.connect(merger);
channel.bend.connect(source.detune);
channel.pan.connect(panner.pan);
const dispose: (() => void)[] = [
() => channel.bend.disconnect(source.detune),
() => channel.pan.disconnect(panner.pan),
]; // 発音が終わった後の後始末のため
const nodes = attach_modulation(context, p, start_time, duration, end_time,
{ detune: source.detune, cutoff: filter.detune, trem, vib_depth: channel.vib, dispose });
if( p.reverb_effect_send > 0 || channel.state.reverb > 0 ){
const send_gen = p.reverb_effect_send / 1000 + channel.state.reverb / 127 * REVERB_CC_MAX;
const send = context.createGain();
send.gain.value = send_gen;
panner.connect(send);
send.connect(reverb.input);
nodes.push(send);
}
if( p.chorus_effect_send > 0 || channel.state.chorus > 0 ){
// Reverbと同様の処理
}
active++;
source.onended = () => {
source.disconnect(); cut.disconnect(); amp.disconnect(); filter.disconnect(); panner.disconnect();
dispose.forEach(f => f());
if( --active === 0 ){
merger.disconnect();
nodes.forEach(a => a.disconnect());
if( trem ) trem.disconnect();
}
};
source.start(start_time);
source.stop(end_time);
}
}
return { output: merger, zones };
}
- is_match_zone: Preset/Instrument Zoneが適応できるかどうかを判定する
- createADRSEnvelope: 先のADRSEnvelopeを設定したGainNodeを作る
- attach_moduation: Generatorの値のみで決まるLFO(Low Frequency Osillator)やトレモロなどのエフェクトをかける。
これらのNodeのコネクションはsource.onendedでdisconnectされるようにしてある。
このようにして発音Noteを作っている
次に
これらは基本的に一つのVoiceで完結する発音であり、これで基本的には曲に聞こえるレベルにはなる。
ここからさらにVoice間のエフェクト(SoundFontのmoudlator)などの実装がある。