Help us understand the problem. What is going on with this article?

KORG NTS-1 のプリセット波形で logue SDK カスタム・オシレータの基本をおさらい

はじめに

KORG NTS-1 上の logue SDK では 6カテゴリ90波形 (*1) のプリセット波形を利用することができます。このプリセット波形を鳴らすだけのシンプルなオシレータを作りながら、logue SDK でカスタム・オシレータを作る際の基本をおさらいします。

(*1 : DSP のライブラリに含まれる sine, square, triangle, saw などはカウントせず)

tl;dr; ポイントまとめ

カスタム・オシレータを作る際の基本となる、このポスト内の重要ポイントをまとめます。

  • サンプリング周波数は 48kHz
  • カスタム・オシレータで生成する音の高さは params->pitch で指定される
  • logue SDK 内での位相は [0.0, 1.0) で表現されている
    • カスタム・オシレータもそれに倣うのが楽
  • カスタム・オシレータ内では音の周波数ではなくてフレームあたりの位相変化量で音高を管理するのが良い
    • osc_w0f_for_note 関数でフレームあたりの位相変化量を取得できる
  • バッファに書き込む値は (-1.0, 1.0) とすること
    • osc_softclipf 関数で両端を切り落とせる

動作環境

このポスト内のコードは以下の環境で動作を確認しました。

$ ./logue-cli probe
> Device: nutekt digital
> System version: 1.02
> Logue API version: 1.01-0
> Available modules:
...

できあがりのカスタム・オシレータ

最終的にはこのようなオシレータができあがりました。ここを目指して組み立ていく中で、カスタム・オシレータを作る際のポイントなどの基本を説明していきます。
preset_waves.cpp

#include "userosc.h"

typedef struct State {
  float phase;
  uint16_t flags;

  const float * const * wavetable;
  uint16_t wavetable_index;
} State;

static State state;

enum {
  flags_clear = 0,
  flag_noteon = 1 << 0,
  flag_noteoff = 1 << 1,
};

void OSC_INIT(uint32_t platform, uint32_t api) {
  state.phase = 0.f;
  state.flags = flags_clear;

  state.wavetable = wavesA;
  state.wavetable_index = 0;
}

void OSC_CYCLE(const user_osc_param_t* params,
               int32_t* yn,
               const uint32_t frames) {  
  // Handle the reset flag on NOTEON.
  if (state.flags & flag_noteon) {
    state.flags &= ~(flag_noteon);
    state.phase = 0.f;
  }

  // Restore the last state.
  float phase = state.phase;
  const float * const * wavetable = state.wavetable;
  const uint16_t wavetable_index = state.wavetable_index;

  // Calculate the phase delta.
  const float w_delta = osc_w0f_for_note((params->pitch)>>8, params->pitch & 0xFF);

  // Prepare the result buffer.
  q31_t* __restrict y = (q31_t*) yn;
  const q31_t* y_e = y + frames;

  while (y != y_e) {
    // Main signal
    float sig  = osc_wave_scanf(wavetable[wavetable_index], phase);
    sig  = osc_softclipf(0.05f, sig);
    *(y++) = f32_to_q31(sig);

    // Next step.
    phase += w_delta;
    phase -= (uint32_t) phase; // to keep phase within 0.0-1.0.
  }

  // Store the state.
  state.phase = phase;
}

void OSC_NOTEON(const user_osc_param_t * const params) {
  state.flags |= flag_noteon;
}

void OSC_NOTEOFF(const user_osc_param_t * const params) {
  state.flags |= flag_noteoff;
}

void OSC_PARAM(uint16_t index, uint16_t value) {
  switch (index) {
  case k_user_osc_param_id1:
    state.wavetable = wavesA;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id2:
    state.wavetable = wavesB;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id3:
    state.wavetable = wavesC;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id4:
    state.wavetable = wavesD;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id5:
    state.wavetable = wavesE;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id6:
    state.wavetable = wavesF;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_shape:
  case k_user_osc_param_shiftshape:
  default:
    break;
  }
}

カスタム・オシレータの処理の流れ

