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?

WebAudioAPIを使ってMIDI Playerを作る(2)

0
Posted at

初めに

前回の続き
この記事ではパースしたMIDIファイルとSoundFontからWebAudioAPIを使って実際に音を出す、本体である。
実装はsrc/web_audio以下に実装されている。

クラス

まずよく使うクラスについて解説する。

Manager.ts

全体のマネージャークラスでAudioContextの管理も受け持っている。
読み込んだSoundFontファイル、MIDIファイルもこのクラスに紐づけている。
演奏自体はMIDIPlayeで管理するがfinishedで演奏が終了してればcontext.close()などを呼び演奏が無効化されるのでその管理などを行う。
volumeはmasger_gain.connect(context.destination)に紐づいた全体のボリュームと同期される。

Manager.ts
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

演奏を管理するクラスで以下のようになっている。

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

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を持ちエフェクトを中間に持つものである。
なおこれらのパラメーターは独自に決め手おり実装依存である。

Chorus.ts
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;
Reverb.ts
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でやっており、主なところを切り抜いたのは以下である。

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)などの実装がある。

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?