メトロノームアプリ作りたいんだけど、テンポが安定しないっ！(Android kotlin 編)

スマホ用メトロノームアプリを作ろうと、まずはメインになる「繰り返し音を鳴らす処理」から取り掛かりました。


まあ、一定間隔でクリック音を鳴らすタイマー処理か、クリック音を鳴らして一定時間遅延処理するループ処理を作ればいけるでしょ。
と、作ってみたところテンポが安定しない。

一般的なアプリや業務用アプリでタイマー処理を使うケースでは数ミリ秒の誤差が出ても特に問題ないケースの方が多い。
ただ、メトロノームアプリの場合は、1ミリ秒遅れが重なっていけばどんどんズレていくし、毎回1～5ミリ秒程度の揺れがでたら「ピッタリの間隔で音が鳴ってほしいから使う」メトロノームとしては使い物にならない。



#### 最初に書いたコード
「クリック音を鳴らして一定時間遅延処理するループ処理」を非同期で実行させないとストップさせたり画面にメトロノームのアニメーションができない、ということは想像できたので以下のように実装

``` kotlin
class MainActivity : ComponentActivity() {
    // SoundPool を MainActivity で管理
    private var soundPool: SoundPool? = null
    private var soundId: Int = 0
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        
        // SoundPoolの設定と音声データのロード
        soundPool = SoundPool.Builder().setAudioAttributes(
            AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .build()
        ).setMaxStreams(10).build()
        
        // 音声データ[woodblock_02.wav]をロード
        soundId = soundPool!!.load(this, R.raw.woodblock_02, 1)
        
        // 画面描画のセットアップ
        setContent {
            MEtronomeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MetronomeScreen(
                        modifier = Modifier.padding(innerPadding),
                        soundPool = soundPool,
                        soundId = soundId
                    )
                }
            }
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MetronomeScreen(
    modifier: Modifier = Modifier,
    soundPool: SoundPool?,
    soundId: Int
) {
    // Coroutineのスコープを取得
    val coroutineScope = rememberCoroutineScope()
    // ジョブの状態を保持
    var loopJob by remember { mutableStateOf<Job?>(null) }
    // メトロノームの状態フラグ
    val isRunning = loopJob != null
    // メトロノームの状態を表示するテキストの状態
    val metronomeStateText = if (isRunning) "動作中" else "停止中"
    
    // Scaffoldを使用してステータスバーを避ける
    Scaffold { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .pointerInteropFilter { event ->
                    when (event.action) {
                        MotionEvent.ACTION_DOWN -> {
                            if (isRunning) {
                                loopJob?.cancel()
                                loopJob = null
                            } else {
                                loopJob = coroutineScope.launch(Dispatchers.Main) {
                                    startMetronomeLoop(soundPool, soundId)
                                }
                            }
                            true
                        }
                        else -> false
                    }
                },
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            // メトロノームの状態を表示するテキスト
            Text(
                text = metronomeStateText,
                style = MaterialTheme.typography.labelSmall
            )
        }
    }
}

// コルーチンのループ処理を外部メソッドとして定義
suspend fun CoroutineScope.startMetronomeLoop(soundPool: SoundPool?, soundId: Int) {
    var BPM: Long = 100
    // 4分音符のミリ秒をセット
    var interval: Long = 60000 / BPM
    
    while (isActive) {
        // クリック音再生
        soundPool?.play(soundId, 1f, 1f, 1, 0, 1f)
        // 計測用ログを出力
        Log.d("startMetronomeLoop", System.nanoTime().toString())
        // ミリ秒単位で遅延処理
        delay(interval)
    }
    
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MEtronomeTheme {
        MetronomeScreen(
            soundPool = null,
            soundId = 0
        )
    }
}
```

BPM:100 なので、600ミリ秒毎で繰り返し処理が実行されます。
このコードで実行、ログを出して繰返し処理の誤差を測定

- 測定結果(100回分)
    - 平均 608.4596ミリ秒
    - 最大 650ミリ秒
    - 最小 603.3ミリ秒

600ミリ秒ピッタリの間隔で繰り返されることはなく
平均の値は BPM:99 よりも遅くなっています。


#### 改善1
遅延処理の delay() はそこまで精度が高くありません。
なので、ナノ秒単位で経過時間を測定、遅延させる処理を作ります。

