🗣️

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

2020/12/12に公開
10

これは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. テキストの左端の位置に座標を合わせているため、若干ズレたように見えてしまっています。 ↩︎

Discussion

kenken

https://github.com/kenta-wakasa/voice_app
のコードを実行したのですが、以下のようなエラーが出ました。
Flutter 3.3.8のバージョンが古いのかと思いバージョンを上げてみても解消できませんでした。
お忙しいところ恐縮ですが、何が原因か回答頂ければ幸いです。

-----------以下、エラー内容---------------
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome...
../../development/flutter/.pub-cache/hosted/pub.dartlang.org/dart_numerics-0.0.5/lib/src/precision.dart:16:27: Error: The integer literal 9223372036854775807 can't be represented exactly in JavaScript.
Try changing the literal to something that can be represented in Javascript. In Javascript 9223372036854775808 is the nearest value that can be represented exactly.
const int int64MaxValue = 9223372036854775807;
^^^^^^^^^^^^^^^^^^^
../../development/flutter/.pub-cache/hosted/pub.dartlang.org/dart_numerics-0.0.5/lib/src/euclid.dart:86:35: Error: The integer literal 0x07EDD5E59A4E28C2 can't be represented exactly in JavaScript.
Try changing the literal to something that can be represented in Javascript. In Javascript 0x7edd5e59a4e28c0 is the nearest value that can be represented exactly.
((number - (number >> 1)) * 0x07EDD5E59A4E28C2) >> 58];
^^^^^^^^^^^^^^^^^^
../../development/flutter/.pub-cache/hosted/pub.dartlang.org/dart_numerics-0.0.5/lib/src/euclid.dart:86:55: Error: The operator '>>' isn't defined for the class 'num'.
Try correcting the operator to an existing operator, or defining a '>>' operator.
((number - (number >> 1)) * 0x07EDD5E59A4E28C2) >> 58];
^^

kenken

ご回答ありがとうございます!
mobileで実行すると動きました!

kenken

こちらの記事を読んで音声解析に興味を持ち、音程(基本周波数)を算出できるようにしているのですが、知識が不十分なこともあり挫折しかかっている状況なので、もしご存じでしたら教えて下さい。
こちらの記事ではパワースペクトルまでは算出しているので、離散フーリエ逆変換をして自己相関関数を算出し、自己相関関数が最大となるインデックス番号を出せれば音程を算出できる(※1)と思っていますが、理解/方針は合っていますでしょうか?
また、離散フーリエ逆変換をするためには※2のライブラリを使用することになるのでしょうか?

※1音声の生成過程と基本周波数の推定
http://makotomurakami.com/blog/2020/05/24/5305/
※2離散フーリエ逆変換ができるライブラリ?
https://pub.dev/packages/fftea

こんぶこんぶ

自己相関関数を用いた基本周波数の推定 はまた別のアプローチですね。
フーリエ変換後の波形ではなく、音声波形そのものの自己相関をとります。
こちらの方が簡単です。

自己相関はパッケージを使わなくても、簡単に計算できると思います。
少しずつずらして掛け合わせて、足し算するだけですね。

フーリエ変換後の波形をつかうのは、ケプストラムを用いた基本周波数の推定 と言う方ですね。
こっちのほうがノイズなどに強いかもしれません。

kenken

ご回答ありがとうございます。
自己相関関数を用いた基本周波数の推定には、音声波形そのものの自己相関を取る方法もあるかと思うのですが、パワースペクトルを逆フーリエ変換した方が高速に計算できるかという記事(https://tadaoyamaoka.hatenablog.com/entry/2016/08/28/114423)を読んだので、この方法も試したいと考えています。誠に恐縮ですが、逆フーリエ変換できるflutterライブラリをご存じないでしょうか?https://pub.dev/packages/ffteaには、逆フーリエ変換ifftにも対応していると記載していますが、ifftのクラスがありませんでしたので。。

kenken

お忙しい中、ご対応ありがとうございます!!

kenken

返信遅れて申し訳ありません。。
お忙しい中、ご対応ありがとうございました!
試してみます!!