M5Stack用MusicIFで作るシンセサイザー

M5Stack用のMusicインターフェースボード(2019年冬発売予定)でシンセサイザーのデモを作りました。

f:id:irieda:20190820190435j:plain

ソースコード: https://github.com/144lab/M5Stack-MusicIF-Demo

以下はその実装の解説です。

USB-MIDIキーボード

以下の記述のspiRead関数でUSB-MIDIキーボードからのメッセージを読めます。 (MusicIF側のSTMマイコンのファームにUSB-MIDIキーボードのハンドリング機能が実装済み)

#include <SPI.h>
SPIClass *vspi = NULL;

void setup() {
  // initialise vspi with default pins
  digitalWrite(5, HIGH);
  pinMode(5, OUTPUT);
  // SCLK = 18, MISO = 19, MOSI = 23, SS = 5
  vspi = new SPIClass(VSPI);
  vspi->begin();
}

uint8_t spiRead() {
  uint8_t v;
  vspi->beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE1));
  digitalWrite(5, LOW);  // pull SS slow to prep other end for transfer
  v = vspi->transfer(0xFE);
  digitalWrite(5, HIGH);  // pull ss high to signify end of data transfer
  vspi->endTransaction();
  delayMicroseconds(300);
  return v;
}

MIDIメッセージの概要

MIDIメッセージは先頭データだけ最上位ビットが1。追加のデータは7bit値が1つまたは2つ続く。

  • 「0x90,0x40,0x7f」というデータは64(0x40)番ノートオン、速度(強度)は最大(0x7f)という意味のメッセージ
  • 「0x80,0x40,0x7f」というデータは64(0x40)番ノートオフ、速度(強度)は最大(0x7f)という意味のメッセージ
  • 「0xb0,0x40,0x7f」というデータはコントロールパラメータの変更で、サステインペダルを最大限踏んだという意味のメッセージ
  • 「0xe0,0x00,0x40」というデータはピッチベンド操作で、ピッチベンドをニュートラルに戻したという意味のメッセージ
ノート番号 周波数 音階 オクターブ
57 440.0 A 4
69 880.0 A 5
81 1760.0 A 6
93 3520.0 A 7

ノート番号(i)に対し、周波数(f)は以下の式で算出できます。

f = 440 * 2 ^ {(i - 57) / 12}

が、こんな計算をCPUにさせるのは荷が重いので、予め計算したものをテーブルにもたせておきます。(-> ソースコード中のtoneMap)

詳しくはMIDI-Standardを参照してください。

今回のデモではノートオンとノートオフ、ピッチベンド、サステインペダルだけハンドルしています。

シンセサイザーの論理構造

  • 波形メモリ
  • エンベロープジェネレータ
  • 波形合成
  • 複数ソースのマルチプレクサ

それぞれ出力が「-1.0〜+1.0」になるように処理します。

上記の結果を加算するマルチプレクサを通した結果もまた 「-1.0〜+1.0」で得られるのでこれをオーディオ出力機構に合わせて出力します。

エンベロープジェネレータ

MIDIキーボードのノートオン、ノートオフの情報を受け取り、 音のエネルギー遷移を生成します。最終的には各音階の音量ゲインを出力します。

以下の疑似パラメータで作ります。

  • アタックレート
  • ディケイレート
  • サステインレベル
  • サステインレート
  • リリースレート

f:id:irieda:20191101101304p:plain

アタックレートが大きいものは打楽器的になり、サステインレートが小さいものは管楽器っぽくなります。ピアノなどは鍵を離す(リリースする)とフェルトが弦に当てられ発音レベルは急峻に減衰します。サステインペダルを踏むとこのフェルトが当てられなくなり鍵を離しても長く発音が続きます。

float envelope(struct Note *n) {
  if (n->gain == 0.0 && n->vel == 0) {
    n->top = false;
    n->phase = 0;
    return 0.0;
  }
  float topLevel = float(n->vel) / 127.0;
  if (n->on) {
    if (!n->top) {
      n->gain += params.attack;
      if (n->gain > topLevel) {
        n->top = true;
        n->gain = topLevel;
      }
    } else {
      if (n->gain > params.sustainLevel * topLevel) {
        n->gain -= params.decay;
      } else {
        n->gain -= params.sustain;
      }
      if (n->gain < 0.0) {
        n->gain = 0.0;
        n->vel = 0.0;
        n->phase = 0;
      }
    }
  } else {
    n->top = false;
    if (n->gain > params.sustainLevel * topLevel) {
      n->gain -= params.decay;
    } else {
      float release = params.release /
                      (1.0 + float(control[64]) * params.sustainRate / 127.0);
      n->gain -= release;
    }
    if (n->gain < 0.0) {
      n->gain = 0.0;
      n->vel = 0.0;
      n->phase = 0;
    }
  }
  return n->gain;
}

