kotlinのAudioRecordでスペクトル包絡を求めて母音推定してみた
この記事の続き的なものです.
(前を読まなくても理解はできると思います)
スペクトル包絡ってなぁに?
スペクトル包絡 = 声道の特性を表す特徴量 = 話す言葉の特徴(あ,い,う,え,お,...)
要するに「あ,い,う,え,お」などには特徴があるんです.
その特徴がスペクトル包絡でわかるんです.
この特徴を用いているのが音声検索や自動字幕などです.
今回はそんなスペクトル包絡を使って母音推定してみました.
今回は自分が公開したライブラリ(AudioSensor)を使っていきます.
処理の流れ
- 母音の波形データをそれぞれFFT
- FFTした結果をパワー・対数スペクトルに変換
- 対数スペクトルをローパスフィルタに通してスペクトル包絡を求める
- 求めたスペクトル包絡を使って母音推定
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
を呼び出します.
context
はActivity
等から取得し,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
にケフレンシの閾値を,sampleRate
はAudioSensor
からサンプリングレートを持ってくる.
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