NTTドコモR&Dの技術ブログです。

【初めてのアプリ開発】Kotlinで歩くリズムをBPMで表すアプリを作ってみた

はじめに

こんにちは。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を行う時系列データの時間長が分かれば良く、

周波数分解能 = \frac{1}{サンプル数 \times サンプリングレート}

で求めることができます。この分解能とピーク時のインデックス値を掛けることで、周波数値が取得できるようになります。

スペクトルの最大値のインデックスを取得したいのですが、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を測定してみます。

左:歩行時のBPM, 右:走行時のBPM

このBPMを指標に、ウォーキングやランニング時に流す最適な曲をBPMから探すことができるようになりました!

実際に調べてみると、BPM115の曲だと

  • 空も飛べるはず - スピッツ
  • Happiness - 嵐
  • Giga - CH4NGE

また、BPM158の曲は

  • サカナクション - 新宝島
  • キタニタツヤ - 聖者の行進
  • DECO*27 - シンデレラ

あたりがヒットしました。私もこれらの曲を聴いて散歩やランニングをしてみようと思います!

おわりに

今回は、歩行時の加速度を取得し、FFTでスペクトル解析をすることで、歩行速度を音楽のBPMで表すアプリを作ってみました。

初めてのアプリ制作ということで、普段使っているPython等と記法がかなり異なっていることもあり最初はなかなか苦労しましたが、Androidアプリの開発における基本的な要素を学ぶことができたのでとても良い経験となりました。 本アプリの応用としては、歩行時のBPM情報を音楽配信サイトのAPIと連携することで、歩行状況に応じた音楽のレコメンド機能なども実装できれば、より快適な散歩体験に繋がりそうですね。

参考文献

内的テンポと外的テンポの不一致が歩行と感情に及ぼす影響

Android Developers | センサーの概要