「control[64]」はサステインペダルの踏み込み強度で0〜127の範囲の整数です。 また、音量ゲインがゼロになったときに位相をリセットしています。 (次回の音の鳴り始めのため)

波形メモリと波形合成

任意の数(今回は128点としました)の波形データを用意して波形合成で利用して指定の周波数で合成出力します。

以下の記述で位相(n->phase)と波形データ(form)から指定の周波数(f)の波形を合成します。

float operate(struct Note *n, float f) {
  if (n->gain == 0.0 && n->vel == 0) {
    return 0.0;
  }
  int i = int(float(formLen) * n->phase);
  float p = n->phase * float(params->formLen) - float(i);
  float v = n->gain * (
    form[i % formLen] * (1 - p) +
    form[(i + 1) % formLen] * (p)
  );
  n->phase += f / SampleRate;
  n->phase = n->phase - float(int(n->phase));
  if (v > 1.0) {
    return 1.0;
  }
  if (v < -1.0) {
    return -1.0;
  }
  return v;
}
  • 「n->phase」に応じて波形データの該当データと該当データの次のデータを取り出して線形補間します。
  • その結果にエンベロープジェネレータの出力である「n->gain」をかけ合わせたものを出力します。
  • n->phaseは0.0〜1.0のスケールで、1サンプルデータごとに「f/SampleRate」分だけ進めます。
  • n->phaseは0.0〜1.0のスケールに収まるよう実数部は常にゼロにします。
  • 大きすぎる出力は頭打ちにしておきます(後段の安全のため)。

線形補間は以下のような方法です。

f:id:irieda:20190820190442j:plain

波形メモリを連続展開したものとエンベロープをかけ合わせた結果は以下のようなイメージです。(実際は周波数がもっと細かい)

波形メモリ内容

f:id:irieda:20191031175555p:plain

連続展開

f:id:irieda:20191031175558p:plain

エンベロープと掛け算

f:id:irieda:20191031175601p:plain

マルチプレクサ

各音階ごとに算出した出力をすべて足し合わせてマスターゲインをかけたものを最終出力とします。

float total = 0.0;
for (int i = BeginNote; i < EndNote; i++) {
  envelope(&notes[i]);
  float n = toneMap[i];
  float m = toneMap[i + 2];
  float p = pitch;
  if (pitch < 0) {
    p = -1 * pitch;
    int i2 = i - 2;
    if (i2 < 0) {
      i2 = 0;
    }
    m = toneMap[i2];
  }
  float f = n * (1 - p) + m * (p);
  total += operate(&notes[i], f);
}
if (total > 1.0) {
  total = 1.0;
}
if (total < -1.0) {
  total = -1.0;
}
total *= MasterGain;

途中にpitchに応じて音階をシフトしているのはピッチベンドの処理です。

オーディオ出力

MusicIFには旭化成のオーディオコーデックAK4954Aが搭載されています。

ここに前述の最終出力(total)を加工して投げ込むことで実際の音声を出力することが出来ます。

AK4954Aのセットアップ

AK4954AはI2Cによる設定レジスタアクセスとI2Sによる音声データ入出力機能があります。 今回のデモでは音声データの出力を行ったのでそのやり方について解説します。

#include <Wire.h>
#include "driver/i2s.h"
const uint16_t I2CSlaveAddr = 0x12;

uint8_t i2cWrite(uint8_t addr, uint8_t data) {
  Wire.beginTransmission(I2CSlaveAddr);
  Wire.write(addr);
  Wire.write(data);
  return Wire.endTransmission();
}

