LoginSignup
5
4

More than 1 year has passed since last update.

ESP32内蔵DACとスピーカで音階を奏でる

Last updated at Posted at 2022-03-20

1. はじめに

ESP32はDAC(デジタルアナログ変換器)を内蔵しているので、スピーカーなどを接続すれば、任意の音を簡単に出すことができます。今回は、サンプリングデータなどを用いずに、メロディを奏でる方法について述べます。

2. 構成

利用部品は、ESP32、ブレッドボード、ワイヤ、スピーカ です。

スピーカは、次のようなものがこの実験には便利でした。

本来はインピーダンス調整とコンデンサが必要ですが、音を鳴らす実験であれば、上記で良いと思います。

circuit.jpg

3. コード

3.1. 基本処理コード(4度飛ばし)

3.1.1. コード全体

IDEはArudino-IDEで、ボードマネージャarduino-ESP32のバージョンは1.06です。スピーカは25番ピンに接続します。 (追記2022.12/29: バージョン2.0.0以降にもマクロで切り替えて鳴るように修正しました。)

(短く書いたつもりでしたが、あまり短くないと感じるかもしれません。)

esp32_DacI2sOutSample01.ino
#include <driver/i2s.h>

//ref to https://lang-ship.com/blog/work/esp32-i2s-dac/

#define PARAM_SAMPLING_FREQUENCY  (44100)
#define NUM_BUFFER_LEN    (1024)

const uint8_t cui_DC_OFFSET = 127;//fixed value
const float cf_FREQ_ORIGN = 55.0;
uint8_t gmuiBuffer[NUM_BUFFER_LEN];

const uint8_t cuiBPM = 80; //beats per minute

const uint8_t cui_AMPLITUDE = 100;//max 127
const uint8_t cuiNumNote = 12; //number of note
const uint8_t cuiMusOctave = 4; 
uint8_t guiMusLen = 4; //note length

float gmfItn[cuiNumNote]; // inversed cycle of current pitch
uint32_t gmuiTn[cuiNumNote];// cycle of current pitch
float gfMusNotesLen = (float)PARAM_SAMPLING_FREQUENCY*60.0*4 /(float)cuiBPM /(float)guiMusLen+0.5; 

uint32_t guiPosNoteCurrent = 0;//index for Current Note
uint32_t guiXt = 0; //counter for ocsillator
uint32_t guiXn = 0; //counter for note

#ifdef IDF_VER
#define I2SOFFSET 1
#define MY_I2S_COMM_FORMAT I2S_COMM_FORMAT_STAND_MSB
#else
#define I2SOFFSET 3
#define MY_I2S_COMM_FORMAT I2S_COMM_FORMAT_I2S_MSB
#endif

void setup_i2s()
{
  i2s_config_t i2s_config = {
    .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_DAC_BUILT_IN),
    .sample_rate          = PARAM_SAMPLING_FREQUENCY,
    .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format       = I2S_CHANNEL_FMT_RIGHT_LEFT,
    .communication_format = MY_I2S_COMM_FORMAT,
    .intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count        = 2,
    .dma_buf_len          = NUM_BUFFER_LEN,
    .use_apll             = false,
    .tx_desc_auto_clear   = true,
    .fixed_mclk           = 0,
  };

  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, NULL);               // 25, 26
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_BOTH_EN);  // 25, 26
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_RIGHT_EN); // 25
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_LEFT_EN ); // 26
  i2s_zero_dma_buffer(I2S_NUM_0);
}


uint8_t sg_main(){
  int16_t iRes = 0;
  float ftmp = sin(2*PI*gmfItn[guiPosNoteCurrent]*guiXt);
  iRes = (int16_t)(ftmp*cui_AMPLITUDE);
  iRes += cui_DC_OFFSET;

  guiXt++;
  guiXn++;

  // reset counter for current pitch
  if(guiXt >= gmuiTn[guiPosNoteCurrent]){
    guiXt = 0;
  }

  // reset counter for current note
  if(guiXn >= gfMusNotesLen){
    guiXn = 0;
    guiPosNoteCurrent +=1;    
    if(guiPosNoteCurrent >= cuiNumNote){
      guiPosNoteCurrent = 0;
    }
  }

  return (uint8_t)iRes;
}//end sg_main()

