🔌

[Rust] NIH-plugでCLAPオーディオプラグインを作る

2024/11/27に公開

https://qiita.com/Saisana299/items/003aa9aa3b40db4f6f89

プラグインにGUIを実装したい

前回の記事 では、RustでCLAPオーディオプラグインを作る方法について解説しました。
https://zenn.dev/saisana299/articles/439fb4eed12acf

これにGUIを実装したかったのですが、上手くいかなかったためClackではなく別のライブラリを使ってみることにしました。

前提

  • Pythonをインストール済み (pip、pipxを使います)
  • Rust開発環境を構築済み
  • CLAPに対応したDAWをインストール済み
  • Windows x86-64を想定

NIH-plug

https://github.com/robbert-vdh/nih-plug
NIH-plugはRust言語でオーディオプラグインを開発するためのライブラリです。
VST3とCLAPのフォーマットに対応しています。
GUIはegui、iced、Viziaがサポートされています。
ライセンスに関しては、VST3バインディングを行うとGPLv3が適用されるため、ISCや独自のライセンスを適用したい場合はCLAPバインディングのみにする必要があります。
本記事では、CLAPプラグインをISCで配布することを想定して、GUI付きのプラグインを作る方法を解説します。

プロジェクト作成

ライブラリ製作者が公開しているプロジェクトテンプレートを使います。
cookiecutterを利用してNIH-plugプロジェクトのビルドができます。
https://github.com/robbert-vdh/nih-plug-template

以下のコマンドでpipxをインストールします。

pip install pipx

次のコマンドでプロジェクトの作成ウィザードが起動します。