まずはカスタム・オシレータがどのようなフローで処理を行うのか、おおまかな流れを整理します。

  1. OSC_INIT でカスタム・オシレータの初期化を行う
    カスタム・オシレータの初期化OSC_INIT 関数内で行います。これは OSC モードのときに TYPE ノブでカスタム・オシレータが選択された際に呼ばれます。ここではオシレータ全体で最初に必要な初期化を行います。発音ごとに必要となる処理はここには書けません。

  2. 発音の開始ごとに OSC_NOTEON がトリガーされる
    発音の要求が来るたびに呼ばれます。発音ごとの初期化などはここに書けば良さそうなものですが、それだとあまりよろしくないのか、ここでは NOTEON が来たというフラグを立てるだけで、実際の初期化処理は次回の OSC_CYCLE 内で行われるのが定石のようです。

  3. OSC_CYCLE で波形データを生成する
    波形データを生成するための関数で、オシレータのいちばん重要な部分となるので後ほど詳細に説明します。音が継続する限り繰り返し呼ばれます。前述の通り、NOTEON 直後の呼び出しでは初期化処理を行う必要がある場合もあります。発音ごとの初期化処理の具体例は、音の波形をスタート地点まで戻す、などです。

  4. 発音の終了は OSC_NOTEOFF
    発音の後片付け等が必要であればここで行います。以降は OSC_CYCLE が呼ばれなくなり (*2)、音が止まるため、OSC_NOTEOFF内で明示的に音を止める処理などは不要です。今回のカスタム・オシレータでは何も行っていません。

(*2 : EG の設定によっては Release の間は呼ばれ続ける場合もあります)

OSC_CYCLE の詳細

OSC_CYCLE は波形データを生成する関数で、カスタム・オシレータの心臓部分となる重要な部分です。userosc.h で次のように定義されています。

#define OSC_CYCLE   __attribute__((used)) _hook_cycle
...
void _hook_cycle(const user_osc_param_t * const params, int32_t *yn, const uint32_t frames);

OSC_CYCLE のパラメータ

入力 : const user_osc_param_t * params

user_osc_param_t はカスタム・オシレータので生成すべき音の基本性質を指定する構造体で、userosc.h で定義されています。このメンバの中でも重要な情報の一つは鳴らすべき音の高さを示す params->pitch で、その音高に対応した周波数の音声データを生成するのがカスタム・オシレータの役割です。

params->pitch の上位バイトが MIDI ノート・ナンバーを示し、下位バイトは半音未満の音高の微調整を指定します。この情報を使うと生成すべき波形データの周波数を算出することができます。ただし、後ほど周波数と位相のところでも詳しく述べますが、実際のカスタム・オシレータの実装では音の周波数を直接扱う場面はあまりなさそうです。

出力用バッファ: int32_t *ynconst uint32_t frames

yn は音声データを収めるためのバッファで、カスタム・オシレータの内部で生成した音声データをこのバッファに書き込むことで音を出力します。frames はバッファの長さを示しています。ここで扱う数値はサンプルごとの音声信号の大きさであり、ノート・ナンバーや周波数などを直接書き出すわけではありません。サンプリング周波数は 48kHz です。この1秒あたり 48k フレームのうちの frames 分の音声データを一度の OSC_CYCLE で生成することになります。

ドキュメントでは must support at least up to 64 frames となっていますが、KORG NTS-1 の挙動を少し確認したところ 64 フレーム以外の長さが渡されてくるケースは確認できませんでした。

OSC_CYCLE の呼び出しサイクルと内部状態の保持

前述の通り、一度の OSC_CYLE の呼び出しで 64 フレーム(もしくはそれ以上)の音声データを生成します。このフレーム数の境界は一周期の波形データの境界とは必ずも一致しないため、一周期の波形データを複数回の OSC_CYCLE に分けて生成できるような仕組みが必要となります。そのために、ある呼び出しでは波形をどこまで生成し終えて、次の呼び出しでは波形のどこから生成を再開するかを保持する必要があります。こうした1波形内における現在位置が位相です。

サンプルコードでは位相を含めた内部状態を以下の構造体で管理しています。

typedef struct State {
  float phase;
  uint16_t flags;

  const float * const * wavetable;
  uint16_t wavetable_index;
} State;

ある OSC_CYCLE コールで生成し終えた位相を phase に保持し、次回のコールではその phase から生成を開始します。それ以外のメンバについては追って説明します。

logues SDK における位相の管理

logue SDK の内部では位相を [0.0, 1.0) で管理することを前提に各種の関数が用意されています。正弦波など一般的には一周期を 0 ≦ θ ≦ 2π として扱う関数にも [0.0, 1.0) を渡すので多少混乱しましたが、直感的には1波形の何%まで進んだかを示していると考えるとわかりやすいです。例えば位相が 0.5 のときは全体の 50% まで来ていると考えることができます。これが 1.0 にたどり着いたときに1波形の生成が終了し、同時に位相は 0.0 に戻ることになります。次の例では正弦波を扱っていますが、後述のプリセット波形を取得する関数も同様に [0.0, 1.0) を引数に取ります。

