🔊

kotlinのAudioRecordで音解析してみた

2023/03/25に公開

スマホにはセンサがたくさん入ってんねん.(流石に200はないです)
Androidだと大半のセンサはSensorManagerを使うが,音に関してはAudioRecordを使って取得できる.
しかし,そこから取れるのは音の波しか取れません.
こんなの↓

つまり音の大きさや高さは波から音解析しないと取れないんですね.
今回はkotlinのAudioRecordで音解析をやったのでまとめます.

追記:
音解析用のライブラリを作りました.
この記事の方法で音を取得しているクラスと取得したデータを解析するクラスがあります.
音解析したいやつ,至急使ってくれや.
https://github.com/kmasan/AudioSensor
https://jitpack.io/#kmasan/AudioSensor

処理の流れ

  1. AudioRecordの設定をする
  2. AudioRecordから定期的にデータをもらう
  3. データを解析して音の大きさや高さを得る
    3.1 音の大きさを得る
    3.2 音の高さを得る

今回は音解析用のクラス(AudioSensor.kt)を作ります

1. AudioRecordの設定をする

音を取るための準備をします.
まずはRECORD_AUDIOの権限が必要なのでManifestに権限を追加します

AndroidManifest.xml

<manifest ... >
	
    // 追加
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

</manifest>

後は権限の許可をアプリ起動時や設定から許可してあげればおk

次にAudioSensorクラスの方を作っていきます
まずはAudioSensorクラスの全体を

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.app.ActivityCompat


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

    private val sampleRate = 44100 // サンプリングレート
    // 音データのバッファサイズ
    private val bufferSize = AudioRecord.getMinBufferSize(
        sampleRate, AudioFormat.CHANNEL_IN_MONO,
        AudioFormat.ENCODING_PCM_16BIT
    )
    lateinit var audioRecord: AudioRecord

    // 録音開始時の初期設定
    fun start() {
	// permissionチェック
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSize
        )
	
	// 録音開始
        audioRecord.startRecording()
    }
}

部分説明

  • private val sampleRate = 44100
    サンプリングレートを指定します(サンプリングレートについては割愛)
  • private val bufferSize = AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT )
    サンプリングレート等から音データのバッファサイズをもらいます
    • AudioFormat.CHANNEL_IN_MONO:チャンネル数
      MONO(1)やSTEREO(2)などがある
    • AudioFormat.ENCODING_PCM_16BIT:エンコーディング方法
      他にも種類があります
  • if (ActivityCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) != PackageManager.PERMISSION_GRANTED ) { return }
    権限チェック 許可されているかを確認(無くても動きはします)
  • lateinit var audioRecord: AudioRecord, audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize )
    録音の設定
    • MediaRecorder.AudioSource.MIC:録音する音源
      マイクの他にも色々指定できる,基本はこれでいいかと
  • audioRecord.startRecording()
    録音を開始

これで録音開始までができました

ここまでのコード

AudioSensor.kt

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.app.ActivityCompat


class AudioSensor(private val context: Context) {

    companion object {
        const val LOG_NAME: String = "AudioSensor"
    }

    private val sampleRate = 44100 // サンプリングレート
    // 音データのサイズ
    private val bufferSize = AudioRecord.getMinBufferSize(
        sampleRate, AudioFormat.CHANNEL_IN_MONO,
        AudioFormat.ENCODING_PCM_16BIT
    )
    lateinit var audioRecord: AudioRecord
    
    private var isRecoding: Boolean = false // 録音しているか

    // 録音開始時の初期設定
    fun start() {
	// permissionチェック
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSize
        )
	
	// 録音開始
        audioRecord.startRecording()
	isRecoding = true
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="">

    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Audio">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

2. AudioRecordから定期的にデータをもらう

音データの取得はaudioRecord.read(buffer,0,bufferSize)でできますが定期的にこの関数を実行しないといけない
なのでスレッド処理で定期的に呼び出してあげます

AudioSensor.kt

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat


class AudioSensor(private val context: Context) {

    companion object {
        const val LOG_NAME: String = "AudioSensor"
    }

    private val sampleRate = 44100 // サンプリングレート
    // 音データのサイズ
    private val bufferSize = AudioRecord.getMinBufferSize(
        sampleRate, AudioFormat.CHANNEL_IN_MONO,
        AudioFormat.ENCODING_PCM_16BIT
    )
    lateinit var audioRecord: AudioRecord
    private var buffer:ShortArray = ShortArray(bufferSize) // バッファの収容先

