この記事は CyberAgent Developers Advent Calendar 2024 1日目の記事です。

こんにちは、AI Lab Audio チームの吉本 (@mulgray) です。

早いもので、Audio チームを立ち上げてからもう 5 年ほどが経ち、チームメンバーの皆さんがたくさんの論文を発表してくれるようになりました。採用ペースはチームとしては現在落ち着いていますが、現在の 10 人からまだ若干増える見込みです。最近、内部的には 2 つのチームに分割し、事業化も研究もよりフットワークを高めることを狙って動いています。

さて、今年はたまたまこちらの Developers Blog と AI Lab Blog の両方でアドカレを書くことになったこともあり、普段やっていることとは違う内容で書こうと思います。

AI アクセラレータゼミ

実は業務のメインタスクとは別に、社内では AI アクセラレータゼミというゼミを立ち上げており、なかなか活動できていないながらも、時々活用ネタを探ることをしています。

音声や映像はリアルタイムにしたいモチベーションがあることが多く、場合によってはネットワークや強い PC が使えない状況で安定稼働できることが重要な価値となることもあるので、メインタスクではないと言いつつもこれは十分業務になり得る分野です。例えば iPhone に搭載された Apple Neural Engine を活用した、完全にオフラインで瞬時に動作する音声 AI アプリケーションを業務としてリリースしたこともあります。

ゼミの活動では、具体的には FPGA や NPU などに類するデバイスで業務で扱う領域のモデルを動かし、実用的な設定やユースケースを探るといったことをしています。

私の普段の業務は音声の生成に関することなのですが、今回の記事では FPGA で音を出すことを目指します。

そもそも FPGA とは

一応今回の想定読者さんとして、FPGA を授業で触れたことがあるか、どこかで聞いたことがあるくらいのソフトウェアエンジニアを想定していますが、少しだけ紹介します。

通常の PC やスマホには CPU に相当するものが入っていて、複雑な計算を全般的にこなし、その上で Mac や Android などの OS が動いています。実際にはどんな機器を使っているか気にすることなく、サウンドなど、どのアプリでも共通して使いたい機能が OS 側で機器の違いを吸収した形で操作できるようになっていて、アプリはこれらを操作して動くように作られます。

一方で FPGA は計算の仕方自体を、回路を組み立てるように作ることができます。はんだ付けの要らない電子工作のようなイメージです。なので CPU や GPU を自分で作ることもできますし、もっと自由に計算機を設計することができます。その代わり、環境ややりたいことの差異などは全て自分で面倒を見ます。

例えば特定のサイズの行列積しか必要ないなら、それらが可能な限り並列で実行されるように設計して、CPU や GPU と比べても爆速で動くようにするだとか、常に一定クロックで実行されることを保証するというようなことができます。さらに、メモリから読み出して計算して書き込んで…を繰り返すノイマン型の計算方式にも縛られず、データが演算モジュール間を直接移動していく設計にすることもでき、より柔軟な視点で効率を極めることができます。

やりたいことが決まった後の究極のチューニングとしてもロマンがありますが、元々はロボットや映像処理など、実行時間が保証されると嬉しい用途に向いています。回路が実現したその先の世界には、ASIC として専用チップの製造などがありますが、ここはもう必要な金額の桁が変わってくるので、専門でやってきた機関しか通常は手を出せません。

お手軽な FPGA

ここでは、私の知る限り最もコスパに優れ、性能が高くて比較的何でもできるデバイスを選択します。それは Xilinx(読み:ザイリンクス、現 AMD)の Kria KV260 です。以前は 3 万円台で買えましたが、半導体不足か、あるいは恐らく円安の影響で値上がりしたかもしれません。個人的には 5 万円でもまだまだ十分コスパに優れていると思っています。


今だと例えば DigiKey などから買えそうです。

これまで、授業等では FPGA で簡単なものを組んだことがあっても、いざ一般のソフトウェアエンジニアとして知らない型番の FPGA を動作させるのはなかなか遠い道のりの印象があったかもしれません。

しかし、最近では FPGA の世界でも知の高速道路が年々構築されていて、例えば渕上さん (@ryuz88) が極めて親切な解説記事「KV260でSystemVerilogでLEDチカしてみる」を公開してくださっているので、一気に駆け抜けることができます。まずは KV260 を買ってLチカしましょう。今回はそこからほんの少しだけ一歩を踏み出します。

