7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustでVSTiを作って、ソフトウェア音源の仕組みを覗いてみる

7
Posted at

はじめに

趣味でピアノを弾いています。

普段は電子ピアノで練習することが多いのですが、内蔵されている音源だけだと少し物足りなく感じることがあります。練習するだけなら十分なのですが、もう少し気持ちよく弾きたいとか、曲に合った音色で弾きたいと思うことがあります。

そこで、電子ピアノをUSB-MIDIでMacに接続して、MainStageやLogic Proでソフトウェア音源を鳴らすことがあります。

電子ピアノ側からはMIDIの情報だけを送り、実際の音はMac上で鳴らします。たとえば、鍵盤を押すと「どの音を、どれくらいの強さで押したか」という情報がMacに送られ、それを受け取ったソフトウェア音源が音を生成します。

この構成にすると、電子ピアノ本体の音源ではなく、Mac上の音源を使えます。

ただ、使っているうちに気になってきました。

こういうソフトウェア音源は、実際にはどうやって音を鳴らしているのか。

MIDI入力を受け取って、音を生成して、ホストアプリケーションに返しているはずですが、その中身はどうなっているのか。自分でも、最低限の音源なら作れるのか。

ということで、今回はRustで簡単なソフトウェア音源を作ってみます。

今回作るものについて

普段使っているMainStageやLogic Proでは、基本的にAudio Unit形式のプラグインを使います。

ただ、ソフトウェア音源のプラグイン規格はAudio Unitだけではありません。WindowsやほかのDAWまで含めると、VSTという規格が広く使われています。今回はこのVSTの形式、その中でも音を生成するインストゥルメントとして、プラグインを作ってみます。こうしたVSTのインストゥルメントプラグインは、VSTiと呼ばれます。

ここで一点注意が必要です。今回作るのはAudio UnitではなくVST3形式なので、この記事で作るプラグインを、そのままLogic ProやMainStageで読み込んで使う、という話ではありません。VSTiそのものがどう動いているのかを、自分の手で作って確かめるのが目的です。

まずは、VSTiとは何かを簡単に整理しておきます。

VSTiとは

VSTは、DAWなどのホストアプリケーション上で動作するプラグイン規格です。

プラグインには大きく分けて、音に処理を加えるエフェクトと、音そのものを生成するインストゥルメントがあります。

エフェクトプラグインの例としては、EQ、コンプレッサー、リバーブ、ディレイなどがあります。入力された音声に対して処理を行い、加工後の音声を出力します。

一方、インストゥルメントプラグインは、MIDI入力などを受け取って音を生成します。ソフトウェアシンセサイザーやピアノ音源、ドラム音源などがこれにあたります。先ほど触れたVSTi、つまりVST Instrumentがこれにあたります。

使うもの

SteinbergのVST3 SDKを直接使って作る方法もありますが、今回はRustで書いてみたかったので、NIH-plugというフレームワークを使います。
https://github.com/robbert-vdh/nih-plug

NIH-plugは現在メンテナンスモードです。

作るもの

今回作るのは、かなり単純なVSTiです。

まずは、MIDIのNote Onを受け取ったら音を鳴らし、Note Offを受け取ったら音を止めるところまでを目標にします。

音源としては、本格的なピアノ音源ではなく、正弦波のような単純な波形を生成します。

やることを分解すると、だいたい次のようになります。

MIDIイベントを受け取る
↓
Note On / Note Offを判定する
↓
ノート番号から周波数を計算する
↓
波形を生成する
↓
音声バッファに書き込む

これだけでも、鍵盤を押したら音が鳴る、離したら音が止まる、という最低限のソフトウェア音源になります。

最終的には、VST3としてビルドし、VST3対応のホストアプリケーションで読み込んで、MIDI入力から音が鳴るところまで確認します。

それでは実際に作っていきます。

手順

プロジェクトを作る

mkdir simple-vst
cd simple-vst

ライブラリクレートと、補助タスク用のクレートを作る

cargo init --lib sine
cargo init --bin xtask

workspaceを設定する

プロジェクトルートにCargo.tomlを作ります

Cargo.toml
[workspace]
members = ["sine", "xtask"]
resolver = "3"

このworkspaceには2つのメンバーがあります。

  • sine: VST3i本体
  • xtask: ビルド済みライブラリをVST3バンドルにまとめる補助コマンド

プラグイン本体のCargo.tomlを書く

sine/Cargo.toml
[package]
name = "sine"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git" }

重要なのはここです。

[lib]
crate-type = ["cdylib"]

VST3プラグインはDAWから動的ライブラリとして読み込まれるため、Rustの通常のライブラリではなく cdylib としてビルドします。

xtaskを設定する

xtask/Cargo.toml を次の内容にします。

xtask/Cargo.toml
[package]
name = "xtask"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "xtask"
path = "src/main.rs"

[dependencies]
nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git" }

xtask/src/main.rs は次の1行だけです。

fn main() -> nih_plug_xtask::Result<()> {
    nih_plug_xtask::main()
}

nih_plug_xtask は、ビルドしたプラグインを .vst3 のディレクトリ構造にまとめてくれるツールです。

bundler.tomlを書く

ルートに bundler.toml を作ります。

bundler.toml
[sine]
name = "Sine"

ここで指定した name が、生成されるVST3バンドル名になります。
この設定だと、最終的に Sine.vst3 が生成されます

正弦波を鳴らすVST3i本体を作る

sine/src/lib.rs
use nih_plug::prelude::*;
use std::f32::consts;
use std::sync::Arc;

