ATOM Echoには、マイクとスピーカとボタンとLEDが付いています。
ATOM Echoのマイクにしゃべった言葉がLINEメッセージとして通知されるようにするとともに、LINEアプリから応答メッセージを入力したら、ATOM EchoのLEDが点灯し、さらにボタンを押したら応答メッセージが音声でATOM Echoのスピーカから流れるようにします。
いくつかのサービスを使っています
・録音したWAVEファイルを、Google Cloud Speech APIの音声認識サービスを使ってテキスト文字に起こします。
・LINEボットの機能を使って、メッセージをLINEアプリに通知したり、LINEアプリに入力したメッセージを受信したりします。
・受信したテキストメッセージを、Amazon Pollyの音声合成サービスを使って音声ファイルにします。
・ATOM Echoで、メッセージ通知を検知するために、MQTTでサブスクライブします。
ソースコードもろもろは、以下のGitHubに上げておきました。
poruruba/LinebotCarrier
https://github.com/poruruba/LinebotCarrier
ATOM Echo側
以下のライブラリを利用しています。
m5stack/M5StickC
https://github.com/m5stack/M5StickC
ボタンの検出に使っています。
knolleary/PubSubClient
https://github.com/knolleary/pubsubclient
MQTTサブスクライブに使っています。
bblanchon/ArduinoJson
https://github.com/bblanchon/ArduinoJson
MQTTサブスクライブで受信するJSONのパースに使っています。
adafruit/Adafruit_NeoPixel
https://github.com/adafruit/Adafruit_NeoPixel
RGBのLED制御に使っています。
録音は、以下を参考にしました。
M5StickCとSpeaker HatでAI Chatと会話
m5stack/M5-ProductExampleCodes
こんな感じです。
// 録音用タスクvoidi2sRecordTask(void*arg){// 初期化recPos=0;memset(soundStorage,0,sizeof(soundStorage));vTaskDelay(100);// 録音処理while(isRecording){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)){isRecording=false;break;}}}// Serial.printf("transBytes=%d, recPos=%d\n", transBytes, recPos);vTaskDelay(1/portTICK_RATE_MS);}i2s_driver_uninstall(I2S_NUM_0);pixels.setPixelColor(0,pixels.Color(0,0,0));pixels.show();if(recPos>0){unsignedlonglen=sizeof(temp_buffer);intret=doHttpPostFile((base_url+"/linebot-carrier-wav2text").c_str(),soundStorage,recPos,"application/octet-stream","upfile","test.bin",NULL,NULL,temp_buffer,&len);if(ret!=0){Serial.println("/linebot-carrier-wav2text: Error");}else{Serial.println((char*)temp_buffer);}}// タスク削除vTaskDelete(NULL);}voidi2sRecord(){isRecording=true;pixels.setPixelColor(0,pixels.Color(0,100,0));pixels.show();i2s_driver_uninstall(I2S_NUM_0);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,// is fixed at 12bit, stereo, MSB.channel_format=I2S_CHANNEL_FMT_ALL_RIGHT,.communication_format=I2S_COMM_FORMAT_I2S,.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1,.dma_buf_count=6,.dma_buf_len=60,};esp_err_terr=ESP_OK;err+=i2s_driver_install(I2S_NUM_0,&i2s_config,0,NULL);i2s_pin_config_ttx_pin_config;tx_pin_config.bck_io_num=I2S_BCLK;tx_pin_config.ws_io_num=I2S_LRC;tx_pin_config.data_out_num=I2S_DOUT;tx_pin_config.data_in_num=I2S_DIN;//Serial.println("Init i2s_set_pin");err+=i2s_set_pin(I2S_NUM_0,&tx_pin_config);//Serial.println("Init i2s_set_clk");err+=i2s_set_clk(I2S_NUM_0,SAMPLING_RATE,I2S_BITS_PER_SAMPLE_16BIT,I2S_CHANNEL_MONO);// 録音開始xTaskCreatePinnedToCore(i2sRecordTask,"i2sRecordTask",4096,NULL,1,NULL,1);}
MP3の再生は、以下を利用させていただきました。
schreibfaul1/ESP32-audioI2S
https://github.com/schreibfaul1/ESP32-audioI2S
PlatformIOを利用している場合は、zipファイルの中身をlibフォルダに突っ込めばコンパイルに含めてくれます。
ポート番号は、ATOM Echoの配線に合わせています。
ただし、うまく動かないところがあり、いくつか修正しています。(この直し方でよいのか自信がないですが。。。)
GitHubには、修正したファイルだけ上げてあります。
録音と再生を切り替えられるように、Audioのコンストラクターから再セットアップ用の関数Audio:setup()に分離しました。
変更前
Audio::Audio(){clientsecure.setInsecure();// if that can't be resolved update to ESP32 Arduino version 1.0.5-rc05 or higher//i2s configurationm_i2s_num=I2S_NUM_0;// i2s port numberm_i2s_config.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX);m_i2s_config.sample_rate=16000;m_i2s_config.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT;m_i2s_config.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT;m_i2s_config.communication_format=(i2s_comm_format_t)(I2S_COMM_FORMAT_I2S|I2S_COMM_FORMAT_I2S_MSB);m_i2s_config.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1;// high interrupt prioritym_i2s_config.dma_buf_count=8;// max buffersm_i2s_config.dma_buf_len=1024;// max valuem_i2s_config.use_apll=APLL_ENABLE;m_i2s_config.tx_desc_auto_clear=true;// new in V1.0.1m_i2s_config.fixed_mclk=I2S_PIN_NO_CHANGE;i2s_driver_install((i2s_port_t)m_i2s_num,&m_i2s_config,0,NULL);m_f_forceMono=false;m_filter[LEFTCHANNEL].a0=1;m_filter[LEFTCHANNEL].a1=0;m_filter[LEFTCHANNEL].a2=0;m_filter[LEFTCHANNEL].b1=0;m_filter[LEFTCHANNEL].b2=0;m_filter[RIGHTCHANNEL].a0=1;m_filter[RIGHTCHANNEL].a1=0;m_filter[RIGHTCHANNEL].a2=0;m_filter[RIGHTCHANNEL].b1=0;m_filter[RIGHTCHANNEL].b2=0;}
変更後
Audio::Audio(){clientsecure.setInsecure();// if that can't be resolved update to ESP32 Arduino version 1.0.5-rc05 or higher}voidAudio::setup(){i2s_driver_uninstall(I2S_NUM_0);//i2s configurationm_i2s_num=I2S_NUM_0;// i2s port numberm_i2s_config.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX);m_i2s_config.sample_rate=16000;m_i2s_config.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT;m_i2s_config.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT;m_i2s_config.communication_format=(i2s_comm_format_t)(I2S_COMM_FORMAT_I2S|I2S_COMM_FORMAT_I2S_MSB);m_i2s_config.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1;// high interrupt prioritym_i2s_config.dma_buf_count=8;// max buffersm_i2s_config.dma_buf_len=1024;// max valuem_i2s_config.use_apll=APLL_ENABLE;m_i2s_config.tx_desc_auto_clear=true;// new in V1.0.1m_i2s_config.fixed_mclk=I2S_PIN_NO_CHANGE;i2s_driver_install((i2s_port_t)m_i2s_num,&m_i2s_config,0,NULL);m_f_forceMono=false;m_filter[LEFTCHANNEL].a0=1;m_filter[LEFTCHANNEL].a1=0;m_filter[LEFTCHANNEL].a2=0;m_filter[LEFTCHANNEL].b1=0;m_filter[LEFTCHANNEL].b2=0;m_filter[RIGHTCHANNEL].a0=1;m_filter[RIGHTCHANNEL].a1=0;m_filter[RIGHTCHANNEL].a2=0;m_filter[RIGHTCHANNEL].b1=0;m_filter[RIGHTCHANNEL].b2=0;}
MP3ファイルが小さすぎると、再生されませんでしたので、ファイルサイズの下限を下げました。
変更前
if((InBuff.bufferFilled()>6000&&!m_f_psram)||(InBuff.bufferFilled()>80000&&m_f_psram)){
変更後
if((InBuff.bufferFilled()>1500&&!m_f_psram)||(InBuff.bufferFilled()>80000&&m_f_psram)){
なぜか、MP3の最後あたりの音が切れてしまいましたので、ウェイトを入れました。
変更前
if(m_f_webfile&&(byteCounter>=m_contentlength-10)&&(InBuff.bufferFilled()<maxFrameSize)){// it is stream from fileserver with known content-length? and// everything is received? and// the buff is almost empty?, issue #66 then comes to an endplayI2Sremains();stopSong();// Correct close when play known length sound #74 and before callback #112sprintf(chbuf,"End of webstream: \"%s\"",m_lastHost);if(audio_info)audio_info(chbuf);if(audio_eof_stream)audio_eof_stream(m_lastHost);}
変更後
if(m_f_webfile&&(byteCounter>=m_contentlength-10)&&(InBuff.bufferFilled()<maxFrameSize)){// it is stream from fileserver with known content-length? and// everything is received? and// the buff is almost empty?, issue #66 then comes to an endwhile(!playI2Sremains()){;}delay(500);stopSong();// Correct close when play known length sound #74 and before callback #112sprintf(chbuf,"End of webstream: \"%s\"",m_lastHost);if(audio_info)audio_info(chbuf);if(audio_eof_stream)audio_eof_stream(m_lastHost);}
WAVEファイルのアップロードは以下を参考にしました。
ESP32でバイナリファイルのダウンロード・アップロード
MQTTサブスクライブは以下を参考にしました。
ESP32で作るBeebotteダッシュボード
Node.js側
ざっと、以下のnpmモジュールを使っています。
・@line/bot-sdk
・google-cloud/speech
・mqtt
・fs
・aws-sdk
constline=require('@line/bot-sdk');constspeech=require('@google-cloud/speech');constclient=newspeech.SpeechClient();constmqtt=require('mqtt');constfs=require('fs');constAWS=require('aws-sdk');constpolly=newAWS.Polly({apiVersion:'2016-06-10',region:'ap-northeast-1'});
以下の2つのエンドポイントを立ち上げています。
・/linebot-carrier
LINEボットのWebhookであり、LINEメッセージを受信すると呼び出されます。
・/linebot-carrier-wav2text
ATOM Echoからの、WAVファイルのアップロードを受け付けます。
説明が面倒なので、ソースをそのまま載せています。すみません。
'use strict';constconfig={channelAccessToken:'【LINEチャネルアクセストークン(長期)】',channelSecret:'【LINEチャネルシークレット】',};constHELPER_BASE=process.env.HELPER_BASE||'../../helpers/';constResponse=require(HELPER_BASE+'response');varline_usr_id='【LINEユーザID】';constLineUtils=require(HELPER_BASE+'line-utils');constline=require('@line/bot-sdk');constapp=newLineUtils(line,config);constspeech=require('@google-cloud/speech');constclient=newspeech.SpeechClient();constmqtt=require('mqtt');constMQTT_HOST=process.env.MQTT_HOST||'【MQTTサーバのURL(例:mqtt://hostname:1883)】';constMQTT_CLIENT_ID='linebot-carrier';constMQTT_TOPIC_TO_ATOM='linebot_to_atom';constTHIS_BASE_PATH=process.env.THIS_BASE_PATH;constMESSAGE_MP3_FNAME=THIS_BASE_PATH+'/public/message.mp3';constfs=require('fs');constAWS=require('aws-sdk');constpolly=newAWS.Polly({apiVersion:'2016-06-10',region:'ap-northeast-1'});constmqtt_client=mqtt.connect(MQTT_HOST,{clientId:MQTT_CLIENT_ID});mqtt_client.on('connect',()=>{console.log("mqtt connected");});app.follow(async(event,client)=>{console.log("app.follow: "+event.source.userId);// line_usr_id = event.source.userId;});app.message(async(event,client)=>{console.log("linebot: app.message");varbuffer=awaitspeech_to_wave(event.message.text);fs.writeFileSync(MESSAGE_MP3_FNAME,buffer);varjson={message:event.message.text};mqtt_client.publish(MQTT_TOPIC_TO_ATOM,JSON.stringify(json));varmessage={type:'text',text:'$',emojis:[{index:0,productId:"5ac1de17040ab15980c9b438",emojiId:120}]};returnclient.replyMessage(event.replyToken,message);});exports.fulfillment=app.lambda();exports.handler=async(event,context,callback)=>{if(event.path=='/linebot-carrier-wav2text'){// console.log(new Uint8Array(event.files['upfile'][0].buffer));varnorm=normalize_wave8(newUint8Array(event.files['upfile'][0].buffer));// 音声認識varresult=awaitspeech_recognize(norm);if(result.length<1)throw'recognition failed';vartext=result[0];console.log(text);app.client.pushMessage(line_usr_id,app.createSimpleResponse(text));returnnewResponse({message:text});}};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);/*
console.log('sum=' + sum);
console.log('avg=' + average);
console.log('amp=' + amplitude);
console.log('max=' + max);
console.log('min=' + 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;}}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;});}asyncfunctionspeech_to_wave(message,voiceid='Mizuki',samplerate=16000){constpollyParams={OutputFormat:'mp3',Text:message,VoiceId:voiceid,TextType:'text',SampleRate:String(samplerate),};returnnewPromise((resolve,reject)=>{polly.synthesizeSpeech(pollyParams,(err,data)=>{if(err){console.log(err);returnreject(err);}varbuffer=Buffer.from(data.AudioStream);returnresolve(buffer);});});}
ファイルアップロードを受信するところは、以下を参考にしてください。
バイナリファイルのアップロード・ダウンロードをする
WAVファイルから音声認識をするところ、テキスト文字列を音声ファイルに変換するところは、以下を参考にしてください。
M5StickCとSpeaker HatでAI Chatと会話
LINEボットにより、メッセージ受信をトリガするには以下を参考にしてください。
LINEボットを立ち上げるまで。LINEビーコンも。
以上