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
ATOM Echo側
以下のライブラリを利用しています。
m5stack/M5StickC
ボタンの検出に使っています。
knolleary/PubSubClient
MQTTサブスクライブに使っています。
bblanchon/ArduinoJson
MQTTサブスクライブで受信するJSONのパースに使っています。
adafruit/Adafruit_NeoPixel
RGBのLED制御に使っています。
録音は、以下を参考にしました。
 M5StickCとSpeaker HatでAI Chatと会話
 m5stack/M5-ProductExampleCodes
こんな感じです。
// 録音用タスク
void i2sRecordTask(void* arg){
  // 初期化
  recPos = 0;
  memset(soundStorage, 0, sizeof(soundStorage));
  vTaskDelay(100);
 
  // 録音処理
  while (isRecording) {
    size_t transBytes;
 
    // I2Sからデータ取得
    i2s_read(I2S_NUM_0, (char*)soundBuffer, BUFFER_LEN, &transBytes, (100 / portTICK_RATE_MS));
 
    // int16_t(12bit精度)をuint8_tに変換
    for (int i = 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 ){
    unsigned long len = sizeof(temp_buffer);
    int ret = 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);
}
void i2sRecord(){
  isRecording = true;
  pixels.setPixelColor(0, pixels.Color(0, 100, 0));
  pixels.show();
  i2s_driver_uninstall(I2S_NUM_0);
  i2s_config_t i2s_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_t err = ESP_OK;
  err += i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_pin_config_t tx_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);    
}
録音が完了したら、以下の部分で音声の生データをファイルとしてアップロードしています。
    int ret = doHttpPostFile((base_url + "/linebot-carrier-wav2text").c_str(), soundStorage, recPos, "application/octet-stream", 
                              "upfile", "test.bin", NULL, NULL, temp_buffer, &len);
MP3の再生は、以下を利用させていただきました。
schreibfaul1/ESP32-audioI2S
(参考)ArduinoでPCM5102Aを使って音楽を再生する
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 configuration
    m_i2s_num = I2S_NUM_0; // i2s port number
    m_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 priority
    m_i2s_config.dma_buf_count        = 8;      // max buffers
    m_i2s_config.dma_buf_len          = 1024;   // max value
    m_i2s_config.use_apll             = APLL_ENABLE;
    m_i2s_config.tx_desc_auto_clear   = true;   // new in V1.0.1
    m_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
}
void Audio::setup(){
    i2s_driver_uninstall(I2S_NUM_0);
    //i2s configuration
    m_i2s_num = I2S_NUM_0; // i2s port number
    m_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 priority
    m_i2s_config.dma_buf_count        = 8;      // max buffers
    m_i2s_config.dma_buf_len          = 1024;   // max value
    m_i2s_config.use_apll             = APLL_ENABLE;
    m_i2s_config.tx_desc_auto_clear   = true;   // new in V1.0.1
    m_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 end
            playI2Sremains();
            stopSong(); // Correct close when play known length sound #74 and before callback #112
            sprintf(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 end
            while(!playI2Sremains()) {
                ;
            }
            delay(500);
            stopSong(); // Correct close when play known length sound #74 and before callback #112
            sprintf(chbuf, "End of webstream: \"%s\"", m_lastHost);
            if(audio_info) audio_info(chbuf);
            if(audio_eof_stream) audio_eof_stream(m_lastHost);
        }
後はこんな感じで呼び出すだけです。引数として、MP3を置いたURLを指定します。
void i2sPlayUrl(const char *url){
  if( audio.isRunning() )
    audio.stopSong();
  audio.setup();
  audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT, I2S_DIN);
  audio.setVolume(I2S_VOLUME); // 0...21
  audio.connecttohost(url);
}
WAVEファイルのアップロードは以下を参考にしました。
 ESP32でバイナリファイルのダウンロード・アップロード
MQTTサブスクライブは以下を参考にしました。
 ESP32で作るBeebotteダッシュボード