struct Sine {
    params: Arc<SineParams>,
    phase: f32,
    sample_rate: f32,
    current_note: Option<u8>,
}

#[derive(Default, Params)]
struct SineParams {}

impl Default for Sine {
    fn default() -> Self {
        Self {
            params: Arc::new(SineParams::default()),
            phase: 0.0,
            sample_rate: 44100.0,
            current_note: None,
        }
    }
}

impl Plugin for Sine {
    const NAME: &'static str = "Sine";
    const VENDOR: &'static str = "My Name";
    const URL: &'static str = "";
    const EMAIL: &'static str = "";
    const VERSION: &'static str = env!("CARGO_PKG_VERSION");

    const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout {
        main_input_channels: None,
        main_output_channels: NonZeroU32::new(2),
        ..AudioIOLayout::const_default()
    }];

    const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
    type SysExMessage = ();
    type BackgroundTask = ();

    fn params(&self) -> Arc<dyn Params> {
        self.params.clone()
    }

    fn initialize(
        &mut self,
        _audio_io_layout: &AudioIOLayout,
        buffer_config: &BufferConfig,
        _context: &mut impl InitContext<Self>,
    ) -> bool {
        self.sample_rate = buffer_config.sample_rate;
        true
    }

    fn reset(&mut self) {
        self.phase = 0.0;
        self.current_note = None;
    }

    fn process(
        &mut self,
        buffer: &mut Buffer,
        _aux: &mut AuxiliaryBuffers,
        context: &mut impl ProcessContext<Self>,
    ) -> ProcessStatus {
        let mut next_event = context.next_event();

        for (sample_id, channel_samples) in buffer.iter_samples().enumerate() {
            while let Some(event) = next_event {
                if event.timing() > sample_id as u32 {
                    break;
                }
                match event {
                    NoteEvent::NoteOn { note, .. } => {
                        self.current_note = Some(note);
                        self.phase = 0.0;
                    }
                    NoteEvent::NoteOff { note, .. } => {
                        if self.current_note == Some(note) {
                            self.current_note = None;
                        }
                    }
                    _ => {}
                }
                next_event = context.next_event();
            }

            let sample = if let Some(note) = self.current_note {
                let freq = util::midi_note_to_freq(note);
                let s = (self.phase * consts::TAU).sin() * 0.2;
                self.phase += freq / self.sample_rate;
                if self.phase >= 1.0 {
                    self.phase -= 1.0;
                }
                s
            } else {
                0.0
            };

            for s in channel_samples {
                *s = sample;
            }
        }

        ProcessStatus::Normal
    }
}

impl Vst3Plugin for Sine {
    const VST3_CLASS_ID: [u8; 16] = *b"SineExamplePlugi";
    const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
        &[Vst3SubCategory::Instrument, Vst3SubCategory::Synth];
}

nih_export_vst3!(Sine);

実装のポイント

このプラグインは音声入力を持たず、ステレオ出力だけを持ちます。

const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout {
    main_input_channels: None,
    main_output_channels: NonZeroU32::new(2),
    ..AudioIOLayout::const_default()
}];

MIDI入力は有効にします。

const MIDI_INPUT: MidiConfig = MidiConfig::Basic;

NoteOn を受け取ったら、現在鳴らしているノートとして保持します。

NoteEvent::NoteOn { note, .. } => {
    self.current_note = Some(note);
    self.phase = 0.0;
}

NoteOff を受け取ったら、同じノートが鳴っている場合だけ停止します。

NoteEvent::NoteOff { note, .. } => {
    if self.current_note == Some(note) {
        self.current_note = None;
    }
}

音を作っている中心部分はここです。

let freq = util::midi_note_to_freq(note);
let s = (self.phase * consts::TAU).sin() * 0.2;

self.phase += freq / self.sample_rate;
if self.phase >= 1.0 {
    self.phase -= 1.0;
}

phase は0.0から1.0まで進む位相です。
1周期ぶん進んだら0.0付近に戻します。

util::midi_note_to_freq(note) によって、MIDIノート番号を周波数に変換しています。たとえばA4なら440Hzになります。

最後に、同じサンプル値を左右チャンネルへ書き込みます。

for s in channel_samples {
    *s = sample;
}

VST3としてビルドする

cargo run -p xtask -- bundle sine --release

成功すると、次のような場所にVST3バンドルができます。

target/bundled/Sine.vst3

これを所定の場所にコピーします

cp -R target/bundled/Sine.vst3 ~/Library/Audio/Plug-Ins/VST3/

その後、DAWを起動すれば、プラグイン一覧に今回作成したSineが表示されるはずです。
インストゥルメントトラックにこのプラグインを設定し、MIDIノートを送れば、対応する音が鳴るはずです。

まとめ

今回は、Rustのnih-plugを使って、かなりシンプルなVST3iを作ってみました。
作ったものは、MIDIのNote On / Note Offを受け取り、それに応じて正弦波を鳴らすだけの小さな音源です。本格的なシンセサイザーやピアノ音源と比べると機能はほとんどありませんが、MIDIイベントを受け取って、音声バッファに波形を書き込む、という基本的な流れは少し見えた気がします。

今回はまず音が鳴るところを優先したので、実装としてはかなり単純です。ここから先は、打鍵の強さ、つまりベロシティに応じて音量を変えたり、複数の鍵盤を同時に押したときに複数の音を鳴らせるようにしたりすると、もう少し音源らしくなりそうです。

7
2
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
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?