Lチカのために用意するもの

  • KV260 Vision AI Starter Kit
  • microSD カード(16 GB 以上推奨)
  • 電源アダプター(12V 3A 推奨)
  • LED
  • 抵抗 100 Ω

KV260 を買うとボードしか入っていないので電源を別途用意する必要があります。センタープラス(丸いプラグの内側がプラス)の 12V で 3A 出せれば何でも大丈夫です。私はこちらの製品を使いました。
Amazon | SoulBay 36W 3V 4.5V 5V 6V 7.5V 9V 12VユニバーサルAC / DCアダプタスイッチング電源充電器 家庭用電子機器用コネクタ8個付き – 最大3000mA | SoulBay | ACアダプタ

KV260 のセットアップ

microSD カードには Xilinx 認定 Ubuntu を入れます。渕上さんの記事通りに進めると、こちらのサイトから Kria K26 SOMs を選択して Download 22.04 LTS することで本記事執筆時点では iot-limerick-kria-classic-desktop-2204-20240304-165.img.xz を入手できました。

microSD への書き込み方法として、公式の手順では Balena Etcher が推奨されていますが、環境によっては失敗することがありました。よって、より成功しやすい方法としては、~.xz を解凍して出てきた ~.img を、Mac や Linux 等を使っている方は dd コマンドを使って、Windows の方は Win32 Disk Imager Renewal を使って直接書き込む方法になりそうです。登さんには色んなところでお世話になっております。


書き込み終わったら microSD カードを KV260 に挿してボードの準備は完了です。
これだけで、電源ケーブルを差し込むと全自動で Ubuntu が起動する状態になっています。
ボードの LAN をつなげば通常の Ubuntu と同様に DHCP で勝手につながってくれますし、ボードの IP アドレスに対して ssh でつながります。初期 ID / Pass はいずれも ubuntu です。

この後、別の強い PC を使ってコンパイルのようなことをして、できたファイルを scp なりでこの KV260 上の Ubuntu にコピーしてきて実行するだけで FPGA を動かすことができます。なのでその強い PC 、つまり母艦となる PC は KV260 と直接つながってなくても開発できますし、そもそも KV260 を買ってなくても開発・動作シミュレーションができます。

FPGA 向けの開発環境のインストール

母艦となる PC には Vitis(読み:ヴァイティス)という色んなツールがまとめられたソフトウェアを入れます。Vitis の中に Vivado(読み:ヴィヴァド)という、今回の記事で使うツールも入っています。こちらは本記事執筆時点では AMD 公式サイトのメニューからリソース&サポート → ダウンロード → Vitis ソフトウェア プラットフォームと進みます。この辺のリンクの構成が変わっていたら頑張って探し出しましょう。

執筆時点ではこのリンクとなりますが、ここから、「Vitis™ コア開発キット – 2024.1 Full Product Installation」のインストーラをダウンロードします。今回は母艦にも Ubuntu を使うことにしたため、「AMD 統合インストーラー (FPGA およびアダプティブ SoC 用) 2024.1: Linux 用自己解凍型ウェブ インストーラー」をダウンロードしました(ダウンロード時に AMD アカウントの作成が必要です)。これはそのまま実行できます。インストールには 300 GB ほど必要なので準備しましょう。

$ ./FPGAs_AdaptiveSoCs_Unified_2024.1_0522_2023_Lin64.bin

基本的にデフォルトのまま Next で進めていきます。最初にダウンロード時に作ったアカウントの情報を入れる必要があります。

プロダクト選択では Vitis を選んでおきます。

細かい選択のところはそのままで OK です。ここに Kria SOMs and Starter Kits があるので、KV260 はデフォルトで扱える状態になっています。

同意チェックを入れていきます。

あとはインストール先を指定すれば、インストールが始まります。

インストールが完了すると最後にこのようなメッセージが出ているので、実行しておきます。

これだけで必要なソフトウェアの準備も完了です。

Vivado 起動からLチカまで

Vivado を起動します。

$ source /tools/Xilinx/Vitis/2024.1/settings64.sh
$ vivado

ここからLチカまでは先ほどの渕上さんの記事の「Vivado でプロジェクトを作る」以降に全て書かれていて、補足できることがほぼありません。

ただ一点、奇妙な現象があり、最初に論理合成 (Synthesis) を実行しようとしたときに失敗してしまいました。

このとき、Vivado の画面はこのようになっていました。Design Sources に design_1 が現れています。ここで、top.sv を編集して 8 行目の design_1 を一旦適当に書き換えて元に戻すということをしてみました。

するとこのように Design Sources は top.sv だけになり、この状態で再度 Synthesis を実行すると…