    private var isRecoding: Boolean = false // 録音しているか
    private var run: Boolean = false // 音解析をしているか

    // 録音開始時の初期設定
    // period: オーディオ処理のインターバル
    fun start(period: Int) {
	// permissionチェック
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSize
        )
	
	// 録音開始
        audioRecord.startRecording()
	
	isRecoding = true
        // 指定period[ms]ごとにrecordingModeで指定した処理を実行
        if (!run) recoding(period)
    }
    
    // AudioRecordから定期的にデータをもらう
    private fun recoding(period: Int) {
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        // こいつ(rnb0) が何回も呼ばれる
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータを入れる
                audioRecord.read(buffer,0,bufferSize)
                // 振幅が出る
                Log.d(LOG_NAME,"${buffer[100]}, ${buffer[300]}")

                // stop用のフラグ
                if (run) {
                    // 指定時間後に自分自身を呼ぶ
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        // 初回の呼び出し
        hnd0.post(rnb0)
    }
}

部分説明

  • private fun recoding(period: Int):定期的に音データを取得
    Handlerを使って定期的に実行
  • audioRecord.read(buffer,0,bufferSize):音データをバッファに保存
    波のデータが入っている

これで音データを定期的に取得するようになった
ここから音解析をしていく

3. データを解析して音の大きさや高さを得る

それぞれ独立の関数を用意します
recodingDB(period: Int):音の大きさ
recodingFrequency(period: Int):音の高さ

3.1 音の大きさを得る

バッファデータから音の大きさ(db)を出す

まずは最大音量の解析をします
ここではバッファ内の値を2乗した平均を出しています

次に最大音量(振幅)をdbに変換します
振幅からのdb変換の式 db = 20 * log10(振幅) を使って計算します

実際のコード

AudioSensor.kt

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import java.util.stream.IntStream
import kotlin.math.log10
import kotlin.math.sqrt

class AudioSensor(private val context: Context) {
    // 前略
    // デシベル変換したやつを出力
    private fun recodingDB(period: Int) {
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータを入れる
                audioRecord.read(buffer,0,bufferSize)

                // 最大音量を解析
                val sum = buffer.sumOf { it.toDouble() * it.toDouble() }
                val amplitude = sqrt(sum / bufferSize)
                // デシベル変換
                val db = (20.0 * log10(amplitude)).toInt()
                //Log.d(LOG_NAME,"db = $db")

                if (run) {
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        hnd0.post(rnb0)
    }
}

3.2 音の高さを得る

バッファデータから周波数解析(FFT)を行い,特定の周波数の振幅や最大振幅の周波数を出す

↓こんな感じ(左:バッファデータ,右:FFT)

ちなみに周波数解析をするにはライブラリを追加する必要がある
今回はcom.github.wendykierp:JTransforms:3.1を使います

build.gradle(モジュール), dependencies部分のみ

dependencies {

    implementation 'androidx.core:core-ktx:1.9.0'
    implementation 'androidx.appcompat:appcompat:1.6.0'
    implementation 'com.google.android.material:material:1.7.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

    // 追加
    implementation 'com.github.wendykierp:JTransforms:3.1'
}

ここからAudioSensorの方を触っていきます
流れ

  1. バッファデータの周波数解析をする
  2. 特定の周波数の振幅を取得する
  3. 振幅が最大の周波数を取得する

1. バッファデータの周波数解析をする

まずはval fft = DoubleFFT_1D(bufferSize.toLong())でFFT処理の準備をする
そしてfft.realForward(fftBuffer)fftBufferにFFTした結果が入ります
が,この関数の引数の指定型がDoubleArrayなので型変換(ShortArrayDoubleArray)をしなければならない
しかもfftBufferはサイズがバッファの2倍なので単純な型変換ではいけない
なので一度単純な型変換をした後にSystem.arraycopyで複製しています

// FFT 結果はfftBufferに入る
val fft = DoubleFFT_1D(bufferSize.toLong())
val fftBuffer = buffer.map { it.toDouble() }.toDoubleArray()
fft.realForward(fftBuffer)

2. 特定の周波数の振幅を取得する

特定の周波数を指定して,そこから特定の周波数が入っているリスト番号を求めて振幅値の解析をしています

// 特定の周波数の振幅値の解析
val targetFrequency = 10000 // 特定の周波数(Hz)
val index = (targetFrequency * fftBuffer.size / sampleRate).toInt() // 特定の周波数が入っているリスト番号
// 振幅値の解析
val amplitude = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]).toDouble())

