💨

Module LLMの音声入力まわり

2025/02/06に公開

Module LLMの音声入力まわり

概要

M5Stack Module LLMにはマイクとスピーカーが搭載されており、音声認識や音声による対話に用いられます。
Module LLMでALSA経由でマイクからの音声を録音すると、量子化ビット数が16bitの設定のときのみ、録音された音声の音程が半分になるという現象が発生しています。
そこで、この問題の調査がてら、Module LLMの音声入力回りを調査しました。

結論

AX630C向けの音声システム用のドライバ内に16bitのときのみ行われる、不要と思われる処理があり、これらを削除すると直ります。

	for (ch_reg = 0; ch_reg < (config->chan_nr / 2); ch_reg++) {
		if (stream == SNDRV_PCM_STREAM_PLAYBACK) {
			i2s_write_reg(dev->i2s_base, TCR(ch_reg),
				      dev->xfer_resolution);
			i2s_write_reg(dev->i2s_base, TFCR(ch_reg),
				      dev->fifo_th - 1);
			i2s_write_reg(dev->i2s_base, TER(ch_reg), 1);
		} else {
			i2s_write_reg(dev->i2s_base, RCR(ch_reg),
				      dev->xfer_resolution);
			i2s_write_reg(dev->i2s_base, RFCR(ch_reg),
				      dev->fifo_th - 1);
			i2s_write_reg(dev->i2s_base, RER(ch_reg), 1);
			// if (16 == config->data_width) {  // これらをコメントアウトする
			// 	i2s_write_reg(dev->i2s_base, RCR(1),
			// 	dev->xfer_resolution);
			// 	i2s_write_reg(dev->i2s_base, RFCR(1),
			// 	dev->fifo_th - 1);
			// 	i2s_write_reg(dev->i2s_base, RER(1), 1);
			// }
		}
	}

修正にはbuildrootのイメージをビルドする必要がありますので、公式で対応されるまでは、面倒な方は24bitまたは32bitの量子化ビット数で録音する対策が良いと思います。

arecord -D plughw:0,0 -f S24_LE -c 1 -r 16000 -d 5 input.wav # 24bit
arecord -D plughw:0,0 -f S32_LE -c 1 -r 16000 -d 5 input.wav # 32bit

現象の確認

問題の現象はALSAの arecord コマンドを使って録音すると簡単に確認できます。

arecord コマンドは出荷時の状態では入っていないので、対応する alsa-utils パッケージをインストールします。

apt install -y alsa-utils

arecord コマンドを使って試しに量子化ビット数16bit、サンプリングレート16kHzで5秒間録音してみます。

arecord -D plughw:0,0 -f S16_LE -c 1 -r 16000 -d 5 input_16.wav
  • -D plughw:0,0 : 録音に使うデバイス指定。Module LLMではこの指定で内蔵マイクになる。
  • -f S16_LE : 量子化ビット数16bit(リトルエンディアン)
  • -r 16000 : サンプリングレート 16000Hz = 16kHz
  • -c 1 : チャネル数 1 (モノラル)
  • -d 5 : 録音時間 5秒
  • input_16.wav : 録音先のファイルのパス

次に、録音したファイルをModule LLMで再生してみます。

aplay -D plughw:0,1 input_16.wav

録音した音声がスロー再生みたいな低い音で再生されると思います。

今度は量子化ビット数 32bit で試してみましょう。

arecord -D plughw:0,0 -f S32_LE -c 1 -r 16000 -d 5 input_32.wav
aplay -D plughw:0,1 input_32.wav

32bitだと録音時のとおり再生されると思います。

16bitのときはどういうデータになっているか内容をダンプして確認してみると、どうも1サンプル毎に謎の 0 が入ってきていることが分かります。
(.wavファイルの先頭はヘッダーなので、 data チャンクの4バイト先、 オフセット 0x2c からが波形データになることに注意)

