LINEボットとATOM Echoでボイスメッセージを作る

ATOM Echoには、マイクとスピーカとボタンとLEDが付いています。
ATOM Echoのマイクにしゃべった言葉がLINEメッセージとして通知されるようにするとともに、LINEアプリから応答メッセージを入力したら、ATOM EchoのLEDが点灯し、さらにボタンを押したら応答メッセージが音声でATOM Echoのスピーカから流れるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/71ab888a-11a2-8596-f03f-d233759939ad.png)

いくつかのサービスを使っています

・録音した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
MQTTサブスクライブに使っています。

https://github.com/knolleary/pubsubclient

bblanchon/ArduinoJson
MQTTサブスクライブで受信するJSONのパースに使っています。

https://github.com/bblanchon/ArduinoJson

adafruit/Adafruit_NeoPixel
RGBのLED制御に使っています。

https://github.com/adafruit/Adafruit_NeoPixel

録音は、以下を参考にしました。
　[M5StickCとSpeaker HatでAI Chatと会話](https://qiita.com/poruruba/items/c618130aff69717bbcda)
　[m5stack/M5-ProductExampleCodes](https://github.com/m5stack/M5-ProductExampleCodes/blob/master/Core/Atom/AtomEcho/Arduino/Repeater/Repeater.ino)

こんな感じです。

```cpp:Arduino\LinebotCarrier\src\main.cpp
// 録音用タスク
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);    
}
```

録音が完了したら、以下の部分で音声の生データをファイルとしてアップロードしています。

```cpp:Arduino\LinebotCarrier\src\main.cpp
    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

https://github.com/schreibfaul1/ESP32-audioI2S

（参考）[ArduinoでPCM5102Aを使って音楽を再生する](https://qiita.com/poruruba/items/6c85e80b9669d323577e)

PlatformIOを利用している場合は、zipファイルの中身をlibフォルダに突っ込めばコンパイルに含めてくれます。
ポート番号は、ATOM Echoの配線に合わせています。

ただし、うまく動かないところがあり、いくつか修正しています。（この直し方でよいのか自信がないですが。。。）
GitHubには、修正したファイルだけ上げてあります。

録音と再生を切り替えられるように、Audioのコンストラクターから再セットアップ用の関数Audio:setup()に分離しました。

変更前

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
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;

}
```

変更後

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
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ファイルが小さすぎると、再生されませんでしたので、ファイルサイズの下限を下げました。

変更前

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
        if((InBuff.bufferFilled() > 6000 && !m_f_psram) || (InBuff.bufferFilled() > 80000 && m_f_psram)) {
```

変更後

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
        if((InBuff.bufferFilled() > 1500 && !m_f_psram) || (InBuff.bufferFilled() > 80000 && m_f_psram)) {
```

なぜか、MP3の最後あたりの音が切れてしまいましたので、ウェイトを入れました。

変更前

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
        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);
        }
```

変更後

```cpp:Arduino\LinebotCarrier\lib\ESP32-audioI2S-master\src\Audio.cpp
        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を指定します。

```cpp:Arduino\LinebotCarrier\src\main.cpp
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でバイナリファイルのダウンロード・アップロード](https://qiita.com/poruruba/items/82a683866aef872665a4)

MQTTサブスクライブは以下を参考にしました。
　[ESP32で作るBeebotteダッシュボード](https://qiita.com/poruruba/items/00b488bc22d95c16f3e1)

#Node.js側

ざっと、以下のnpmモジュールを使っています。

・line/line-bot-sdk-nodejs

https://github.com/line/line-bot-sdk-nodejs

・googleapis/nodejs-speech

https://github.com/googleapis/nodejs-speech

・mqttjs/MQTT.js

https://github.com/mqttjs/MQTT.js

・aws/aws-sdk-js

https://github.com/aws/aws-sdk-js

```js:Node.js\api\controllers\linebot-carrier\index.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ファイルのアップロードを受け付けます。

説明が面倒なので、ソースをそのまま載せています。すみません。

```js:Node.js\api\controllers\linebot-carrier\index.js
'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);
		});
	});
}
```

ファイルアップロードを受信するところは、以下を参考にしてください。
　[バイナリファイルのアップロード・ダウンロードをする](https://qiita.com/poruruba/items/fb834d07bfd7a356884f)

WAVファイルから音声認識をするところ、テキスト文字列を音声ファイルに変換するところは、以下を参考にしてください。
　[M5StickCとSpeaker HatでAI Chatと会話](https://qiita.com/poruruba/items/c618130aff69717bbcda)

LINEボットにより、メッセージ受信をトリガするには以下を参考にしてください。
　[LINEボットを立ち上げるまで。LINEビーコンも。](https://qiita.com/poruruba/items/c71c96ca83c7403c2930)

以上