``` kotlin
// コルーチンのループ処理を外部メソッドとして定義
suspend fun CoroutineScope.startMetronomeLoop(soundPool: SoundPool?, soundId: Int) {
    var BPM: Long = 100
    // 4分音符のミリ秒をセット
    var interval: Long = 60000 / BPM

    while (isActive) {
        // 現在のナノ秒タイムを取得
        val startTime = System.nanoTime()

        // クリック音再生
        soundPool?.play(soundId, 1f, 1f, 1, 0, 1f)

        // 計測用ログを出力
        Log.d("startMetronomeLoop", System.nanoTime().toString())

        // ミリ秒単位で遅延処理
        delay(interval - 50)

        // 残りの正確な時間をループで補正
        while (System.nanoTime() - startTime < interval * 1000000) {

        }

    }
}
```

- 測定結果(100回分)
    - 平均 600.0532ミリ秒
    - 最大 603.9ミリ秒
    - 最小 596.2ミリ秒

平均値は申し分ない値になりました
ただ、最大と最小の誤差がまだ大きいです。
速いテンポにして楽器練習に使うと、まだまだリズムがヨレヨレに感じます。


#### 改善2
繰返し処理を バックグラウンドスレッド でおこなうよう
"startMetronomeLoop" の呼び出しを
Dispatchers.Main から Dispatchers.Default に変えます。

``` kotlin
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MetronomeScreen(
    modifier: Modifier = Modifier,
    soundPool: SoundPool?,
    soundId: Int
) {
    // Coroutineのスコープを取得
    val coroutineScope = rememberCoroutineScope()
    // ジョブの状態を保持
    var loopJob by remember { mutableStateOf<Job?>(null) }
    // メトロノームの状態フラグ
    val isRunning = loopJob != null
    // メトロノームの状態を表示するテキストの状態
    val metronomeStateText = if (isRunning) "動作中" else "停止中"
    
    // Scaffoldを使用してステータスバーを避ける
    Scaffold { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .pointerInteropFilter { event ->
                    when (event.action) {
                        MotionEvent.ACTION_DOWN -> {
                            if (isRunning) {
                                loopJob?.cancel()
                                loopJob = null
                            } else {
                                loopJob = coroutineScope.launch(Dispatchers.Default) {
                                    startMetronomeLoop(soundPool, soundId)
                                }
                            }
                            true
                        }
                        else -> false
                    }
                },
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            // メトロノームの状態を表示するテキスト
            Text(
                text = metronomeStateText,
                style = MaterialTheme.typography.labelSmall
            )
        }
    }
}
```

- 測定結果(100回分)
    - 平均 600.05ミリ秒
    - 最大 600.9ミリ秒
    - 最小 599.1ミリ秒

誤差幅も減り、使い心地も問題ない状態になりました。



#### 補足
音を出しながら、例えばランプのようなものを点滅させたり、メトロノームの振り子を描画させるなど
アニメーション処理(アプリのUI処理)を実装する場合は メインスレッドでおこなう必要があります。

``` kotlin
// コルーチンのループ処理を外部メソッドとして定義
uspend fun CoroutineScope.startMetronomeLoop(soundPool: SoundPool?, soundId: Int) {
    var BPM: Long = 100
    // 4分音符のミリ秒をセット
    var interval: Long = 60000 / BPM

    // アニメーション用コルーチンのJobを保持
    var animationJob: Job? = null

    while (isActive) {
        // 現在のナノ秒タイムを取得
        val startTime = System.nanoTime()

        // クリック音再生
        soundPool?.play(soundId, 1f, 1f, 1, 0, 1f)

        // 前回のアニメーションをキャンセル
        animationJob?.cancel()
        // アニメーションを並行して実行
        animationJob = launch {
            uiAnimationAsync()
        }

        // 計測用ログを出力
        Log.d("startMetronomeLoop", System.nanoTime().toString())

        // ミリ秒単位で遅延処理
        delay(interval - 50)

        // 残りの正確な時間をループで補正
        while (System.nanoTime() - startTime < interval * 1000000) {
            Log.d("startMetronomeLoop", System.nanoTime().toString())

        }

    }
}

suspend fun uiAnimationAsync() {
    withContext(Dispatchers.Main) {
        // 動作中アニメーション処理など...
    }
}

```