1. はじめに
音声合成用LSI「AquesTalk pico LSI」を M5Stack に接続して動かしました。ESP32 をプロセッサに持つ M5Stack の場合、音声合成ライブラリ AquesTalk-ESP32 を使用すれば、わざわざ LSI を繋ぐ必要はありません。しかしながら I2C, UART, SPI の 3 種類のインタフェースで制御可能という点に惹かれ、試してみることにしました。
作成したコード全体は、GitHub12 にあります。製作したプリント基板を委託販売345しています。
- I2C(Inter-Integrated Circuit)
- UART(Universal Asynchronous Receiver/Transmitter)
- SPI(Serial Peripheral Interface)
2. AquesTalk pico LSI
AquesTalk pico LSI6は、株式会社アクエスト7の製品です。Atmel ATmega328(P)8 に音声合成エンジンが書き込んであります。製品が 2 種 (ATP30119, ATP301210) があります。音質、クロック、ピン配置、最大ボーレート等の違いはありますが I2C, UART, SPI の仕様は同じです。
3. M5Stack
M5Stack11 は ESP3212 をベースとする IoT コントローラです。今回、開発環境に Aruduino-IDE13 を使用しています。ハードウェア機能として実装されている UART, I2C, SPI は、標準装備ライブラリで使用できます。GPIO への信号はデフォルトの割り当てを使います。
3.1 I2C のデフォルト割り当て (M5Stack Basic)
名称 | SDA | SCL | 用途 | 備考 |
---|---|---|---|---|
Wire | GPIO21 | GPIO22 | 内蔵デバイス・Grove-A | 今回利用 |
Wire1 | 未指定 | 未指定 | - |
- GPIO(General Purpose Input/Output)
- SDA(Serial DAta)
- SCL(Serial CLock)
3.2 UART のデフォルト割り当て (M5Stack Basic)
名称 | RX | TX | 用途 | 備考 |
---|---|---|---|---|
Serial | GPIO3 | GPIO1 | USB-C | |
Serial2 | GPIO16 | GPIO17 | - | 今回利用 |
- RX(Receive)
- TX(Transmit)
3.3 SPI のデフォルト割り当て (M5Stack Basic)
名称 | SCLK | MISO | MOSI | SS | 用途 | 備考 |
---|---|---|---|---|---|---|
SPI | GPIO6 | GPIO7 | GPIO8 | GPIO11 | 内蔵FLASH(GPIO6-11) | |
HSPI | GPIO14 | GPIO12 | GPIO13 | GPIO15 | - | |
VSPI | GPIO18 | GPIO19 | GPIO23 | GPIO14 | 内蔵LCD | |
VSPI | GPIO18 | GPIO19 | GPIO23 | GPIO4 | 内蔵TF | |
VSPI | GPIO18 | GPIO19 | GPIO23 | GPIO5 | (VSPIのデフォルト) | 今回利用 |
- SCLK(Serial CLocK)
- MISO(Mater In Slave Out)
- MOSI(Master Out Slave In)
- SS(Slave Select)
- LCD(Liquid Crystal Display)
- TF(TransFlash)
4. ライブラリ
AquesTalk pico LSI とのインタフェースを、I2C, UART, SPI に固有な部分と、共通部分に分けてコーディングします。共通部分を基底クラスとし、固有部分を派生クラスとします。メインプログラムからは、インタフェースを選んだ上で派生クラスをインクルードします。
4.1 基底クラス BF_AquesTalkPico.h
-
virtual int Send(const char* msg) = 0
AquesTalk pico LSI に文字列 msg を送信します。送信の様子をシリアルモニタに出力します。純粋仮想関数として定義し、使用するインタフェースに合わせて派生クラスで実装します。 -
virtual size_t Recv(char* res, size_t res_size) = 0
AquesTalk pico LSI から文字列を受信します。受信完了は、'>' または '*' で判断します。純粋仮想関数として定義し、使用するインタフェースに合わせて派生クラスで実装します。 -
virtual bool Busy() = 0
発声中など AquesTalk pico LSI が処理中の場合trueを応答します。false の場合発声が終了したと判断できます。純粋仮想関数として定義し、使用するインタフェースに合わせて派生クラスで実装します。I2C, SPI ではおのずとポーリングができますが、UART では Send() による明示的なポーリングが必要です。 -
int ShowRes(int res_length_to_show = 1)
AquesTalk pico LSI からの応答をシリアルモニタに出力します。res_length_to_show に 2 を渡すと、応答が 1 文字以下の場合にシリアルモニタへの出力を抑止でき、I2C、SPI のポーリングによる応答が正常 ('>') の場合のシリアルモニタへの出力を省略できます。 -
int DumpEeprom()
AquesTalk pico LSI の EEPROM (アドレス 0x000-0x3FF) の値をシリアルモニタに出力します。 -
int WriteEeprom(int address, int data)
AquesTalk pico LSI の EEPROM にデータを書き込みます。 -
int WritePresetMsg(const char* msg[], int num_of_msg)
AquesTalk pico LSI の EEPROM にプリセットメッセージを書き込みます。書き込みの様子をシリアルモニタに出力します。 -
int WriteSerialSpeed(int serial_speed)
AquesTalk pico LSI (ATP3012) のシリアル通信速度を書き換えます。EEPROM への書き換えであり、UART でなくても更新できます。 -
int WriteI2cAddress(int i2c_address)
AquesTalk pico LSI の I2C アドレスを書き換えます。EEPROM への書き換えであり、I2C でなくても更新できます。
#pragma once
class AquesTalkPico {
public:
AquesTalkPico();
~AquesTalkPico();
virtual int Send(const char* msg) = 0;
virtual size_t Recv(char* res, size_t res_size) = 0;
virtual bool Busy() = 0;
int ShowRes(int res_length_to_show = 1);
int DumpEeprom();
int WriteEeprom(int address, int data);
int WritePresetMsg(const char* msg[], int num_of_msg);
int WriteSerialSpeed(int serial_speed);
int WriteI2cAddress(int i2c_address);
private:
char HexChar(int n);
};
4.2 派生クラス BF_AquesTalkPicoWire.h
AquesTalk pico LSI と I2C でインタフェースする派生クラスです。
-
int AquesTalkPicoWire::Begin(TwoWire &wire, int i2c_address = 0x2e);
AquesTalk pico LSI を接続する I2C と I2C アドレスを指定します。I2C では、アドレスが異なる複数のデバイスを 1 つのバスに接続できます。M5Stack の内蔵デバイスや Grove-A ポートに接続したデバイスと、AquesTalk pico LSI のアドレス 0x2E が重複しない場合、Wire をそのまま使用できます。Begin() で Wire を受けとって利用します。 -
int AquesTalkPicoWire::Send(const char* msg);
Arudino UNO などでは I2C に 32 バイトの制限がありますが、ESP32 では 127byte まで送れます。AuesTalk pico LSI は AVR ですが、I2C で 32 バイト以上の受信ができる模様です。コーディングが楽です。 -
size_t AquesTalkPicoWire::Recv(char* res, size_t res_size);
エラーコードなど、AquesTalk pico LSI のレスポンスは不定長と言えます。Wire.requestFrom() は受信バイト数を明示する必要があります。そこで、1 バイトずつ受信しバッファに追加していく様にしました。区切り記号 '>' または '*' を受信したら Recv() の結果として返します。 -
bool AquesTalkPicoWire::Busy()
AquesTalk pico LSI から読み出しを行い、区切り記号 '>' 1文字のみを受信できた場合に false を返します。それ以外は動作中を示す true を返します。
#pragma once
#include "BF_AquesTalkPico.h"
#include <Wire.h>
class AquesTalkPicoWire : public AquesTalkPico {
public:
AquesTalkPicoWire();
~AquesTalkPicoWire();
// 0x2e: defaut/safe-mode i2c address of AquesTalk pico LSI
int Begin(TwoWire &wire, int i2c_address = 0x2e);
int Send(const char* msg);
size_t Recv(char* res, size_t res_size);
private:
TwoWire* m_wire;
int m_i2c_address;
};
int AquesTalkPicoWire::Begin(TwoWire &wire, int i2c_address)
{
delay(80); // 80ms: reset process of AquesTalk pico LSI
m_wire = &wire;
m_i2c_address = i2c_address;
return 0;
}
int AquesTalkPicoWire::Send(const char* msg)
{
m_wire->beginTransmission(m_i2c_address);
m_wire->write(msg);
return m_wire->endTransmission();
}
size_t AquesTalkPicoWire::Recv(char* res, size_t res_size)
{
int i = 0;
while (i < res_size - 1) {
m_wire->requestFrom(m_i2c_address, 1);
if (m_wire->available()) {
char recv_data = m_wire->read();
res[i++] = recv_data;
if (recv_data == '>' || recv_data == '*')
break;
}
else
break;
}
res[i] = '\0';
return strlen(res);
}
bool AquesTalkPicoWire::Busy()
{
char res[10];
int res_length = Recv(res, sizeof(res));
if (res_length >= 2) {
Serial.printf("[AquesTalk Wire] Receive:%s\n", res);
return true;
}
if (res[0] != '>')
return true;
return false;
}
4.3 派生クラス BF_AquesTalkPicoSerial.h
AquesTalk pico LSI と UART でインタフェースする派生クラスです。
-
int AquesTalkPicoSerial::Begin(Stream &stream)
AquesTalk pico LSI を接続する UART (Serial2) を指定します。 -
int AquesTalkPicoSerial::Send(const char* msg);
UART における送信処理は、大変シンプルです。 -
size_t AquesTalkPicoSerial::Recv(char* res, size_t res_size);
UART からのレスポンスは Serial2.available() を監視して受信することができます。AquesTalk pico LSI は、最大 5 文字を返します。ボーレートが 9600bps の場合 5ms かかります。受信スピードの影響を減らすため受信バッファを用意し Serial2.available() 検出毎に追加する様にしました。区切り記号である '>' または '*' を受信したら Recv() の受信データとします。 -
bool AquesTalkPicoSerial::Busy()
AquesTalk pico LSI にポーリングを行います。'\r' を送信し、区切り記号 '>' 1文字のみを受信できた場合に false を返します。それ以外は動作中を示す true を返します。なんらかの受信があるまで待機する作りです。
#pragma once
#include "BF_AquesTalkPico.h"
class AquesTalkPicoSerial : public AquesTalkPico {
public:
AquesTalkPicoSerial();
~AquesTalkPicoSerial();
int Begin(Stream &stream);
int Send(const char* msg);
size_t Recv(char* res, size_t res_size);
private:
Stream* m_stream;
const int m_recv_size = 10;
char* m_recv;
int m_recv_count;
};
int AquesTalkPicoSerial::Begin(Stream &stream)
{
delay(80); // 80ms: reset process of AquesTalk-Pico
m_stream = &stream;
m_recv_count = 0;
return 0;
}
int AquesTalkPicoSerial::Send(const char* msg)
{
m_stream->write(msg);
return 0;
}
size_t AquesTalkPicoSerial::Recv(char* res, size_t res_size)
{
while (m_recv_count < m_recv_size - 1) {
if (m_stream->available()) {
char recv_data = m_stream->read();
m_recv[m_recv_count++] = recv_data;
if (recv_data == '>' || recv_data == '*')
break;
}
else
return 0;
}
if (m_recv_count > res_size - 1)
m_recv_count = res_size - 1;
for (int i = 0; i < m_recv_count; ++i)
res[i] = m_recv[i];
res[m_recv_count] = '\0';
m_recv_count = 0;
return strlen(res);
}
bool AquesTalkPicoSerial::Busy()
{
Send("\r");
char res[10];
int res_length(0);
do {
res_length = Recv(res, sizeof(res));
} while (res_length == 0);
if (res_length >= 2) {
Serial.printf("[AquesTalk Serial] Receive:%s\n", res);
return true;
}
if (res[0] != '>')
return true;
return false;
}
4.4 派生クラス BF_AquesTalkPicoSpi.h
AquesTalk pico LSI と SPI でインタフェースする派生クラスです。
-
int AquesTalkPicoSpi::Begin(SPIClass &spi, int ss)
SPI バスは、SS 信号を分けることによって共有できます。Begin() で SPI バスのインスタンス(vspi)と SS(slave select)ピンの GPIO 番号を受け取って使用します。 -
int AquesTalkPicoSpi::Send(const char* msg);
AquestTalk pico LSI では SPI において 1 バイト毎に 20μs の間隔が必要です。transfer() で 1 バイト転送する毎に delayMicroseconds(20) で間隔を確保しています。 -
size_t AquesTalkPicoSpi::Recv(char* res, size_t res_size);
vspi.transfer() で 1 バイト転送する毎にバッファに追加していきます。区切り記号 '>' または '*' を受信したら aqtp.Recv() の結果として返します。AquestTalk pico LSI では SPI において 1 バイト毎に 20μs の間隔が必要です。transfer() で 1 バイト転送する毎に delayMicroseconds(20) で間隔を確保しています。 -
bool AquesTalkPicoSpi::Busy()
AquesTalk pico LSI から読み出しを行い、区切り記号 '>' 1文字のみを受信できた場合に false を返します。それ以外は動作中を示す true を返します。
#pragma once
#include "BF_AquesTalkPico.h"
#include <SPI.h>
class AquesTalkPicoSpi : public AquesTalkPico {
public:
AquesTalkPicoSpi();
~AquesTalkPicoSpi();
int Begin(SPIClass &spi, int ss);
int Send(const char* msg);
size_t Recv(char* res, size_t res_size);
private:
SPIClass* m_spi;
int m_ss;
};
int AquesTalkPicoSpi::Begin(SPIClass &spi, int ss)
{
delay(80); // 80ms: reset process of AquesTalk-Pico
m_spi = &spi;
m_ss = ss;
pinMode(m_ss, OUTPUT);
digitalWrite(m_ss, HIGH);
return 0;
}
int AquesTalkPicoSpi::Send(const char* msg)
{
int i = 0;
m_spi->beginTransaction(SPISettings());
digitalWrite(m_ss, LOW);
while (msg[i] != '\0') {
m_spi->transfer(msg[i++]);
delayMicroseconds(20);
}
digitalWrite(m_ss, HIGH);
m_spi->endTransaction();
return 0;
}
size_t AquesTalkPicoSpi::Recv(char* res, size_t res_size)
{
int i = 0;
m_spi->beginTransaction(SPISettings());
digitalWrite(m_ss, LOW);
while (i < res_size - 1) {
char recv_data = m_spi->transfer(0xff);
delayMicroseconds(20);
res[i++] = recv_data;
if (recv_data == '>' || recv_data == '*')
break;
}
digitalWrite(m_ss, HIGH);
m_spi->endTransaction();
res[i] = '\0';
return strlen(res);
}
bool AquesTalkPicoSpi::Busy()
{
char res[10];
int res_length = Recv(res, sizeof(res));
if (res_length >= 2) {
Serial.printf("[AquesTalk Spi] Receive:%s\n", res);
return true;
}
if (res[0] != '>')
return true;
return false;
}
5. メインプログラム
I2C, UART, SPI の 3 種類作成しますが、初期化部分が異なるのみで、処理の内容は同じです。
5.1 #include
AquesTalk pico LSI とインタフェースする派生クラスで I2C, UART, SPI のいずれかを読み込み、インスタンス aqtp を宣言します。SPI については、インスタンス vspi の宣言も必要です。
#include <M5Stack.h>
// in the case of I2C
#include "BF_AquesTalkPicoWire.h"
AquesTalkPicoWire aqtp;
// In the case of Serial
#include "BF_AquesTalkPicoSerial.h"
AquesTalkPicoSerial aqtp;
// in the case of SPI
#include "BF_AquesTalkPicoSpi.h"
AquesTalkPicoSpi aqtp;
SPIClass vspi(VSPI);
const int vspi_ss = 5;
5.2 setup()
-
Begin()
インタフェース情報を渡して初期化します。
UART については Serial2.begin() でボーレートの設定が必要です。
SPI についてはインタフェース自体の初期化 vspi.begin() が必要です。 -
SLEEP と UART 速度設定
SLEEP 信号と GPIO を接続している場合、SLEEP 信号を適切に制御する必要があります。
AquesTalk pico LSI は、ATP3011 の場合、UART 速度を自動設定します。その仕組みは、リセット解除直後または SLEEP 解除直後に文字 '?' を受信して測定します。コントローラ側としては、リセット解除時点または SLEEP 解除時点で送信信号をハイレベルに安定させる必要があります。
M5Stack のリセット信号 EN を AquesTalk pico LSI のリセットに接続した場合、リセット解除時の条件を満足させることは難しいと考えられます。このため GPIO13 を Aquestalk pico LSI の SLEEP に接続し、aqtp.Begin() で一旦 SLEEP させてこれを解除し、指定の 80ms の経過を待って '?' を送信しています。
AquesTalk pico LSI のピン PMOD(1,0) = (HIGH, LOW) に設定して「セーフモード」にすると、ボーレートは 9600bps 固定になり、SLEEP の手順は不要です。しかし最大 76,800bps(ATP3011)のスピードをあきらめることになります。作成した M-BUS モジュール基板の場合、38,400bps まで動作を確認できました。 -
EEPROM の書き込み、読み出し
UART のスピード(ATP3012)、I2C のアドレスの設定、プリセットメッセージの書き込み、EEPROM データのダンプなどを指定できます。 -
バージョン、チャイム
AquesTalk pico LSI のバージョンを表示し、チャイム 2 種を鳴らして、準備完了を知らせます。
void setup()
{
M5.begin(lcd_enable, sd_enable, serial_enable, i2c_enable);
// in the case of I2C
aqtp.Begin(Wire); // default or safe mode
// In the case of Serial
Serial2.begin(9600); // safe mode
aqtp.Begin(Serial2);
// in the case of SPI
vspi.begin();
aqtp.Begin(vspi, vspi_ss);
// designate "true" if sleep pin is connected
if (true /*false*/) {
const int aqtp_sleep_pin(13); // GPIO13 for sleep pin of m-bux module
pinMode(aqtp_sleep_pin, OUTPUT);
digitalWrite(aqtp_sleep_pin, HIGH);
// designate "true" to set speed automatic for ATP3011 & not safe mode
if (/*true*/ false) {
digitalWrite(aqtp_sleep_pin, LOW);
delay(500);
digitalWrite(aqtp_sleep_pin, HIGH);
delay(80);
aqtp.Send("?");
for (int i = 0; i < 10; ++i) {
aqtp.ShowRes();
delay(200);
}
}
}
// option: set serial-speed into EEPROM of ATP3012
aqtp.WriteSerialSpeed( 38400);
for (int i = 0; i < 10; ++i) {
aqtp.ShowRes();
delay(200);
}
// option: write i2c address into EEPROM
aqtp.WriteI2cAddress(0x2E); // change i2c address to customize
// option: write preset message into EEPROM
aqtp.WritePresetMsg(preset_msg, sizeof(preset_msg)/sizeof(preset_msg[0]));
// option: dump EEPROM to the serial monitor
aqtp.DumpEeprom();
aqtp.Send("#V\r"); // read version
for (int i = 0; i < 10; ++i) {
aqtp.ShowRes();
delay(200);
}
aqtp.Send("#J\r"); // chime sound J
for (int i = 0; i < 10; ++i) {
aqtp.ShowRes();
delay(200);
}
aqtp.Send("#K\r"); // chime sound K
for (int i = 0; i < 10; ++i) {
aqtp.ShowRes();
delay(200);
}
}
5.3 loop()
ループの中で、AquesTalk pico LSI からのレスポンスを aqtp.Recv() で受信してシリアルモニタに出力します。I2C, SPI では、ポーリングを行います。ポーリング間隔を 10ms 以上にする必要があります。
M5Stack のボタンが押されたら、Busy() で AquesTalk pico LSI が発声中か否かを判断し、それに応じて aqtp.Send() でコマンドを送信します。発声を中断するには '$' を送信します。
M5 | ボタン | 停止中 | 発声中 |
---|---|---|---|
M5Stack | A | 1つ前のpreset_msgを発声 | 発声を中断して1つ前のpreset_msgを発声 |
M5Stack | B | 現在のpreset_msgを発声 | 発声を中断して停止 |
M5Stack | C | 1つ後のpreset_msgから連続して発声 | 発声を中断して1つ後のpreset_msgから連続して発生 |
void loop()
{
M5.update();
if (M5.BtnA.wasReleased()) {
if (aqtp.Busy()) {
aqtp.Send("$"); // Abort
}
play_command = play_previous;
}
if (M5.BtnB.wasReleased()) {
if (aqtp.Busy()) {
aqtp.Send("$"); // Abort
play_command = play_stop;
}
else {
play_command = play_current;
}
}
if (M5.BtnC.wasReleased()) {
if (aqtp.Busy()) {
aqtp.Send("$"); // Abort
}
play_command = play_forward;
}
int num_of_msg = sizeof(preset_msg)/sizeof(preset_msg[0]);
switch (play_command) {
case play_current:
if (!aqtp.Busy()) {
aqtp.Send(preset_msg[msg_selected]);
play_command = play_stop;
}
break;
case play_previous:
if (!aqtp.Busy()) {
if (--msg_selected < 0)
msg_selected = num_of_msg - 1;
aqtp.Send(preset_msg[msg_selected]);
play_command = play_stop;
}
break;
case play_forward:
if (!aqtp.Busy()) {
if (++msg_selected >= num_of_msg)
msg_selected = 0;
delay(500);
aqtp.Send(preset_msg[msg_selected]);
}
break;
default:
play_command = play_stop;
break;
}
aqtp.ShowRes(2);
delay(loop_ms + loop_last_ms - millis());
loop_last_ms = millis();
}
6. 波形観測
各インタフェースの波形を観測しました。
6.1 I2C 波形(正常)
- Ch-1(黄色):SCL
- Ch-2(水色):SDA
6.2 UART Send 波形(正常)
- Ch-1(黄色): M5Stack --> AquesTalk pico LSI
- Ch-2(水色): M5Stack <-- AquesTalk pico LSI
6.3 UART Send-Receive 波形(正常)
6.4 SPI 波形(正常)
- Ch-1(黄色):SS
- Ch-2(水色):SCLK
- Ch-3(紫色):MOSI
- Ch-4(青色):MISO
"oyasum.." の各バイトの送出に対して '>' で応答しています。発声も正常です。
6.5 SPI 波形(正常・拡大)
6.6 SPI 波形(わざと異常にした例)
各バイトの間隔をわざとゼロにした例です。'>' の応答は最初のみでした。発声も異常となりました。
7. おわりに
I2C は、GPIO を新たに消費せずシンプルかつ高速度で使えます。UART は専用の GPIO が必要となるほか、ボーレートの設定に面倒な点があります。SPI は一般には転送レートで有利ですが 20μs の間隔の影響でそれほどでもありません。また、GPIO としては SS の追加のみで済みますが、SPI を共有する LCD や TF Card への影響も懸念されます。
GPIO の条件
インターフェース | 専用GPIO | 共用GPIO | 備考 |
---|---|---|---|
I2C | - | 2 (SDA, SCL) | |
UART | 2 (RX, TX) | - | |
SPI | 1 (SS) | 3 (SCLK, MISO, MOSI) | LCD/TFへの影響の懸念あり |
転送レートの概算
インターフェース | 速度 | 1 バイトの所要時間 | 転送レート | 備考 |
---|---|---|---|---|
I2C | 100kHz | 90μs(実測) | 11kB/s | M5Stackデフォルト |
I2C | 400kHz | 23μs(推定) | 44kB/s | 上限 |
UART | 9600bps | 1040μs(実測) | 0.9kB/s | セーフモード |
UART | 76800bps | 130μs(推定) | 7kB/s | ATP3011上限(*1) |
UART | 115200bps | 87μs(推定) | 11kB/s | ATP3012上限(*2) |
SPI | 1MHz(バイト間隔 20μs) | 31μs(実測) | 32kB/s | 上限 |
(*1) 作成した M-BUS モジュール基板では、38,400BPS の動作を確認できました。
(*2) 作成した M-BUS モジュール基板では、76,800BPS の動作を確認できました。