はじめに
こんにちは。NTTドコモ サービスイノベーション部の一年目社員の豊田です。 普段は、AIに関する研究開発を行っています。
今回は、業務でアプリ開発に初めて取り組む機会を頂いたことをきっかけに、試しにKotlinでアプリを作ってみたので、その制作過程を共有したいと思います。
突然ですが、皆さんは普段歩いたり、走ったりといった移動時に音楽を聴くことが多いでしょうか?私はよく散歩をするときに音楽を聴くことが多いのですが、そのときにいつも気になっているのが、
音楽のテンポと歩くリズムが一緒だったらいいのに、、
ということです。音楽と歩く速さを揃えるには、まず自分の歩く速さを知る必要がありますね。音楽にはBPMという単位があり、これは1分間に何回ビートがあるかという単位で、音楽のテンポを表すために使われます。つまり、自分の歩く速さをBPMで知ることができれば、そのBPMに合わせた音楽を選んで聴くことで、より快適な散歩を楽しむことができるのではないか?というわけです。実際、音楽と人間の歩行速度の関連については多くの研究が行われており、音楽のテンポと歩行速度をあわせることで、気分の向上に繋がるとされています(文献)。
そこで、今回はスマートフォンで歩行時の加速度を取得し、その振動からBPMを測定するという簡易的なアプリを作ることを目標とします。実際の開発の流れに沿って、解説をしていきます。
開発環境
以下の環境で開発を行いました。Android Studioの導入はこちらの解説記事がとっても参考になります。
Android Studio: Giraffe | 2022.3.1 Android SDK Platform: Version 33
アプリの開発フロー
1. センサーで測定した加速度データを表示してみる
まずは、実際に加速度を取得し、簡単に画面に表示するところから始めます。
Androidでのセンサー情報取得の概要については、下記のAndroid Developer内のセンサーの概要ページがとっても参考になります。 加速度センサはモーションセンサーにカテゴライズされており、モーションセンサーのページによると、
- TYPE_ACCELEROMETER
- TYPE_ACCELEROMETER_UNCALIBRATED
- TYPE_LINEAR_ACCELERATION
と、細かく分けて3種類の加速度に関するセンサーデータが取得できるそうです。 ここでは、重力の影響を取り除いた純粋な加速度を取得可能なTYPE_LINEAR_ACCELERATIONを選択します。
それでは、早速コードを実装していきましょう、、!と行きたいのですが、Androidアプリ開発において重要な概念となるライフサイクルというものについてここで事前に触れておきます。
普段使うアプリでは、起動、フォアグラウンド表示、バックグラウンド表示、タスクキルなど、様々な状態に遷移します。このような遷移の全体像がライフサイクルです。Androidアプリ開発においては、これらの各状態において、どのような処理をするかを意識してコードを書いていく必要があります。例えば、アプリ起動後に最初に呼び出される状態はonCreate()
というコールバックであり、基本的にはここでUI等レイアウトや様々な処理の初期化を行うことになります。
それでは、このライフサイクルに注意して、まずは加速度を取得するためのアプリ起動時の処理を記述してみます。
// アプリ起動時の処理 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // SensorManagerのインスタンスを取得 sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager // 加速度表示用テキストの用意 textViewacc = findViewById<TextView>(R.id.text_viewacc) }
ここでは、SensorManagerのインスタンスの取得と同時に、UI上に表示する加速度のテキストを用意します。
次に、アプリ実行時、一時停止状態の時の処理も記述します。
// アプリ実行後(フォアグラウンド移動後)の処理 override fun onResume() { super.onResume() // Listenerの登録 val accel = sensorManager!!.getDefaultSensor( Sensor.TYPE_LINEAR_ACCELERATION ) sensorManager!!.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI) } // アプリが一時停止状態となった際の処理 override fun onPause() { super.onPause() // Listenerを解除 sensorManager!!.unregisterListener(this) }
ここでは、registerListenerと呼ばれるメソッドを用いて、実際にセンサーの値を取得できる状態にします。 第二引数には、取得したいセンサーデータに対応するセンサーオブジェクトを、第三引数にはセンサーの取得頻度を選択します。
アプリが裏に行った際には、Listenerを一旦解除するために、onPauseメソッド内にはunregisterListenerメソッドも記載しておきます。
このListenerを設定することで、センサー値が更新された時(onSensorChanged())、センサーの精度が変わった時(onAccuracyChanged())の測定データを取得できるようになります。実際に、センサーの値を取得し、画面に表示するテキストを設定する処理を書いてみましょう。
// センサー値が更新された時 override fun onSensorChanged(event: SensorEvent) { val accsensorX: Float val accsensorY: Float val accsensorZ: Float if (event.sensor.type == Sensor.TYPE_LINEAR_ACCELERATION) { // X, Y, Zの値を取得 accsensorX = event.values[0] accsensorY = event.values[1] accsensorZ = event.values[2] val accstrTmp = """加速度センサー X: %03.4f Y: %03.4f Z: %03.4f""".format(accsensorX,accsensorY,accsensorZ) textViewacc!!.text = accstrTmp // TextViewの文字を更新 } } // センサーの精度が変更された時 override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { }
これで、画面に取得された加速度の値が表示されるようになりました!実際にエミュレーターで実行してみると、、?
このように加速度が画面に表示されるようになりました!(画面がとてもさみしいですが笑)
2. 加速度データを記録する機能を追加する
次に、加速度データを処理できるように、配列データとして保存できるようにしてみます。
ここでは、ボタンのON/OFFで記録開始/停止をできる仕組みを実装し、任意の区間でセンシングした加速度データのみ取得できる機能を目指します。
まずは、activity_main.xmlでボタンを実装してみます(細かいレイアウト設定は別途行う必要有)。
<Button android:id="@+id/button" android:layout_width="165dp" android:layout_height="75dp" android:text="@string/buttonon"/>
そして、ボタンの押下ごとにフラグの切り替えを行うコードを追加します。フラグがtrueの間だけ、加速度センサーから取得したデータを配列(accArrayX, accArrayY, accArrayZ)に格納します。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // SensorManagerのインスタンスを取得 sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager // 加速度表示用テキストの用意 textViewacc = findViewById<TextView>(R.id.text_viewacc) + val btn = findViewById<View>(R.id.button) as Button + btn.setOnClickListener(View.OnClickListener { + if (flag_btn) { + // ボタンをOFFにしたとき(ボタン上文字を測定開始に変更) + btn.text = getString(R.string.buttonon) + flag_btn = false + } + else { + // ボタンをONにしたとき(ボタン上文字を測定終了に変更) + btn.text = getString(R.string.buttonoff) + flag_btn = true + } + }) } override fun onSensorChanged(event: SensorEvent) { val accsensorX: Float val accsensorY: Float val accsensorZ: Float if (event.sensor.type == Sensor.TYPE_LINEAR_ACCELERATION) { accsensorX = event.values[0] accsensorY = event.values[1] accsensorZ = event.values[2] val accstrTmp = """加速度センサー X: %03.4f Y: %03.4f Z: %03.4f""".format(accsensorX,accsensorY,accsensorZ) textViewacc!!.text = accstrTmp // TextViewの文字を更新 + if (flag_btn) { + accArrayX = accArrayX.plus(accsensorX.toDouble()) + accArrayY = accArrayY.plus(accsensorY.toDouble()) + accArrayZ = accArrayZ.plus(accsensorZ.toDouble()) + } } }
これで、加速度の値を配列として保持することができるようになりました。
3. 記録した加速度データをFFTしてみる
取得した加速度データにFast Fourier Transform(FFT)を適用します。FFTとは、フーリエ変換を行うアルゴリズムの一種で、時系列的なデータを周波数領域の表現に変換することができます。これにより、取得した加速度データの周期性を把握し、歩行時の周波数を導くことができるようになります。
Kotlinでは、FFTを実装するために、Jtransformsと呼ばれるライブラリを用います。 build.gradle.kts内のdependencies{}内に以下の記載をすることで、Jtransformsのライブラリが実装できます。
implementation("com.github.wendykierp:JTransforms:3.1")
そして、FFTを行う関数DoubleFFT_1D
をインポートし、以下のように、MainActivityを書き換えます。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // SensorManagerのインスタンスを取得 sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager // 加速度表示用テキストの用意 textViewacc = findViewById<TextView>(R.id.text_viewacc) val btn = findViewById<View>(R.id.button) as Button btn.setOnClickListener(View.OnClickListener { if (flag_btn) { // ボタンをOFFにしたとき(ボタン上文字を測定開始に変更) btn.text = getString(R.string.buttonon) + // Y軸の加速度データをFFT + val toDoubleArray = accArrayY.toDoubleArray() + val bufferSize = toDoubleArray.size.toLong() + + // FFTの計算 + val fft = DoubleFFT_1D(bufferSize) + fft.realForward(toDoubleArray) + + // 複素数を実数値に変換 + val toDoubleArrayFFT = DoubleArray((bufferSize / 2).toInt()) + for(s in toDoubleArrayFFT.indices) { + val re = toDoubleArray[s * 2]; + val im = toDoubleArray[s * 2 + 1]; + toDoubleArrayFFT[s] =sqrt(re * re + im * im) / bufferSize + } + + accArrayX = emptyArray<Double>() + accArrayY = emptyArray<Double>() + accArrayZ = emptyArray<Double>() flag_btn = false } else { // ボタンをONにしたとき(ボタン上文字を測定終了に変更) btn.text = getString(R.string.buttonoff) flag_btn = true } }) }
ここでは、Y軸の加速度データのみに対して、FFTを実行しています。スマホ本体と軸は以下の画像のように定義づけられており、縦の移動のみ観測することで歩行時の動きを補足できるように実装します。
また、FFTで得られる結果は複素数の値のため、スペクトルの実数値を得るためには実部と虚部をそれぞれ2乗し、ルートをかけてあげましょう。FFT後は保持した値を初期化するため、空の配列で上書きします。
4. FFT後のスペクトルからBPMを算出してみる
最後に、FFTによって抽出されたスペクトルからBPMを計算し、画面に表示してみましょう。
歩行時のBPMを求めるためには、歩行時の周波数がどれくらいかを知る必要があります。FFTによって得たスペクトルデータは、元信号がどの周波数の成分を持っているかを示すものであり、最も強い成分(ピーク値)を示すときの横軸の値を読み取ることで、歩行時の周波数を知ることができます。
スペクトルデータの横軸の値は、周波数の分解能によって決まります。分解能は、FFTを行う時系列データの時間長が分かれば良く、
で求めることができます。この分解能とピーク時のインデックス値を掛けることで、周波数値が取得できるようになります。
スペクトルの最大値のインデックスを取得したいのですが、Kotlinではデフォルトで実装されていないため、配列操作を行う関数argMax
を自作します。
private fun argMax(array: DoubleArray):Int { var maxValue = 0.0 var maxValueIndex = 0 for (i in array.indices) { // 周波数0成分のピークを除外 if (i==0){ continue } if (array[i] > maxValue) { maxValue = array[i] maxValueIndex = i } } return maxValueIndex }
周波数は一秒間の周期的な繰り返し回数を示す値、一方、BPMは一分間の拍数を示す値なので、単純に周波数を60倍してあげることでBPMを導出できます。
val maxValueIndex = argMax(toDoubleArrayFFT) val frequency = maxValueIndex * freqResolution val bpm = frequency*60
以上で歩行のリズムを表すBPMを導くことができるようになりました!!
結果
最終的に作成したアプリのソースコード全体は以下の通りです。
// MainActivity.kt package com.example.bpmmeasurement import android.annotation.SuppressLint import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Bundle import android.view.View import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import org.jtransforms.fft.DoubleFFT_1D import kotlin.math.sqrt class MainActivity : AppCompatActivity(), SensorEventListener { private var sensorManager: SensorManager? = null private var textViewacc: TextView? = null private var textBpm: TextView? = null private var accArrayX = emptyArray<Double>() private var accArrayY = emptyArray<Double>() private var accArrayZ = emptyArray<Double>() private var flag_btn = false private val sensorSamplingRate = 20000 // [μs] // アプリ起動時の処理 @SuppressLint("MissingInflatedId") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // SensorManagerのインスタンスを取得 sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager // UI表示用テキストのIDを指定 textViewacc = findViewById<TextView>(R.id.text_viewacc) textBpm = findViewById<TextView>(R.id.bpm) val btn = findViewById<View>(R.id.button) as Button btn.setOnClickListener(View.OnClickListener { if (flag_btn) { // ボタンをOFFにしたとき(ボタン上文字を測定開始に変更) btn.text = getString(R.string.buttonon) // Y軸の加速度データをFFT val toDoubleArray = accArrayY.toDoubleArray() val bufferSize = toDoubleArray.size.toLong() // 周波数分解能の計算 val freqResolution = 1000000 / sensorSamplingRate / bufferSize.toFloat() // FFTの計算 val fft = DoubleFFT_1D(bufferSize) fft.realForward(toDoubleArray) // 複素数を実数値に変換 val toDoubleArrayFFT = DoubleArray((bufferSize / 2).toInt()) for(s in toDoubleArrayFFT.indices) { val re = toDoubleArray[s * 2]; val im = toDoubleArray[s * 2 + 1]; toDoubleArrayFFT[s] =sqrt(re * re + im * im) / bufferSize } // BPMの計算 val maxValueIndex = argMax(toDoubleArrayFFT) val frequency = maxValueIndex * freqResolution val bpm = frequency*60 textBpm!!.text = "%03.1f BPM".format(bpm) // BPMを表示 accArrayX = emptyArray<Double>() accArrayY = emptyArray<Double>() accArrayZ = emptyArray<Double>() flag_btn = false } else { // ボタンをONにしたとき(ボタン上文字を測定終了に変更) btn.text = getString(R.string.buttonoff) flag_btn = true } }) } // アプリ実行後(フォアグラウンド移動後)の処理 override fun onResume() { super.onResume() // Listenerの登録 val accel = sensorManager!!.getDefaultSensor( Sensor.TYPE_LINEAR_ACCELERATION ) sensorManager!!.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI) } // アプリが一時停止状態となった際の処理 override fun onPause() { super.onPause() // Listenerを解除 sensorManager!!.unregisterListener(this) } override fun onSensorChanged(event: SensorEvent) { val accsensorX: Float val accsensorY: Float val accsensorZ: Float if (event.sensor.type == Sensor.TYPE_LINEAR_ACCELERATION) { accsensorX = event.values[0] accsensorY = event.values[1] accsensorZ = event.values[2] val accstrTmp = """加速度センサー X: %03.4f Y: %03.4f Z: %03.4f""".format(accsensorX,accsensorY,accsensorZ) textViewacc!!.text = accstrTmp // TextViewの文字を更新 if (flag_btn) { accArrayX = accArrayX.plus(accsensorX.toDouble()) accArrayY = accArrayY.plus(accsensorY.toDouble()) accArrayZ = accArrayZ.plus(accsensorZ.toDouble()) } } } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} private fun argMax(array: DoubleArray):Int { var maxValue = 0.0 var maxValueIndex = 0 for (i in array.indices) { // 周波数0成分のピークを除外 if (i==0){ continue } if (array[i] > maxValue) { maxValue = array[i] maxValueIndex = i } } return maxValueIndex } }
<!-- activity_main.xml --> <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="71dp" android:background="#27273C" android:orientation="vertical"> <TextView android:id="@+id/title" android:layout_width="351dp" android:layout_height="match_parent" android:gravity="center" android:text="@string/titleName" android:textColor="#FFFFFF" android:textSize="28sp" android:textStyle="bold" /> </LinearLayout> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="#11111C"> <Button android:id="@+id/button" android:layout_width="165dp" android:layout_height="75dp" android:backgroundTint="#363657" android:text="@string/buttonon" android:textColor="#FFFFFF" android:textSize="24sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.84" /> <TextView android:id="@+id/text_viewacc" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:textColor="#FFFFFF" android:textSize="24sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.121" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.0" /> <TextView android:id="@+id/yourBpmIs" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="left" android:text="@string/bpmIs" android:textColor="#FFFFFF" android:textSize="20sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.064" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/text_viewacc" app:layout_constraintVertical_bias="0.213" /> <TextView android:id="@+id/bpm" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFEFE" android:textSize="48sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> </LinearLayout>
<!-- strings.xml --> <resources> <string name="app_name">Your BPM</string> <string name="titleName">あなたのBPM測定アプリ</string> <string name="buttonon">測定開始</string> <string name="buttonoff">測定終了</string> <string name="bpmIs">あなたのBPMは...</string> <string name="bpm">BPM</string> </resources>
このアプリを使って、実際に自分のBPMを測定してみます。