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ビーコンも。
以上