pipx run cookiecutter gh:robbert-vdh/nih-plug-template
# プロジェクト名を入力
[1/11] project_name (your_plugin_name (use underscores)): claptest
# struct_nameとplugin_nameは必要があれば変更してください
[2/11] struct_name (Claptest):
[3/11] plugin_name (Claptest):
# 制作者の名前を入力
[4/11] author (Your Name): Saisana299
# 連絡用メールアドレスを入力
[5/11] email_address (your@email.com):
# プラグインのWebサイトなどのURLを入力
[6/11] url (https://youtu.be/dQw4w9WgXcQ):
# プラグインについての説明を入力
[7/11] description (A short description of your plugin):
# CLAPプラグイン用のID (ドメインの逆順)です
[8/11] clap_id (com.your-domain.claptest):
# 今回はVST3バインディングを利用しないのでそのままにします
[9/11] vst3_id (Exactly16Chars!!):
# 2のISCを選択
[10/11] Select license
    1 - GPL-3.0-or-later
    2 - ISC
    3 - Other licenses can be set in Cargo.toml,
but using the project needs to be GPLv3 compliant to be able
to use the VST3 exporter.Check Cargo.toml for more information.
    Choose from [1/2/3] (1): 2
# Enterでウィザードが終了します
Make sure to change the CLAP features and VST3 categories
in src/lib.rs (press enter to finish):

VST3機能を無効化

Cargo.tomlのdependenciesの項目を編集して、VST3機能を無効にします。

Cargo.toml
[dependencies]
# Remove the `assert_process_allocs` feature to allow allocations on the audio
# thread in debug builds.
# 以下をコメントアウト
# nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", features = ["assert_process_allocs"] }
# Uncomment the below line to disable the on-by-default VST3 feature to remove
# the GPL compatibility requirement
# 以下のコメントを解除
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", default-features = false, features = ["assert_process_allocs"] }

GUI機能の追加

今回はGUIにicedを使います。
icedはRust用のクロスプラットフォームGUIライブラリです。
https://iced.rs/

nih_plug_icedはNIH-plugと同じリポジトリにあります。
https://github.com/robbert-vdh/nih-plug/tree/master/nih_plug_iced

dependenciesに以下を追加します。

Cargo.toml
nih_plug_iced = { git = "https://github.com/robbert-vdh/nih-plug.git" }

lib.rsからVST3に関する記述を消す

必要のないVST3の項目を削除します。

lib.rs
impl Vst3Plugin for Claptest {
    const VST3_CLASS_ID: [u8; 16] = *b"Exactly16Chars!!";

    // And also don't forget to change these categories
    const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
        &[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics];
}

nih_export_vst3!(Claptest);

以上で準備は完了です。

コードを書く

公式にあるicedを使ったgainプラグインのexampleを実装してみたいと思います。
https://github.com/robbert-vdh/nih-plug/tree/master/plugins/examples/gain_gui_iced

Cargo.tomlのdependenciesに以下を追加します。

Cargo.toml
atomic_float = "0.1"

lib.rs

変更の無いコードは省略しています。

lib.rs
...
use atomic_float::AtomicF32; // 追加
use nih_plug_iced::IcedState; // 追加

mod editor; // 追加

/// 完全に無音に切り替えた後、ピークメーターが12dB減衰するまでの時間。
const PEAK_METER_DECAY_MS: f64 = 150.0; // 追加

struct Claptest {
...
    /// ピークメーターの正規化用
    peak_meter_decay_weight: f32, // 追加
    /// ピークメーターの現在のデータ
    peak_meter: Arc<AtomicF32>, // 追加
}

...
struct ClaptestParams {
    /// エディターの状態
    #[persist = "editor-state"] // 追加
    editor_state: Arc<IcedState>, // 追加
...


impl Default for Claptest {
    fn default() -> Self {
        Self {
            ...

            peak_meter_decay_weight: 1.0, // 追加
            peak_meter: Arc::new(AtomicF32::new(util::MINUS_INFINITY_DB)), // 追加
        }
    }
}

impl Default for ClaptestParams {
    fn default() -> Self {
        Self {
            editor_state: editor::default_state(), // 追加

            ...
        }
    }
}

impl Plugin for Claptest {
    ...

    /// AUDIO_IO_LAYOUTSを変更
    const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
        AudioIOLayout {
            main_input_channels: NonZeroU32::new(2),
            main_output_channels: NonZeroU32::new(2),
            ..AudioIOLayout::const_default()
        },
        AudioIOLayout {
            main_input_channels: NonZeroU32::new(1),
            main_output_channels: NonZeroU32::new(1),
            ..AudioIOLayout::const_default()
        },
    ];

    /// 以下は削除
    // const MIDI_INPUT: MidiConfig = MidiConfig::None;
    // const MIDI_OUTPUT: MidiConfig = MidiConfig::None;

    ...

    /// 以下を追加
    fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
        editor::create(
            self.params.clone(),
            self.peak_meter.clone(),
            self.params.editor_state.clone(),
        )
    }

    fn initialize(
        &mut self,
        _audio_io_layout: &AudioIOLayout,
        buffer_config: &BufferConfig, // アンダーバーを消す
        _context: &mut impl InitContext<Self>,
    ) -> bool {
        // 以下を追加
        self.peak_meter_decay_weight = 0.25f64
            .powf((buffer_config.sample_rate as f64 * PEAK_METER_DECAY_MS / 1000.0).recip())
            as f32;

        true
    }

    /// 以下を削除
    // fn reset(&mut self) {
    // }

    /// processの内容を編集
    fn process(
        &mut self,
        buffer: &mut Buffer,
        _aux: &mut AuxiliaryBuffers,
        _context: &mut impl ProcessContext<Self>,
    ) -> ProcessStatus {
        for channel_samples in buffer.iter_samples() {
            let mut amplitude = 0.0;
            let num_samples = channel_samples.len();

            let gain = self.params.gain.smoothed.next();
            for sample in channel_samples {
                *sample *= gain;
                amplitude += *sample;
            }

            // GUIが表示されている時のみGUIの計算を行う
            if self.params.editor_state.is_open() {
                amplitude = (amplitude / num_samples as f32).abs();
                let current_peak_meter = self.peak_meter.load(std::sync::atomic::Ordering::Relaxed);
                let new_peak_meter = if amplitude > current_peak_meter {
                    amplitude
                } else {
                    current_peak_meter * self.peak_meter_decay_weight
                        + amplitude * (1.0 - self.peak_meter_decay_weight)
                };

                self.peak_meter
                    .store(new_peak_meter, std::sync::atomic::Ordering::Relaxed)
            }
        }

        ProcessStatus::Normal
    }
}