# 例:
# 正弦波1波形の 25% の時点の値を返す。つまり sin(M_PI/2) = 1.0
osc_sinf(0.25);
# 同様に 50%。sin(M_PI) = 0.0
osc_sinf(0.5);
# 100% は 0.0 に周期が戻り sin(0) = 0.0
osc_sinf(0.0);

ただしこの [0.0, 1.0) は標準で用意されたライブラリを活用する場合の制約であり、自前の関数で波形成性を行う場合はこの限りではありません。例えば一周期を [-1.0, 1.0) としたり [-M_PI, M_PI) としたりすることもできます。ただし、後述のフレームあたりの位相変化量の扱いでややこしくなるため、内部では位相を [0.0, 1.0) で扱いつつ、自前の関数への入力は定数倍するなどして定義域を変換する方法が簡単だと思います。

# 例:my_sin の定義域 [-M_PI, M_PI) とする場合
my_sin((phase * 2.0f - 1.0f) * M_PI);

音の周波数と位相の進め方の関係

たとえば 400Hz の音の場合、一秒間に 400 個の波形を出力することになります。カスタム・オシレータのサンプリング周波数が 48kHz なので、1波形あたりに使えるフレーム数は 48,000(フレーム/s) / 400(波形/s) = 120(フレーム/波形) となります。1波形 = 120 フレームの間に位相が 0.0 から 1.0 まで変化するので、1フレームあたりでは位相を 1 / 120 ずつ進めることになります。同様の計算を行うことで、音の周波数に応じて1フレームあたりに変化させる位相の量を求められます。

カスタム・オシレータの実装では、音の周波数そのものではなくて、この1フレームあたりの位相の変化量から生成するデータの位相を制御します。出力する音の高さは params->pitch として渡ってきているので、これを使って1フレームあたりの位相変化量を求める関数が用意されています。

const float w_delta = osc_w0f_for_note((params->pitch)>>8, params->pitch & 0xFF);

この関数の戻り値を使って、1フレームの処理が終わるたびに w_delta だけ位相を進めます。1波形分を生成し終わると位相が 1.0 となるので、-1.0 することで位相を最初に戻します。これを行っているのが以下の部分です。

    phase += w_delta;
    phase -= (uint32_t) phase; // to keep phase within 0.0-1.0.

余談

osc_w0f_for_note に渡している第二引数は、半音未満の音程の微調整に使われます。半音を 256 段階に区切り、与えられた値の分だけ音高を上げる、という処理を行います。ただし注意する点は、256段階の分割は周波数の線形補完であることです。本来はセントなどのような対数上での補完を行うものなんじゃないかと思っていたのですが、計算の簡単のためなのかシンプルなアプローチが取られています。このため、例えば 128 を指定したとしても、それは第一引数で指定された音とその半音上の音の中間の高さにはなりません。

余談2

KORG NTS-1 は MIDI ノートナンバーに対応する周波数を Hz としてルックアップするテーブルを内部に持っています。osc_w0f_for_note 内ではそのルックアップテーブルから MIDI ノートナンバーに対応する周波数を得て、それとサンプリング周波数を用いて1フレームあたりの位相変化量を計算しています。カスタム・オシレータからは音の周波数は見えないようになっていますが、内部的に周波数を使っているんですね。

バッファに書き出す波形データの値域と表現形式

波形データのバッファは int32_t の配列として渡ってきます。ここに書き込むデータは値域 (-1.0, 1.0) の Q31 形式が期待されているようです。明確にドキュメントされているものを見つけられていませんが、サンプルコードを読む限りはそうなっています。logue SDK ではこの値域に合わせて両端を切り落とす関数が用意されています。

 __fast_inline float osc_softclipf(const float c, float x)

この関数は任意のfloat 値をとって [-(1.0 - c), (1.0 - c)] の float 値を返します。例えば次のような場合、値域は [-0.95, 0.95] となります。

sig  = osc_softclipf(0.05f, sig);

こうして得られた (-1.0, 1.0) の float 値を Q31 形式に変換してバッファに書き込みます。

*(y++) = f32_to_q31(sig);

ここで副作用としてバッファを一つ進めていることに留意します。

ここまでのコード

ここまでの議論で、カスタム・オシレータの基本的な枠組みは出来上がります。コードに落とすと次のようになります。

