M5StickCにはマイクがついています。また、M5stickCの拡張端子に接続できるSpeaker Hatがあるので、それを組み合わせれば、何かできそう。
M5StickC Speaker Hat(PAM8303搭載)
https://m5stack.com/products/m5stickc-speaker-hat?_pos=7&_sid=b84fce0ec&_ss=r
ということで、M5StackCとSpeaker Hatを組み合わせて、Web上にいるAI Chatと会話をしてみたいと思います。
全体的な流れは以下の通りです。
音声認識には、Google Cloud Speech APIのSpeech-to-Textを利用しました。
音声合成には、AWSのAmazon Pollyを利用しました。
そして、主題のAI Chatには、ユーザローカル社の人工知能チャットボット(chatbot)を利用しました。
以降は、以下の流れに沿って、補足していきます。
・M5StickCでの録音・再生
・M5StickCからWAVEデータの送信・受信
・中継サーバでのM5StickCからの受信・送信
・中継サーバでの音声認識の呼び出し
・中継サーバでのAI Chatの呼び出し
・中継サーバでの音声合成の呼び出し
作っては見ましたが、マイクの音量は小さめですし、スピーカやマイクの音質はかなり悪かったです。(M5Stackでやったほうが良いかも)
ですが、かなりマイクの音質が悪いにも関わらず、GoogleのSpeech-to-Textはしっかり音声認識してくれるのは驚きました。
ソースコード一式は、GitHubに上げておきました。
Swagger-nodeを使ったサーバです。Arduinoのソースコードも上げています。
poruruba/m5stickc_chat
https://github.com/poruruba/m5stickc_chat
M5StickCでの録音・再生
Arduinoを使います。
実装は、以下の記事を参考にさせていただき、ほぼそのまま使わせていただきました。(ありがとうございます!)
M5StickCのマイクを使ってみる その3 録音再生
https://lang-ship.com/blog/work/m5stickc-mic-3/
これ以上の説明はいらないぐらいシンプルにして記載いただいているので、非常に助かりました。
録音すると、8ビットのサンプリングデータが出力され、8ビットのサンプリングデータを用意すれば、再生できるところまで、関数化してくれています。
8ビットのサンプリングデータの中央値(無音)は、128です。signed charではなく、unsigned charです。
あとで説明する、M5StickCからのWAVEデータの送信・受信では、WiFiを使っていまして、そうすると残メモリ容量が厳しくなってしまいました。
サンプリングレートは、8KHz、録音できる秒数を4秒にしています。
ただ、スピーカの音質はかなり悪く、聞き取れないぐらいでした。(PIN設定が違うのかな???)
#M5StickCからWAVEデータの送信・受信
M5StickCからの送受信の通信路には、WiFiを利用し、通信プロトコルはHTTP Postです。
また、WAVEはバイナリファイルなので、Base64にエンコードして送信し、Base64で受信するのでデコードの処理を行います。
MimeTypeは、送信が「application/json」、受信が「text/plain」です。
Densaugeo/base64_arduino
https://github.com/Densaugeo/base64_arduino
ライブラリマネージャから「base64」をインストールします。
HTTP Post部分のソースコードを抜粋します。
HTTPClient http;
const char *host = "【中継サーバのエントリポイントのURL】";
char httpBuffer[(STORAGE_LEN + 2)/ 3 * 4];
int http_post(uint8_t *p_inout_buffer, int in_length){
strcpy(httpBuffer, "{\"message\": \"");
encode_base64(p_inout_buffer, in_length, (unsigned char*)&httpBuffer[strlen(httpBuffer)]);
strcat((char*)httpBuffer, "\"}");
Serial.println("HTTP Post");
http.begin(host);
http.addHeader("Content-Type", "application/json");
int status_code = http.POST((uint8_t*)httpBuffer, strlen(httpBuffer));
if( status_code != HTTP_CODE_OK ){
Serial.println("Status is not 200");
http.end();
return -1;
}
int len = http.getSize();
WiFiClient * stream = http.getStreamPtr();
int ptr = 0;
while(http.connected() && (len > 0 || len == -1)) {
size_t size = stream->available();
if(size) {
if(size > (sizeof(httpBuffer) - ptr) ){
Serial.println("receive overflow");
http.end();
return -1;
}
int c = stream->readBytes(&httpBuffer[ptr], size);
ptr += c;
if(len > 0)
len -= c;
}
delay(1);
}
httpBuffer[ptr] = '\0';
http.end();
return decode_base64((unsigned char*)httpBuffer, p_inout_buffer);
}
application/jsonのBody部の生成に、ArduinoJsonを使いたかったのですが、残メモリが限界で、諦めました。受信も、application/jsonにしたかったのですが、同じ理由で、JSON解析不要のtext/plainにしています。
送信時のJSONは、
"{\"message\": \"" + base64化したWAVEデータ + "\"}"
として、決め打ちのJSONにしています。
#中継サーバでのM5StickCからの受信・送信
サーバには、Swagger-nodeを使っています。
Swagger定義は以下の通りです。
/speech:
post:
x-swagger-router-controller: routing
operationId: speech
parameters:
- in: body
name: body
required: true
schema:
type: object
required:
- message
properties:
message:
type: "string"
produces:
- text/plain
responses:
200:
description: Success
schema:
type: string
あとは、以降で説明する、音声認識、AI Chat、音声合成を提供しているサーバにリクエストを順番に出していきます。
const TextResponse = require(HELPER_BASE + 'textresponse');
exports.handler = async (event, context, callback) => {
var body = JSON.parse(event.body);
console.log(body);
if( !body.message )
throw 'message is not set';
var wav = Buffer.from(body.message, 'base64');
// 音声の正規化+16ビット化
var norm = normalize_wave8(wav);
// 音声認識
var ret = await speech_recognize(norm);
console.log(ret);
if( ret.length < 1 )
throw 'recognition failed';
// AI Chat
var ret2 = await speech_talk(ret[0]);
console.log(ret2);
// 音声合成
var ret3 = await speech_to_wave(ret2);
console.log(ret3);
// 16ビットから8ビットに変換
var res = speech_wave16_to_wave8(ret3);
console.log(res);
return new TextResponse("text/plain", res.toString('base64'));
// return new TextResponse("text/plain", body.message); // echoback
};
ユーティリティです。
class TextResponse{
constructor(content_type, context){
this.statusCode = 200;
this.headers = {'Access-Control-Allow-Origin' : '*', 'Cache-Control' : 'no-cache', 'Content-Type': content_type };
if( context )
this.set_body(context);
else
this.body = "";
}
set_error(error){
this.body = JSON.stringify({"err": error});
return this;
}
set_body(content){
this.body = content;
return this;
}
get_body(){
return content;
}
}
module.exports = TextResponse;
その前後で、WAVEデータの整形を行っています。
<音声の正規化+16ビット化>
正規化は、音声が大きすぎたり、小さすぎたりしている場合に、適当なレベルに合わせることです。また、M5StickCで録音したWAVEデータは、中央値が0(unsigned 8bitの場合は128)ではなく少しずれているので、それの補正をします。ですが、GoogleのSpeech-to-Textは優れモノなので、特に正規化しなくても大丈夫です。
正規化と一緒に、WAVEデータの16ビット化をしています。こちらが本当に必要な作業です。
M5StickCから送られるWAVEデータは、データ量削減の意味もあって、モノラル1サンプリング8ビット長です。ですが、GoogleのSpeech-to-Textは、モノラル16ビット長を期待しているので、その変換を行う必要があります。
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);
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;
}
}
<16ビットから8ビットに変換>
音声合成であるAWS Amazon Pollyからのレスポンスは、モノラル16ビット(signed)です。M5StickC側が期待するのは、モノラル8ビット(unsigned)です。その変換するための関数が以下です。
function speech_wave16_to_wave8(wav){
var buffer = Buffer.alloc(wav.length / 2);
for( var i = 0 ; i < buffer.length ; i++ ){
buffer[i] = Math.floor(wav.readInt16LE(i * 2) / 256 + 128);
}
return buffer;
}
#中継サーバでの音声認識の呼び出し
音声認識には、Google Cloud Speech APIのSpeech-to-Textを使っています。「OK Google」でも有名ですが、機械学習で賢くなっていることを期待して使わせていただいています。
呼び出す前に、準備が必要です。
GCPのコンソールから、「APIとサービス」→「APIライブラリ」を選択します。
そこから、Cloud Speech-to-Text APIを選択し、「有効にする」を押下します。
次に、またGCPのコンソールから、「IAMと管理」→「サービスアカウント」を選択します。
そこで、「サービスアカウントとの作成」を押下し、適当なサービスアカウント名を入力して、最後にキーを作成します。形式はJSONにします。
そうすると、プロジェクト名-XXXXX-XXXXXXXXXXXX.json
のようなファイルが作られます。(本番では、権限を絞った方が良いです)
あとは、そのファイルをSwagger-nodeの適当な場所において、.envにその場所とファイル名を記載しておきます。(環境変数への設定でもよいですが、dotenvが楽なので。。。)
GOOGLE_APPLICATION_CREDENTIALS=【ファイルのパス】
もう一つの準備として、Speech-to-Textの呼び出しのため、Googleが提供しているnpmモジュールをインストールしておきます。
npm install @google-cloud/speech
これで準備ができました。あとは、以下のように実装します。
const speech = require('@google-cloud/speech');
const client = new speech.SpeechClient();
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;
});
}
認識結果が複数返ってきますので、それを配列にして返してあげる関数です。(ですが、結局先頭の結果しか使っていませんが)
#中継サーバでのAI Chatの呼び出し
AI Chatには、ユーザローカル社の人工知能チャットボット(chatbot)を使わせていただきました。以下から個人開発者向けのボットAPI利用申請をしていないようでしたら、申請しておきます。
人工知能チャットボット(chatbot):ユーザローカル
https://ai.userlocal.jp/document/free/top/
そうすると、メールでAPIキーが払い出されます。
あとは、以下のように呼ぶだけです。
npmモジュールのnode-fetchを使っています。
npm install node-fetch
const fetch = require('node-fetch');
const USERLOCAL_API_KEY = '【ユーザローカルのAPIキー】';
async function speech_talk(message){
var body = {
message: message,
key: USERLOCAL_API_KEY,
};
return do_post('https://chatbot-api.userlocal.jp/api/chat', body)
.then(json =>{
return json.result;
});
}
function do_post(url, body){
return fetch(url, {
method : 'POST',
body : JSON.stringify(body),
headers: { "Content-Type" : "application/json; charset=utf-8" }
})
.then((response) => {
if(!response.ok)
throw "status is not 200.";
return response.json();
});
}
#中継サーバでの音声合成の呼び出し
音声合成は、AWS Amazon Polly を使っています。
npmモジュールのaws-sdkを使っています。aws configureは実行しておきましょう。(詳細は省略!)
async function speech_to_wave(message){
const pollyParams = {
OutputFormat: 'pcm', // 音声フォーマット
Text: message,
VoiceId: 'Mizuki',
TextType: 'text',
SampleRate : '8000',
};
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);
});
});
}
声の種類を変えられますが、今回はMizukiさんを選択しています。
出力フォーマットは、mp3とかも選べるのですが、後続処理でサンプリングデータを加工するので、pcmにしておきます。pcmを選択すると、モノラル16ビット(signed)となります。サンプリングレートは、8KHzです。
#中継サーバの起動
一応これで、中継サーバの準備は整ったはずです。
GitHubに上がっているファイルの起動方法は以下の通りです。
unzip m5stickc_chat.zip
cd m5stickc_chat
npm install
node app.js
これで、ポート10080で待ち受けているはずです。
#M5StickCへの書き込み
Arduinoで書き込みます。
最終的なソースはこんな感じです。
#include <M5StickC.h>
#include <driver/i2s.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <base64.hpp>
HTTPClient http;
const char *host = "【中継サーバのURL】/speech";
const char* wifi_ssid = "【WiFiアクセスポイントのSSID】";
const char* wifi_password = "【WiFiアクセスポイントのパスワード】";
#define htonl(x) ( ((x)<<24 & 0xFF000000UL) | \
((x)<< 8 & 0x00FF0000UL) | \
((x)>> 8 & 0x0000FF00UL) | \
((x)>>24 & 0x000000FFUL) )
#define PIN_CLK (0) // I2S Clock PIN
#define PIN_DATA (34) // I2S Data PIN
#define SAMPLING_RATE (8192) // サンプリングレート(44100, 22050, 16384, more...)
#define BUFFER_LEN (1024) // バッファサイズ
#define SAMPLEING_SEC (4) // 最大サンプリング時間(秒)
#define STORAGE_LEN (SAMPLING_RATE * SAMPLEING_SEC) // 本体保存容量
#define WAVE_EXPORT (0) // WAVEファイルに出力するか
#define BLANK_LINE " "
uint8_t soundBuffer[BUFFER_LEN]; // DMA転送バッファ
uint8_t soundStorage[STORAGE_LEN]; // サウンドデータ保存領域
char httpBuffer[(STORAGE_LEN + 2)/ 3 * 4];
bool recFlag = false; // 録音状態
int recPos = 0; // 録音の長さ
int http_post(uint8_t *p_inout_buffer, int in_length);
// 再生をする
void i2sPlay(){
// 再生設定
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_DAC_BUILT_IN),
.sample_rate = SAMPLING_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_I2S_MSB,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 2,
.dma_buf_len = BUFFER_LEN,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0,
};
// 再生設定実施
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
i2s_set_pin(I2S_NUM_0, NULL);
i2s_zero_dma_buffer(I2S_NUM_0);
// 再生
size_t transBytes;
size_t playPos = 0;
while( playPos < recPos ){
for( int i = 0 ; i < BUFFER_LEN ; i+=2 ){
soundBuffer[i] = 0; // 下位8ビットは無視される
soundBuffer[i+1] = soundStorage[playPos]; // 上位8ビットにuint8_tのデータを入れる
playPos++;
}
// データ転送
i2s_write(I2S_NUM_0, (char*)soundBuffer, BUFFER_LEN, &transBytes, (100 / portTICK_RATE_MS));
}
// 後始末
i2s_zero_dma_buffer(I2S_NUM_0);
i2s_driver_uninstall(I2S_NUM_0);
}
// 録音をする
void i2sRecord() {
// 録音用設定
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,
.channel_format = I2S_CHANNEL_FMT_ALL_RIGHT,
.communication_format = I2S_COMM_FORMAT_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 2,
.dma_buf_len = BUFFER_LEN,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0,
};
// PIN設定
i2s_pin_config_t pin_config;
pin_config.bck_io_num = I2S_PIN_NO_CHANGE;
pin_config.ws_io_num = PIN_CLK;
pin_config.data_out_num = I2S_PIN_NO_CHANGE;
pin_config.data_in_num = PIN_DATA;
// 録音設定実施
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pin_config);
i2s_set_clk(I2S_NUM_0, SAMPLING_RATE, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO);
// 録音開始
recFlag = true;
xTaskCreatePinnedToCore(i2sRecordTask, "i2sRecordTask", 2048, NULL, 1, NULL, 1);
}
// 録音用タスク
void i2sRecordTask(void* arg)
{
// 初期化
recPos = 0;
memset(soundStorage, 0, sizeof(soundStorage));
vTaskDelay(500); //delay(portMAX_DELAY);
// LED On
digitalWrite(GPIO_NUM_10, LOW );
// 録音処理
while (recFlag) {
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) ){
recFlag = false;
break;
}
}
}
Serial.printf("transBytes=%d, recPos=%d\n", transBytes, recPos);
vTaskDelay(1 / portTICK_RATE_MS);
}
// LED Off
digitalWrite(GPIO_NUM_10, HIGH);
i2s_driver_uninstall(I2S_NUM_0);
if( recPos > 0 ){
int ret = http_post(soundStorage, recPos);
if( ret > 0 ){
recPos = ret;
i2sPlay();
}
}
// タスク削除
vTaskDelete(NULL);
}
int http_post(uint8_t *p_inout_buffer, int in_length){
strcpy(httpBuffer, "{\"message\": \"");
encode_base64(p_inout_buffer, in_length, (unsigned char*)&httpBuffer[strlen(httpBuffer)]);
strcat((char*)httpBuffer, "\"}");
Serial.println("HTTP Post");
http.begin(host);
http.addHeader("Content-Type", "application/json");
int status_code = http.POST((uint8_t*)httpBuffer, strlen(httpBuffer));
if( status_code != HTTP_CODE_OK ){
Serial.println("Status is not 200");
http.end();
return -1;
}
int len = http.getSize();
WiFiClient * stream = http.getStreamPtr();
int ptr = 0;
while(http.connected() && (len > 0 || len == -1)) {
size_t size = stream->available();
if(size) {
if(size > (sizeof(httpBuffer) - ptr) ){
Serial.println("receive overflow");
http.end();
return -1;
}
int c = stream->readBytes(&httpBuffer[ptr], size);
ptr += c;
if(len > 0)
len -= c;
}
delay(1);
}
httpBuffer[ptr] = '\0';
http.end();
return decode_base64((unsigned char*)httpBuffer, p_inout_buffer);
}
void wifi_connect(void){
Serial.print("WiFi Connenting");
WiFi.begin(wifi_ssid, wifi_password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.print(".");
}
Serial.println("");
Serial.print("Connected : ");
Serial.println(WiFi.localIP());
}
void setup() {
M5.begin();
M5.Lcd.setRotation(3);
M5.Lcd.fillScreen(BLACK);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.println("[M5StickC]");
pinMode(GPIO_NUM_10, OUTPUT);
digitalWrite(GPIO_NUM_10, HIGH);
i2sPlay();
wifi_connect();
M5.Lcd.println("Sound Recorder");
M5.Lcd.println("BtnA Record");
M5.Lcd.println("BtnB Play");
}
void loop() {
M5.update();
if ( M5.BtnA.wasPressed() ) {
// 録音スタート
M5.Lcd.setCursor(0, 36);
M5.Lcd.println("REC...");
Serial.println("Record Start");
i2sRecord();
} else if ( M5.BtnA.wasReleased() ) {
// 録音ストップ
M5.Lcd.setCursor(0, 36);
M5.Lcd.println(BLANK_LINE);
recFlag = false;
delay(100); // 録音終了まで待つ
Serial.println("Record Stop");
// WAVEファイルをシリアルに出力
if ( WAVE_EXPORT ) {
Serial.printf("52494646"); // RIFFヘッダ
Serial.printf("%08lx", htonl(recPos + 44 - 8)); // 総データサイズ+44(チャンクサイズ)-8(ヘッダサイズ)
Serial.printf("57415645"); // WAVEヘッダ
Serial.printf("666D7420"); // フォーマットチャンク
Serial.printf("10000000"); // フォーマットサイズ
Serial.printf("0100"); // フォーマットコード
Serial.printf("0100"); // チャンネル数
Serial.printf("%08lx", htonl(SAMPLING_RATE)); // サンプリングレート
Serial.printf("%08lx", htonl(SAMPLING_RATE)); // バイト/秒
Serial.printf("0100"); // ブロック境界
Serial.printf("0800"); // ビット/サンプル
Serial.printf("64617461"); // dataチャンク
Serial.printf("%08lx", htonl(recPos)); // 総データサイズ
for (int n = 0; n <= recPos; n++) {
Serial.printf("%02x", soundStorage[n]);
}
Serial.printf("\n");
}
} else if ( M5.BtnB.wasReleased() ) {
// 再生スタート
M5.Lcd.setCursor(0, 36);
M5.Lcd.println("Play...");
Serial.println("Play Start");
i2sPlay();
M5.Lcd.setCursor(0, 36);
M5.Lcd.println(BLANK_LINE);
Serial.println("Play Stop");
}
delay(10);
}
以下の部分を環境に合わせて変更してください。
const char *host = "【中継サーバのURL】/speech";
const char* wifi_ssid = "WiFiアクセスポイントのSSID";
const char* wifi_password = "【WiFiアクセスポイントのパスワード】";
#M5StickCの使い方
Aボタンを押したまま、チャットしたい言葉を話します。LEDが点灯しますのでわかるかと思います。最大4秒間です。
そうすると勝手に中継サーバに録音データをアップして、チャットの結果が返ってきて、スピーカHATから再生されます。
#終わりに
・再生のサンプリングレートがあっていないような気がします。。。早口なんですよねえ。
・再生の品質がわるいです。PIN設定が間違っているのか、8ビット+8KHzの宿命なのか、これがスピーカ性能の限界なのか。。。
・WiFiが不安定です。もともとM5StickCのアンテナの性能は高くないです。
・全体的に不安定だなあ。
以上