...

editor.rs

新しく editor.rs というファイルを作成してください。

editor.rs
use atomic_float::AtomicF32;
use nih_plug::prelude::{util, Editor, GuiContext};
use nih_plug_iced::widgets as nih_widgets;
use nih_plug_iced::*;
use std::sync::Arc;
use std::time::Duration;

use crate::ClaptestParams;

pub(crate) fn default_state() -> Arc<IcedState> {
    IcedState::from_size(200, 150)
}

pub(crate) fn create(
    params: Arc<ClaptestParams>,
    peak_meter: Arc<AtomicF32>,
    editor_state: Arc<IcedState>,
) -> Option<Box<dyn Editor>> {
    create_iced_editor::<GainEditor>(editor_state, (params, peak_meter))
}

struct GainEditor {
    params: Arc<ClaptestParams>,
    context: Arc<dyn GuiContext>,

    peak_meter: Arc<AtomicF32>,

    gain_slider_state: nih_widgets::param_slider::State,
    peak_meter_state: nih_widgets::peak_meter::State,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    /// パラメータの値を更新
    ParamUpdate(nih_widgets::ParamMessage),
}

impl IcedEditor for GainEditor {
    type Executor = executor::Default;
    type Message = Message;
    type InitializationFlags = (Arc<ClaptestParams>, Arc<AtomicF32>);

    fn new(
        (params, peak_meter): Self::InitializationFlags,
        context: Arc<dyn GuiContext>,
    ) -> (Self, Command<Self::Message>) {
        let editor = GainEditor {
            params,
            context,

            peak_meter,

            gain_slider_state: Default::default(),
            peak_meter_state: Default::default(),
        };

        (editor, Command::none())
    }

    fn context(&self) -> &dyn GuiContext {
        self.context.as_ref()
    }

    fn update(
        &mut self,
        _window: &mut WindowQueue,
        message: Self::Message,
    ) -> Command<Self::Message> {
        match message {
            Message::ParamUpdate(message) => self.handle_param_message(message),
        }

        Command::none()
    }

    fn view(&mut self) -> Element<'_, Self::Message> {
        Column::new()
            .align_items(Alignment::Center)
            .push(
                Text::new("Gain GUI")
                    .font(assets::NOTO_SANS_LIGHT)
                    .size(40)
                    .height(50.into())
                    .width(Length::Fill)
                    .horizontal_alignment(alignment::Horizontal::Center)
                    .vertical_alignment(alignment::Vertical::Bottom),
            )
            .push(
                Text::new("Gain")
                    .height(20.into())
                    .width(Length::Fill)
                    .horizontal_alignment(alignment::Horizontal::Center)
                    .vertical_alignment(alignment::Vertical::Center),
            )
            .push(
                nih_widgets::ParamSlider::new(&mut self.gain_slider_state, &self.params.gain)
                    .map(Message::ParamUpdate),
            )
            .push(Space::with_height(10.into()))
            .push(
                nih_widgets::PeakMeter::new(
                    &mut self.peak_meter_state,
                    util::gain_to_db(self.peak_meter.load(std::sync::atomic::Ordering::Relaxed)),
                )
                .hold_time(Duration::from_millis(600)),
            )
            .into()
    }

    fn background_color(&self) -> nih_plug_iced::Color {
        nih_plug_iced::Color {
            r: 0.98,
            g: 0.98,
            b: 0.98,
            a: 1.0,
        }
    }
}

プロジェクトのビルド

以下のコマンドでCLAPプラグインをビルドします。

cargo xtask bundle <プロジェクト名> --release
# ここでは
cargo xtask bundle claptest --release

target/bundled にclapファイルが生成されているので、C:/Program Files/Common Files/CLAP内に移動させてDAWで読み込むことができます。

実際に動かしてみる

以下のような画面が出て、パラメータを調整して音量が変化すれば成功です。

Discussion