void OSC_CYCLE(const user_osc_param_t* params,
               int32_t* yn,
               const uint32_t frames) {  
  const float w_delta = osc_w0f_for_note((params->pitch)>>8, params->pitch & 0xFF);
  q31_t* __restrict y = (q31_t*) yn;
  const q31_t* y_e = y + frames;

  while (y != y_e) {
    float sig  = your_wave(phase); // <-- ここで位相から波形データを生成する関数を呼ぶ
    *(y++) = f32_to_q31(osc_softclipf(0.05f, sig));

    state.phase += w_delta;
    state.phase -= (uint32_t) state.phase;
  }
}

これに加えて、実際の波形データ生成を担う float sig = your_wave(phase) の部分を実装するとカスタム・オシレータの完成です。

このポストでは your_wave(phase) にプリセット波形を用いることになります。次の節からはその方法を説明します。

プリセット波形

プリセット波形は wavesA から wavesF の6カテゴリ、合計90波形が用意されています。カテゴリごとの波形数は次の通りです。

カテゴリ 波形数
wavesA 16
wavesB 16
wavesC 14
wavesD 13
wavesE 15
wavesF 16

プリセット波形の形状

それぞれの波形は次のチャートのようになっています。1波形分を切り出したものをカテゴリごとにつないでまとめています。

wavesA.png
wavesB.png
wavesC.png
wavesD.png
wavesE.png
wavesF.png

プリセット波形の読み出し

logue SDK ではプリセット波形にアクセスする関数 osc_wave_scanf が用意されています。この関数を呼ぶには次の3つが必要です。

  • 波形のカテゴリ(wavesA - wavesF)
  • カテゴリごとの波形番号 (0-15、ただしカテゴリによって最大値は異なる)
  • 位相

波形のカテゴリは、その名前がそのまま波形へのポインタの配列を指し示す変数として定義されていて、例えば wavesA[0], wavesF[12] といった形で波形を参照することができます。

#define k_waves_a_cnt       16

  extern const float * const wavesA[k_waves_a_cnt];

位相はこれまで同様に [0.0, 1.0) で指定します。例えば wavesA の二番目の波形には以下のようにアクセスします。

    float sig  = osc_wave_scanf(wavesA[1], phase);

この値をバッファに次々と書き込むことで、プリセット波形を音として鳴らすことができます。前述の通り、波形データは osc_softclipf 関数を使って両端を切って Q31 形式に変換するので、このようなコードになります。

    float sig  = osc_wave_scanf(state.wavetable[state.wavetable_index], phase);
    sig  = osc_softclipf(0.05f, sig);
    *(y++) = f32_to_q31(sig);

state.wavetable[state.wavetable_index] の部分は、後述のエディット・パラメータを使ってユーザーが変更可能になっています。エディット・パラメータを扱う関数と OSC_CYCLE とで共通の値を参照する必要があるので、内部状態を保持する構造体に値を格納しています。

余談

これらのプリセット波形は内部的にそれぞれ 128 個の float 値の配列として定義されていて、データポイントの間の値を要求された場合は線形補間が行われているようです。

OSC_NOTEONOSC_NOTEOFF

発音の開始と終了ではそれぞれ OSC_NOTEONOSC_NOTEOFF が呼ばれます。MIDI のノートオンとノートオフに対応する関数です。

OSC_NOTEON では発音の最初に必要となる初期処理を行うことが期待されます。例えば位相を初期位置の 0.0 に戻す処理などが考えられます。

OSC_NOTEOFF では発音の後始末に必要な処理が期待されます。例えば不要となった内部バッファ領域の解放などが考えられます。

ただし、どちらの処理も OSC_CYCLE で音声データを生成している途中に内部状態が変更されると、音のつながりがおかしくなる恐れがあります。そのため OSC_NOTEONOSC_NOTEOFF ではフラグを立てるにとどめて、実際の初期化処理、後始末は次の OSC_CYCLE で行うことが望ましいです。

今回のカスタム・オシレータでもフラグを enum として定義して、OSC_NOTEONOSC_NOTEOFF ではフラグを操作するだけで、実際の処理は OSC_CYCLE に委ねています。

enum {
  flags_clear = 0,
  flag_noteon = 1 << 0,
  flag_noteoff = 1 << 1,
};

void OSC_CYCLE(const user_osc_param_t* params,
               int32_t* yn,
               const uint32_t frames) {  
  // Handle the reset flag on NOTEON.
  if (state.flags & flag_noteon) {
    state.flags &= ~(flag_noteon);
    state.phase = 0.f;
  }
  ...
}

void OSC_NOTEON(const user_osc_param_t * const params) {
  state.flags |= flag_noteon;
}