3. 振幅が最大の周波数を取得する

やっていることはほとんど2.の逆
fftBufferから最大振幅のリスト番号を捜査してそこから周波数を特定している

//振幅が最大の周波数とその振幅値の解析
var maxAmplitude = 0.0 // 最大振幅
var maxIndex = 0 // 最大振幅が入っているリスト番号
// 最大振幅が入っているリスト番号を捜査
for(index in IntStream.range(0, fftBuffer.size - 1)){
    val tmp = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]))
    if (maxAmplitude < tmp){
	maxAmplitude = tmp
	maxIndex = index
    }
}
// 最大振幅の周波数
val maxFrequency: Int = (maxIndex * sampleRate / fftBuffer.size)

実際のコード

AudioSensor.kt

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import org.jtransforms.fft.DoubleFFT_1D
import java.util.stream.IntStream
import kotlin.math.log10
import kotlin.math.sqrt

class AudioSensor(private val context: Context) {
    // 前略
    // 最大音量の周波数を出力
    private fun recodingFrequency(period: Int){
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        // こいつ(rnb0) が何回も呼ばれる
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータが入る
                audioRecord.read(buffer,0,bufferSize)

                // FFT 結果はfftBufferに入る
                val fft = DoubleFFT_1D(bufferSize.toLong())
                val fftBuffer = buffer.map { it.toDouble() }.toDoubleArray()
                fft.realForward(fftBuffer)

                // Log.d(LOG_NAME, "${fftBuffer.toList()}")
                // Log.d(LOG_NAME, "${fftBuffer.size}")

                // 特定の周波数の振幅値の解析
                val targetFrequency = 10000 // 特定の周波数(Hz)
                val index = (targetFrequency * fftBuffer.size / sampleRate).toInt() // 特定の周波数が入っているリスト番号
                // 振幅値の解析
                val amplitude = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]).toDouble())
                
                //振幅が最大の周波数とその振幅値の解析
                var maxAmplitude = 0.0 // 最大振幅
                var maxIndex = 0 // 最大振幅が入っているリスト番号
                // 最大振幅が入っているリスト番号を捜査
                for(index in IntStream.range(0, fftBuffer.size - 1)){
                    val tmp = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]))
                    if (maxAmplitude < tmp){
                        maxAmplitude = tmp
                        maxIndex = index
                    }
                }
                // 最大振幅の周波数
                val maxFrequency: Int = (maxIndex * sampleRate / fftBuffer.size)
                Log.d(LOG_NAME, "maxFrequency = $maxFrequency")

                // stop用のフラグ
                if (run) {
                    // 指定時間後に自分自身を呼ぶ
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        // 初回の呼び出し
        hnd0.post(rnb0)
    }
}

まとめ

今回はkotlinのAudioRecordで音解析してみた
AudioRecordでは音の波しか取れないから大きさや高さは解析しないと得られない
音の大きさや高さを利用したい人向けの記事でした

最終的なコード

AudioSensor.kt
start()でレコーディングモードを指定する形にしています

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import org.jtransforms.fft.DoubleFFT_1D
import java.util.stream.IntStream
import kotlin.math.log10
import kotlin.math.sqrt


class AudioSensor(private val context: Context) {

    companion object {
        const val LOG_NAME: String = "AudioSensor"
        const val RECORDING_NORMAL = 0 // 特に処理はしない
        const val RECORDING_DB = 1 // volumeに音量を記載
        const val RECORDING_FREQUENCY = 2 // 周波数解析
    }

    private val sampleRate = 44100
    private val bufferSize = AudioRecord.getMinBufferSize(
        sampleRate, AudioFormat.CHANNEL_IN_MONO,
        AudioFormat.ENCODING_PCM_16BIT
    )
    lateinit var audioRecord: AudioRecord
    private var buffer:ShortArray = ShortArray(bufferSize)

    private var isRecoding: Boolean = false
    private var run: Boolean = false

        // 録音開始時の初期設定
    // period: オーディオ処理のインターバル, recordingMode: 処理の種類(定数として宣言済み)
    fun start(period: Int, recordingMode: Int) {
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSize
        )
        audioRecord.startRecording()