void setup() {
  Wire.begin();

  // リセット解除
  i2cWrite(0x00, 0x00);
  delay(10);

  // クロックセットアップ
  i2cWrite(0x01, 0x08);
  i2cWrite(0x05, 0x21);
  i2cWrite(0x06, 0x09);
  delay(10);
  i2cWrite(0x01, 0x0c);
  delay(10);

  // コーデック設定
  i2cWrite(0x00, 0x64);
  i2cWrite(0x01, 0x30);
  i2cWrite(0x04, 0x34);
  i2cWrite(0x12, 0x00);
  i2cWrite(0x13, 0x0c);
  i2cWrite(0x14, 0x0c);
  i2cWrite(0x1d, 0x03);

  // BEEP音設定
  i2cWrite(0x16, 0x05);
  i2cWrite(0x17, 0x05);
  i2cWrite(0x18, 0x01);  // 2 times
  i2cWrite(0x19, 0x88);

  // i2sセットアップ
  i2s_config_t i2s_config_dac = {
      .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
      .sample_rate = SampleRate,
      .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
      .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
      .communication_format =
          (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_LSB),
      .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,  // lowest interrupt priority
      .dma_buf_count = DMABufCount,
      .dma_buf_len = DMABufLength,
      .use_apll = true,  // Use audio PLL
      .tx_desc_auto_clear = false,
      .fixed_mclk = SampleRate * 16 * 16,
  };
  i2s_driver_install((i2s_port_t)I2S_NUM_0, &i2s_config_dac, 0, NULL);
  i2s_pin_config_t pins = {.bck_io_num = GPIO_NUM_12,
                           .ws_io_num = GPIO_NUM_13,
                           .data_out_num = GPIO_NUM_15,
                           .data_in_num = I2S_PIN_NO_CHANGE};
  i2s_set_pin((i2s_port_t)I2S_NUM_0, &pins);
  i2s_zero_dma_buffer((i2s_port_t)I2S_NUM_0);
  // マスタークロック出力を有効にする
  PIN_FUNC_SELECT(PERIPHS_IO_MUX_GPIO0_U, FUNC_GPIO0_CLK_OUT1);
  WRITE_PERI_REG(PIN_CTRL, READ_PERI_REG(PIN_CTRL) & 0xFFFFFFF0);
}

MusicIFではAK4954Aはスレーブモードでマスタークロックが必要なデザインで実装されていますので、ESP32側でマスタークロックの出力が必須です。

オーディオデータの出力

上記セットアップが終わればあとは以下の記述でサンプリングデータを書き込み続ければ音がMusicIFのステレオミニジャックから得られます。

size_t wrote;
int16_t data[2] = {int16_t(32767 * total), int16_t(32767 * total)};
i2s_write((i2s_port_t)I2S_NUM_0, data, 4, &wrote, 100);

dataの構造はLSBファーストの符号付き16bit整数を右、左の順番としたデータです。 (I2CもI2Sもセットアップ時にそうなるようにした)

画面表示

エンベロープジェネレータの出力をバーグラフで出力。

for (int i = BeginNote; i < EndNote; i++) {
  int h = int(notes[i].gain * 128);
  M5.Lcd.fillRect(i * 2, 10, 2, 128 - h, TFT_BLACK);
  M5.Lcd.fillRect(i * 2, 138 - h, 2, h, TFT_WHITE);
}

f:id:irieda:20190820190419j:plain

各鍵の立ち上がりや減衰具合が見れて良い感じ。

実際の実装で必要になった調整

  • MIDIメッセージハンドリング、表示、ボタン操作ハンドリングはメインコアで処理、音声出力処理はサブコアで処理するようにマルチタスク機能で分散した。
  • マルチタスク化にあわせて共有メモリをミューテックスで排他処理を入れた。
  • 処理集中によりDMAバッファのアンダーフローはどうしても起こりうるのでその際にバッファのクリア処理をする設定にした。
  • i2s_driverのESP-IDF実装単体ではマスタークロック(MCLK)出力は得られなかった。ESP32の仕様書をめっちゃ読み込まないとレジスタの設定に自力ではたどり着けない。CLK_OUT1が関係しているあたりでググってやっと関連情報にたどり着いた感じ。
  • DMAのアンダーフローを繰り返すと音声出力処理でCPUコアを専有しきってしまう。この状態だとウォッチドッグタイマがリセットをかけてくる。基本はDMAバッファを埋めきって空き待ちが発生する状態を維持しなければならない。
  • 同時発音数を8つに制限すればサンプリング周波数22KHzで駆動できるようになった。

まとめ

  • オーディオ処理の鉄則は出力波形に不連続な波形を出さないこと
  • 波形メモリとエンベロープ、位相による操作これらが連携することで間違って角のある波形を出力することがない
  • 結構いい音だせたし、波形データをいじるだけでもかなり特殊な音色が出せる。
  • M5StackのESP32はfloat型のままでもそこそこ処理早いですね。
  • それでも音合成しながらだとサンプリング周波数16K程度が限界だった<-同時発音数の制限を加えると22KHzまでいけた!
  • ボリュームコントロールも実装した。
  • 自分の声から周期性波形を切り出して入れてみたりしたら猫っぽくなった。
  • 波形合成にビブラート、トレモロ、リバーブなどを加えればもっと複雑な表現の音が出せる。

この拡張「M5Stack用MusicIF拡張ボード」はスイッチサイエンスにて今冬発売に向けて鋭意設計中です。