当方プロ志向、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) {
// 動作中アニメーション処理など...
}
}