1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-19

当方プロ志向、SES会社にコネあり

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

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

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

最初に書いたコード

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

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() はそこまで精度が高くありません。
なので、ナノ秒単位で経過時間を測定、遅延させる処理を作ります。

// コルーチンのループ処理を外部メソッドとして定義
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 に変えます。

@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処理)を実装する場合は メインスレッドでおこなう必要があります。

// コルーチンのループ処理を外部メソッドとして定義
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) {
        // 動作中アニメーション処理など...
    }
}

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?