📘

KORG NTS-3 kaoss pad kitで適当なサンプラーをつくる

2024/09/25に公開

北千住のコーヒー屋でハードウェア楽器を触るイベントが毎月開催されていて、毎月「サンプラーとはなにか?」という哲学的な問いと向き合うことになるのですが、そろそろふつうのサンプラー持ってくか...と思ったところ、手元に NTS-3 があったので、なんちゃってサンプラーを実装してみました。こんな感じ:

https://x.com/yutannihilation/status/1838561959630758305

以下がレポジトリですが、難しいことは何もなく、サンプルコードをちょっといじった程度のものです。
C++ わからないので雰囲気で書いています。

https://github.com/yutannihilation/nts-3-sampler-test/

effect.hが実際のコードです。主にやったのは、

  • パラメータの追加
  • touchEvent() でタップしたときに録音もしくは再生を始めるようにパラメータをセットする
  • Process() でバッファの読み書きをする

という感じです。

バッファ

ここはテンプレートのコードそのままですが、どういうコードになっているかいちおう見ておきます。

バッファ長は 0x40000(= 262144)になっています。sample rate が 48000Hz でステレオで扱うとすると、2.5秒くらい?になるみたいです。

  enum
  {
    BUFFER_LENGTH = 0x40000
  };

このバッファは Init() の中で初期化されます。sdram_alloc() という関数でメモリをアロケートして、allocated_buffer_ というフィールドにポインタを格納しています。

    // If SDRAM buffers are required they must be allocated here
    if (!desc->hooks.sdram_alloc)
      return k_unit_err_memory;
    float *m = (float *)desc->hooks.sdram_alloc(BUFFER_LENGTH * sizeof(float));
    if (!m)
      return k_unit_err_memory;

    // Make sure memory is cleared
    buf_clr_f32(m, BUFFER_LENGTH);

    allocated_buffer_ = m;

追加したパラメータ

以下の4つのパラメータを追加しています。(「パラメータ」という用語がちょっと紛らわしいですが、これは内部的に使っているだけでユーザーから直接操作できるものではないです)

  • s_writeidx: バッファの書き込み位置
  • s_readidx: バッファの読み込み位置
  • s_readidx_end: バッファの読み込み終了位置
  • speed: 再生速度

touchEvent()

NTS-3 は、unit_touch_event() というタッチイベントを処理する API を実装する必要があります(仕様)。デフォルトのテンプレートだと、unit.cc にこの実装があり、内容としては effect.h にある実装 touchEvent() を呼び出すだけになっています。

引数の id はマルチタッチイベントの処理の際に使うものらしいですが NTS-3 はシングルタッチなので無視で OK です。

phase は、イベントの種類(タッチの開始、移動、終了など)が入ります。今回は、タップされたかどうかだけが関心事なので k_unit_touch_phase_began だけを見ています。

xy はタッチの座標です。正規化されていない生の値が入ります。正しくは、それを Init() の際に渡される解像度(runtime_desc_ に入っている)で割ると 0〜1 に正規化することができますが、めんどくさかったので解像度は常に 1024 だと仮定してコードを書いています。

if (params_.depth < 0) は何をしているかというと、サンプラーなので録音と再生を切り替えたいんですが、そういうのに使えるボタンは特についてないので、FX depth の部分で切り替えるようにしています。0以下(下半分)ならタップしたときに録音し、0か0以上ならタップしたときに再生します。

(と聞いて、「え、じゃあ他のエフェクトいじってるときに FX depth を触ったら間違って上書きされたりするってこと?」と思った人、正解です。まじで適当な実装です...)

  inline void touchEvent(uint8_t id, uint8_t phase, uint32_t x, uint32_t y)
  {

    // ..snip..

    switch (phase)
    {
    case k_unit_touch_phase_began:
      if (params_.depth < 0)
      {
        // 録音モード
      }
      else
      {
        // 再生モード
      }
      break;
    default:
      break;
    }
  }

録音

録音の場合、書き込み位置をリセットします。これだけです。

        s_writeidx = 0;

ちなみに、このコードだと、一度タップすればそのままバッファが一杯になるまで録音が続きそうな気がするんですが、なんかずっと押さえてないとうまく行きませんでした。理由はわかりません。指を離すとパラメータがリセットされるのか、音がエフェクトユニットに送られなくなるのか...

再生

再生の場合は、X軸で再生開始位置、Y軸でピッチを変えられるようにします。

再生開始位置に関しては、バッファを8等分して、位置に応じてその1/8のサンプルだけが再生されるようにしたいので、ビットシフトして8段階にした値をバッファ長にかけています。s_readidx_end の方はそれに 1/8 を足しています。最後だけバッファ長を超えちゃう気がしますが、いったんここでは気にせず、再生の方でチェックすることにしています。

ピッチは4段階にしています。ピッチシフターの実装がわからなかったので、単純に「2倍速 = サンプルを1つ飛ばしで実行」という適当な実装にしています。その都合上、整数倍しか無理なので、8段階は多すぎるので4段階にしています。

        // TODO: lazily assume width is 1024 (2 ^ 10)
        float quantized_x = (float)(x >> 7);
        s_readidx = (uint32_t)(BUFFER_LENGTH * quantized_x / 8.0);
        s_readidx_end = (uint32_t)(BUFFER_LENGTH * (quantized_x + 1.0) / 8.0);

        // TODO: lazily assume height is 1024 (2 ^ 10). max: 1024 >> 8 = 4
        float quantized_y = (float)(y >> 8);
        speed = 1.0 + quantized_y;

Process()

こちらも録音モードと再生モードに分かれています。

  fast_inline void Process(const float *in, float *out, size_t frames)
  {
    const float *__restrict in_p = in;
    float *__restrict out_p = out;
    const float *out_e = out_p + (frames << 1); // assuming stereo output

    if (params_.depth < 0)
    {
      // 録音
    }
    else
    {
      // 再生
    }
  }

録音

録音は、単純に書き込み位置をひとつつづつ進めながらバッファに書いていくだけです。この for ループの回し方もテンプレートそのままです。

      uint32_t writeidx = s_writeidx;
      for (; out_p != out_e; in_p += 2, out_p += 2)
      {
        if (writeidx <= BUFFER_LENGTH - 1)
        {
          allocated_buffer_[writeidx + 0] = in_p[0];
          allocated_buffer_[writeidx + 1] = in_p[1];
          writeidx += 2;
        }
      }
      s_writeidx = writeidx;

再生

再生の方も、再生位置を進めながらバッファの値を出力に詰めていくだけです。違うのは、speed に応じてサンプルを飛ばしたりする点と、上で見たように s_readidx_end の値は BUFFER_LENGTH を超えているかもしれないので <= BUFFER_LENGTH - 1 かどうかのチェックも入れています。

      uint32_t readidx = s_readidx;
      for (; out_p != out_e; in_p += 2, out_p += 2)
      {
        if (readidx <= s_readidx_end && readidx <= BUFFER_LENGTH - 1)
        {
          out_p[0] = allocated_buffer_[readidx + 0];
          out_p[1] = allocated_buffer_[readidx + 1];
          readidx += 2 * speed;
        }
      }
      s_readidx = readidx;

Discussion