        isRecoding = true
        // 指定period[ms]ごとにrecordingModeで指定した処理を実行
        if (!run) when(recordingMode){
            RECORDING_NORMAL -> recoding(period)
            RECORDING_DB -> recodingDB(period)
            RECORDING_FREQUENCY -> recodingFrequency(period)
            RECORDING_DB_AND_FREQUENCY -> recodingDBAndFrequency(period)
        }
    }

    // 特に処理はしない
    private fun recoding(period: Int) {
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        // こいつ(rnb0) が何回も呼ばれる
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータを入れる
                audioRecord.read(buffer,0,bufferSize)
                // 振幅が出る
                Log.d(LOG_NAME,"${buffer[100]}, ${buffer[300]}")

                // stop用のフラグ
                if (run) {
                    // 指定時間後に自分自身を呼ぶ
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        // 初回の呼び出し
        hnd0.post(rnb0)
    }

    // デシベル変換したやつを出力
    private fun recodingDB(period: Int) {
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータを入れる
                audioRecord.read(buffer,0,bufferSize)

                // 最大音量を解析
                val sum = buffer.sumOf { it.toDouble() * it.toDouble() }
                val amplitude = sqrt(sum / bufferSize)
                // デシベル変換
                val db = (20.0 * log10(amplitude)).toInt()
                //Log.d(LOG_NAME,"db = $db")

                if (run) {
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        hnd0.post(rnb0)
    }

    // 最大音量の周波数を出力
    private fun recodingFrequency(period: Int){
        val hnd0 = Handler(Looper.getMainLooper())
        run = true
        // こいつ(rnb0) が何回も呼ばれる
        val rnb0 = object : Runnable {
            override fun run() {
                // bufferにデータが入る
                audioRecord.read(buffer,0,bufferSize)

                // FFT 結果はfftBufferに入る
                val fft = DoubleFFT_1D(bufferSize.toLong())
                val fftBuffer = DoubleArray(bufferSize * 2)
                val doubleBuffer: DoubleArray = buffer.map { it.toDouble() }.toDoubleArray()
                System.arraycopy(doubleBuffer, 0, fftBuffer, 0, bufferSize)
                fft.realForward(fftBuffer)

                // Log.d(LOG_NAME, "${fftBuffer.toList()}")
                // Log.d(LOG_NAME, "${fftBuffer.size}")

                // 特定の周波数の振幅値の解析
                // val targetFrequency = 10000 // 特定の周波数(Hz)
                // val index = (targetFrequency * fftBuffer.size / sampleRate).toInt() // 特定の周波数が入っているリスト番号
                // 振幅値の解析
                // val amplitude = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]).toDouble())
                
                //振幅が最大の周波数とその振幅値の解析
                var maxAmplitude = 0.0 // 最大振幅
                var maxIndex = 0 // 最大振幅が入っているリスト番号
                // 最大振幅が入っているリスト番号を捜査
                for(index in IntStream.range(0, fftBuffer.size - 1)){
                    val tmp = sqrt((fftBuffer[index] * fftBuffer[index] + fftBuffer[index + 1] * fftBuffer[index + 1]))
                    if (maxAmplitude < tmp){
                        maxAmplitude = tmp
                        maxIndex = index
                    }
                }
                // 最大振幅の周波数
                val maxFrequency: Int = (maxIndex * sampleRate / fftBuffer.size)
//                Log.d(LOG_NAME, "maxFrequency = $maxFrequency")

                // stop用のフラグ
                if (run) {
                    // 指定時間後に自分自身を呼ぶ
                    hnd0.postDelayed(this, period.toLong())
                }
            }
        }
        // 初回の呼び出し
        hnd0.post(rnb0)
    }

    // AudioSensorの取得を停止
    fun stop() {
        run = false
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="">

    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Audio">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

build.gradle(モジュール)
dependencies部分のみ

dependencies {

    implementation 'androidx.core:core-ktx:1.9.0'
    implementation 'androidx.appcompat:appcompat:1.6.0'
    implementation 'com.google.android.material:material:1.7.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

    implementation 'com.github.wendykierp:JTransforms:3.1'
}

参考サイト等

Android.AudioFormat
com.github.wendykierp:JTransforms:3.1

Discussion