void  setup_calc_music_note(){
  for(uint32_t k =0; k < cuiNumNote; k++){
    gmfItn[k] = pow(2.0,(k*2)/12.0+cuiMusOctave)*cf_FREQ_ORIGN/(float)PARAM_SAMPLING_FREQUENCY;
    gmuiTn[k] = (1.0/gmfItn[k]);
  }
}//end setup_calc_music_note()

void setup_zero_clear_buffer(){
  for (uint32_t k = 0; k < NUM_BUFFER_LEN; k++) gmuiBuffer[k] = 0;
}//end setup_zeroClear_buffer()

void setup() {
  Serial.begin(115200);
  setup_i2s();

  setup_calc_music_note();

  setup_zero_clear_buffer();
  
}//end setup()


void loop() {
  size_t transBytes;
  i2s_write(I2S_NUM_0, (char*)gmuiBuffer, NUM_BUFFER_LEN, &transBytes, portMAX_DELAY);

  for (uint32_t k = 0; k < NUM_BUFFER_LEN; k += 4) {
    gmuiBuffer[k + I2SOFFSET] = sg_main();
  } 
}//end loop()

3.1.2. 解説:全体の流れ

まずは、setup関数とloop関数に注目しましょう。

setup関数は、次の3つです。(Serial関連は省略して考えています)

  • i2sの設定
  • 譜面の変換処理
  • バッファのクリア

loop関数は、次の2つです。

  • i2sへの書き込み
  • 1サンプル毎に算出し、バッファを更新

i2sへの書き込みは、バッファが空になると行われ、終わると鳴り始めます。鳴っている間にバッファの更新を行います。全体を通して https://lang-ship.com/blog/work/esp32-i2s-dac/ を参考にしています。

3.1.3. 波形算出部分

今回の肝は、「1サンプル毎に算出」という部分になります。このアイディア自体は、だまこソフトのたつこドライバーのサウンドエンジン t2kSCoreの考え方を借用しています。
今回、バッファは1024個にしています。ただし、2バイトで1サンプル、左右チャネル(という設定)なので、4バイトで1サンプルとなり、256サンプルとなります。44.1kHzで256サンプルは5.8msです。つまり、5.8msの間に音が鳴っており、その間に次の5.8msの音を準備するという処理になります。1サンプル=1/44100秒ですので、1サンプルを22us以内に計算する必要があります。
次の部分が「1サンプル毎に算出」する部分です。

uint8_t sg_main(){
  int16_t iRes = 0;

  float ftmp = sin(2*PI*gmfItn[guiPosNoteCurrent]*guiXt);

  iRes = (int16_t)(ftmp * cui_AMPLITUDE);
  iRes += cui_DC_OFFSET;//波形を0から255にするために127のオフセット

  guiXt++;//発振用カウンタ
  guiXn++;//音長用カウンタ

  // 音階の1周期でカウンタのリセット
  if(guiXt >= gmuiTn[guiPosNoteCurrent]){
    guiXt = 0;
  }

  // 音長分でカウンタのリセット
  if(guiXn >= gfMusNotesLen){
    guiXn = 0;
    guiPosNoteCurrent +=1;//次の音符に移動
    if(guiPosNoteCurrent >= cuiNumNote){
      guiPosNoteCurrent = 0;//最後になったら最初に戻る
    }
  }

  return (uint8_t)iRes;
}//end sg_main()

最も計算量が多いのはsin関数でしょう。sin関数単体で10万回行うと1238msでした(millis()関数利用で計測)。sin関数1回の処理時間は13usと見積もることができます。他の乗算については、FPU(浮動小数点ユニット)が内蔵されていますので、最悪10クロック程度とすれば、240MHzで1回あたり42ns程度と見積もることができます。4回乗算があれば、0.17us程度です。合計14us以内ですので、22usに間に合います。

また、計算を高速化したいので、除算(割り算)は極力排除しました。時刻を$x_t$、音程の周期を$T_n$とすると次の計算になります。
$$ \sin(2 \pi \displaystyle\frac{x_t}{T_n}) $$
予め$T_n$の逆数$I_n$を準備することで、除算が無い式にできます。

$$ \sin(2 \pi I_n x_t) $$

この準備処理については、次に述べます。
なお、除算の処理時間は、1回0.8us程度(240MHzにて)でした。

3.1.4. 音階と音長の計算

setup時に、累乗計算や除算を予め行い、演奏する音階の周期や音長を配列に格納します。こうすることで、先に述べた1サンプル毎計算は乗算と加算だけにすることができます。
その処理部分が下記です。

(一部抜粋)
const float cf_FREQ_ORIGN = 55.0;//基準周波数 低いラの音

const uint8_t cuiBPM = 80; //beats per minute

const uint8_t cuiNumNote = 12; //音符の個数
const uint8_t cuiMusOctave = 4; //基準オクターブ
uint8_t guiMusLen = 4; //音長、ここでは四分音符で固定

float gmfItn[cuiNumNote]; //音階の周期の逆数を格納する配列
uint32_t gmuiTn[cuiNumNote];// 音階の周期を格納する配列
float gfMusNotesLen = (float)PARAM_SAMPLING_FREQUENCY*60.0*4 /(float)cuiBPM /(float)guiMusLen+0.5; //サンプル数換算の音長
  :
  :
  :
void  setup_calc_music_note(){
  for(uint32_t k =0; k < cuiNumNote; k++){
    gmfItn[k] = pow(2.0,(k*2)/12.0+cuiMusOctave)*cf_FREQ_ORIGN/(float)PARAM_SAMPLING_FREQUENCY;
    gmuiTn[k] = (1.0/gmfItn[k]);
  }
}//end setup_calc_music_note()

まず、サンプル数換算の音長について説明します。
この例では四分音符で固定し、guiMusLenに4を格納しています。テンポであるBPM値は、1分間に四分音符が鳴る回数として、80としました。1秒間のサンプル数は、サンプリング周波数$f_s$と同じ値44100です。1分間は、60秒ですので、サンプル数は$60f_s$になります。

$$
L_n = \frac{ 60 \cdot f_s }{ BPM } \cdot \frac{ 4 }{ NOTE }
$$

四分音符であれば、NOTEに4が代入されます。全音符は、NOTEに1が、64分音符は64が代入されます。8分音符の3連符については、12を代入します。

音程の周期$T_n$を予め逆数$I_n$にしておきます。$T_n$はgmuiTn[]、$I_n$はgmfItn[]に格納しています。
ここで、音程の計算ですが、次の式で計算しています。音程の番号$k_n$として、オクターブを$M_o$、基準周波数を$f_o$、音程$f_n$は次のようになります。詳しくは「西洋音階」で検索してみてください。

$$
f_n = 2^{ \frac{k_n}{12} }\cdot 2^{M_o} \cdot f_o
$$

基準周波数は、ラの音として440Hzが代表的ですが、今回は3オクターブ下のラの音である55Hzを$f_o$としています。そして、音程の番号$k_n$は、ラの音を0(ゼロ)として、半音ずつ加算し、1オクターブ上のラが12となるように番号付けします。

音程の周期$T_n$は、出力サンプリング周波数を$f_s$とすると、次のようになります。

$$
T_n = \frac{f_s}{f_n}
$$

$I_n$はこの逆数なので次のようになります。

$$
I_n = \frac{1}{T_n} = \frac{f_n}{f_s}
$$

時刻カウンタ$x_t$は、0から$T_n$まで加算します。時刻$x_t$における位相$\phi_t$は次になります。
$$
\phi_t = 2\pi I_n x_t
$$
この式における、$I_n x_t$の値域は、0から1までです。この値はsinの計算で使えるだけでなく、ノコギリ波、三角波や矩形波などの計算に使えます

3.2. 拡張版(任意メロディと音色変更)

さすがにMMLまでは実装できてませんが、配列に数値を記述することで、任意のメロディを演奏できる形にしたものです。単純なエンベロープや、ノコギリ波や三角波、テーブル定義波形なども実装してみました。

3.2.1 拡張版のコード全体

esp32_DacI2sOutSample02.ino
#include <driver/i2s.h>

#define PARAM_SAMPLING_FREQUENCY  (44100)
#define NUM_BUFFER_LEN    (1024)
#define PIN_DACOUT (25)

uint8_t gmuiBuffer[NUM_BUFFER_LEN];

const uint8_t cui_DC_OFFSET = 127;//fixed value
const float cf_FREQ_ORIGN = 55.0;

const uint8_t cuiBPM = 80; //beats per minute

const uint8_t cui_AMPLITUDE = 100;//max 127
const uint8_t cuiNumNote = 14; //number of note
const uint8_t cmuiMusLen[]={4,4,4,4,4,4,2,  4,4,4,4,4,4,2 };
const uint8_t cmuiMusNote[]={3, 3, 10 , 10 , 12 ,12, 10    ,8, 8, 7,7,5,5,3};
//   0 1  2 3 4  5 6  7 8 9  10 11 12  13  14
//   a a# b c c# d d# e f f# g  g#  a  a#  b
const uint8_t cuiMusOctave = 3; 

//const uint8_t cuiToneNumber = 0; //0:sin, 1:saw, 2:triangle, 3:rectangle8, 4:table
#define TONE_NUMBER (2) //0:sin, 1:saw, 2:triangle, 3:rectangle8, 4:table

#define NUM_LENGTH_WAVE_TABLE (50)
const float  cf_PARAM_TABLE_ORIGIN_FREQ =(880.0);
const float  cf_PARAM_TABLE_INDEX_COEF = ((float)PARAM_SAMPLING_FREQUENCY/880.0);
//const char cmcTableWave[] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01";
const char cmcTableWave[] =   "89ABCCCCCCDDDDDDEEDCBA9876543211112222333333334567";
const uint8_t cui_VAL_WAVE_TABLE_HIGHT = (16);
const uint8_t cui_VAL_WAVE_TABLE_DC = (cui_VAL_WAVE_TABLE_HIGHT>>1);
const float cf_VAL_WAVE_TABLE_HIGHT_INV = 1/(float)cui_VAL_WAVE_TABLE_HIGHT;
uint8_t gmuiTableWave[NUM_LENGTH_WAVE_TABLE];

const float cf_ENVELOPE_DECAY_INV = ((1/(float)PARAM_SAMPLING_FREQUENCY/2));

float gmfItn[cuiNumNote]; // inversed cycle of current pitch
uint32_t gmuiTn[cuiNumNote];//cycle of current pitch
//float gfMusNotesLen = (float)PARAM_SAMPLING_FREQUENCY*60.0*4 /(float)cuiBPM /(float)guiMusLen+0.5; 
float gmfMusNotesLen[cuiNumNote];

uint32_t guiPosNoteCurrent = 0;//index for Current Note
uint32_t guiXt = 0; //counter for ocsillator
uint32_t guiXn = 0; //counter for note
uint32_t guiXe = 0; //counter for envelope


void setup_i2s()
{
  i2s_config_t i2s_config = {
    .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_DAC_BUILT_IN),
    .sample_rate          = PARAM_SAMPLING_FREQUENCY,
    .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format       = I2S_CHANNEL_FMT_RIGHT_LEFT,
    .communication_format = I2S_COMM_FORMAT_I2S_MSB,
    .intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count        = 2,
    .dma_buf_len          = NUM_BUFFER_LEN,
    .use_apll             = false,
    .tx_desc_auto_clear   = true,
    .fixed_mclk           = 0,
  };

  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, NULL);               // 25, 26
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_BOTH_EN);  // 25, 26
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_RIGHT_EN); // 25
  //i2s_set_dac_mode(I2S_DAC_CHANNEL_LEFT_EN ); // 26
  i2s_zero_dma_buffer(I2S_NUM_0);
}//end setup_i2s()

float sg_envelope(){
  float ftmp = 1.0;
  ftmp -= cf_ENVELOPE_DECAY_INV*guiXe;
  ftmp *= (float)(ftmp>0);
  return ftmp;
}

int16_t sg_sin(){
  float ftmp = sin(2*PI*gmfItn[guiPosNoteCurrent]*(float)guiXt);
  int16_t iRes = (int16_t)((float)cui_AMPLITUDE * ftmp * sg_envelope() );
  return iRes;
}//end sg_sin()

int16_t sg_saw(){
  int16_t iRes=0;
  float ftmp =  (gmfItn[guiPosNoteCurrent]*(float)guiXt) ;
  iRes = (int16_t)((float)cui_AMPLITUDE * (2 * ftmp -1) * sg_envelope()); 
  return iRes;
}//end sg_saw()


int16_t sg_triangle(){
  int16_t iRes=0;
  float ftmp = 0;
  if( guiXt > (gmuiTn[guiPosNoteCurrent]>>1) ){
    ftmp =  2-(gmfItn[guiPosNoteCurrent]*2 * (float)guiXt );
  }
  else{
    ftmp =  (gmfItn[guiPosNoteCurrent]*2 * (float)guiXt);
  }
  iRes = (int16_t)((float)cui_AMPLITUDE * (2 * ftmp -1)* sg_envelope()); 
  return iRes;
}//end sg_triangle()

int16_t sg_rectangle8(){
  int16_t iRes=0;
  float ftmp = 0;
  if( guiXt < (gmuiTn[guiPosNoteCurrent]>>3) ){
    ftmp =  -1;
  }
  if( guiXt < (gmuiTn[guiPosNoteCurrent]>>4) ){
    ftmp =  1;
  }
  iRes = (int16_t)((float)cui_AMPLITUDE * 1 * ftmp * sg_envelope() ); 
  return iRes;
}//end sg_rectangle8()


int16_t sg_waveTable(){
  int16_t iRes=0;
  float ftmp = 0;
  uint32_t uiInx = (uint32_t)((float)guiXt *gmfItn[guiPosNoteCurrent]*cf_PARAM_TABLE_INDEX_COEF);
  ftmp = (float)((int32_t)gmuiTableWave[uiInx] - (int32_t)cui_VAL_WAVE_TABLE_DC)* cf_VAL_WAVE_TABLE_HIGHT_INV ;
  iRes = (int16_t)((float)cui_AMPLITUDE * 1 * ftmp * sg_envelope() ); 
  return iRes;
}//end sg_waveTable()


uint8_t sg_main(){
  int16_t iRes = 0;

  switch (TONE_NUMBER) {
    case 1:
      iRes = sg_saw();
      break;
    case 2:
      iRes = sg_triangle();
      break;
    case 3:
      iRes = sg_rectangle8();
      break;
    case 4:
      iRes = sg_waveTable();
      break;
    default:
      iRes = sg_sin();
      break;
  }
  //iRes = sg_sin();
  //iRes = sg_saw();
  //iRes = sg_triangle();
  //iRes = sg_rectangle8();
  //iRes = sg_waveTable();

  iRes += cui_DC_OFFSET;

  guiXt++;
  guiXn++;
  guiXe++;

  // reset counter for current pitch
  if( guiXt >= gmuiTn[guiPosNoteCurrent]){
    guiXt = 0;
  }

  // reset counter for current note
  if( guiXn >= gmfMusNotesLen[guiPosNoteCurrent]){
    guiXn = 0;//for note
    guiXe = 0;//for envelope
    guiPosNoteCurrent +=1;    
    if(guiPosNoteCurrent >= cuiNumNote){
      guiPosNoteCurrent = 0; //loop music
    }//end if
  }//end if

  return (uint8_t)iRes;
}//end sg_main()


#define VAL_SECONDS_IN_MINUTE (60.0)
#define VAL_BASE_BEAT_NOTE_LENGTH (4.0)
#define VAL_ROUNDUP_NUMBER (0.5)
#define VAL_OCTAVE_DIVIDE_NUMBER (12.0)

void  setup_calc_music_note(){
  for(uint32_t k =0; k < cuiNumNote; k++){
    gmfMusNotesLen[k] = (float)PARAM_SAMPLING_FREQUENCY*VAL_SECONDS_IN_MINUTE*VAL_BASE_BEAT_NOTE_LENGTH /(float)cuiBPM /(float)cmuiMusLen[k]+VAL_ROUNDUP_NUMBER;
    gmfItn[k] = pow(2 ,((float)cmuiMusNote[k])/VAL_OCTAVE_DIVIDE_NUMBER+(float)cuiMusOctave) * cf_FREQ_ORIGN/(float)PARAM_SAMPLING_FREQUENCY;
    gmuiTn[k] = (1.0/gmfItn[k]);//floor
  }
}//end setup_calc_music_note()

int hex2int(char y){
  uint8_t x = (uint8_t)y;
  if( x>=48 && x<=57) return ( x-48);//0-9
  if( x>=65 && x<=70) return ( x-55);//A-F
  if( x>=97 && x<=102) return ( x-87);//a-f
  return (0);
}

void setup_table_wave(){
  for(uint32_t k =0; k < NUM_LENGTH_WAVE_TABLE; k++){
    gmuiTableWave[k] = hex2int(cmcTableWave[k]);
    //Serial.println(gmuiTableWave[k]);
  }
}//end setup_table_wave()

void setup_zero_clear_buffer(){
  for (uint32_t k = 0; k < NUM_BUFFER_LEN; k++) gmuiBuffer[k] = 0;
}//end setup_zeroClear_buffer()

void setup() {
  Serial.begin(115200);
  setup_i2s();

  setup_calc_music_note();
  setup_table_wave();

  setup_zero_clear_buffer();
  
}//end setup()


void loop() {
  size_t transBytes;
  i2s_write(I2S_NUM_0, (char*)gmuiBuffer, NUM_BUFFER_LEN, &transBytes, portMAX_DELAY);

  for (uint32_t k = 0; k < NUM_BUFFER_LEN; k += 4) {
    gmuiBuffer[k + 3] = sg_main();
  } 
}//end loop()


3.2.2. 拡張版の全体の流れ

全体は、基本版と同じです。波形を選択できるのが大きく異なります。
次の値で変更します。
#define TONE_NUMBER (2)
0:サイン波、1:ノコギリ波、2:三角波、3:矩形波(1:7)、4:テーブル定義波形

音長は、cmuiMusLen[]に記載します。四分音符は4です。付点やタイやスラーは対応してません。
音階は、cmuiMusNote[]に記載します。音階を数値で記述します。

MML音階標記 A A# B C C# D D# E F F# G G# A
入力する数値 0 1 2 3 4 5 6 7 8 9 10 11 12

cuiMusOctaveに音階数値0のオクターブを記述します。例えば、4は0(=下のラ)が880Hzです。3か4が丁度良いと思います。

音符の数をcuiNumNoteに入れて下さい。

例えば、きらきら星の冒頭は次のように入力します。

cuiNumNote = 14; 
cmuiMusLen[]={4,4,4,4,4,4,2,  4,4,4,4,4,4,2 };
cmuiMusNote[]={3, 3, 10 , 10 , 12 ,12, 10    ,8, 8, 7,7,5,5,3};
cuiMusOctave = 3; 

音量はcui_AMPLITUDEに記述します。127が最大ですが、100ぐらいで良いと思います。

3.2.3. ノコギリ波生成部の説明

ノコギリ波とは、こんな波形です。
/|/|/|/|
1周期ずっと上がり調子で終わるとゼロに戻ります。この計算は、sin波の位相計算で用いた$I_n x_t$そのものです。$I_n$はgmfItn[guiPosNoteCurrent] です。$x_t$はguiXtです。この関数の計算結果は、符号付き16bit整数ですが、$I_n$は1未満の数値ですので、作り易さからfloatを使っています。最後に2倍のcui_AMPLITUDEを乗じて、cui_AMPLITUDEを減じて、交流信号として出力します。
なお、エンベロープ値をかけ算していますが、これは後ほど説明します。

int16_t sg_saw(){
  int16_t iRes=0;
  float ftmp =  (gmfItn[guiPosNoteCurrent]*(float)guiXt) ;
  iRes = (int16_t)((float)cui_AMPLITUDE * (2 * ftmp - 1) * sg_envelope()); 
  return iRes;
}//end sg_saw()

3.2.4. 三角波生成部の説明

三角波とはこんな波形です。
/\/\/\/\
1周期の半分までは上りでその後下ります。ノコギリ波の傾斜を2倍にして、半分以降は下りにすればよいことが判ります。
1周期の半分はgmuiTn[n] >> 1と表現しています。格納されているのは整数変数なので、わざわざ0.5の乗算や2での割り算を行うよりも、左への1ビットシフトで表現した方が高速です。

int16_t sg_triangle(){
  int16_t iRes=0;
  float ftmp = 0;
  if( guiXt > (gmuiTn[guiPosNoteCurrent]>>1) ){
    ftmp =  2-(gmfItn[guiPosNoteCurrent]*2 * (float)guiXt );
  }
  else{
    ftmp =  (gmfItn[guiPosNoteCurrent]*2 * (float)guiXt);
  }
  iRes = (int16_t)((float)cui_AMPLITUDE * (2 * ftmp -1)* sg_envelope()); 
  return iRes;
}//end sg_triangle()

わかりやすさの観点から、if文を使っていますが、論理式でも良いでしょう。

