M5StickCにはマイクがついています。また、M5stickCの拡張端子に接続できるSpeaker Hatがあるので、それを組み合わせれば、何かできそう。
M5StickC Speaker Hat(PAM8303搭載)
https://m5stack.com/products/m5stickc-speaker-hat?_pos=7&_sid=b84fce0ec&_ss=r
ということで、M5StackCとSpeaker Hatを組み合わせて、Web上にいるAI Chatと会話をしてみたいと思います。
全体的な流れは以下の通りです。
音声認識には、Google Cloud Speech APIのSpeech-to-Textを利用しました。
音声合成には、AWSのAmazon Pollyを利用しました。
そして、主題のAI Chatには、ユーザローカル社の人工知能チャットボット(chatbot)を利用しました。
以降は、以下の流れに沿って、補足していきます。
・M5StickCでの録音・再生
・M5StickCからWAVEデータの送信・受信
・中継サーバでのM5StickCからの受信・送信
・中継サーバでの音声認識の呼び出し
・中継サーバでのAI Chatの呼び出し
・中継サーバでの音声合成の呼び出し
作っては見ましたが、マイクの音量は小さめですし、スピーカやマイクの音質はかなり悪かったです。(M5Stackでやったほうが良いかも)
ですが、かなりマイクの音質が悪いにも関わらず、GoogleのSpeech-to-Textはしっかり音声認識してくれるのは驚きました。
ソースコード一式は、GitHubに上げておきました。
Swagger-nodeを使ったサーバです。Arduinoのソースコードも上げています。
poruruba/m5stickc_chat
https://github.com/poruruba/m5stickc_chat
M5StickCでの録音・再生
Arduinoを使います。
実装は、以下の記事を参考にさせていただき、ほぼそのまま使わせていただきました。(ありがとうございます!)
M5StickCのマイクを使ってみる その3 録音再生
https://lang-ship.com/blog/work/m5stickc-mic-3/
これ以上の説明はいらないぐらいシンプルにして記載いただいているので、非常に助かりました。
録音すると、8ビットのサンプリングデータが出力され、8ビットのサンプリングデータを用意すれば、再生できるところまで、関数化してくれています。
8ビットのサンプリングデータの中央値(無音)は、128です。signed charではなく、unsigned charです。
あとで説明する、M5StickCからのWAVEデータの送信・受信では、WiFiを使っていまして、そうすると残メモリ容量が厳しくなってしまいました。
サンプリングレートは、8KHz、録音できる秒数を4秒にしています。
ただ、スピーカの音質はかなり悪く、聞き取れないぐらいでした。(PIN設定が違うのかな???)
M5StickCからWAVEデータの送信・受信
M5StickCからの送受信の通信路には、WiFiを利用し、通信プロトコルはHTTP Postです。
また、WAVEはバイナリファイルなので、Base64にエンコードして送信し、Base64で受信するのでデコードの処理を行います。
MimeTypeは、送信が「application/json」、受信が「text/plain」です。
Densaugeo/base64_arduino
https://github.com/Densaugeo/base64_arduino
ライブラリマネージャから「base64」をインストールします。
この部分のHTTP Post部分のソースコードを抜粋します。
HTTPClienthttp;constchar*host="【中継サーバのエントリポイントのURL】";charhttpBuffer[(STORAGE_LEN+2)/3*4];inthttp_post(uint8_t*p_inout_buffer,intin_length){strcpy(httpBuffer,"{\"message\": \"");encode_base64(p_inout_buffer,in_length,(unsignedchar*)&httpBuffer[strlen(httpBuffer)]);strcat((char*)httpBuffer,"\"}");Serial.println("HTTP Post");http.begin(host);http.addHeader("Content-Type","application/json");intstatus_code=http.POST((uint8_t*)httpBuffer,strlen(httpBuffer));if(status_code!=HTTP_CODE_OK){Serial.println("Status is not 200");http.end();return-1;}intlen=http.getSize();WiFiClient*stream=http.getStreamPtr();intptr=0;while(http.connected()&&(len>0||len==-1)){size_tsize=stream->available();if(size){if(size>(sizeof(httpBuffer)-ptr)){Serial.println("receive overflow");http.end();return-1;}intc=stream->readBytes(&httpBuffer[ptr],size);ptr+=c;if(len>0)len-=c;}delay(1);}httpBuffer[ptr]='\0';http.end();returndecode_base64((unsignedchar*)httpBuffer,p_inout_buffer);}
application/jsonのBody部の生成に、ArduinoJsonを使いたかったのですが、残メモリが限界で、諦めました。受信も、application/jsonにしたかったのですが、同じ理由で、JSON解析不要のtext/plainにしています。
送信時のJSONは、
"{\"message\": \"" + base64化したWAVEデータ + "\"}"
として、決め打ちのJSONにしています。
中継サーバでのM5StickCからの受信・送信
サーバには、Swagger-nodeを使っています。
Swagger定義は以下の通りです。
/speech:post:x-swagger-router-controller:routingoperationId:speechparameters:-in:bodyname:bodyrequired:trueschema:type:objectrequired:-messageproperties:message:type:"string"produces:-text/plainresponses:200:description:Successschema:type:string
あとは、以降で説明する、音声認識、AI Chat、音声合成を提供しているサーバにリクエストを順番に出していきます。
constTextResponse=require(HELPER_BASE+'textresponse');exports.handler=async(event,context,callback)=>{varbody=JSON.parse(event.body);console.log(body);if(!body.message)throw'message is not set';varwav=Buffer.from(body.message,'base64');// 音声の正規化+16ビット化varnorm=normalize_wave8(wav);// 音声認識varret=awaitspeech_recognize(norm);console.log(ret);if(ret.length<1)throw'recognition failed';// AI Chatvarret2=awaitspeech_talk(ret[0]);console.log(ret2);// 音声合成varret3=awaitspeech_to_wave(ret2);console.log(ret3);// 16ビットから8ビットに変換varres=speech_wave16_to_wave8(ret3);console.log(res);returnnewTextResponse("text/plain",res.toString('base64'));// return new TextResponse("text/plain", body.message); // echoback};
ユーティリティです。
classTextResponse{constructor(content_type,context){this.statusCode=200;this.headers={'Access-Control-Allow-Origin':'*','Cache-Control':'no-cache','Content-Type':content_type};if(context)this.set_body(context);elsethis.body="";}set_error(error){this.body=JSON.stringify({"err":error});returnthis;}set_body(content){this.body=content;returnthis;}get_body(){returncontent;}}module.exports=TextResponse;
その前後で、WAVEデータの整形を行っています。
<音声の正規化+16ビット化>
正規化は、音声が大きすぎたり、小さすぎたりしている場合に、適当なレベルに合わせることです。また、M5StickCで録音したWAVEデータは、中央値が0(unsigned 8bitの場合は128)ではなく少しずれているので、それの補正をします。ですが、GoogleのSpeech-to-Textは優れモノなので、特に正規化しなくても大丈夫です。
正規化と一緒に、WAVEデータの16ビット化をしています。こちらが本当に必要な作業です。
M5StickCから送られるWAVEデータは、データ量削減の意味もあって、モノラル1サンプリング8ビット長です。ですが、GoogleのSpeech-to-Textは、モノラル16ビット長を期待しているので、その変換を行う必要があります。
functionnormalize_wave8(wav,out_bitlen=16){varsum=0;varmax=0;varmin=256;for(vari=0;i<wav.length;i++){varval=wav[i];if(val>max)max=val;if(val<min)min=val;sum+=val;}varaverage=sum/wav.length;varamplitude=Math.max(max-average,average-min);if(out_bitlen==8){constnorm=Buffer.alloc(wav.length);for(vari=0;i<wav.length;i++){varvalue=(wav[i]-average)/amplitude*(127*0.8)+128;norm[i]=Math.floor(value);}returnnorm;}else{constnorm=Buffer.alloc(wav.length*2);for(vari=0;i<wav.length;i++){varvalue=(wav[i]-average)/amplitude*(32767*0.8);norm.writeInt16LE(Math.floor(value),i*2);}returnnorm;}}
<16ビットから8ビットに変換>
音声合成であるAWS Amazon Pollyからのレスポンスは、モノラル16ビット(signed)です。M5StickC側が期待するのは、モノラル8ビット(unsigned)です。その変換するための関数が以下です。
functionspeech_wave16_to_wave8(wav){varbuffer=Buffer.alloc(wav.length/2);for(vari=0;i<buffer.length;i++){buffer[i]=Math.floor(wav.readInt16LE(i*2)/256+128);}returnbuffer;}
中継サーバでの音声認識の呼び出し
音声認識には、Google Cloud Speech APIのSpeech-to-Textを使っています。「OK Google」でも有名ですが、機械学習で賢くなっていることを期待して使わせていただいています。
呼び出す前に、準備が必要です。
GCPのコンソールから、「APIとサービス」→「APIライブラリ」を選択します。
そこから、Cloud Speech-to-Text APIを選択し、「有効にする」を押下します。
次に、またGCPのコンソールから、「IAMと管理」→「サービスアカウント」を選択します。
そこで、「サービスアカウントとの作成」を押下し、適当なサービスアカウント名を入力して、最後にキーを作成します。形式はJSONにします。
そうすると、プロジェクト名-XXXXX-XXXXXXXXXXXX.json
のようなファイルが作られます。(本番では、権限を絞った方が良いです)
あとは、そのファイルをSwagger-nodeの適当な場所において、.envにその場所とファイル名を記載しておきます。(環境変数への設定でもよいですが、dotenvが楽なので。。。)
GOOGLE_APPLICATION_CREDENTIALS=【ファイルのパス】
もう一つの準備として、Speech-to-Textの呼び出しのため、Googleが提供しているnpmモジュールをインストールしておきます。
npm insall @google-cloud/speech
これで準備ができました。あとは、以下のように実装します。
constspeech=require('@google-cloud/speech');constclient=newspeech.SpeechClient();asyncfunctionspeech_recognize(wav){constconfig={encoding:'LINEAR16',sampleRateHertz:8192,languageCode:'ja-JP',};constaudio={content:wav.toString('base64')};constrequest={config:config,audio:audio,};returnclient.recognize(request).then(response=>{consttranscription=[];for(vari=0;i<response[0].results.length;i++)transcription.push(response[0].results[i].alternatives[0].transcript);returntranscription;});}
認識結果が複数返ってきますので、それを配列にして返してあげる関数です。(ですが、結局先頭の結果しか使っていませんが)
中継サーバでのAI Chatの呼び出し
AI Chatには、ユーザローカル社の人工知能チャットボット(chatbot)を使わせていただきました。以下から個人開発者向けのボットAPI利用申請をしていないようでしたら、申請しておきます。
人工知能チャットボット(chatbot):ユーザローカル
https://ai.userlocal.jp/document/free/top/
そうすると、メールでAPIキーが払い出されます。
あとは、以下のように呼ぶだけです。
npmモジュールのnode-fetchを使っています。
npm install node-fetch
constfetch=require('node-fetch');constUSERLOCAL_API_KEY='【ユーザローカルのAPIキー】';asyncfunctionspeech_talk(message){varbody={message:message,key:USERLOCAL_API_KEY,};returndo_post('https://chatbot-api.userlocal.jp/api/chat',body).then(json=>{returnjson.result;});}functiondo_post(url,body){returnfetch(url,{method:'POST',body:JSON.stringify(body),headers:{"Content-Type":"application/json; charset=utf-8"}}).then((response)=>{if(!response.ok)throw"status is not 200.";returnresponse.json();});}
中継サーバでの音声合成の呼び出し
音声合成は、AWS Amazon Polly を使っています。
npmモジュールのaws-sdkを使っています。aws configureは実行しておきましょう。(詳細は省略!)
asyncfunctionspeech_to_wave(message){constpollyParams={OutputFormat:'pcm',// 音声フォーマットText:message,VoiceId:'Mizuki',TextType:'text',SampleRate:'8000',};returnnewPromise((resolve,reject)=>{polly.synthesizeSpeech(pollyParams,(err,data)=>{if(err){console.log(err);returnreject(err);}varbuffer=Buffer.from(data.AudioStream);returnresolve(buffer);});});}
声の種類を変えられますが、今回はMizukiさんを選択しています。
出力フォーマットは、mp3とかも選べるのですが、後続処理でサンプリングデータを加工するので、pcmにしておきます。pcmを選択すると、モノラル16ビット(signed)となります。サンプリングレートは、8KHzです。
中継サーバの起動
一応これで、中継サーバの準備は整ったはずです。
GitHubに上がっているファイルの起動方法は以下の通りです。
unzip m5stickc_chat.zip
cd m5stickc_chat
npm install
node app.js
これで、ポート10080で待ち受けているはずです。
M5StickCへの書き込み
Arduinoで書き込みます。
最終的なソースはこんな感じです。
#include <M5StickC.h>
#include <driver/i2s.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <base64.hpp>
HTTPClienthttp;constchar*host="【中継サーバのURL】/speech";constchar*wifi_ssid="【WiFiアクセスポイントのSSID】";constchar*wifi_password="【WiFiアクセスポイントのパスワード】";#define htonl(x) ( ((x)<<24 & 0xFF000000UL) | \
((x)<< 8 & 0x00FF0000UL) | \
((x)>> 8 & 0x0000FF00UL) | \
((x)>>24 & 0x000000FFUL) )
#define PIN_CLK (0) // I2S Clock PIN
#define PIN_DATA (34) // I2S Data PIN
#define SAMPLING_RATE (8192) // サンプリングレート(44100, 22050, 16384, more...)
#define BUFFER_LEN (1024) // バッファサイズ
#define SAMPLEING_SEC (4) // 最大サンプリング時間(秒)
#define STORAGE_LEN (SAMPLING_RATE * SAMPLEING_SEC) // 本体保存容量
#define WAVE_EXPORT (0) // WAVEファイルに出力するか
#define BLANK_LINE " "
uint8_tsoundBuffer[BUFFER_LEN];// DMA転送バッファuint8_tsoundStorage[STORAGE_LEN];// サウンドデータ保存領域charhttpBuffer[(STORAGE_LEN+2)/3*4];boolrecFlag=false;// 録音状態intrecPos=0;// 録音の長さinthttp_post(uint8_t*p_inout_buffer,intin_length);// 再生をするvoidi2sPlay(){// 再生設定i2s_config_ti2s_config={.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX|I2S_MODE_DAC_BUILT_IN),.sample_rate=SAMPLING_RATE,.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT,.channel_format=I2S_CHANNEL_FMT_ONLY_LEFT,.communication_format=I2S_COMM_FORMAT_I2S_MSB,.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1,.dma_buf_count=2,.dma_buf_len=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);i2s_zero_dma_buffer(I2S_NUM_0);// 再生size_ttransBytes;size_tplayPos=0;while(playPos<recPos){for(inti=0;i<BUFFER_LEN;i+=2){soundBuffer[i]=0;// 下位8ビットは無視されるsoundBuffer[i+1]=soundStorage[playPos];// 上位8ビットにuint8_tのデータを入れるplayPos++;}// データ転送i2s_write(I2S_NUM_0,(char*)soundBuffer,BUFFER_LEN,&transBytes,(100/portTICK_RATE_MS));}// 後始末i2s_zero_dma_buffer(I2S_NUM_0);i2s_driver_uninstall(I2S_NUM_0);}// 録音をするvoidi2sRecord(){// 録音用設定i2s_config_ti2s_config={.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_RX|I2S_MODE_PDM),.sample_rate=SAMPLING_RATE,.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT,.channel_format=I2S_CHANNEL_FMT_ALL_RIGHT,.communication_format=I2S_COMM_FORMAT_I2S,.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1,.dma_buf_count=2,.dma_buf_len=BUFFER_LEN,.use_apll=false,.tx_desc_auto_clear=true,.fixed_mclk=0,};// PIN設定i2s_pin_config_tpin_config;pin_config.bck_io_num=I2S_PIN_NO_CHANGE;pin_config.ws_io_num=PIN_CLK;pin_config.data_out_num=I2S_PIN_NO_CHANGE;pin_config.data_in_num=PIN_DATA;// 録音設定実施i2s_driver_install(I2S_NUM_0,&i2s_config,0,NULL);i2s_set_pin(I2S_NUM_0,&pin_config);i2s_set_clk(I2S_NUM_0,SAMPLING_RATE,I2S_BITS_PER_SAMPLE_16BIT,I2S_CHANNEL_MONO);// 録音開始recFlag=true;xTaskCreatePinnedToCore(i2sRecordTask,"i2sRecordTask",2048,NULL,1,NULL,1);}// 録音用タスクvoidi2sRecordTask(void*arg){// 初期化recPos=0;memset(soundStorage,0,sizeof(soundStorage));vTaskDelay(500);//delay(portMAX_DELAY);// LED OndigitalWrite(GPIO_NUM_10,LOW);// 録音処理while(recFlag){size_ttransBytes;// I2Sからデータ取得i2s_read(I2S_NUM_0,(char*)soundBuffer,BUFFER_LEN,&transBytes,(100/portTICK_RATE_MS));// int16_t(12bit精度)をuint8_tに変換for(inti=0;i<transBytes;i+=2){if(recPos<STORAGE_LEN){int16_t*val=(int16_t*)&soundBuffer[i];soundStorage[recPos]=(*val+32768)/256;recPos++;if(recPos>=sizeof(soundStorage)){recFlag=false;break;}}}Serial.printf("transBytes=%d, recPos=%d\n",transBytes,recPos);vTaskDelay(1/portTICK_RATE_MS);}// LED OffdigitalWrite(GPIO_NUM_10,HIGH);i2s_driver_uninstall(I2S_NUM_0);if(recPos>0){intret=http_post(soundStorage,recPos);if(ret>0){recPos=ret;i2sPlay();}}// タスク削除vTaskDelete(NULL);}inthttp_post(uint8_t*p_inout_buffer,intin_length){strcpy(httpBuffer,"{\"message\": \"");encode_base64(p_inout_buffer,in_length,(unsignedchar*)&httpBuffer[strlen(httpBuffer)]);strcat((char*)httpBuffer,"\"}");Serial.println("HTTP Post");http.begin(host);http.addHeader("Content-Type","application/json");intstatus_code=http.POST((uint8_t*)httpBuffer,strlen(httpBuffer));if(status_code!=HTTP_CODE_OK){Serial.println("Status is not 200");http.end();return-1;}intlen=http.getSize();WiFiClient*stream=http.getStreamPtr();intptr=0;while(http.connected()&&(len>0||len==-1)){size_tsize=stream->available();if(size){if(size>(sizeof(httpBuffer)-ptr)){Serial.println("receive overflow");http.end();return-1;}intc=stream->readBytes(&httpBuffer[ptr],size);ptr+=c;if(len>0)len-=c;}delay(1);}httpBuffer[ptr]='\0';http.end();returndecode_base64((unsignedchar*)httpBuffer,p_inout_buffer);}voidwifi_connect(void){Serial.print("WiFi Connenting");WiFi.begin(wifi_ssid,wifi_password);while(WiFi.status()!=WL_CONNECTED){delay(1000);Serial.print(".");}Serial.println("");Serial.print("Connected : ");Serial.println(WiFi.localIP());}voidsetup(){M5.begin();M5.Lcd.setRotation(3);M5.Lcd.fillScreen(BLACK);M5.Lcd.setTextColor(WHITE,BLACK);M5.Lcd.println("[M5StickC]");pinMode(GPIO_NUM_10,OUTPUT);digitalWrite(GPIO_NUM_10,HIGH);i2sPlay();wifi_connect();M5.Lcd.println("Sound Recorder");M5.Lcd.println("BtnA Record");M5.Lcd.println("BtnB Play");}voidloop(){M5.update();if(M5.BtnA.wasPressed()){// 録音スタートM5.Lcd.setCursor(0,36);M5.Lcd.println("REC...");Serial.println("Record Start");i2sRecord();}elseif(M5.BtnA.wasReleased()){// 録音ストップM5.Lcd.setCursor(0,36);M5.Lcd.println(BLANK_LINE);recFlag=false;delay(100);// 録音終了まで待つSerial.println("Record Stop");// WAVEファイルをシリアルに出力if(WAVE_EXPORT){Serial.printf("52494646");// RIFFヘッダSerial.printf("%08lx",htonl(recPos+44-8));// 総データサイズ+44(チャンクサイズ)-8(ヘッダサイズ)Serial.printf("57415645");// WAVEヘッダSerial.printf("666D7420");// フォーマットチャンクSerial.printf("10000000");// フォーマットサイズSerial.printf("0100");// フォーマットコードSerial.printf("0100");// チャンネル数Serial.printf("%08lx",htonl(SAMPLING_RATE));// サンプリングレートSerial.printf("%08lx",htonl(SAMPLING_RATE));// バイト/秒Serial.printf("0100");// ブロック境界Serial.printf("0800");// ビット/サンプルSerial.printf("64617461");// dataチャンクSerial.printf("%08lx",htonl(recPos));// 総データサイズfor(intn=0;n<=recPos;n++){Serial.printf("%02x",soundStorage[n]);}Serial.printf("\n");}}elseif(M5.BtnB.wasReleased()){// 再生スタートM5.Lcd.setCursor(0,36);M5.Lcd.println("Play...");Serial.println("Play Start");i2sPlay();M5.Lcd.setCursor(0,36);M5.Lcd.println(BLANK_LINE);Serial.println("Play Stop");}delay(10);}
以下の部分を環境に合わせて変更してください。
const char *host = "【中継サーバのURL】/speech";
const char* wifi_ssid = "WiFiアクセスポイントのSSID";
const char* wifi_password = "【WiFiアクセスポイントのパスワード】";
M5StickCの使い方
Aボタンを押したまま、チャットしたい言葉を話します。LEDが点灯しますのでわかるかと思います。最大4秒間です。
そうすると勝手に中継サーバに録音データをアップして、チャットの結果が返ってきて、スピーカHATから再生されます。
終わりに
・再生のサンプリングレートがあっていないような気がします。。。早口なんですよねえ。
・再生の品質がわるいです。PIN設定が間違っているのか、8ビット+8KHzの宿命なのか、これがスピーカ性能の限界なのか。。。
・WiFiが不安定です。もともとM5StickCのアンテナの性能は高くないです。
・全体的に不安定だなあ。
以上