はじめに
前回は、RustとNIH-plugを使って、簡単なVST3インストゥルメントを作りました。
MIDIのNote Onを受け取ったら正弦波を鳴らして、Note Offを受け取ったら止める、というものです。
前回はこちらです。
ただ、前回の実装では1音しか鳴らせませんでした。
たとえばキーボードでド・ミ・ソを同時に押しても、最後に押した1音だけが鳴るような状態です。
今回はこれを少し改造して、複数の音を同時に鳴らせるようにしてみます。
難しい音作りの話は一旦置いておいて、まずは「複数の音が鳴っているときは、それぞれの波が重なっているはず」というくらいの理解で実装していきます。
前回の実装
前回は、今鳴っている音を次のように持っていました。
current_note: Option<u8>,
Option<u8> なので、持てるノートは1つだけです。
また、正弦波を進めるための phase も1つだけでした。
phase: f32,
つまり、前回の状態はかなり単純で、
今鳴っているノート: 1つ
波の進み具合: 1つ
という形でした。
この形だと、複数の音を同時に扱うことができません。
たとえば、ドを押したあとにミを押すと、current_note がミで上書きされます。
前回のNote Onの処理は、このような感じでした。
NoteEvent::NoteOn { note, .. } => {
self.current_note = Some(note);
self.phase = 0.0;
}
Note Onが来るたびに current_note を置き換えているので、あとから押した音だけが残ります。
同時に複数の音を鳴らしたいなら、今鳴っている音を複数持つ必要があります。
1つの音をVoiceとして持つ
今回は、鳴っている1音を Voice という構造体で表すことにします。
struct Voice {
note: u8,
phase: f32,
velocity: f32,
}
note はMIDIのノート番号です。
phase は、その音の波が今どこまで進んでいるかを表す値です。
velocity は鍵盤を押した強さです。今回はこれをそのまま音量っぽく使います。
前回はプラグイン全体で phase を1つだけ持っていました。
今回は、それぞれの音が別々の phase を持つようにします。
イメージとしては、前回はこうでした。
Sine
├── current_note
└── phase
今回はこうします。
Sine
└── voices
├── Voice { note, phase, velocity }
├── Voice { note, phase, velocity }
└── Voice { note, phase, velocity }
ドの音にはド用の波の進み具合があり、ミの音にはミ用の波の進み具合があります。
周波数が違えば波の進む速さも違うので、それぞれ別々に持っておく必要があります。
やること
今回やることは、そこまで複雑ではありません。
Note Onを受け取る
↓
鳴らす音をvoicesに追加する
↓
Note Offを受け取る
↓
対応する音をvoicesから消す
↓
毎サンプル、voicesに入っている音を全部計算する
↓
それらを足し合わせて出力する
複数の音を同時に鳴らすときは、それぞれの波を計算して足します。
かなりそのままですが、今回の実装ではこれで同時発音できるようになります。
ただし、音を足していくので、たくさん鳴らすと音量が大きくなります。
そのため、1音ごとの音量は少し小さめにしておきます。
const VOICE_GAIN: f32 = 0.12;
また、無限に音を増やすわけにもいかないので、今回は最大8音までにします。
const MAX_VOICES: usize = 8;
実装
前回の sine/src/lib.rs を次のように変更します。
use nih_plug::prelude::*;
use std::f32::consts;
use std::num::NonZeroU32;
use std::sync::Arc;
const MAX_VOICES: usize = 8;
const VOICE_GAIN: f32 = 0.12;
struct Sine {
params: Arc<SineParams>,
sample_rate: f32,
voices: Vec<Voice>,
}
#[derive(Debug, Clone, Copy)]
struct Voice {
note: u8,
phase: f32,
velocity: f32,
}
#[derive(Default, Params)]
struct SineParams {}
impl Default for Sine {
fn default() -> Self {
Self {
params: Arc::new(SineParams::default()),
sample_rate: 44100.0,
voices: Vec::with_capacity(MAX_VOICES),
}
}
}
impl Sine {
fn note_on(&mut self, note: u8, velocity: f32) {
// 同じノートがすでに鳴っている場合はいったん消してから鳴らす
self.voices.retain(|voice| voice.note != note);
// 最大同時発音数を超える場合は、古いVoiceを1つ消す
if self.voices.len() >= MAX_VOICES {
self.voices.remove(0);
}
self.voices.push(Voice {
note,
phase: 0.0,
velocity,
});
}
fn note_off(&mut self, note: u8) {
self.voices.retain(|voice| voice.note != note);
}
}
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.voices.clear();
}
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, velocity, .. } => {
if velocity == 0.0 {
self.note_off(note);
} else {
self.note_on(note, velocity);
}
}
NoteEvent::NoteOff { note, .. } => {
self.note_off(note);
}
NoteEvent::Choke { note, .. } => {
self.note_off(note);
}
_ => {}
}
next_event = context.next_event();
}
let mut sample = 0.0;
for voice in &mut self.voices {
let freq = util::midi_note_to_freq(voice.note);
sample += (voice.phase * consts::TAU).sin()
* voice.velocity
* VOICE_GAIN;
voice.phase += freq / self.sample_rate;
if voice.phase >= 1.0 {
voice.phase -= 1.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);
前回から変えたところ
前回は、Sine が current_note と phase を直接持っていました。
struct Sine {
params: Arc<SineParams>,
phase: f32,
sample_rate: f32,
current_note: Option<u8>,
}
今回は、代わりに voices を持たせています。
struct Sine {
params: Arc<SineParams>,
sample_rate: f32,
voices: Vec<Voice>,
}
Vec<Voice> なので、複数の音を入れられます。
たとえばド・ミ・ソを押しているときは、だいたい次のような状態になります。
voices
├── ド
├── ミ
└── ソ
もちろん実際には「ド」や「ミ」という文字ではなく、MIDIノート番号として入っています。
Note Onで音を追加する
Note Onを受け取ったときは、note_on を呼びます。
fn note_on(&mut self, note: u8, velocity: f32) {
self.voices.retain(|voice| voice.note != note);
if self.voices.len() >= MAX_VOICES {
self.voices.remove(0);
}
self.voices.push(Voice {
note,
phase: 0.0,
velocity,
});
}
まず、同じノートがすでに鳴っていたら消しています。
self.voices.retain(|voice| voice.note != note);
これは、同じ音が何個も重なって残るのを避けるためです。
そのあと、すでに最大数まで音が鳴っていたら、一番古い音を消します。
if self.voices.len() >= MAX_VOICES {
self.voices.remove(0);
}
今回は最大8音までにしているので、9音目を鳴らそうとしたら古い音を1つ消します。
かなり単純な処理ですが、まずはこれで十分だと思います。
最後に、新しい音を追加します。
self.voices.push(Voice {
note,
phase: 0.0,
velocity,
});
これで、Note Onが来るたびに voices の中身が増えていきます。
Note Offで音を消す
Note Offを受け取ったときは、対応するノートを voices から消します。
fn note_off(&mut self, note: u8) {
self.voices.retain(|voice| voice.note != note);
}
たとえばド・ミ・ソが鳴っている状態でミだけを離したら、ミだけが消えます。
Before:
ド, ミ, ソ
Note Off:
ミ
After:
ド, ソ
前回は1音だけだったので、Note Offが来たら current_note を None にするだけでした。
今回は複数の音があるので、「指定された音だけを消す」という処理にしています。
音を作る部分
音を作っているのは、この部分です。
let mut sample = 0.0;
for voice in &mut self.voices {
let freq = util::midi_note_to_freq(voice.note);
sample += (voice.phase * consts::TAU).sin()
* voice.velocity
* VOICE_GAIN;
voice.phase += freq / self.sample_rate;
if voice.phase >= 1.0 {
voice.phase -= 1.0;
}
}
まず、出力するサンプル値を 0.0 から始めます。
let mut sample = 0.0;
そのあと、今鳴っている音を1つずつ見ていきます。
for voice in &mut self.voices {
それぞれの音について正弦波を計算して、sample に足しています。
sample += (voice.phase * consts::TAU).sin()
* voice.velocity
* VOICE_GAIN;
ここが今回一番大事なところです。
前回は1つの正弦波だけを出していました。
今回は、鳴っている音の数だけ正弦波を作って、それを全部足しています。
出力 = ドの波 + ミの波 + ソの波
かなり単純ですが、複数の音が同時に聞こえるというのは、ざっくり言えばこういうことなのかなと思っています。
それぞれの音のphaseを進める
波を足すだけでなく、それぞれの音の phase も進める必要があります。
voice.phase += freq / self.sample_rate;
freq はそのノートの周波数です。
高い音ほど周波数が高いので、phase の進み方も速くなります。
前回は phase が1つしかありませんでしたが、今回は Voice ごとに phase を持っています。
そのため、ドはドの速さで進み、ミはミの速さで進みます。
ドのphase → ドの速さで進む
ミのphase → ミの速さで進む
ソのphase → ソの速さで進む
この状態でそれぞれの正弦波を足すと、和音っぽく聞こえるようになります。
velocityも使ってみる
前回は、鍵盤をどれくらい強く押したかは使っていませんでした。
今回は velocity を Voice に持たせています。
struct Voice {
note: u8,
phase: f32,
velocity: f32,
}
そして、音を足すときに掛けています。
sample += (voice.phase * consts::TAU).sin()
* voice.velocity
* VOICE_GAIN;
これで、強く押した音は大きく、弱く押した音は小さくなります。
とはいえ、今回はかなり単純にそのまま掛けているだけです。
音楽的に自然かどうかは一旦気にせず、MIDIから来た値を音量に使ってみる、というくらいの扱いです。
音量を少し下げている理由
複数の音を足すと、当然ですが全体の値も大きくなります。
1音だけなら問題なくても、8音を同時に鳴らすとかなり大きくなる可能性があります。
そのため、1音ごとに VOICE_GAIN を掛けています。
const VOICE_GAIN: f32 = 0.12;
この値は、ちゃんと計算して決めたというより、同時に鳴らしたときに大きくなりすぎないように小さめにしているだけです。
今回は音質を細かく作り込む段階ではないので、まずは音が割れにくいくらいの値にしておきます。
ビルドする
ビルド方法は前回と同じです。
cargo run -p xtask -- bundle sine --release
成功すると、次の場所にVST3が作られます。
target/bundled/Sine.vst3
macOSの場合は、前回と同じようにVST3のディレクトリへコピーします。
rm -rf ~/Library/Audio/Plug-Ins/VST3/Sine.vst3
cp -R target/bundled/Sine.vst3 ~/Library/Audio/Plug-Ins/VST3/
あとはDAWやホストアプリケーションで読み込めば使えます。
キーボードで複数の鍵盤を押すと、前回とは違って複数の音が同時に鳴るようになりました。
今回できたこと
今回は、前回作った単音のVSTiを、複数の音を同時に鳴らせるようにしました。
やったことはかなり単純です。
前回は、現在鳴っているノートを1つだけ持っていました。
current_note: Option<u8>
今回は、鳴っている音を Vec<Voice> で複数持つようにしました。
voices: Vec<Voice>
そして、毎サンプルごとに voices の中を見て、それぞれの正弦波を計算して足しています。
複数の音を鳴らす
= 複数の波を作る
= それらを足す
かなり素朴な考え方ですが、これだけでも和音を鳴らすことができました。
まだ雑なところ
今回の実装は、まだかなり雑です。
たとえば、キーを離した瞬間に、その音を voices から消しています。
fn note_off(&mut self, note: u8) {
self.voices.retain(|voice| voice.note != note);
}
なので、音の消え方は少し急です。
実際の楽器っぽくするなら、キーを離したあとに少しずつ音を小さくするような処理を入れた方が自然そうです。
ただ、今回はそこまではやりません。
まずは「複数の音を持つ」「複数の波を足す」というところまでを目的にしています。
まとめ
今回は、RustとNIH-plugで作った簡単なVSTiを、同時発音に対応させました。
前回は current_note と phase が1つずつしかなかったので、1音しか鳴らせませんでした。
今回は Voice という構造体を作り、発音中の音を Vec<Voice> で管理するようにしました。
それぞれの Voice が自分の note、phase、velocity を持っています。
そして、音を出すときは、各 Voice の正弦波を計算して全部足します。
その結果、複数の鍵盤を押したときに複数の音が同時に鳴るようになりました。
実装としてはかなり単純ですが、前回より少し楽器っぽくなった気がします。
次にやるなら、音を離したときに急に消えないようにしたり、サステインにも対応させると面白そうです。