void OSC_NOTEOFF(const user_osc_param_t * const params) {
  state.flags |= flag_noteoff;
}

エディット・パラメータ

カスタム・オシレータは、ユーザーが NTS-1 演奏時に B ノブを操作することで変更可能なエディット・パラメータを、最大で6つまで持つことができます。これらのパラメータの操作に基づいて OSC_CYCLE 内で波形の生成方法を変化させるなど、リアルタイムにカスタム・オシレータの挙動を変化させることが可能です。

使用するエディット・パラメータの個数、それぞれのエディット・パラメータの名前、最大値、最小値などは manifest.json ファイル内で定義します。

ユーザーによって設定値が変化した場合は OSC_PARAM がパラメータ番号とパラメータ値を伴って呼ばれます。この挙動の詳細は「KORG NTS-1 で OSC_PARAM に渡ってくる値の実際」のポストを参照してください。

今回のカスタム・オシレータでのエディット・パラメータの扱い

今回のカスタム・オシレータでは6つのパラメータにそれぞれプリセット波形のカテゴリをアサインし、パラメータ値がそのままカテゴリ内の波形番号を指し示すようにしました。

パラメータ カテゴリ 最小値 最大値
k_user_osc_param_id1 wavesA 0 15
k_user_osc_param_id2 wavesB 0 15
k_user_osc_param_id3 wavesC 0 13
k_user_osc_param_id4 wavesD 0 12
k_user_osc_param_id5 wavesE 0 14
k_user_osc_param_id6 wavesF 0 15

この場合、manifest.json は以下のように設定します。

        "num_param" : 6,
        "params" : [
          ["A", "0", "15", ""],
          ["B", "0", "15", ""],
          ["C", "0", "13", ""],
          ["D", "0", "12", ""],
          ["E", "0", "14", ""],
          ["F", "0", "15", ""]
        ]

パラメータ値の変更を扱う OSC_PARAM は以下のように実装します。パラメータ番号に基づいてプリセット波形のカテゴリを切り替え、パラメータ値でカテゴリ内の波形番号を指定しています。カテゴリ、波形番号、ともに OSC_PARAMOSC_CYCLE の双方から参照する必要があるため、内部状態を保持する構造体に格納しています。

void OSC_PARAM(uint16_t index, uint16_t value) {
  switch (index) {
  case k_user_osc_param_id1:
    state.wavetable = wavesA;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id2:
    state.wavetable = wavesB;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id3:
    state.wavetable = wavesC;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id4:
    state.wavetable = wavesD;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id5:
    state.wavetable = wavesE;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_id6:
    state.wavetable = wavesF;
    state.wavetable_index = value;
    break;
  case k_user_osc_param_shape:
  case k_user_osc_param_shiftshape:
  default:
    break;
  }
}

ビルドとデプロイ

ここまででプリセット波形を鳴らすカスタム・オシレータの挙動は一通り出来上がりました。ソースファイルと一緒に公開されている Makefile などを正しく入手してあれば、以下の手順でカスタム・オシレータをビルドしてKORG NTS-1 実機にデプロイすることができます。

$ make
...
$  ./logue-cli load -s 1 -u path/to/binary/preset_waves.cpp.ntkdigunit

操作方法

  1. OSC モードで、TYPE ノブにより preset カスタム・オシレータを選択
  2. OSC + TYPE ノブでエディット・パラメータを選択。これによりプリセット波形のカテゴリが選択される。
  3. B ノブで カテゴリ内の波形番号を指定
  4. 鍵盤を触って音を鳴らしてみる
  5. 他の波形にも切り替えて試してみる

まとめ

以上で、KORG NTS-1 の logue SDK 実装にデフォルトで用意されているプリセット波形を鳴らすカスタム・オシレータが完成しました。内部での位相の表現形式やバッファが期待する値域など、ドキュメントなどで明示されていなくて暗黙の前提になっているようなあたりを色々と調べることは良い勉強になりました。

再度、ポイントをまとめておきます。

  • サンプリング周波数は 48kHz
  • カスタム・オシレータで生成する音の高さは params->pitch で指定される
  • logue SDK 内での位相は [0.0, 1.0) で表現されている
    • カスタム・オシレータもそれに倣うのが楽
  • カスタム・オシレータ内では音の周波数ではなくてフレームあたりの位相変化量で音高を管理するのが良い
    • osc_w0f_for_note 関数でフレームあたりの位相変化量を取得できる
  • バッファに書き込む値は (-1.0, 1.0) とすること
    • osc_softclipf 関数で両端を切り落とせる

以上

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした