【Flutter】音声分析をやってみた

公開:2020/12/12
更新:2021/01/16
6 min読了の目安(約5600字TECH技術記事

これはFlutter #2 Advent Calendar 2020の 19 日目の記事です。

この記事を読んでわかること

  1. 作ろうと思ったきっかけ
  2. スペクトルアナライザとは
  3. 作ったものの全体像
  4. 音声を取り込むときに使えるパッケージ
  5. 音声のフーリエ変換
  6. Canvas を用いたグラフ表示
  7. 軸にラベルを付与する

作ろうと思ったきっかけ

はじめまして、2021 年 1 月 からエンジニアになる予定のこんぶです。

わたしは大学時代に音声分析の研究をしていました。
その知見をアプリ開発にも活かしたい。
そこでひとまず音声分析の基礎であるスペクトルアナライザを作ってみようと思いました。

実装のための調査をはじめてみると Flutter でリアルタイム音声分析をしているサンプルは少なく、同じようなことを実装したい人の役に立てるのではないかと思い、この記事を書いています。

スペクトルアナライザとは

先ほどスペクトルアナライザという言葉が出てきました。
聞き馴染みのない方も多いと思いますので軽く解説します。

スペクトルアナライザとは周波数分析する計測器のことです。
わたしたちが普段聞いている音声には様々な周波数成分がふくまれています。
しかし音声を録音してその波形を見てみても、周波数の強度を知ることはできません。
これは音声波形が時間領域の情報だからです。
この時間領域の情報を周波数領域に変換する方法があります。
そうです、おなじみ、フーリエ変換です。
スペクトルアナライザではフーリエ変換を用いることで音の周波数成分を知ることができます。

では周波数成分を知ると、なにが嬉しいのでしょうか?
周波数成分の違いは主に音色に影響を与えます。
たとえば音の高さや大きさが同じであっても、A さんと B さんの声が違うことはわかりますよね。
その違いは周波数成分を観察するとよくわかるのです。

周波数分析が基礎となって話者推定などができるかもしれません。
そんなわけで、音色の違いに着目したいとき、まず周波数成分を観察しようとするのが一般的です。

今回はスマートフォンのマイクから音声を取り込み、その周波数成分を画面上に表示することを目指します。

作ったものの全体像

Twitter にデモをあげましたので、そちらをご覧ください。

アプリを起動すると自動的に音声の入力受付が開始され、0kHz~10kHz までの周波数成分が表示されます。

母音の違いによって周波数分布が異なっていることがお分かりいただけると思います。

GitHub にも公開しましたのでクローンして遊んでみてください。

音声を取り込むときに使えるパッケージ

音声の取り込みには audio_streamer を使わせていただきました。

とても素直に音声を取り込むことができます。

音声を取り込むための最小コードはこんな感じです。

final _streamer = AudioStreamer();
void onAudio(List<double> buffer) {
  print(buffer);
}
void handleError(PlatformException error) {
  print(error);
}
Future<void> start() async {
  try {
    await _streamer.start(onAudio, handleError);
   } catch (error) {
    print(error);
   }
}

任意の場所で start を呼び出せば、buffer に音声データが流れ込んできます。

音声のフーリエ変換

フーリエ変換には fft というパッケージを使わせていただきました。
fft は Fast Fourier Transform の略で高速フーリエ変換のことです。補足資料

フーリエ変換でパワースペクトルを得るには次のような手順を踏みます。

  1. 得られた音声波形を 2 の累乗の長さになるよう 0 埋めする[1]
    (fft を使うため : 補足資料)
  2. 音声波形の断片に窓関数をかける
    (切り取ったことによる周波数への影響を少なくするため : 補足資料)
  3. パワースペクトルに変換する
    (フーリエ変換で得られるのは複素数なのでそれをパワーとして扱いため :補足資料)

コードは次のようになっています。

void onAudio(List<double> buffer) {
  /// fft は 2の累乗しか受け付けないため0埋めしている
  for (var i = 0; i < buffer.length; i++) {
    audio[i] = buffer[i]; // audioの長さは 2^15 で宣言されている
  }

  /// 窓掛け処理をおこなう
  final window = Window(WindowType.HAMMING);
  final windowed = window.apply(audio);

  /// フーリエ変換
  final fft = FFT().Transform(windowed);
  for (var i = 0; i < _windowLength / 2; i++) {
    // パワースペクトルを dB 単位に変換
    // 参考 : https://marui.hatenablog.com/entry/2019/12/20/071400
    final tmpPower = (fft[i] * fft[i].conjugate).real / _windowLength;
    spectrum[i] = -(10 * log10(tmpPower)); // canvas が下方向に正のため反転している
  }
}

Canvas を用いたグラフ表示

ここまででパワースペクトルを得ることができました。
次は得られた値をグラフとして描画したいと思います。
描画には Canvas を使いました。

さっそくコードです。
解説はコメントに書きました。

wave_painter.dart
import 'package:flutter/material.dart';

class WavePainter extends CustomPainter {
  // samples に得られたパワースペクトルのリストが入っている
  WavePainter(this.samples, this.color, this.constraints);

  final _hightOffset = 0.25;
  BoxConstraints constraints;
  List<double> samples;
  List<Offset> points;
  Color color;

  Size size;

  // Set max val possible in stream, depending on the config
  final absMax = 30;

  
  void paint(Canvas canvas, Size size) {
    // 色、太さ、塗り潰しの有無などを指定
    final paint = Paint()
      ..color = color
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke;

    // 得られたデータをオフセットのリストに変換する
    // やっていることは決められた範囲で等間隔に点を並べているだけ
    points = toPoints(samples);

    // addPolygon で path をつくり deawPath でグラフを表現する
    final path = Path()..addPolygon(points, false);
    canvas.drawPath(path, paint);
  }

  
  bool shouldRepaint(CustomPainter oldPainting) => true;

  // 得られたデータを等間隔に並べていく
  List<Offset> toPoints(List<double> samples) {
    final points = <Offset>[];
    for (var i = 0; i < (samples.length / 2); i++) {
      points.add(
        Offset(
          i / (samples.length / 2) * constraints.maxWidth,
          project(samples[i], absMax, constraints.maxHeight),
        ),
      );
    }
    return points;
  }

  double project(double val, int max, double height) {
    final waveHeight = (val / max) * _hightOffset * height;
    return waveHeight + _hightOffset * height;
  }
}

軸にラベルを付与する

グラフは描画できましたが、このままではどのあたりが何 Hz なのか判断することができません。
そこで x 軸にラベルを追加していきます。
サンプリングレートは 44.1 kHz のため標本化定理により分析できる最大の周波数は 22.05 kHz です。
ですが、人の声の分析では 10 kHz くらいまでの情報が知れれば十分だと思います。
今回は 10 kHz 辺りまで描画することにします。

やっていることは、画面上での 1 kHz の幅を求めて、そこにテキストを配置しているだけです[2]

// ラベルの描画
List<Widget> label(BoxConstraints constraints) {
  final list = <Widget>[];
  const maxFrequency = 44100 / 4;
  for (var i = 0; i < (maxFrequency / 1000); i++) {
    list.add(
      Positioned(
        bottom: 0,
        left: i * (constraints.maxWidth / (maxFrequency / 1000)),
        child: Text(
          '${i}k',
          textAlign: TextAlign.center,
          style: const TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }
  return list;
}

おしまいに

Flutter での音声分析についてまとめてみました。
この記事をきっかけに音声について興味を持ってくれる方が増えるといいなと思います!
それでは、よいお年を!

脚注
  1. 本当は 0 埋めせず、取ってくる音声波形の幅を 2 の累乗に合わせた方がいいです。
    0 埋めしている時点でわざわざ窓関数をかけている意味がほどんどありません。 ↩︎

  2. テキストの左端の位置に座標を合わせているため、若干ズレたように見えてしまっています。 ↩︎