👄

kotlinのAudioRecordでスペクトル包絡を求めて母音推定してみた

2023/06/22に公開

この記事の続き的なものです.
(前を読まなくても理解はできると思います)

スペクトル包絡ってなぁに?
スペクトル包絡 = 声道の特性を表す特徴量 = 話す言葉の特徴(あ,い,う,え,お,...)
要するに「あ,い,う,え,お」などには特徴があるんです.
その特徴がスペクトル包絡でわかるんです.
この特徴を用いているのが音声検索や自動字幕などです.

今回はそんなスペクトル包絡を使って母音推定してみました.

今回は自分が公開したライブラリ(AudioSensor)を使っていきます.

処理の流れ

  1. 母音の波形データをそれぞれFFT
  2. FFTした結果をパワー・対数スペクトルに変換
  3. 対数スペクトルをローパスフィルタに通してスペクトル包絡を求める
  4. 求めたスペクトル包絡を使って母音推定

1. 母音の波形データをそれぞれFFT

収集した母音の波形データをFFTします.
これは前の記事でもやりました.

母音の音データ(一例)









今回はライブラリを使うので前回とコードが変わってきます.
音収集と解析用のクラス(MyAudioSensor)を作ります.

MyAudioSensor.kt

import android.content.Context
import android.util.Log
import com.kmasan.audiosensor.*


class MyAudioSensor(context: Context): AudioSensor.AudioSensorListener {
    companion object {
        const val LOG_NAME: String = "MyAudioSensor"
    }

    private val audioSensor = AudioSensor(context, this)
    private val audioAnalysis = AudioAnalysis()

    fun start(period: Int) = audioSensor.start(period)
    fun stop() = audioSensor.stop()

    override fun onAudioSensorChanged(data: ShortArray) {
        val fft = audioAnalysis.fft(data)
    }
}

これをActivity等で呼び出してstartするとval fftに下図のようなデータが入ります.

部分説明

  • private val audioSensor = AudioSensor(context, this)
    ライブラリから音収集用のクラスAudioSensorを呼び出します.
    contextActivity等から取得し,thisにはクラスにimplementしているAudioSensor.AudioSensorListenerを指定しています
  • private val audioAnalysis = AudioAnalysis()
    ライブラリから音解析用のクラスAudioAnalysisを呼び出します.
  • fun start(period: Int) = audioSensor.start(period), fun stop() = audioSensor.stop()
    AudioSensor内の関数fun start(period: Int)fun stop()を間接的に呼び出す関数.
    periodには音の波形データの取得間隔[ms]を指定します.
  • override fun onAudioSensorChanged(data: ShortArray)
    AudioSensorから音の波形データが随時送られます.
  • val fft = audioAnalysis.fft(data)
    AudioAnalysisから関数を呼び出してFFTしてます.

2. FFTした結果をパワー・対数スペクトルに変換

スペクトル包絡を求める方法はさまざまありますが,今回はケプストラムを用いた方法を使います.
ケプストラムは対数パワースペクトルをFFTすると得られます.
ケプストラムの低ケフレンシ成分のみを抽出し逆FFTするとスペクトル包絡が得られます.

まずはFFT結果をパワースペクトルに変換した後,対数スペクトルに変換します.
この2つの変換はAudioAnalysis内蔵の関数でそれぞれできます.

  • パワースペクトル:FFT結果を2乗したもの
    変換:fun toPowerSpectrum(fftBuffer: DoubleArray)

  • 対数スペクトル:FFT結果やパワースペクトルの対数(20 * log10(abs(it)))をとったもの
    変換:fun toLogSpectrum(fftBuffer: DoubleArray)

これらの処理をしたものが下図になります.

3. 対数スペクトルをローパスフィルタに通してスペクトル包絡を求める

次に対数スペクトルをFFTしてケプストラムを求めて低ケフレンシ成分のみを抽出し逆FFTします.
この処理は実質対数スペクトルをローパスフィルタに通しているのと同じです.

対数スペクトルをFFTしたものが下図になります.

ここからケフレンシの閾値を設定し,閾値以上の値を0にして成分の低い部分のみにします.
最後に逆FFTをしたらスペクトル包絡の出来上がりです.
以下5母音のスペクトル包絡線(ケフレンシ閾値:2.0ms)

なんとライブラリでは2と3の処理を一括でしてくれる関数を用意してます.
audioAnalysis.toSpectrumEnvelope(fftBuffer: DoubleArray, quefrencyTh: Double, sampleRate: Int)
fftBufferにはFFTした結果を,quefrencyThにケフレンシの閾値を,sampleRateAudioSensorからサンプリングレートを持ってくる.

MyAudioSensor.kt

import android.content.Context
import android.util.Log
import com.kmasan.audiosensor.*
import java.util.stream.IntStream.range
import kotlin.math.*


class MyAudioSensor(context: Context): AudioSensor.AudioSensorListener {
    companion object {
        const val LOG_NAME: String = "MyAudioSensor"
    }

    private val audioSensor = AudioSensor(context, this)
    private val audioAnalysis = AudioAnalysis()

    fun start(period: Int) = audioSensor.start(period)
    fun stop() = audioSensor.stop()

    override fun onAudioSensorChanged(data: ShortArray) {
        val fft = audioAnalysis.fft(data)
        val envelope = audioAnalysis.toSpectrumEnvelope(fft, 2.0, audioSensor.sampleRate)
    }
}

4. 求めたスペクトル包絡を使って母音推定

3で求めた5母音のスペクトル包絡をベースとして母音推定をします.
5母音のスペクトル包絡をcsvにまとめて,得られた音データのスペクトル包絡とのコサイン類似度をそれぞれ計算し,コサイン類似度が一番高いものを推定値とします.