int16_t sg_triangle(){
  int16_t iRes=0;
  ftmp = (guiXt > (gmuiTn[guiPosNoteCurrent]>>1) )*
     (2-(gmfItn[guiPosNoteCurrent]*2 * (float)guiXt ));
  ftmp += (guiXt <= (gmuiTn[guiPosNoteCurrent]>>1) )*
     (gmfItn[guiPosNoteCurrent]*2 * (float)guiXt );

if文はコンパイルするとジャンプ命令になっている事が多いです。
ジャンプ命令は、処理時間を消費しやすいので、論理式の方が高速な場合があります。

3.2.5. 矩形波生成部の説明

矩形波とはこんな感じです。
||___||___||___

1と0の区間の時間比率をデューティ比と呼びますが、今回は1:7にしています。ファミコンの矩形波設定をまねしています。 (この音にすると、木琴みたいな音になるはずと思ったのですが、実際はなりませんでした。)
処理は次のようにしており、+1,-1,0,0,0.....となるような波形にしました。この処理も論理式でも良いのですが、1:7の波形で、1周期中で0の区間が多いため、if文でも合計としてはあまり変わらないかもしれません。

int16_t sg_rectangle8(){
  int16_t iRes=0;
  float ftmp = 0;
  if( guiXt < (gmuiTn[guiPosNoteCurrent]>>3) ){
    ftmp =  -1;
  }
  if( guiXt < (gmuiTn[guiPosNoteCurrent]>>4) ){
    ftmp =  1;
  }
  iRes = (int16_t)((float)cui_AMPLITUDE * 1 * ftmp * sg_envelope() ); 
  return iRes;
}//end sg_rectangle8()

3.2.6. テーブル定義波生成部の説明

ここでのテーブル定義波形とは、1周期の波形を任意の形にするものです。msxplay.com のSCCテーブル定義をイメージしています。SCCは64個ですが、こちらは50個にしています。880Hzの1周期が44.1kHzで50.1サンプルなので、それに合わせました。

該当部分のリストはこちらです。


#define NUM_LENGTH_WAVE_TABLE (50)
const float  cf_PARAM_TABLE_ORIGIN_FREQ =(880.0);
const float  cf_PARAM_TABLE_INDEX_COEF = ((float)PARAM_SAMPLING_FREQUENCY/880.0);
const char cmcTableWave[] =   "89ABCCCCCCDDDDDDEEDCBA9876543211112222333333334567";
const uint8_t cui_VAL_WAVE_TABLE_HIGHT = (16);
const uint8_t cui_VAL_WAVE_TABLE_DC = (cui_VAL_WAVE_TABLE_HIGHT>>1);
const float cf_VAL_WAVE_TABLE_HIGHT_INV = 1/(float)cui_VAL_WAVE_TABLE_HIGHT;
uint8_t gmuiTableWave[NUM_LENGTH_WAVE_TABLE];


void setup_table_wave(){
  for(uint32_t k =0; k < NUM_LENGTH_WAVE_TABLE; k++){
    gmuiTableWave[k] = hex2int(cmcTableWave[k]);
  }
}//end setup_table_wave()


int16_t sg_waveTable(){
  int16_t iRes=0;
  float ftmp = 0;
  uint32_t uiInx = (uint32_t)((float)guiXt *gmfItn[guiPosNoteCurrent]*cf_PARAM_TABLE_INDEX_COEF);
  ftmp = (float)((int32_t)gmuiTableWave[uiInx] - (int32_t)cui_VAL_WAVE_TABLE_DC)* cf_VAL_WAVE_TABLE_HIGHT_INV ;
  iRes = (int16_t)((float)cui_AMPLITUDE * 1 * ftmp * sg_envelope() ); 
  return iRes;
}//end sg_waveTable()

テーブルは、cmcTableWave[]に十六進で50個格納します。時間は左から右に移動します。ここが msxplay.com と同じ形です。 テーブルの読み込みは、setup_table_wave()で行います。gmuiTableWave[]に、整数値で格納します。
テーブル処理は、sg_waveTable()で行います。補間処理は入れずに、常にテーブルの値を参照する形にしています。インデックス値は、周期をfloatで50に正規化し切り捨てで整数値にしています。880Hzを基準にしているので、低い周波数では高調波が出ると思います。

3.2.7. エンベロープ生成部の説明

エンベロープのカウンタを$x_e$と別に設けていますが、音符のカウンタと同じ動きをします。今回はディケイだけを実装しました。本来は、アタック、リリースなどが必要ですが、そのあたりの表現データが必要になるため、実装していません。

エンベロープは、float値にし、1.0を最大にしています。0.5秒で0になるような値をcf_ENVELOPE_DECAY_INVに代入しています。

const float cf_ENVELOPE_DECAY_INV = ((1/(float)PARAM_SAMPLING_FREQUENCY/2));

float sg_envelope(){
  float ftmp = 1.0;
  ftmp -= cf_ENVELOPE_DECAY_INV*guiXe;
  ftmp *= (float)(ftmp>0);
  return ftmp;
}

4. 実験

色々と部品が映っていますが実際は使っておらず、ESP32にダイナミックスピーカを直結しています。
波形は三角波を選択しています。

波形を確認したい場合は、各波形計算関数のreturnの直前にSerial.printを挿入し、Arduino-IDEのシリアルプロッタで表示すると判り易いです。(ただし、print処理が重いため、音はブチブチとしか鳴らなくなります)
また、波形出力を確認したい場合、オシロスコープがあれば判り易いですが、無い場合は、もう一台ESP32を用意し、ADC取得して、シリアルプロッタで確認すると良いでしょう。

5. おわりに

ESP32にI${}^2$Sが使えるDACが内蔵されており色々と使えます。音声データに取得することで、音声を出力することはできます。しかし、自身で音を作り出す仕掛けを実装しても良いだろうと思いました。多くの方々は実施しているのですが、私なりのコードで記述し、文書化してみました。
なお、私のコーディングルールは、変数名の単語間は大文字区切りで冒頭に型を付けるようにしています。

A. 参考サイト

参考にしたのは次です。大変感謝です。
https://lang-ship.com/blog/work/esp32-i2s-dac/
http://salami.sblo.jp/
https://another.maple4ever.net/archives/2466/

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4