Node.js側
ざっと、以下のnpmモジュールを使っています。
・line/line-bot-sdk-nodejs
・googleapis/nodejs-speech
・mqttjs/MQTT.js
・aws/aws-sdk-js
const line = require('@line/bot-sdk');
const speech = require('@google-cloud/speech');
const client = new speech.SpeechClient();
const mqtt = require('mqtt');
const AWS = require('aws-sdk');
const polly = new AWS.Polly({
  apiVersion: '2016-06-10',
  region: 'ap-northeast-1'
});
以下の2つのエンドポイントを立ち上げています。
・/linebot-carrier
LINEボットのWebhookであり、LINEメッセージを受信すると呼び出されます。
・/linebot-carrier-wav2text
ATOM Echoからの、WAVファイルのアップロードを受け付けます。
説明が面倒なので、ソースをそのまま載せています。すみません。
'use strict';
const config = {
  channelAccessToken: '【LINEチャネルアクセストークン(長期)】',
  channelSecret: '【LINEチャネルシークレット】',
};
const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
var line_usr_id = '【LINEユーザID】';
const LineUtils = require(HELPER_BASE + 'line-utils');
const line = require('@line/bot-sdk');
const app = new LineUtils(line, config);
const speech = require('@google-cloud/speech');
const client = new speech.SpeechClient();
const mqtt = require('mqtt');
const MQTT_HOST = process.env.MQTT_HOST || '【MQTTサーバのURL(例:mqtt://hostname:1883)】';
const MQTT_CLIENT_ID = 'linebot-carrier';
const MQTT_TOPIC_TO_ATOM = 'linebot_to_atom';
const THIS_BASE_PATH = process.env.THIS_BASE_PATH;
const MESSAGE_MP3_FNAME	= THIS_BASE_PATH + '/public/message.mp3';
const fs = require('fs');
const AWS = require('aws-sdk');
const polly = new AWS.Polly({
  apiVersion: '2016-06-10',
  region: 'ap-northeast-1'
});
const mqtt_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");
  var buffer = await speech_to_wave(event.message.text);
  fs.writeFileSync(MESSAGE_MP3_FNAME, buffer);
  var json = {
    message: event.message.text
  };
  mqtt_client.publish(MQTT_TOPIC_TO_ATOM, JSON.stringify(json));
	var message = {
		type: 'text',
		text: '$',
		emojis: [
			{
				index: 0,
				productId: "5ac1de17040ab15980c9b438",
				emojiId: 120
			}
		]
	};
  return client.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));
		var norm = normalize_wave8(new Uint8Array(event.files['upfile'][0].buffer));
		// 音声認識
		var result = await speech_recognize(norm);
		if( result.length < 1 )
			throw 'recognition failed';
    var text = result[0];
    console.log(text);
    app.client.pushMessage(line_usr_id, app.createSimpleResponse(text));
    return new Response({ message: text });
  }
};
function normalize_wave8(wav, out_bitlen = 16){
	var sum = 0;
	var max = 0;
	var min = 256;
	for( var i = 0 ; i < wav.length ; i++ ){
		var val = wav[i];
		if( val > max ) max = val;
		if( val < min ) min = val;
		sum += val;
	}
	var average = sum / wav.length;
	var amplitude = 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 ){
		const norm = Buffer.alloc(wav.length);
		for( var i = 0 ; i < wav.length ; i++ ){
			var value = (wav[i] - average) / amplitude * (127 * 0.8) + 128;
			norm[i] = Math.floor(value);
		}
		return norm;
	}else{
		const norm = Buffer.alloc(wav.length * 2);
		for( var i = 0 ; i < wav.length ; i++ ){
			var value = (wav[i] - average) / amplitude * (32767 * 0.8);
			norm.writeInt16LE(Math.floor(value), i * 2);
		}
		return norm;
	}
}
async function speech_recognize(wav){
	const config = {
		encoding: 'LINEAR16',
		sampleRateHertz: 8192,
		languageCode: 'ja-JP',
	};
	const audio = {
		content: wav.toString('base64')
	};
	
	const request = {
		config: config,
		audio: audio,
	};	
	return client.recognize(request)
	.then(response =>{
		const transcription = [];
		for( var i = 0 ; i < response[0].results.length ; i++ )
			transcription.push(response[0].results[i].alternatives[0].transcript);
			return transcription;
	});
}
async function speech_to_wave(message, voiceid = 'Mizuki', samplerate = 16000 ){
	const pollyParams = {
    OutputFormat: 'mp3',
		Text: message,
		VoiceId: voiceid,
		TextType: 'text',
		SampleRate : String(samplerate),
	};
	
	return new Promise((resolve, reject) =>{
		polly.synthesizeSpeech(pollyParams, (err, data) =>{
			if( err ){
				console.log(err);
				return reject(err);
			}
			var buffer = Buffer.from(data.AudioStream);
			return resolve(buffer);
		});
	});
}
ファイルアップロードを受信するところは、以下を参考にしてください。
 バイナリファイルのアップロード・ダウンロードをする
WAVファイルから音声認識をするところ、テキスト文字列を音声ファイルに変換するところは、以下を参考にしてください。
 M5StickCとSpeaker HatでAI Chatと会話
LINEボットにより、メッセージ受信をトリガするには以下を参考にしてください。
 LINEボットを立ち上げるまで。LINEビーコンも。
以上
