AIスタックチャンにAAC圧縮機能を組み込む
AIスタックチャンにAAC圧縮機能を組み込む
本記事は スタックチャン Advent Calendar 2023の 9日目の記事です。
概要
AIスタックチャンでは、音声からテキストへの変換処理 (STT) にGoogle CloudのSpeech-To-Text API、もしくはOpenAIのWhisperを用います。
このとき、オリジナルの実装では、音声データを無圧縮のまま送信していますが、音声データを圧縮することにより通信量を削減し、通信処理の高速化が可能か試しました。
具体的には、OpenAIのWhisperへの送信データをESP32上でAAC-LCに圧縮して送信し、圧縮を行わない場合と比較して送信処理全体の所要時間が短縮されるかどうかを確認しました。
結論からいうと、 単純に録音完了後の送信処理時に音声データを圧縮するだけでは所要時間が短縮できず、むしろ伸びる* という結果になりましたが、音声圧縮処理の実装方法はなにかしらの参考になると思いますので、主に自分向けの備忘録として解説しておきます。 (なのであまりスタックチャンそのものには関係が無いです)
6秒程度の音声を送信する場合、Whisperへの送信処理時間は圧縮処理を行わない場合の方が 300[ms] ほど短くなりました。
この300[ms]は、ほぼAAC-LCでの音声圧縮処理にかかっている時間です。
AIスタックチャンの内部では、マイクから録音した音声データは、16bitモノラル、16kHzとして記録されます。よって6秒の音声データの場合は
16[bit] * 16[kHz] * 6[s] = 1536[kbit] = 192[kB]
より、 192[kB]
となります。この音声データを 12[kb/s]
のAAC-LCで圧縮すると、平均で 12[kb/s] * 6[s] = 72[kbit] = 9[kB]
となります
つまり、6秒程度の無圧縮音声データ 192[kB]
を送信するのにかかる時間よりも、AAC-LCによる圧縮処理と圧縮後の音声データ 9[kB]
を送るのにかかる時間の方が 300[ms] ほど長いということです。
圧縮形式の決定
AIスタックチャンが対象としているM5Stackシリーズは、Espressifの無線通信機能付きMCU ESP32 が搭載されています。AIスタックチャンの処理はこのMCU上で実行されています。
一方、EspressifはESP32向けに音声処理用のフレームワークとして ESP-ADF (ESP Audio Development Framework) https://github.com/espressif/esp-adf を開発しています。
ESP-ADFにはさまざまな音声信号処理用の機能が実装されており、音声の圧縮・伸長処理も含まれています。 [1]
これらの音声圧縮・伸長処理は、ESP-ADKのサブモジュールである esp-adf-libs にふくまれる esp_audio_codec に実装されています。対応している圧縮形式は以下の通りです。
- AAC-LC
- AMR-NB
- AMR-WB
- ADPCM
- G711
- Opus
一方、OpenAI Whisperが対応している音声フォーマットとしては、圧縮形式は明示されていませんが、
ドキュメント
には、
File uploads are currently limited to 25 MB and the following input file types are supported: mp3, mp4, mpeg, mpga, m4a, wav, and webm.
と記載されています。これらの形式のうち、esp_audio_codecが対応している圧縮形式を格納できるのは、 mp4, mpga, m4a, wav, webm
です。
拡張子 wav
の所謂 RIFF WAVE形式には、無圧縮PCM音声データ以外にも圧縮音声データとしてAMRやADPCMなどを格納できますが、これらのファイルを試しにWhisperに入力してみたところ、対応しないファイル形式であるというエラーが返ってきました。よって wav
が指すものは無圧縮のPCM音声データのみということのようです。
残りは、 mp4, mpga, m4a
に格納できる AAC-LC か、 webm
に格納できる Opus となります。
esp_audio_codecのページには、各圧縮形式ごとのメモリ使用量とCPU負荷率が記載されています。
これによると、AIスタックチャンで扱うサンプリングレート16[kHz]のモノラル音声データに対して、
- AAC-LC: メモリ使用量 56[kB], CPU負荷率 6.0[%]
- Opus : メモリ使用量 43[kB], CPU負荷率 16.8[%]
となっており、今回はメモリ使用量が多いもののCPU負荷率が低い AAC-LC を選択しました。
AAC-LC 圧縮処理の実装
esp_audio_codecでの音声圧縮処理の手順は、READMEのUsage に記載されており、この通りにAPIを呼び出せば簡単に圧縮処理を実装できます。
処理の流れは以下の通りです。
必要なヘッダのinclude
AAC-LCでの圧縮処理には以下のヘッダが必要です。
#include "esp_aac_enc.h"
#include "esp_audio_enc_def.h"
#include "esp_audio_def.h"
esp_aac_enc_config_t
型の変数の初期化
esp_aac_enc_config_t config = ESP_AAC_ENC_CONFIG_DEFAULT();
config.sample_rate = 16000; // AIスタックチャンの音声入力は16[kHz]なので16000
config.channel = 1; // モノラルなので1
config.bitrate = 22000; // 22000[bps] 16[kHz]の場合は 22-96[kbps]が指定可能
config.adts_used = false; // ADTS (Audio Data Transport Stream) ヘッダをつけない。
ビットレートの指定可能範囲は、 esp_aac_enc.h
の esp_aac_enc_config_t
の定義に対するコメント [1:1] に記載されています。
また、前述のとおり音声データの格納形式としてMP4を用いるため、ADTSヘッダはつけないようにします。
エンコーダの初期化と圧縮パラメータの取得
void* enc_handle = NULL; // エンコーダのハンドル
int in_frame_size = 0; // 一度の処理でエンコーダへ入力する音声データサイズ (バイト)
int out_frame_size = 0; // 一度の処理でエンコーダから出力される音声データサイズの最大値 (バイト)
esp_audio_err_t ret = esp_aac_enc_open(&config, sizeof(esp_aac_enc_config_t), &enc_handle);
if (ret != 0) {
// エラー
}
ret = esp_aac_enc_get_frame_size(enc_handle, &in_frame_size, &out_frame_size);
if (ret != 0) {
// エラー
}
// 入力データや出力バッファを `in_frame_size` や `out_frame_size` に基いて用意する
// ...
// 使い終わったらエンコーダのリソースを解放する
esp_aac_enc_close(enc_handle);
先ほど定義した esp_aac_enc_config_t
をパラメータとして esp_aac_enc_open
を呼び出します。成功した場合0が帰ります。
また第3引数に指定した void*
型のポインタにエンコーダのハンドルが格納されます。
つぎに、esp_aac_enc_get_frame_size
を呼び出して、エンコーダの入出力単位を取得します。
in_frame_size
out_frame_size
はエンコード処理で使います。
エンコード処理が終わったら esp_aac_enc_close
を忘れずに呼び出します。
エンコード処理
const uint8_t* raw_audio_data; // 音声データが入っている
size_t raw_audio_size; // 音声データの長さ (バイト単位) が入っている
uint8_t* input_buffer = malloc(in_frame_size);
uint8_t* output_buffer = malloc(out_frame_size);
size_t remaining_bytes = raw_audio_size; // 未処理データ量 (バイト単位)
esp_audio_enc_in_frame_t in_frame = { 0 }; // エンコーダへの入力
esp_audio_enc_out_frame_t out_frame = { 0 }; // エンコーダからの出力
while( remaining_bytes > 0 ) {
// 入力の設定
in_frame.buffer = raw_audio_data;
in_frame.len = in_frame_size;
size_t bytes_to_input = in_frame_size;
if( remainig_bytes < in_frame_size ) {
// 入力の残りが足りないので、入力バッファに残りをコピーして、足りない部分を0で埋める
bytes_to_input = remaining_bytes;
in_frame.buffer = input_buffer;
memcpy(input_buffer, raw_audio_data, remaining_bytes);
memset(input_buffer + remaining_bytes, (in_frame_size - remaining_bytes), 0);
}
// 出力の設定
out_frame.buffer = output_buffer;
out_frame.len = out_frame_size;
// エンコード処理実行
ret = esp_aac_enc_process(enc_handle, &in_frame, &out_frame);
if( ret != 0 ) {
// エラー処理
}
// 入力位置更新
raw_audio_data += in_frame_size;
// out_frame.encoded_bytes に実際の圧縮処理後のサイズが入っている
// out_frame.buffer[0] ~ out_frame.buffer[out_frame.encoded_bytes-1] が圧縮後のデータ
}
esp_audio_enc_in_frame_t
esp_audio_enc_out_frame_t
にそれぞれ入出力データのバッファと長さを指定して、 esp_aac_enc_process
を入力データの終わりまで繰り返し呼べばAAC-LCでの圧縮処理を行えます。
入力は in_frame_size
バイト単位で行う必要があるので、音声データの末尾で入力の長さが足りない場合は足りない部分を0で埋めて入力します。
MP4出力処理
前述の通り、OpenAI Whisperは限られたフォーマットの音声データのみ入力可能です。
AAC-LCで圧縮された音声データは主に拡張子 *.aac
で表されることの多い ADTS (Audio Data Transport Stream) もしくは MP4 に格納されます。
ADTSは名前の通り、通信時などのストリームに流すときに用いられるフォーマットで、圧縮データのフレーム単位でパケット化されています。
esp_audio_codecのエンコーダでもADTSのヘッダをつけたパケットを出力できるため、このフォーマットが使えれば楽だったのですが、残念ながらWhisperではエラーとなってしまいます。
変わりに、 *.mp4
や *.m4a
等の拡張子でおなじみのMP4形式でAAC-LCで圧縮した音声データを格納します。
ただし、このMP4の仕様がとても複雑で、理解して実装するのに少々手間どりました。
以下、AAC-LCを格納するのに必要なMP4の構成をざっくり説明します。
MP4の構造
MP4はBoxと呼ばれる構造で定義される木構造のデータ形式になっています。
Boxは先頭にサイズおよびタイプのヘッダを持っていて、そのあとにタイプに従ったデータが続きます。
Boxの中にはさらに別のBoxが入っている場合もあります。
struct BoxHeader {
u32be size; // Box全体 (size, typeも含む) のバイト単位でのサイズ
u8 type[4]; // Boxの種類を表す4バイトの文字列
};
各フィールドのバイトオーダーは、指定が無い場合は ビッグエンディアン です。 (面倒な…)
MP4の資料
MP4ファイルは正式には ISO Base Media File Format (ISOBMFF) という形式で、ISOの関連規格で規定されている映像データ当の記録用フォーマットです。
AppleがQuickTimeで用いていたフォーマット (QTFF) を元にしているため、Appleの開発者サイトにあるQuickTime File Formatのドキュメントが参考になります。
見るとわかりますが、これだけだと複雑すぎてよくわからないので、適宜手元になにかしらのAAC-LCを格納したMP4ファイルを用意して、バイナリエディタ等で開いて内容を追いかけます。
基本型の定義
とりあえず繰り返し出てくる基本的な型を定義しておきます。
整数型
MP4 (ISOBMFF) で出てくる整数型は基本的にビッグエンディアンなので、リトルエンディアンのCPUで適当に扱えるように型を定義しておきます。
u8
は1バイトなのでそのまま std::uint8_t
でいいとして、 u16
u24
u32
u64
はバイト列との相互変換を定義しておきます。
typedef std::uint8_t u8;
struct __attribute__((packed)) u16 {
std::uint8_t octets[2];
constexpr u16() : octets {0, 0} {}
constexpr u16(std::uint16_t value) : octets {std::uint8_t(value >> 8), std::uint8_t(value)} {}
constexpr operator std::uint16_t() const {
return (static_cast<std::uint32_t>(this->octets[0]) << 8)
| (static_cast<std::uint32_t>(this->octets[1]) << 0)
;
}
};
struct __attribute__((packed)) u24 {
std::uint8_t octets[3];
constexpr u24() : octets {0, 0, 0} {}
constexpr u24(std::uint32_t value) : octets { std::uint8_t(value >> 16), std::uint8_t(value >> 8), std::uint8_t(value) } {}
constexpr operator std::uint32_t() const {
return (static_cast<std::uint32_t>(this->octets[0]) << 16)
| (static_cast<std::uint32_t>(this->octets[1]) << 8)
| (static_cast<std::uint32_t>(this->octets[2]) << 0)
;
}
};
struct __attribute__((packed)) u32 {
std::uint8_t octets[4];
constexpr u32() : octets {0, 0, 0, 0} {}
constexpr u32(std::uint32_t value) : octets{std::uint8_t(value >> 24), std::uint8_t(value >> 16), std::uint8_t(value >> 8), std::uint8_t(value)} {}
constexpr operator std::uint32_t() const {
return (static_cast<std::uint32_t>(this->octets[0]) << 24)
| (static_cast<std::uint32_t>(this->octets[1]) << 16)
| (static_cast<std::uint32_t>(this->octets[2]) << 8)
| (static_cast<std::uint32_t>(this->octets[3]) << 0)
;
}
};
struct __attribute__((packed)) u64 {
std::uint8_t octets[8];
constexpr u64() : octets {0, 0, 0, 0, 0, 0, 0, 0} {}
constexpr u64(std::uint64_t value) : octets{
std::uint8_t(value >> 56),
std::uint8_t(value >> 48),
std::uint8_t(value >> 40),
std::uint8_t(value >> 32),
std::uint8_t(value >> 24),
std::uint8_t(value >> 16),
std::uint8_t(value >> 8),
std::uint8_t(value),
} {}
constexpr operator std::uint32_t() const {
return (static_cast<std::uint32_t>(this->octets[0]) << 24)
| (static_cast<std::uint32_t>(this->octets[1]) << 16)
| (static_cast<std::uint32_t>(this->octets[2]) << 8)
| (static_cast<std::uint32_t>(this->octets[3]) << 0)
;
}
};
BoxType
Boxのタイプを表す4バイトの文字列に対応する型です。文字列で適当に "ftyp"
とか書けた方が便利なので、文字列から構築できるようにしておきます。
struct __attribute__((packed)) BoxType {
std::uint8_t octets[4];
constexpr BoxType() : octets {0, 0, 0, 0} {}
constexpr BoxType(const char* s) : octets {std::uint8_t(s[0]), std::uint8_t(s[1]), std::uint8_t(s[2]), std::uint8_t(s[3])} {}
constexpr BoxType(const BoxType&) = default;
constexpr bool operator==(const BoxType& other) const {
return this->octets[0] == other.octets[0]
&& this->octets[1] == other.octets[1]
&& this->octets[2] == other.octets[2]
&& this->octets[3] == other.octets[3];
}
constexpr bool operator!=(const BoxType& other) const {
return !this->operator==(other);
}
};
AtomHeader
Box (Atom) のヘッダの型です。前述の通りサイズとタイプを持ちます。
struct __attribute__((packed)) AtomHeader {
u32 size;
BoxType type;
};
Matrix
3x3の行列型です。音声データだと何に使うのかわかりませんが、映像データ等での変形などを表現するのではないかと思います。
面倒なのでデフォルト値 (単位行列?) を入れてあります。
template<typename T>
struct __attribute__((packed)) Matrix {
T values[9];
constexpr Matrix() : values{0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000} {}
constexpr Matrix(const T values[9]) : values(values) {}
};
AAC-LC格納に必要最低限のBox
ftyp
moov
mdat
が必要です。このうち、 moov
には音声データの構造に関する情報が入っていて結構複雑です。
- ftyp
- mdat
- moov
- mvhd
- trak
- tkhd
- edts
- elst
- mdia
- mdhd
- hdlr
- minf
- stbl
- stsd
- esds
- mp4a
- btrt
- esds
- stts
- stsc
- stsz
- stco
- stsd
1つ1つのBoxに格納する情報はそんなに難しくないので順に説明します。
ftyp
ISOBMFFのファイル形式についての情報を格納するBoxです。MP4の場合は isom
とか mp41
とか入れておけばいいようです。
struct FtypAtom {
AtomHeader header;
BoxType major_brand;
u32 minor_version;
BoxType compatible_brands[2];
};
FtypAtom ftyp;
ftyp.major_brand = "isom";
ftyp.minor_version = 0x00000200;
ftyp.compatible_brands[0] = "isom";
ftyp.compatible_brands[1] = "mp41";
moov
mvhd
と trak
で構成されています。
struct MoovBox {
AtomHeader header;
MvhdAtom mvhd;
TrakBox trak;
}
mvhd
struct __attribute__((packed)) MvhdAtom {
AtomHeader header;
Version version;
Flags flags;
u32 creation_time;
u32 modification_time;
u32 timescale;
u32 duration;
u32 rate;
u16 volume;
u8 reserved[10];
Matrix<u32> matrix;
u32 preview_time;
u32 preview_duration;
u32 poster_time;
u32 selection_time;
u32 selection_duration;
u32 current_time;
u32 next_track_id;
}
// mvhd
moov.mvhd.version = 0;
moov.mvhd.flags = 0;
moov.mvhd.creation_time = 0;
moov.mvhd.modification_time = 0;
moov.mvhd.timescale = 1000;
moov.mvhd.duration = number_of_samples * 1000 / sample_rate;
moov.mvhd.rate = 0x00010000;
moov.mvhd.volume = 0x0100;
std::fill(moov.mvhd.reserved, moov.mvhd.reserved + sizeof(moov.mvhd.reserved), 0);
moov.mvhd.matrix = Matrix<u32>();
moov.mvhd.preview_time = 0;
moov.mvhd.preview_duration = 0;
moov.mvhd.poster_time = 0;
moov.mvhd.selection_time = 0;
moov.mvhd.selection_duration = 0;
moov.mvhd.current_time = 0;
moov.mvhd.next_track_id = 2;
mvhd
には時間に関する基本的なメタデータが入っているようです。
timescale
フィールドは音声データの時間の単位を表す値を入れます。 1000
を入れて 1/1000[s]
単位とすることが多いようです。
duration
フィールドには、 timescale
単位での音声データの長さを入れます。
音声データのサンプル数 number_of_samples
音声データのサンプリングレート sample_rate
から number_of_samples * 1000 / sample_rate
で求めます。
rate
は再生レートを表す係数のようです。32bit値ですが、実際は16bit + 16bitの固定小数点となっており、1.0
を表す 0x00010000
を入れます。
next_track_id
は何に使われるかわかりません (動画編集時のトラックIDの割り当て用?) が、 2
を適当に入れておけば良いようです。
trak
struct TrakBox {
AtomHeader header;
TkhdAtom tkhd;
EdtsBox edts;
MdiaBox mdia;
};
tkhd
edts
mdia
が入っています。
tkhd
struct __attribute__((packed)) TkhdAtom {
AtomHeader header;
Version version;
Flags flags;
u32 creation_time;
u32 modification_time;
u32 track_id;
u32 reserved_0;
u32 duration;
u32 reserved_1[2];
u16 layer;
u16 alternate_group;
u16 volume;
u16 reserved_2;
Matrix<u32> matrix;
u32 width;
u32 height;
};
// trak/tkhd
moov.trak.tkhd.version = 0;
moov.trak.tkhd.flags = 0x0003;
moov.trak.tkhd.creation_time = 0;
moov.trak.tkhd.modification_time = 0;
moov.trak.tkhd.track_id = 1;
moov.trak.tkhd.reserved_0 = 0;
moov.trak.tkhd.duration = number_of_samples * 1000 / sample_rate;
std::fill(moov.trak.tkhd.reserved_1, moov.trak.tkhd.reserved_1 + sizeof(moov.trak.tkhd.reserved_1), 0);
moov.trak.tkhd.layer = 0;
moov.trak.tkhd.alternate_group = 1;
moov.trak.tkhd.volume = 0x0100;
moov.trak.tkhd.reserved_2 = 0;
moov.trak.tkhd.matrix = Matrix<u32>();
moov.trak.tkhd.width = 0;
moov.trak.tkhd.height = 0;
flags
の意味はわかりませんが、とりあえず 0x003
を入れておけばよいようです。
track_id
にはトラックのIDとして 1
を入れておきます。
duration
は mvhd.duration
と同様にトラックの timescale
単位での長さを入れておきます。
alternate_group
もよくわかりませんが、とりあえず 1
で良いようです。
volume
は音量の係数のようです。16bit値ですが 8bit + 8bitの固定小数点なので、1.0
を表す 0x0100
を入れておきます。
edts
struct __attribute__((packed)) EdtsBox {
AtomHeader header;
ElstAtom elst;
};
elst
struct __attribute__((packed)) ElstAtom {
struct __attribute__((packed)) ElstEntry {
u32 segment_duration;
u32 media_time;
u32 media_rate;
template<typename S> void write(S& stream) const {
AACMP4::write(stream, this->segment_duration);
AACMP4::write(stream, this->media_time);
AACMP4::write(stream, this->media_rate);
}
};
AtomHeader header;
Version version;
Flags flags;
u32 entry_count;
ElstEntry entries[1];
}
// trak/edts/elst
moov.trak.edts.elst.version = 0;
moov.trak.edts.elst.flags = 0;
moov.trak.edts.elst.entry_count = 1;
moov.trak.edts.elst.entries[0].segment_duration = number_of_samples * 1000 / sample_rate;
moov.trak.edts.elst.entries[0].media_time = 0x00000000;
moov.trak.edts.elst.entries[0].media_rate = 0x00010000;
トラックを構成するメディア内の位置のリストを持てるようですが、単純に音声データの最初から最後までで良いので、リストの要素数は1つだけで良いです。
ここまでと同様、 segment_duration
に timescale
単位での長さ、 media_rate
に32bit固定小数点でのレートを入れておきます。
mdia
struct MdiaBox {
AtomHeader header;
MdhdAtom mdhd;
HdlrAtom hdlr;
MinfBox minf;
};
mdhd
struct __attribute__((packed)) MdhdAtom {
AtomHeader header;
Version version;
Flags flags;
u32 creation_time;
u32 modification_time;
u32 timescale;
u32 duration;
u16 language;
u16 quality;
};
// trak/mdia/mdhd
moov.trak.mdia.mdhd.version = 0;
moov.trak.mdia.mdhd.flags = 0;
moov.trak.mdia.mdhd.creation_time = 0;
moov.trak.mdia.mdhd.modification_time = 0;
moov.trak.mdia.mdhd.timescale = sample_rate;
moov.trak.mdia.mdhd.duration = number_of_samples;
moov.trak.mdia.mdhd.language = 0x55c4;
moov.trak.mdia.mdhd.quality = 0;
timescale
には音声データの時間の単位を指定します。 tkhd
で指定した値 1/1000
ではなく、 サンプリングレートの値 をいれておき、1サンプル単位で時間を表現できるようにします。
duration
にはサンプル数を入れておきます。 language
はISO language codeとやらで表された言語コードを格納します。 0x55c4
は undetermined languageで言語不定を表します。
hdlr
struct __attribute__((packed)) HdlrAtom {
AtomHeader header;
Version version;
Flags flags;
u32 component_type;
u32 handler_type;
u32 reserved[3];
u8 name[13];
};
// trak/mdia/hdlr
moov.trak.mdia.hdlr.version = 0;
moov.trak.mdia.hdlr.flags = 0;
moov.trak.mdia.hdlr.component_type = 0;
moov.trak.mdia.hdlr.handler_type = 0x736F756E; // soun
std::fill(moov.trak.mdia.hdlr.reserved, moov.trak.mdia.hdlr.reserved + sizeof(moov.trak.mdia.hdlr.reserved), 0);
std::memcpy(moov.trak.mdia.hdlr.name, "SoundHandler", 13);
データの処理方法を表しているようにみえる。とりあえず handler_type
に soun
、 name
に SoundHandler
を指定しておけば良いようです。
minf
struct MinfBox {
AtomHeader header;
SmhdAtom smhd;
DinfBox dinf;
StblBox stbl;
};
smhd
struct __attribute__((packed)) SmhdAtom {
AtomHeader header;
u8 reserved[8];
};
std::fill(moov.trak.mdia.minf.smhd.reserved, moov.trak.mdia.minf.smhd.reserved + sizeof(moov.trak.mdia.minf.smhd.reserved), 0);
何か情報を入れるようですが、all-0だったのでreservedにしてあります。
dinf
struct __attribute__((packed)) DinfBox {
AtomHeader header;
DrefBox dref;
};
dref
struct __attribute__((packed)) DrefBox {
struct __attribute__((packed)) DataEntry {
AtomHeader header;
Version version;
Flags flags;
};
AtomHeader header;
Version version;
Flags flags;
u32 entry_count;
DataEntry data_entries[1];
};
moov.trak.mdia.minf.dinf.dref.version = 0;
moov.trak.mdia.minf.dinf.dref.flags = 0;
moov.trak.mdia.minf.dinf.dref.entry_count = 1;
moov.trak.mdia.minf.dinf.dref.data_entries[0].header.type = "url ";
moov.trak.mdia.minf.dinf.dref.data_entries[0].version = 1;
moov.trak.mdia.minf.dinf.dref.data_entries[0].flags = 0;
データの位置を表すBoxらしいがよくわからない。データの位置の種類にいくつかあるようだが、オーディオファイルの場合は url
型で空文字列を格納しておけばよいようである。
stbl
struct StblBox {
AtomHeader header;
StsdBox stsd;
SttsAtom stts;
StscAtom stsc;
StszBox stsz;
StcoAtom stco;
};
stsd, esds
struct StsdBox {
struct __attribute__((packed)) SampleDescriptionEntryHeader {
AtomHeader header;
u8 reserved[6];
u16 data_reference_index;
u16 version;
u16 revision_level;
u32 vendor;
u16 number_of_channels;
u16 sample_size;
u16 compression_id;
u16 packet_size;
u32 sample_rate;
};
struct __attribute__((packed)) SampleDescriptionEntry {
SampleDescriptionEntryHeader header;
EsdsAtom esds;
BtrtAtom btrt;
};
struct __attribute__((packed)) StsdHeader {
AtomHeader header;
Version version;
Flags flags;
u32 entry_count;
};
StsdHeader header;
std::vector<SampleDescriptionEntry> sample_description_entries;
};
// MPEG-4 elementary stream descriptor atom
// https://developer.apple.com/documentation/quicktime-file-format/mpeg-4_elementary_sound_stream_descriptor_atom
struct __attribute__((packed)) EsdsAtom {
static constexpr u8 TAG_ES_DESCRIPTOR = 0x03;
static constexpr u8 TAG_DECODER_CONFIG = 0x04;
static constexpr u8 TAG_DECODER_SPECIFIC = 0x05;
static constexpr u8 TAG_SL_CONFIG_DESCRIPTOR = 0x06;
struct __attribute__((packed)) SLConfigDescriptor {
u8 tag;
u8 size[4];
u8 predefined;
};
struct __attribute__((packed)) DecoderSpecificInfo {
u8 tag;
u8 size[4];
u8 specific[5];
};
struct __attribute__((packed)) DecoderConfiguration {
u8 tag;
u8 size[4];
u8 object_type;
u8 flags;
u24 buffer_size;
u32 max_bit_rate;
u32 average_bit_rate;
DecoderSpecificInfo decoder_specific;
};
struct __attribute__((packed)) ESDescriptor {
u8 tag;
u8 size[4];
u16 es_id;
u8 flags;
DecoderConfiguration decoder_config;
SLConfigDescriptor sl_config;
};
AtomHeader header;
u32 version;
ESDescriptor desc;
};
StsdBox::SampleDescriptionEntry sd;
sd.header.data_reference_index = 1;
sd.header.version = 0;
sd.header.revision_level = 0;
sd.header.vendor = 0;
sd.header.number_of_channels = 1; // チャネル数。モノラルなので1
sd.header.sample_size = 16; // チャネルあたりのビット数。16bit
sd.header.compression_id = 0;
sd.header.packet_size = 0;
sd.header.sample_rate = std::uint32_t(sample_rate << 16); // サンプリングレート 32bit固定小数点なので 16bit左シフトしておく
sd.esds.version = 0;
sd.esds.desc.tag = EsdsAtom::TAG_ES_DESCRIPTOR;
AACMP4::array_adapter(sd.esds.desc.size) = {0x80, 0x80, 0x80, 0x25}; // 37 bytes
sd.esds.desc.es_id = 1;
sd.esds.desc.decoder_config.tag = EsdsAtom::TAG_DECODER_CONFIG;
AACMP4::array_adapter(sd.esds.desc.decoder_config.size) = {0x80, 0x80, 0x80, 0x17}; // 23 bytes
sd.esds.desc.decoder_config.object_type = 0x40; // MPEG-4 AAC LC
sd.esds.desc.decoder_config.flags = 0x15;
sd.esds.desc.decoder_config.buffer_size = 0;
sd.esds.desc.decoder_config.max_bit_rate = 69000;
sd.esds.desc.decoder_config.average_bit_rate = 58223;
sd.esds.desc.decoder_config.decoder_specific.tag = EsdsAtom::TAG_DECODER_SPECIFIC;
AACMP4::array_adapter(sd.esds.desc.decoder_config.decoder_specific.size) = {0x80, 0x80, 0x80, 0x05}; // 5 bytes
AACMP4::array_adapter(sd.esds.desc.decoder_config.decoder_specific.specific) = {0x14, 0x08, 0x56, 0xe5, 0x00};
sd.esds.desc.sl_config.tag = EsdsAtom::TAG_SL_CONFIG_DESCRIPTOR;
AACMP4::array_adapter(sd.esds.desc.sl_config.size) = {0x80, 0x80, 0x80, 0x01}; // 1 byte
sd.esds.desc.sl_config.predefined = 0x02;
sd.btrt.buffer_size = 0;
sd.btrt.max_bit_rate = 0;
sd.btrt.average_bit_rate = 0;
moov.trak.mdia.minf.stbl.stsd.header.flags = 0;
moov.trak.mdia.minf.stbl.stsd.header.version = 0;
moov.trak.mdia.minf.stbl.stsd.sample_description_entries.push_back(sd);
stsdには音声データの構造についての情報が含まれている。特にesdsの中の desc
フィールドには、MPEG4の規格で規定されてる ESDescriptor
と呼ばれる構造で、オーディオデータの形式を規定している。ここでは細かい内容は気にせずにおく。
btrt
struct __attribute__((packed)) BtrtAtom {
AtomHeader header;
u32 buffer_size;
u32 max_bit_rate;
u32 average_bit_rate;
};
sd.btrt.buffer_size = 0;
sd.btrt.max_bit_rate = 0;
sd.btrt.average_bit_rate = 0;
おそらくデコード時の参考情報としてのバッファサイズやレート情報と思われる。全て 0
で問題ない。
stts
struct __attribute__((packed)) SttsAtom {
struct __attribute__((packed)) SttsEntry {
u32 count;
u32 duration;
};
AtomHeader header;
Version version;
Flags flags;
u32 number_of_entries;
SttsEntry entries[2];
};
moov.trak.mdia.minf.stbl.stts.version = 0;
moov.trak.mdia.minf.stbl.stts.flags = 0;
std::uint32_t remainder_samples = number_of_samples % max_samples_per_frame;
moov.trak.mdia.minf.stbl.stts.number_of_entries = remainder_samples == 0 ? 1 : 2;
moov.trak.mdia.minf.stbl.stts.entries[0].count = number_of_samples / max_samples_per_frame;
moov.trak.mdia.minf.stbl.stts.entries[0].duration = max_samples_per_frame;
if(remainder_samples != 0) {
moov.trak.mdia.minf.stbl.stts.entries[1].count = 1;
moov.trak.mdia.minf.stbl.stts.entries[1].duration = remainder_samples;
}
音声データのフレームの長さを時間に変換するためのリストです。
各リストには timescale
単位での長さを表す duration
フィールドと、その長さを持つフレームの個数を表す count
フィールドを持ちます。
単純なオーディオデータの場合、1フレーム当たりの最大サンプル数 max_samples_per_frame
(AAC-LC圧縮時の in_frame_size
バイト分のサンプル数) で全体のサンプル数 number_of_samples
を割った個数と、その余りの要素を入れておきます。
stsc
struct __attribute__((packed)) StscAtom {
struct __attribute__((packed)) StscEntry {
u32 first_chunk;
u32 samples_per_chunk;
u32 sample_description_id;
};
AtomHeader header;
Version version;
Flags flags;
u32 number_of_entries;
StscEntry entries[1];
};
moov.trak.mdia.minf.stbl.stsc.version = 0;
moov.trak.mdia.minf.stbl.stsc.flags = 0;
moov.trak.mdia.minf.stbl.stsc.number_of_entries = 1;
moov.trak.mdia.minf.stbl.stsc.entries[0].first_chunk = 1;
moov.trak.mdia.minf.stbl.stsc.entries[0].samples_per_chunk = (number_of_samples + max_samples_per_frame - 1) / max_samples_per_frame;
moov.trak.mdia.minf.stbl.stsc.entries[0].sample_description_id = 1;
各チャンクに格納されているサンプル数のリストを指定します。
単純な音声データの場合チャンクは1つなので要素数は1固定で良いです。
stsz
struct StszBox {
StszAtomHeader header;
std::vector<u32> entries;
};
moov.trak.mdia.minf.stbl.stsz.header.version = 0;
moov.trak.mdia.minf.stbl.stsz.header.flags = 0;
moov.trak.mdia.minf.stbl.stsz.header.sample_size = 0;
moov.trak.mdia.minf.stbl.stsz.entries = sample_sizes;
stsz Boxには、各サンプル列のバイト数のリストを格納します。
AAC-LCでの圧縮処理時にフレーム単位で圧縮しましたが、そのときの出力データのサイズをリストとして格納しておきます。
stco
struct StcoAtom {
AtomHeader header;
Version version;
Flags flags;
u32 number_of_entries;
u32 entries[1];
};
moov.trak.mdia.minf.stbl.stco.version = 0;
moov.trak.mdia.minf.stbl.stco.flags = 0;
moov.trak.mdia.minf.stbl.stco.number_of_entries = 1;
moov.trak.mdia.minf.stbl.stco.entries[0] = ftyp.header.size + moov.header.size + 8; // ftyp box + moov box + mdat header
Chunk Offset Atomという名が示す通り、各chunkの開始オフセットをバイト単位で指定するリストを持ちます。
ここで注意しないといけないのは、chunkの開始オフセットは mdat
Boxの先頭からのオフセットではなく、 MP4ファイルの先頭からのオフセット という点です。
よって、 `mdat`` Boxの先頭データのオフセットを計算して格納する必要があります。
実際の実装
ここまで解説した通り、単にAAC-LCの音声データを格納したいだけなのにかなり複雑なメタデータを生成する必要があることが分かります。
これらの処理は面倒なので aacmp4
としてヘッダのみのライブラリとしてまとめておき、適宜呼び出して使うようにしました。
使い方はいたって簡単で、 aacmp4.hpp
をincludeして、 AACMP4::write()
に音声データとフレームのリストを渡して呼び出すだけです。
動作確認
さすがにいきなりM5Stack上でMP4の出力処理を動かしても動かないだろうということで、PC上でテストできるようにしてあります。
esp_audio_codecはESP32上でしか動かないので、変わりのAAC-LCエンコーダとして FDK-AACライブラリを使うようになっています。
AIスタックチャンへの組込み
ここまででようやく、音声データをAAC-LCで圧縮してMP4形式に格納できるようになったので、AIスタックチャンのOpenAI Whisperへのデータ送信処理を改造して、AAC-LCで圧縮したデータを送るようにしてみます。ベースは M5Unified_AI_StackChan_Lite とします。
ビルド構成の変更
esp_audio_codecはIDF Component Managerで管理された ESP-IDFのコンポーネントとして公開されています。
一方、M5Unified_AI_StackChan_Lite はPlatformIOのarduinoフレームワークでビルドする構成となっており、このままではかなり無理やりな手順でなければ導入できません。
幸い、PlatformIOのESP32向けプラットフォームでは、arduinoとespidfフレームワークを組み合わせた構成が可能です。platformio.ini
の framework
に arduino, espidf
と指定しておくと、ESP-IDFのビルドシステムを使ってビルドしつつ、Arduino coreをESP-IDFのコンポーネントとして追加するようになります。
これにより、Arduinoに依存したコードやライブラリをPlatformIOを使ってESP-IDFでビルドできます。
framework = arduino, espidf
このままだとESP8266Audioの警告がエラー扱いとなってビルドできなくなるので、 build_unflags
を使って -Werror=all
の指定を外しておきます。また、C++17を使うので合わせて --std=gnu++11
の指定も除外し、変わりに build_flags
に -std=gnu++17
を指定しておきます。
build_unflags = -Werror=all -std=gnu++11
build_flags = -std=gnu++17
また、先ほど説明したMP4書き出しライブラリ aacmp4
の参照を追加しておきます。
lib_deps =
m5stack/M5Unified @ 0.1.11
meganetaaan/M5Stack-Avatar@ 0.8.6
earlephilhower/ESP8266Audio @ 1.9.7
bblanchon/ArduinoJson @ ^6
https://github.com/ciniml/aacmp4.git
最終的に platformio.ini
は以下のようになります。
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = m5stick-c
[env]
platform = espressif32@6.3.2
framework = arduino, espidf
board_build.partitions = no_ota.csv
upload_speed = 1500000
monitor_speed = 115200
monitor_filters = time, colorize, esp32_exception_decoder
lib_deps =
m5stack/M5Unified @ 0.1.11
meganetaaan/M5Stack-Avatar@ 0.8.6
earlephilhower/ESP8266Audio @ 1.9.7
bblanchon/ArduinoJson @ ^6
https://github.com/ciniml/aacmp4.git
build_unflags = -Werror=all -std=gnu++11
build_flags = -std=gnu++17
[env:m5stick-c]
board = m5stick-c
[env:m5stack-atoms3]
board = esp32-s3-devkitc-1
build_flags=
-std=gnu++17
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_M5Stack_ATOMS3
[env:m5stack-core2]
board = m5stack-core2
sdkconfig.defaultsの作成
Arduino coreをESP-IDFでビルドするには、いくつかのESP-IDFの設定を変更する必要があります。ビルド前に sdkconfig.defaults
を作っておくと、その設定でビルドされるようになります。
CONFIG_FREERTOS_HZ=1000
はArduino coreが同様の設定でビルドされているため、同じ設定をしておく必要があります。異なった値になっているとコンパイル時にエラーが発生します。
また、Arduino coreをコンポーネントとして使った場合に、 setup()
や loop()
が呼び出されるように CONFIG_AUTOSTART_ARDUINO=y
としておきます。
CONFIG_AUTOSTART_ARDUINO=y
CONFIG_FREERTOS_HZ=1000
CONFIG_MBEDTLS_PSK_MODES=y
CONFIG_MBEDTLS_KEY_EXCHANGE_PSK=y
CONFIG_ESP_TASK_WDT=n
CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL=3
CONFIG_ARDUHAL_LOG_COLORS=y
CONFIG_ARDUHAL_PARTITION_SCHEME_NO_OTA=y
CONFIG_ARDUHAL_PARTITION_SCHEME="no_ota"
esp_audio_codecの導入
ESP-IDFのComponent Manager機能をつかって、esp_audio_codecを導入します。 src/idf_component.yml
として以下の内容のファイルを作っておくと、ビルド時に自動的に esp_audio_codecが取得されます。
dependencies:
espressif/esp_audio_codec: ">=1.0.1"
idf:
version: ">=4.4.0"
AAC-LC圧縮処理の組込み
AIスタックチャンのOpenAI Whisperの通信処理は、 src/Whisper.cpp
に実装されています。
まずは必要なヘッダを読みこみます。 USE_AAC_ENCODER
を定義しているときはAAC-LCでの圧縮処理を行い、定義していない時は従来の無圧縮での送信処理を行います。
#define USE_AAC_ENCODER
#ifdef USE_AAC_ENCODER
#include "esp_aac_enc.h"
#include "esp_audio_enc_def.h"
#include "esp_audio_def.h"
#include "aacmp4.hpp"
#include <vector>
#endif
このファイルで定義されている Whisper::Transcrbe
関数内の送信処理にAAC-LC圧縮処理を組み込みます。
まずはHTTPでのファイル送信処理で、送信するファイル名を speak.wav
から speak.mp4
に変更します。
const String header = "--" + String(boundary) + "\r\n"
"Content-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n"
"--" + String(boundary) + "\r\n"
"Content-Disposition: form-data; name=\"language\"\r\n\r\nja\r\n"
"--" + String(boundary) + "\r\n"
#ifdef USE_AAC_ENCODER
"Content-Disposition: form-data; name=\"file\"; filename=\"speak.mp4\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n";
#else
"Content-Disposition: form-data; name=\"file\"; filename=\"speak.wav\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n";
#endif
次に、AAC-LCの圧縮処理を行って、新しいバッファに圧縮済みの音声データをMP4ファイルとして格納します。
元々の処理では無圧縮音声データをWAVファイル形式で送っていたので、音声データのバッファの先頭44バイトにはRIFF WAVE形式のヘッダがついています。
AAC-LCで圧縮するときは、ヘッダの44バイト分をスキップしておきます。
#ifdef USE_AAC_ENCODER
{
void* enc_handle = nullptr;
esp_audio_err_t ret;
esp_aac_enc_config_t config = ESP_AAC_ENC_CONFIG_DEFAULT();
config.sample_rate = 16000;
config.channel = 1;
config.bitrate = 12000;
config.adts_used = 0;
// Skip RIFF WAV header (44 bytes)
remainings -= 44;
ptr += 44;
// Create encoder handle
ret = esp_aac_enc_open(&config, sizeof(esp_aac_enc_config_t), &enc_handle);
if (ret != 0) {
printf("Fail to create encoder handle.");
} else {
// Get in/out buffer size and malloc in/out buffer
size_t input_offset = 0;
int in_frame_size = 0;
int out_frame_size = 0;
ret = esp_aac_enc_get_frame_size(enc_handle, &in_frame_size, &out_frame_size);
std::vector<uint8_t> in_buf(in_frame_size);
std::vector<AACMP4::u32> chunks;
chunks.reserve(128);
std::vector<uint8_t> out_buffer;
out_buffer.reserve(out_frame_size * chunks.size());
// Encode audio data and send.
while( remainings > 0) {
esp_audio_enc_in_frame_t in_frame = { 0 };
esp_audio_enc_out_frame_t out_frame = { 0 };
in_frame.buffer = const_cast<std::uint8_t*>(reinterpret_cast<const std::uint8_t*>(ptr)) + input_offset;
in_frame.len = in_frame_size;
// Extend the output buffer
size_t out_offset = out_buffer.size();
out_buffer.resize(out_offset + out_frame_size);
out_frame.buffer = out_buffer.data() + out_offset;
out_frame.len = out_frame_size;
auto bytes_processed = in_frame_size;
if( remainings < in_frame_size ) {
// Copy the remaining data to the buffer and add padding.
std::copy(ptr, ptr + remainings, in_buf.begin());
std::fill(in_buf.begin() + remainings, in_buf.end(), 0);
in_frame.buffer = in_buf.data();
bytes_processed = remainings;
remainings = 0;
} else {
remainings -= in_frame_size;
}
const auto samples_processed = bytes_processed / sizeof(int16_t);
ret = esp_aac_enc_process(enc_handle, &in_frame, &out_frame);
if (ret != ESP_AUDIO_ERR_OK) {
printf("audio encoder process failed.\n");
break;
}
input_offset += in_frame_size;
if(out_frame.encoded_bytes == 0) continue;
chunks.push_back(out_frame.encoded_bytes);
out_buffer.resize(out_offset + out_frame.encoded_bytes);
out_offset += out_frame.encoded_bytes;
}
esp_aac_enc_close(enc_handle);
auto encode_end_time = esp_timer_get_time();
AACMP4::DummyWriter dummy_writer;
// Calculate output size.
AACMP4::write_aac_mp4(dummy_writer, chunks, out_buffer, 16000, input_offset / 2, in_frame_size / 2);
// for debug
// listen with `nc -l 12345` on the host machine to receive the encoded audio file.
// "192.168.2.14" must be replaced with the address of the host machine.
// {
// WiFiClient debugClient;
// if( debugClient.connect("192.168.2.14", 12345) ) {
// //debugClient.write(out_buffer.data(), out_buffer.size());
// AACMP4::write_aac_mp4(debugClient, chunks, out_buffer, 16000, input_offset / 2, in_frame_size / 2);
// debugClient.flush();
// } else {
// log_e("Failed to connect to debug server.");
// }
// }
log_w("Input samples: %lu, Frame size: %lu, Output size: %d, elapsed: %lu", input_offset/2, in_frame_size/2, dummy_writer.bytes_written, encode_end_time - send_start_time);
client.printf("Content-Length: %d\n", header.length() + dummy_writer.bytes_written + footer.length());
client.printf("Content-Type: multipart/form-data; boundary=%s\n", boundary);
client.println();
client.print(header.c_str());
client.flush();
ClientStreamAdapter adapter(client);
AACMP4::write_aac_mp4(adapter, chunks, out_buffer, 16000, input_offset / 2, in_frame_size / 2);
client.flush();
}
}
#else
client.printf("Content-Length: %d\n", header.length() + audio->GetSize() + footer.length());
client.printf("Content-Type: multipart/form-data; boundary=%s\n", boundary);
client.println();
client.print(header.c_str());
client.flush();
while (remainings > 0) {
auto sz = (remainings > 512) ? 512 : remainings;
client.write(ptr, sz);
client.flush();
remainings -= sz;
ptr += sz;
}
#endif
Whisperに送るMP4ファイルが正しく作れているかを確認するためのデバッグ出力を入れておきます。必要に応じてアンコメントして有効化します。
Linux上で nc -l 12345
を実行しておき、 debugClient.connect("...", 12345)
のアドレスをLinuxが動いているPCのアドレスに変更しておけば、実際にWhisperに送られるMP4ファイルの内容がLinux PCにも送信されます。
// for debug
// listen with `nc -l 12345` on the host machine to receive the encoded audio file.
// "192.168.2.14" must be replaced with the address of the host machine.
{
WiFiClient debugClient;
if( debugClient.connect("192.168.2.14", 12345) ) {
//debugClient.write(out_buffer.data(), out_buffer.size());
AACMP4::write_aac_mp4(debugClient, chunks, out_buffer, 16000, input_offset / 2, in_frame_size / 2);
debugClient.flush();
} else {
log_e("Failed to connect to debug server.");
}
}
送信処理に要した時間を計測するために、タイムスタンプを記録して結果を表示します。
auto send_start_time = esp_timer_get_time();
// (上記送信処理)
auto send_elapsed_time = esp_timer_get_time() - send_start_time;
printf("Send elapsed time: %lld us\n", send_elapsed_time);
ソースコード
M5Unified_AI_StackChan_Liteをforkして stt_acc
ブランチとして置いてあります。
実行結果
AAC-LC圧縮有効
06:27:12.902 > 音声認識開始
06:27:15.006 > [0;33m[ 49179][W][Whisper.cpp:161] Transcribe(): Input samples: 30720, Frame size: 1024, Output size: 6055, elapsed: 279812
06:27:15.617 > Send elapsed time: 891450 us
06:27:17.312 > 音声認識終了
06:27:17.312 > 音声認識結果
06:27:17.312 > スタックちゃんてなに
AAC-LC圧縮無効
06:28:57.814 > 音声認識開始
06:29:00.172 > Send elapsed time: 532481 us
06:29:01.952 > 音声認識終了
06:29:01.952 > 音声認識結果
06:29:01.952 > スタックちゃんてなに
というわけで、冒頭のとおり、AAC-LC圧縮したほうがトータルでは遅いという結果になりました。
圧縮にかかった時間はこの例だと 279[ms] のようです。
一方、圧縮前のデータは30720サンプル (61440バイト) なのに対して、圧縮後は 6055バイトとおよそ1/10になっています。
おわりに
今回はAAC-LCの圧縮処理をAIスタックチャンに組み込んだものの、当初の目的である通信時間の短縮による応答速度の向上は達成できませんでした。
ただし、圧縮処理により音声データのサイズが1/10程度になることが確認できているため、録音と同時に圧縮処理を行うことにより、少ないメモリで長時間の録音も可能になると思われます。
そのうち実装して試してみたいと思います。
Discussion