無事に Synthesis が通りました。こういうところはまだ手順を表面だけ追っているうちは理屈がわからないので、今後精進していきたいところです。

あともう一点小さな補足をすると、初めて進めていたときは「I/O Ports」というタブが出ていなかったのですが、それは Window メニューの中にあります。左の Flow Navigator で SYNTHESIS を選択している時だけかもしれません。

あと物理的な配線はこのようになっています。Pmod のピンは上側(ボードから遠い側)の 1 番と右から 2 番目です。


ブレッドボード側はこのようにしています。


ここまでで、無事渕上さんの手順を再現できました。

KV260 で音を出す

KV260 は CPU に相当する部分もあるし HDMI 出力もあるので、とにかく音を出すことだけが目的であれば、恐らく HDMI か USB デバイスから音を出せば何もコードを書くこともなく一発だと思います(未確認)。

しかし今回は FPGA から直接操作できる IO 端子から音を出します。こうすれば処理開始後は OS にもドライバにも割り込みにも影響を受けず、事前に算出できる理論値に常に固定された超低遅延で音が出るはずです。もし本当にそうなればロマンがありますね。

音を出すために用意するもの

  • KV260 Vision AI Starter Kit(Lチカと同じ)
  • microSD カード(Lチカと同じ)
  • 電源アダプター(Lチカと同じ)
  • 圧電ブザー
  • 抵抗 1 kΩ

圧電ブザーも何でも良いのですが、動作確認できているものを一つ貼っておきます。
Amazon.co.jp: uxcell DC 1-30V 90dB サウンドパッシブ 電子ブザーアラーム ブラック 30x 6mm 5個入り : DIY・工具・ガーデン

Lチカの振り返りとその延長線

さて、先ほどのLチカを眺めていると、目視でだいたい 14 秒くらいの間に 21 回くらい光っています。ということは 1 秒あたり 1.5 回光ります。26 bit でカウントできる 67108864
でクロックの 100 MHz を割るとほぼ 1.5 なので、辻褄が合いそうです。

馴染み深い音を感じるために、例えばラの音である 440 Hz を出したいと思います。
すると単純には 440 / 1.5 = 293.333 倍速でチカチカさせるノリで圧電ブザーを駆動すれば良さそうです。1.5 だと誤差が大きいのでより正しくは 440 / (10^8 / 2^26) = 295.279 ですね。

先ほどのLチカコードの 12 行目で 295 を足すようにしてみます。

counter <= counter + 295;

これだけの変更をして、あとは全く同じまま、抵抗を 1 kΩ へ、LED をブザーへと付け替えます。
結果はこうなりました。一発でそれっぽく動くと嬉しいですね。

数ヘルツ低めな気もしますが、原因の一つは 0.279 の部分を無視したからと言えそうです。そこについてはカウンタを 26 bit よりもう少し増やしてやると解決するかもしれません。

デルタシグマ変調で FPGA 上にデジタル to アナログ変換 (DAC) を作る

しかし何とかして任意の音を出したいものです。
低周波なオンオフの制御だと矩形波になってしまってお馴染みのブザー音になりますが、何しろベースとなるクロックは 100 MHz もあるので、もっと頑張って非常に細かい間隔で制御してやると滑らかな波形を出すこともできそうです。

今回はそのような手法の一つであるデルタシグマ変調と呼ばれる方法で任意の信号を出すことを目指します。デルタシグマ変調についても柴田さんによる極めて親切な解説記事「DA コンバータがなくてもできる FPGA ピアノ (3) | ACRi Blog」があるので、私が補足できることはほとんどありません。
誤差をとってそれを減らすように制御するという考え方はなんだか色んなところに出てくる気がしますね。

Wave プレイヤーを作る

このままピアノを作ってもいいのですが、今回はこの DAC に普通に Wave データを流し込むことを考えます。Wave データをどこから読みこんでくるかについても選択肢がいくつかあるのですが、今回は FPGA に搭載されたメモリである BRAM を使ってみます。BRAM は容量的に DRAM には劣りますが意外と大きく、それでいてクロックと同期して読みに行ける非常に高速なメモリです。

Wave プレイヤー全体の構成としては、① 48 kHz のクロックを作る部分、② BRAM に入れた Wave を逐次読み出す部分、③ DAC とし、できる限りシンプルに作ってみます。


① は clock_divider.sv 、② は wav_bram.sv 、③ は delta_sigma.sv として、top.sv を追加したときと同様に追加していきます。上の画面は最後に top.sv にそれらを呼び出す記述をしたときの見え方です。

① 48 kHz のクロックを作る部分は 100 MHz をLチカのカウンタと同じ要領で分周して作ります。DIV_FACTOR は (入力周波数 / (2 * 出力周波数)) です。一周期の半分の長さを見るごとに clk_out を 0 にしたり 1 にしたりを切り替えています。

clock_divider.sv
`timescale 1ns / 1ps

module clock_divider #(
  parameter int DIV_FACTOR = 1042
)(
  input  logic clk_in,
  output logic clk_out = 0
);

  logic [$clog2(DIV_FACTOR)-1:0] counter = 0;

  always_ff @(posedge clk_in) begin
    if (counter == DIV_FACTOR - 1) begin
      counter <= 0;
      clk_out <= ~clk_out;
    end else begin
      counter <= counter + 1;
    end
  end

endmodule

② BRAM を読み出す部分では、BRAM のどのアドレスを読むかのポインタ的なものを作っておき、48 kHz のクロックが切り替わるたびに BRAM からそのときのポインタが指し示しているアドレスの値を取り出してポインタを一つ進めるということをします。

ADDR_WIDTH で Wave の長さを設定しています。今回、Wave に合わせるというよりは先に 2 秒分くらいに相当する ADDR_WIDTH を決めてしまいました。2^17 = 131072 サンプルを格納する仕様です。48 kHz だと 131072 / 48000 = 2.731 秒になります。
addr がこの長さになるまで増加してはオーバーフローして 0 になるを繰り返すので、dout には wav_data.hex に書かれていたデータがループ再生されていきます。DATA_WIDTH では 16 bit の音声であると設定しています。

$readmemh で指定するファイルに、~.wav ファイルから変換した 16 進の値を入れておきます。絶対パスで指定すればどこに置いてあっても大丈夫です。データの内容は最終的な bitstream に埋め込まれるので、最終的に KV260 に実装を送るときはLチカと同じように ~.bit だけで OK です。

wav_bram.sv
`timescale 1ns / 1ps

module wav_bram #(
  parameter int ADDR_WIDTH = 17,
  parameter int DATA_WIDTH = 16
)(
  input  logic                  clk,
  output logic [DATA_WIDTH-1:0] dout
);

  logic [DATA_WIDTH-1:0] memory [0:(2**ADDR_WIDTH)-1];
  logic [ADDR_WIDTH-1:0] addr = '0;

  initial begin
    $readmemh("/home/mulgray/wav_data.hex", memory);
  end

  always_ff @(posedge clk) begin
    dout <= memory[addr];
    addr <= addr + 1;
  end

endmodule

wav_data.hex の中身は 16 進で 16 bit を 1 行に書いていきます。1 行が 1 サンプル、つまり 1 秒の 1/48000 の一つのデータ点です。これが例えば以下のように並びます。

wav_data.hex
7FFD
8001
7FFC
8000
…

これを普通の ~.wav ファイルから作ります。ここはこだわりがないので Python で用意しました。符号なし 16 bit にしないと FPGA 側にもう一つ考慮が必要になるので符号なしにしています。input.wav が 2^17 = 131072 サンプルを超える場合は超えた部分を捨て、131072 サンプルより短い場合は残りを無音で埋めて、ぴったり 131072 サンプルのファイルを作ります。

convert_wav.py
import wave
import math
import numpy as np

def generate_wav_data_hex(input_wav_file, addr_width, output_hex_file):
    """
    Convert a WAV file to a hex file with size adjusted to 2**ADDR_WIDTH.

    Args:
        input_wav_file (str): Path to the input WAV file.
        addr_width (int): Address width (determines WAV_SIZE as 2**ADDR_WIDTH).
        output_hex_file (str): Path to the output HEX file.
    """
    # Calculate WAV_SIZE
    wav_size = 2 ** addr_width

    # Open the WAV file
    with wave.open(input_wav_file, 'r') as wav_file:
        num_channels = wav_file.getnchannels()
        sample_width = wav_file.getsampwidth()
        frame_rate = wav_file.getframerate()
        num_frames = wav_file.getnframes()

        print(f"Input WAV Info: {num_channels} channels, {sample_width*8}-bit, {frame_rate} Hz, {num_frames} frames")

        # Read the WAV data
        raw_data = wav_file.readframes(num_frames)
        audio_data = np.frombuffer(raw_data, dtype=np.int16)  # Assuming 16-bit WAV
        audio_data = audio_data[::num_channels]  # If stereo, take the first channel

        # Truncate or pad the data to match WAV_SIZE
        if len(audio_data) > wav_size:
            print(f"Truncating WAV data from {len(audio_data)} to {wav_size} samples.")
            audio_data = audio_data[:wav_size]
        elif len(audio_data) < wav_size:
            print(f"Padding WAV data from {len(audio_data)} to {wav_size} samples.")
            audio_data = np.pad(audio_data, (0, wav_size - len(audio_data)), mode='constant', constant_values=0)

        # Normalize to unsigned 16-bit
        audio_data = ((audio_data + 32768) & 0xFFFF).astype(np.uint16)

    # Write the data to a HEX file
    with open(output_hex_file, 'w') as hex_file:
        for sample in audio_data:
            hex_file.write(f"{sample:04X}\n")  # Write in 4-digit hexadecimal

    print(f"HEX data written to {output_hex_file}, total samples: {wav_size}")


if __name__ == "__main__":
    input_wav_file = "input.wav"        # Path to the input WAV file
    addr_width = 17                    # Example address width (2^10 = 1024 samples)
    output_hex_file = "wav_data.hex"   # Path to the output HEX file

    generate_wav_data_hex(input_wav_file, addr_width, output_hex_file)

③ DAC については柴田さんの実装をそのまま使います。ありがたや…。
シミュレーションしながらデバッグしていたときに不定値に悩まされたことがあったので一応 X の考慮を入れていますが、もしかしたらなくても大丈夫かもしれません。

delta_sigma.sv
`timescale 1ns / 1ps

module delta_sigma #(                                                                          
    parameter int WIDTH = 16                                                  
  )
  (
    input logic             clk,
    input logic [WIDTH-1:0] data_in,
    output logic           pulse_out
  );

  logic [(WIDTH+1)-1:0] sigma_reg = '1;

  always_ff @(posedge clk) begin
    if (data_in !== 'X) begin
      sigma_reg <= sigma_reg + {pulse_out, data_in};
    end
  end

  assign pulse_out = ~sigma_reg[(WIDTH+1)-1];

endmodule

最後に、これらを呼び出す top.sv を以下のように修正します。

top.sv
`timescale 1ns / 1ps

module top (
    output var logic [0:0] led
  );
    
  logic clk;
  design_1 i_design1(.pl_clk0_0(clk));
    
  logic clk_48;

  clock_divider #(
    .DIV_FACTOR(1042)
  ) clock_divider_inst (
    .clk_in(clk),
    .clk_out(clk_48)
  );

  logic [15:0] dout;

  wav_bram #(
    .ADDR_WIDTH(17),
    .DATA_WIDTH(16)
  ) wav_bram_inst (
    .clk(clk_48),
    .dout(dout)
  );

  logic pulse_out;

  delta_sigma #(
    .WIDTH(16)
  ) delta_sigma_inst (
    .clk(clk),
    .data_in(dout),
    .pulse_out(pulse_out)
  );

  assign led = pulse_out;

endmodule

さてここまでの内容で論理合成と配置配線まですると、リソース利用率は以下のようになっていました。さすがにまだ全然余力があります。


これをLチカと同様に転送して実行します。
結果は以下のようになりました。スマホ収録で特に加工していないので聞き取りにくいですが、確かに声が聞こえます…!メリークリスマス!!

周波数特性の関係か、動画だと音割れしたような感じになってしまいますが、実際に直接聞くと圧電ブザーに対して期待していたよりだいぶ高音質で面白いです。

余談ですがこの音声は最近作っていた音声合成モデルで発話させた音声です。

さて本当はここからが面白くなってくるところというか、やっと入口に立てた感触なのですが、既に長くなってしまったので本記事はこのあたりで一区切りとしたいと思います。ここまで一切公式ドキュメント等を読まずに一晩くらいでできてしまったので、各記事の執筆者の方には本当に感謝しております。

今後の展望ですが、任意の音声が扱えるようになると音声の変換や生成処理も FPGA に乗せて様々なサウンドデバイスが作れるようになります。その前に、別回路を使った DAC や ADC を制御してアンプも付け足して、ちゃんと高音質なデバイスにすることも考えられます。その辺は、またいずれ…。それでは、良いお年を!

アバター画像
2017年新卒入社。自然言語処理を活用した広告プロダクトから、AI Lab での音声の研究開発に転身。現在までに音声合成、声質変換、音声認識に取り組み、広告に限らない応用先を広げている。