xxd input_16.wav | head
00000000: 5249 4646 2471 0200 5741 5645 666d 7420  RIFF$q..WAVEfmt
00000010: 1000 0000 0100 0100 803e 0000 007d 0000  .........>...}..
00000020: 0200 1000 6461 7461 0071 0200 ecff 0000  ....data.q......
00000030: e8ff 0000 eeff 0000 f5ff 0000 fbff 0000  ................
00000040: fbff 0000 0000 0000 0a00 0000 0d00 0000  ................
00000050: 0700 0000 0700 0000 0700 0000 f5ff 0000  ................
00000060: e4ff 0000 dbff 0000 d1ff 0000 cbff 0000  ................
00000070: cdff 0000 dfff 0000 eaff 0000 e9ff 0000  ................
00000080: e4ff 0000 e5ff 0000 e5ff 0000 e4ff 0000  ................
00000090: e7ff 0000 edff 0000 f6ff 0000 f3ff 0000  ................

当初はなにかしら録音のタイミングの制御がおかしいのかと思っていましたが、どうもどちらかというと余計なデータが転送されているのが原因ではないか?ということが分かります。

先ほどはモノラル ( -c 1 ) で音声を取り込みましたが、Module LLMにはマイクはR側にしかついていませんがステレオ (-c 2) で録音も試してみます。

arecord -D plughw:0,0 -f S16_LE -c 2 -r 16000 -d 5 input_16_c2.wav
aplay -D plughw:0,1 input_16_c2.wav

音声はモノラル同様半分の音程になります。ただしダンプ結果は少し変わります。

xxd input_16_c2.wav | head
00000000: 5249 4646 24e2 0400 5741 5645 666d 7420  RIFF$...WAVEfmt
00000010: 1000 0000 0100 0200 803e 0000 00fa 0000  .........>......
00000020: 0400 1000 6461 7461 00e2 0400 fcff ecff  ....data........
00000030: 0000 0000 feff f9ff 0000 0000 fdff fcff  ................
00000040: 0000 0000 fcff f8ff 0000 0000 ffff 2100  ..............!.
00000050: 0000 0000 ffff 3400 0000 0000 feff eaff  ......4.........
00000060: 0000 0000 fdff faff 0000 0000 fbff 1600  ................
00000070: 0000 0000 fcff f3ff 0000 0000 fcff fbff  ................
00000080: 0000 0000 fdff 0000 0000 0000 feff f1ff  ................
00000090: 0000 0000 fcff c9ff 0000 0000 fdff cfff  ................

今度は4バイトごとに 0000 0000 が入るようになりました。16bitでステレオなので1サンプルあたり4バイトなので、モノラル・ステレオに関わらず、1サンプルごとに余分な 0 が1サンプル分入るということがわかります。

AX630Cの音声まわり

Module LLMに搭載されているSoCである AX630C のハードウェア周りの仕様は公開されていないので、公開されているbuildrootでのイメージ作成環境の内容などから調べていきます。

まずはAX630Cのハードウェア構成を調べるために、デバイスツリーの内容をダンプします。
(デバイスツリーは組み込みシステムで動くLinuxで、デバイスの構成を表現するデータ構造とそれを用いたデバイス管理の仕組みです)

デバイスツリー・コンパイラを入れます。

apt install -y device-tree-compiler

/sys/firmware/fdt からFlattened Device Treeという形式でLinuxカーネルが仕様しているデバイスツリーのバイナリが読めるようになっているので、dtc コマンドでテキストに変換します。

dtc -I dtb -O dts /sys/firmware/fdt -o devicetree.dts 

これでAX630Cが動作している状態でのデバイスツリーが devicetree.dts に出力されます。

このデバイスツリーの中身を確認すると、音声回りと思われる箇所がいくつか見つかります。

