10
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-07

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

image.png

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

・録音した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

こんな感じです。

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);    
}

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

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

(参考)ArduinoでPCM5102Aを使って音楽を再生する

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

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

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

変更前

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;

}

変更後

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ファイルが小さすぎると、再生されませんでしたので、ファイルサイズの下限を下げました。

変更前

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

変更後

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

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

変更前

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);
        }

変更後

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を指定します。

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でバイナリファイルのダウンロード・アップロード

MQTTサブスクライブは以下を参考にしました。
 ESP32で作るBeebotteダッシュボード

Node.js側

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

・line/line-bot-sdk-nodejs

・googleapis/nodejs-speech

・mqttjs/MQTT.js

・aws/aws-sdk-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ファイルのアップロードを受け付けます。

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

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);
        });
    });
}

ファイルアップロードを受信するところは、以下を参考にしてください。
 バイナリファイルのアップロード・ダウンロードをする

WAVファイルから音声認識をするところ、テキスト文字列を音声ファイルに変換するところは、以下を参考にしてください。
 M5StickCとSpeaker HatでAI Chatと会話

LINEボットにより、メッセージ受信をトリガするには以下を参考にしてください。
 LINEボットを立ち上げるまで。LINEビーコンも。

以上

10
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
10
4