今回のデータは自分の声でやってます.
csvのデータ型はこんな感じ

vowel,envelope
a,"[0.0,0.0,...]"
i,"[0.0,0.0,...]"
...

csvの読み込みとデータ保管はVowelEnvelopeDataで行い,コードの例は最終的なコードにあります.

実際に推定してみると,自分ではある程度の精度は得られました.
本来はかなり多くのデータから5母音のスペクトル包絡のベースを作るので他の人でやったら精度は落ちると思う.

まとめ

ここまでで音波形データからスペクトル包絡を求めて母音推定しましたが,処理の流れがわかったのではないでしょうか.
推定部分はAIが主流だけど知っとくと何かの役に立つかもね.

あと初めてライブラリ化したけどすごいコード量減ってびっくり.
モジュール化って大事なんだな.

最終的なコード

MyAudioSensor.kt

import android.content.Context
import android.util.Log
import com.kmasan.audiosensor.*
import java.util.stream.IntStream.range
import kotlin.math.*


class MyAudioSensor(context: Context): AudioSensor.AudioSensorListener {
    companion object {
        const val LOG_NAME: String = "MyAudioSensor"
    }

    private val audioSensor = AudioSensor(context, this)
    private val audioAnalysis = AudioAnalysis()
    private val vowelEnvelopeData = VowelEnvelopeData(context)

    var vowel = "null" // 現在の母音
        private set

    fun start(period: Int) = audioSensor.start(period)
    fun stop() = audioSensor.stop()

    override fun onAudioSensorChanged(data: ShortArray) {
        val fft = audioAnalysis.fft(data)
        val envelope = audioAnalysis.toSpectrumEnvelope(fft, 2.0, audioSensor.sampleRate)

        // 母音推定
        vowel = vowelAnalysis(envelope)
    }

    fun vowelAnalysis(envelope: DoubleArray): String{
        // 母音推定
        // コサイン類似度の計算
        val cosineSimilarityA = cosineSimilarity(envelope, vowelEnvelopeData.voiceA)
        val cosineSimilarityI = cosineSimilarity(envelope, vowelEnvelopeData.voiceI)
        val cosineSimilarityU = cosineSimilarity(envelope, vowelEnvelopeData.voiceU)
        val cosineSimilarityE = cosineSimilarity(envelope, vowelEnvelopeData.voiceE)
        val cosineSimilarityO = cosineSimilarity(envelope, vowelEnvelopeData.voiceO)
        val cosineSimilarityList = listOf(
            cosineSimilarityA, cosineSimilarityI, cosineSimilarityU,
            cosineSimilarityE, cosineSimilarityO
        )
        Log.d(LOG_NAME, "cosineSimilarity: $cosineSimilarityList")

        // コサイン類似度の比較
        return when(cosineSimilarityList.max()){
            cosineSimilarityA -> "a"
            cosineSimilarityI -> "i"
            cosineSimilarityU -> "u"
            cosineSimilarityE -> "e"
            cosineSimilarityO -> "o"
            else -> "null"
        }
    }

    fun cosineSimilarity(dataA: DoubleArray, dataB: DoubleArray): Double {
        // コサイン類似度を計算
        var dotProduct = 0.0
        var normA = 0.0
        var normB = 0.0
        for ( i in range(0, dataA.size)) {
            dotProduct += dataA[i] * dataB[i];
            normA += dataA[i].pow(2.0)
            normB += dataB[i].pow(2.0)
        }
        return dotProduct / (sqrt(normA) * sqrt(normB))
    }
}

VowelEnvelopeData.kt

import android.content.Context
import android.content.res.AssetManager
import org.apache.commons.csv.*
import java.io.IOException


class VowelEnvelopeData(context: Context) {
    private val assetManager: AssetManager = context.resources.assets
    lateinit var voiceA: DoubleArray
        private set
    lateinit var voiceI: DoubleArray
        private set
    lateinit var voiceU: DoubleArray
        private set
    lateinit var voiceE: DoubleArray
        private set
    lateinit var voiceO: DoubleArray
        private set
    lateinit var voiceNull: DoubleArray
        private set

    init {
        csvRead()
    }

    private fun csvRead(): Boolean{
        //CSVファイルの読み取り
        try{
            //読み込み先指定
	    //csvデータはsrc/main/assets に置いてあります
            val reader = assetManager.open("vowel-envelope.csv").reader()

            //読み込み
            val csvReader = CSVFormat.DEFAULT.withHeader().parse(reader)
            for (record in csvReader) {
                val vowel = record["vowel"]
                val envelope = record["envelope"]
                    .replace("\"","")
                    .replace("[","")
                    .replace("]","")
                    .split(",")
                    .map { it.toDouble() }.toDoubleArray()
                when(vowel){
                    "a" -> voiceA = envelope
                    "i" -> voiceI = envelope
                    "u" -> voiceU = envelope
                    "e" -> voiceE = envelope
                    "o" -> voiceO = envelope
                    "null" -> voiceNull = envelope
                }
            }
            return true
        }catch (e: IOException){
            //エラー処理
            return false
        }
    }
}

build.gradle:app (dependencies部分)

dependencies {

    implementation 'androidx.core:core-ktx:1.8.0'
    implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.5.1'
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'
    implementation "androidx.compose.runtime:runtime-livedata"
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    debugImplementation 'androidx.compose.ui:ui-tooling'
    debugImplementation 'androidx.compose.ui:ui-test-manifest'

    implementation 'org.apache.commons:commons-csv:1.8'
    implementation 'com.github.kmasan:AudioSensor:1.2'
    // implementation project(':audioSensor')
}

Discussion