audio_codec@0x23F2000 {
  compatible = "axera,ax_actt";
  reg = <0x00 0x23f2000 0x00 0x1000>;
  resets = <0x2e 0x00 0x58 0x00 0x5c 0x24 0x08 0x28 0x08 0x2c 0x08 0x2d 0x00 0xd8 0x00 0xdc 0x04 0x03 0xb0 0x03 0xb4 0x03>;
  reset-names = "prst\0rst";
  clocks = <0x2f 0x7b 0x2c 0x1e>;
  clock-names = "audio_tlb_clk\0audio_clk";
  #sound-dai-cells = <0x00>;
  status = "okay";
  gpio-mic-rp = <0x17 0x07 0x00>;
  gpio-mic-rn = <0x17 0x06 0x00>;
  gpio-mic-ln = <0x17 0x05 0x00>;
  gpio-mic-lp = <0x17 0x04 0x00>;
  gpio-pa-speaker = <0x17 0x08 0x00>;
  phandle = <0x67>;
};
sound {
  status = "okay";
  compatible = "simple-audio-card";
  simple-audio-card,name = "Axera Audio";
  simple-audio-card,widgets = "Microphone\0Mic Jack";
  simple-audio-card,routing = "AMIC\0Mic Jack";
  #address-cells = <0x01>;
  #size-cells = <0x00>;

  simple-audio-card,dai-link@0 {
    format = "i2s";
    bitclock-master = <0x65>;
    frame-master = <0x65>;
    capture-only;

    cpu {
      sound-dai = <0x66>;
    };

    codec {
      sound-dai = <0x67>;
      system-clock-frequency = <0xbb8000>;
      phandle = <0x65>;
    };
  };

  simple-audio-card,dai-link@1 {
    format = "i2s";
    playback-only;

    cpu {
      sound-dai = <0x68>;
    };

    codec {
      sound-dai = <0x67>;
      system-clock-frequency = <0xbb8000>;
    };
  };
};

これをもとに公開されているbuildroot環境に対応するものが無いか探すと、ビルド時に適用されるパッチにより対応するファイルが出来ているのが確認できました。
( LLM_buildroot-external-m5stack/tools/build_Module_LLM_buidlroot/buildroot/output/build/linux-custom/include/dtc/include-prefixes/arm64/m5stack-ax630c-module-llm.dts として配置されています。 )

#include "AX620E.dtsi"
// (省略)
sound {
  status = "okay";
  compatible = "simple-audio-card";
  simple-audio-card,name = "Axera Audio";
  simple-audio-card,widgets =
    "Microphone", "Mic Jack";
  simple-audio-card,routing =
    "AMIC", "Mic Jack";
  #address-cells = <1>;
  #size-cells = <0>;
  simple-audio-card,dai-link@0 {
    format = "i2s";
    bitclock-master = <&codec_m6>;
    frame-master = <&codec_m6>;
    capture-only;
    cpu {
      sound-dai = <&i2s_inner_slv0>;
    };
    codec_m6: codec {
      sound-dai = <&audio_codec>;
      system-clock-frequency = <12288000>;
    };
  };
  simple-audio-card,dai-link@1 {
    format = "i2s";
    playback-only;
    cpu {
      sound-dai = <&i2s_inner_mst0>;
    };
    codec {
      sound-dai = <&audio_codec>;
      system-clock-frequency = <12288000>;
    };
  };
};

&audio_codec {
	gpio-mic-rp = <&ax_gpio1 7 0>; /* GPIO1_A7 */
	gpio-mic-rn = <&ax_gpio1 6 0>; /* GPIO1_A6 */
	gpio-mic-ln = <&ax_gpio1 5 0>; /* GPIO1_A5 */
	gpio-mic-lp = <&ax_gpio1 4 0>; /* GPIO1_A4 */
	gpio-pa-speaker = <&ax_gpio1 8 0>;
	status = "okay";
};

// capture
&i2s_inner_slv0 {
	status = "okay";
	/*i2s - pad0*/
	i2s-m-aec-cycle-sel = <0>;    /*23-22*/
	i2s-m-aec-sclk-sel = <0>;     /*21*/
	i2s-inner-codec-en = <1>;     /*20*/
	i2s-exter-codec-en = <0>;     /*19*/
	i2s-m-exter-codec-en = <0>;   /*18*/
	i2s-exter-codec-mst = <0>;    /*17*/
	i2s-m-exter-codec-mst = <0>;  /*16*/
	iis-out-tdm-en = <0>;         /*15*/
	iis-m-out-tdm-en = <0>;       /*14*/
	i2s-m-rx0-sel = <0>;          /*13*/
	i2s-m-rx1-sel = <0>;          /*12-11*/
	i2s-s-rx0-sel = <0>;          /*10-9*/
	i2s-s-rx1-sel = <2>;          /*8-7*/
	i2s-s-sclk-sel = <0>;         /*6-5*/
	tdm-m-rx-sel = <0>;           /*4-3*/
	tdm-s-rx-sel = <0>;           /*2-1*/
	tdm-s-sclk-sel = <0>;         /*0*/
};

デバイスツリーの記述では他のファイルを #include で読み込むことができます。 m5stack-ax630c-module-llm.dts もAxeraが提供する大本の AX620E.dtsi を読み込んでいるので、そちらも調べます。 ( LLM_buildroot-external-m5stack/tools/build_Module_LLM_buidlroot/buildroot/output/build/linux-custom/build/linux-4.19.125/arch/arm/boot/dts/axera/AX620E.dtsi にあります。)

i2s_inner_slv0: i2s_slv@1 {
  compatible = "axera,dwc-i2s-slv";
  reg = <0x0 0x6051000 0x0 0x400>,
        <0x0 0x4870000 0x0 0x10000>;
  interrupt-names = "i2s_slv";
  interrupts = <GIC_SPI 145 IRQ_TYPE_LEVEL_HIGH>;
  //dmas = <&dma_per 17 &dma_per 16>;
  //dma-names = "rx", "tx";
  clocks = <&periph_clk AX620X_PCLK_I2S_S_EB>,
      <&periph_clk AX620X_CLK_I2S_AUDIO_REF_EB>;
  clock-names = "i2s_pclk", "i2s_mclk";
  resets = <&periph_reset_async 8 0xE0 8 0XE4 0XC 28 0XC0 28 0XC4 28>,
      <&periph_reset 9 0x1C 1>;  /* slave reset without clk */
  reset-names = "prst", "rst";
  channel = <3>;
  #sound-dai-cells = <0>;
  status = "disabled";
};
audio_codec: audio_codec@0x23F2000 {
  compatible = "axera,ax_actt";
  reg = <0x0 0x23F2000 0x0 0x1000>;
  resets = <&comm_reset_async 0 0x58 0 0X5C 0X24 8 0X28 8 0X2C 8>,
      <&periph_reset_async 0 0xD8 0 0XDC 0X4 3 0XB0 3 0XB4 3>;
  reset-names = "prst", "rst";
  clocks = <&common_clk AX620X_CLK_AUDIO_TLB_EB>,
      <&periph_clk AX620X_CLK_I2S_AUDIO_REF_EB>;
  clock-names = "audio_tlb_clk","audio_clk";
  #sound-dai-cells = <0>;
  status = "disabled";
};

Module LLMの実機から読み出したデバイスツリーと対応している部分が見つかりました。

これらを調べると、

  • compatible = の行より、 dwc-i2s-slvax_actt という名前に対応したドライバがこれらのデバイス制御している
  • sound の定義より、
    • オーディオデバイスとして ALSA SoC Layer の DAI (Digital Audio Interface) の仕組みに則ったデバイスを定義している
    • DAIのデバイスとして再生用のインターフェース (playback-only) と録音用のインターフェース (capture-only) を持っている
    • 録音用インターフェースでは、 i2s_inner_slv0 を使っている
  • i2s_inner_slv0 の定義より
    • 録音用のインターフェースは I2S (Inter-IC Sound) で通信を行う

ということがわかります。I2SはESP32でもおなじみの音声信号をやり取りするための通信規格で、CoreS3のマイクやスピーカーでも用いられています。

但し、AX630Cのマイクは回路図を見る限りはチップにほぼ直接マイクが繋がっており、I2Sの通信を行っているようには見えません。
これはドライバのソースコードを読むと構造が分かります。

ドライバのソースコード

Linuxのドライバは、デバイスツリーの compatible 行の内容を見て対応するデバイスかどうかを判定して割り当てられます。
よって、ドライバのソースコードには compatible に記載されている文字列が含まれているため、この文字列でbuildrootのビルド環境を検索すると、展開されたLinuxのソースコード (LLM_buildroot-external-m5stack/tools/build_Module_LLM_buidlroot/buildroot/output/build/linux-custom/build/linux-4.19.125) の下に
sound/soc/axera/dwc-i2s.csound/soc/axera/ax-actt.c が見つかります。これらはそれぞれ、i2s_inner_slv0audio_codec に対応するドライバのソースコードです。

これらのソースコードを読んでいくと、音声入力まわりはざっくりこんな構造になっていそうということが分かります。

音声入力のデータの流れ

dwc-i2s のほうはASIC屋や組み込みLinux屋ならすぐ気付くと思いますが、SynopsysのDesignWareのI2S IP[1]です。
実際、dwc-i2s.c の先頭にもDesignWareと書いてあります。

/*
 * ALSA SoC Synopsys I2S Audio Layer
 *
 * sound/soc/dwc/designware_i2s.c
 *
 * Copyright (C) 2010 ST Microelectronics
 * Rajeev Kumar <rajeevkumar.linux@gmail.com>
 *
 * This file is licensed under the terms of the GNU General Public
 * License version 2. This program is licensed "as is" without any
 * warranty of any kind, whether express or implied.
 */

DesignWareのIPは様々なSoCで採用されているのでデータシートから使い方がわからないか検索したところ、なぜかDesignWareのI2S IPのデータシートそのものが落ちていたので、内容を参考にコードを読み進めました。

動作確認

いくつかあやしい箇所のコードを修正しては書き込んで動作確認をします。
手順としてはbuildroot環境でのビルドを行って生成されたイメージをAxeraのダウンロードツール AXDL で書き込んで起動するだけですが、buildroot出のビルド中にパッチで生成されたソースコードに手を加える必要があるので、少しだけ特殊な操作が必要です。

もう少し効率の良い手順がありそうですが、今のところ以下の手順で行っています。

  1. コードを修正する
  2. buildroot内のLinuxカーネルの再ビルドをする
cd tools/build_Module_LLM_buidlroot/buildroot
make linux-rebuild
  1. ld.conf.d が既にあるというエラーになるので削除しておく。
rm -rf tools/build_Module_LLM_buidlroot/buildroot/output/target/etc/ld.so.conf.d
  1. 再度 creat_Module_LLM_ubuntu22_04_image.sh を実行してAXDL用のイメージを作成する。
cd tools
./creat_Module_LLM_ubuntu22_04_image.sh
  1. 作成したイメージをAXDLで書き込む。カーネル修正だとrootfsは書き換える必要が無いので、ROOTFSを外しておくと書き込みがすぐ終わる。

AXDL

動作中のレジスタ値の確認

ここまででI2Sハードウェアの仕様がわかったので、動かしながら設定値を確認していきます。
現象としては

  • 32bit, 24bitのときは問題が起きない
  • 16bitのときは1サンプル分余分なデータがでてくる

ということが起こっています。この余分なデータは何なのかというところでI2S IPのデータシートを確認すると、どうもこのIPは複数のI2Sチャネルを持っていることがわかりました。実際、このIPコアの設定値が書かれているレジスタ I2S_COMP_PARAM_1 (0x1f4)devmem を使って読み出してみると、 0x024C00EE が返ってきます。
(I2Sが 0x06051000 にマップされてるのはデバイスツリーに書いてあるのでレジスタのオフセット 0x1f4 を足して 0x060511f4 から32bit値を読み出します。)

devmem 0x060511f4 32
0x024C00EE

2進数で 0b10010011000000000 ですが、下位ビットから [8:7] の2ビットが I2S_RX_CHANNELS という受信チャネル数を表す値となっており、実際に 2 が格納されています。
これより、AX630CのI2S受信モジュールは2つの入力を受信できることがわかります。

0b10010011000000000
01  // I2S_RX_CHANNELS = 2
1   // I2S_RECEIVER_BLOCK = 1
1   // I2S_TRANSMITTER_BLOCK = 1
0   // I2S_MODE_EN = 0  (Master mode is disabled)
11  // I2S_FIFO_DEPTH_GLOBAL = FIFO_DEPTH_16
10  // APB_DATA_WIDTH = 32

一方、DMA転送ではI2S IPの RXDMA レジスタを読み取るように設定されています。この RXDMA レジスタは値を読み出すたびに

  1. チャネル 0 左
  2. チャネル 0 右
  3. チャネル 1 左
  4. チャネル 1 右
  5. チャネル 0 左
  6. (繰り返し)

のように、有効になっているチャネルの左右のデータを順に読み出します。ハードウェア自体にはモノラルの設定はないので、チャネル0だけ有効としても、左右のデータが読み出されます。

当初はモノラル設定ができないのが原因ではないかと考えましたが、ステレオで読み出したときも同様に問題が発生しているため、 余分なチャネルを読んでいるのでは? と考えました。

音声入力のデータの流れ(再掲)

チャネルが有効かどうかは RERx レジスタの0bit目を確認すればわかるので早速確認してみます。RERx はチャネルごとに存在し、オフセットアドレスは 0x028 + 0x40*x (xはチャネル番号) で計算できるので、 0x060510280x06051068 がそれぞれチャネル0とチャネル1に対応することがわかります。

arecord でS16_LEで録音を行ってから、つまりドライバによるレジスタ設定を実行した状態で読み出しを行います。

arecord -D plughw:0,0 -f S16_LE -c 1 -r 16000 -d 1 dummy.wav
devmem 0x06051028
devmem 0x06051068
0x00000001
0x00000001

16bitのときはチャネル0とチャネル1が 両方 有効になっていることが分かります。

では32bitではどうなるのか確認しましょう。

arecord -D plughw:0,0 -f S32_LE -c 1 -r 16000 -d 1 dummy.wav
devmem 0x06051028
devmem 0x06051068
0x00000001
0x00000000

チャネル0 のみ 有効になりました。これより、余分なデータの元は なぜか 16bit時だけ有効になるチャネル1が原因ということが分かります。

コードの修正

ソースコード中の RERx レジスタの設定箇所を確認したところ、結論に書いた通り、 dw_i2s_config 関数で 16 == config->data_width つまり量子化ビット数が16bitのときだけ強制的に RER11 を書き込んでチャネル1を有効にするようになっていました。この部分をコメントアウトすると、問題の現象は起きなくなります。

static void dw_i2s_config(struct dw_i2s_dev *dev, int stream)
{
	u32 ch_reg;
	struct i2s_clk_config_data *config = &dev->config;


	i2s_disable_channels(dev, stream);

	for (ch_reg = 0; ch_reg < (config->chan_nr / 2); ch_reg++) {  // チャネル数分回るループ
		if (stream == SNDRV_PCM_STREAM_PLAYBACK) {
			i2s_write_reg(dev->i2s_base, TCR(ch_reg),
				      dev->xfer_resolution);
			i2s_write_reg(dev->i2s_base, TFCR(ch_reg),
				      dev->fifo_th - 1);
			i2s_write_reg(dev->i2s_base, TER(ch_reg), 1);
		} else {
			i2s_write_reg(dev->i2s_base, RCR(ch_reg),
				      dev->xfer_resolution);
			i2s_write_reg(dev->i2s_base, RFCR(ch_reg),
				      dev->fifo_th - 1);
			i2s_write_reg(dev->i2s_base, RER(ch_reg), 1);
// ここからコメントアウトする
			if (16 == config->data_width) { // データ幅16bitのとき
				i2s_write_reg(dev->i2s_base, RCR(1),
				dev->xfer_resolution);
				i2s_write_reg(dev->i2s_base, RFCR(1),
				dev->fifo_th - 1);
				i2s_write_reg(dev->i2s_base, RER(1), 1);  // チャネル1を強制的に有効にしている
			}
// ここまでコメントアウトする
		}
	}
}

この部分に関してLinuxカーネル本体にある d2c-i2s.c を確認したところ、このような特殊処理は存在しませんでした。(履歴をさかのぼっても存在しませんでした)
https://github.com/torvalds/linux/blob/master/sound/soc/dwc/dwc-i2s.c#L264-L269

Axera側で追加した処理と思われますが、なぜこのようなことになっているかは分かりません。
ALSA経由で直接音声入力するときは16bitで正しく入力するためにはこの修正が必要ですが、一方Axeraが提供するSDKを使用する場合にどのような影響がでるかはわかりません。
出荷時イメージには sample_audio というAxera SDK経由で録音を試すコマンドが入っているようですが、ビルドしなおしたイメージには含まれておらず、動作確認ができませんでした。

まとめ

  • dwc-i2s.c を修正すればALSA経由での16ビットでの音声入力を正常に行えるようになる。
    • 24ビット、32ビットでは元から問題は発生しない。
  • 問題箇所は特定したが、なぜこのような内容になっているか不明なので、Axeraに確認する必要がある。
  • Axeraはもう少し技術資料を公開してほしい。
脚注
  1. Intellectual Property。ソフトウェアに置けるライブラリと思ってもらえればよい。ハードウェアの世界ではこういった機能単位のモジュールをIPとして売っているところが多く存在する。Synopsysは有名な設計ツールやIPベンダー。 ↩︎

Discussion