これは はてなエンジニア - Qiita Advent Calendar 2025 - Qiita の2026/01/12 の記事です。

最近ギターを弾く機会と、チューナーに触れる機会が多かったので、チューナーを作りたいなと思いました。
一見すると音声処理は難しそうなテーマですが、今はAIに相談しながら進められるので、音声制御のロジック的な部分は補強してもらいつつ、それなりのものが作れるのではないかと思い、試してみることにしました。
今回はシンプルなチューナーを作って、実際に使ってみて、最低限必要そうな機能を問題ベースで足していくという進め方をしています。
今回目指したゴールは以下のように設定しました。
- UIは最低限で分かりやすく、チューナーとしては「単音のチューニングができる」こと
- 精度を追い込むより、「チューナーとして使っていて違和感がない」ことを優先する
- しっかりした評価実験や端末差の検証まではせず、位置づけとしてはプロトタイプ
1. シンプルなチューナーを作る
基本的なロジックの実装
まずはロジックです。チューナーの役割をざっくり言うと、マイクから音を取って、その音の高さを数値として推定し、それを表示するだけです。この「音の高さ」をこの記事ではまとめてpitchと呼びます。pitch は Hz(周波数)や、そこから計算したズレ量を含んだ概念です。
ここで出てくる Hz(周波数)は「1秒あたり何回振動しているか」という値で、いわゆる音の高さそのものです。一方で sampleRate は「1秒あたり何個のサンプルとして録るか」で、Hzを計算するときの前提になります。
録音する
まずは録音します。リアルタイム処理が前提なので、今回は AudioRecord を使います。C++のライブラリに比べるとレイテンシはありますが、チューナー用途であればレイテンシーは気にならない想定です。AudioRecord はマイク入力をそのままPCMデータとして扱えるので、後段の処理がやりやすいです。
val sampleRate = 48000 val bufferSize = AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) val audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize )
ここで使っている sampleRate は「1秒間に何回サンプリングするか」という値です。48000Hzなら、1秒の音が48000個の数値として取得されます。後で出てくる周波数計算は、この数値を前提にしています。
音の高さ(pitch)を推定する
音を取れたら、次はその音がどれくらいの高さなのかを推定します。今回は FFT ではなく自己相関を使いました。
チューナーなどの単音解析であれば、自己相関はシンプルで扱いやすいというメリットがあるようです。
自己相関という名前は難しそうに聞こえますが、やっていることはとても素朴です。波形を少しずつ横にずらして重ねてみて、「一番よく一致するずらし量」を探しています。この「ずらし量」をlagと呼びます。lag は「何サンプル分ずらすか」という単位なので、sampleRate / lag をすると周波数(Hz)が求まります。
/** * 自己相関を計算する(非正規化) * * corr[lag] = Σ x[i] * x[i + lag] */ private fun autocorrelation( x: FloatArray, minLag: Int, maxLag: Int ): FloatArray { // lag ごとの相関値を入れる配列 // index = lag(ずらし量) val corr = FloatArray(maxLag + 1) // lag を小さい方から順に試していく for (lag in minLag..maxLag) { // この lag に対する相関の合計値 var sum = 0f // i + lag が配列範囲を超えないようにするための上限 val n = x.size - lag var i = 0 while (i < n) { // 元の波形 x[i] と // lag だけずらした波形 x[i + lag] を掛け合わせる // // ・形が似ていると正の値が積み上がる // ・似ていないと正負が打ち消し合う sum += x[i] * x[i + lag] i++ } // この lag に対する自己相関値 corr[lag] = sum } // lag ごとの相関値をまとめて返す return corr }
波形が似ていると掛け算の結果が揃い、似ていないと相殺されるので、「似ている度合い」が数値として出てきます。これを lag ごとに並べると、「周期っぽい場所」でピークが立つ、というのが自己相関の直感です。
音程に変換して、centで合わせる
ピークとなるlagからHzを出し、基準音と比べてcentを計算します。ここでは NoteMapper が Hz を音名とcentに変換する役目を持っています。
また音程(Hz)が分かっても、そのまま表示してもチューニングには使いにくいです。そこで基準音との差をcentという単位で扱います。centは「0がちょうど合っている」「プラスなら高い」「マイナスなら低い」という、UI向きのズレ量です。半音が100cent、1オクターブが1200cent、という扱いなので、UIでは「どれだけズレているか」をそのまま数字で表現できます。
/** * UI に渡すためのピッチ結果 * * frequencyHz : 実際に検出された周波数 * noteName : 最も近い音名(例: A4, C#3) * cents : その音名からのズレ量(±cent) */ data class PitchResult( val frequencyHz: Float, val noteName: String, val cents: Int ) /** * 周波数(Hz)を * ・音名(note) * ・オクターブ * ・cent(ズレ量) * に変換するクラス * * チューナーUI向けの「人間に分かる形」への変換担当 */ class NoteMapper( // 基準音 A4 の周波数(通常は 440Hz) private val a4Hz: Float = 440f ) { // 半音12個分の音名テーブル(C始まり) private val noteNames = listOf( "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" ) /** * 周波数(Hz)を PitchResult に変換する */ fun map(frequencyHz: Float): PitchResult { // --------------------------------------------------------- // 1) 周波数 → MIDIノート番号(浮動小数) // // MIDIでは A4 = 69 と決められている // 1オクターブ = 12半音 // // ln(f / A4) / ln(2) で // 「A4から何オクターブ離れているか」を求めている // --------------------------------------------------------- val noteFloat = 69f + 12f * (ln(frequencyHz / a4Hz) / ln(2.0).toFloat()) // --------------------------------------------------------- // 2) 最も近い半音(整数のMIDIノート番号) // // チューナーでは「一番近い音名」を基準にするため // 四捨五入している // --------------------------------------------------------- val note = noteFloat.roundToInt() // --------------------------------------------------------- // 3) そのノート番号に対応する「理論上の周波数」 // // ここが cent 計算の基準になる // --------------------------------------------------------- val nearestHz = a4Hz * 2f.pow((note - 69) / 12f) // --------------------------------------------------------- // 4) cent の計算 // // cent = 半音の 1/100 // 1オクターブ = 1200 cent // // 今の周波数が、最も近い音から // どれだけズレているかを求めている // --------------------------------------------------------- val centsFloat = 1200f * (ln(frequencyHz / nearestHz) / ln(2.0).toFloat()) // UIでは整数で十分なので丸める val cents = centsFloat.roundToInt() // --------------------------------------------------------- // 5) ノート番号 → 音名 // // %12 で半音テーブルに対応付ける // 負数対策として +12 → %12 をしている // --------------------------------------------------------- val name = noteNames[(note % 12 + 12) % 12] // --------------------------------------------------------- // 6) オクターブ番号 // // MIDIノートは C-1 = 0 なので // チューナー表記に合わせて -1 している // --------------------------------------------------------- val octave = (note / 12) - 1 // --------------------------------------------------------- // 7) UI向けの結果としてまとめて返す // --------------------------------------------------------- return PitchResult( frequencyHz = frequencyHz, noteName = "$name$octave", cents = cents ) } }
この時点で、「音を取って」「高さを推定して」「ズレ量を出す」という最低限のロジックは揃いました。
UIの実装
UIもまずはシンプルです。
Screenレベルでは、現在の pitch を表示するだけにしています。
@Composable fun TunerScreen(pitch: PitchResult?) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = pitch?.noteName ?: "--") Text(text = pitch?.cents?.toString() ?: "--") } }
この時点では、「動くけど使いづらい」状態です。
ここから実際に使ってみて、問題が見えてきました。
2. 見えてきた問題点と対策
ロジック側の問題
無音やノイズでもHzが出る
弦を鳴らしていないのに、Hzが出たりします。これは「計算できてしまう」だけで、「信じていい値か」を見ていないのが原因です。
まずはオフセットを消します。マイク入力には微妙な直流成分が乗ることがあるので、平均値を引きます。
/** * DC成分(平均値)を除去する */ private fun removeDc(samples: FloatArray): FloatArray { var mean = 0f for (v in samples) mean += v mean /= samples.size return FloatArray(samples.size) { i -> samples[i] - mean } }
次に、音量が小さいときは処理しないようにします。ここでは RMS を使っています。RMSはざっくり言うと音量の指標です。
/** * エネルギーと RMS を同時に計算する * * energy = Σ(x^2) * rms = sqrt(energy / N) */ private fun energyAndRms(x: FloatArray): Pair<Float, Float> { var energy = 0f for (v in x) energy += v * v val rms = sqrt(energy / x.size) return energy to rms }
さらに、自己相関のピークが弱いものは信頼できないとして捨てます。
val norm = bestValue / max(energy, EPS) if (norm < minCorrelation) return null
normとは
norm = bestValue / energy
- 1 に近い → ほぼ完全に周期的(理想的な単音)
- 小さい → 周期性が弱い(ノイズ・ブレた音)
という信頼度を表すスコアです。自己相関のピーク(bestValue)が、波形全体のエネルギー(energy)に対してどれくらい強いか、という見方をしています。
max(energy, EPS)の意味
max(energy, EPS)
- energy が 0(無音)に近いと
- 0 除算や異常値が出る
ので、
- 最低限の小さい値(EPS)を保証しています。
安全装置です。
高周波数側に張り付く
自己相関は、lagが小さい、つまり高周波数側で誤検出しやすい傾向があります。ここはアルゴリズムで頑張るより、割り切って上限付近を切ります。
// ------------------------------------------------------------------ // 6) 上限付近の誤検出を除外 // lag が小さすぎる場合に出やすい高音誤検出対策 // ------------------------------------------------------------------ if (hz > maxHz * maxHzMargin) return null
キーのブレが大きい
倍音の影響で、音の高さ(pitch)が細かくブレることがあります。ここでは周波数の中央値を取ることで、外れ値を落としています。
class MedianFilter(private val size: Int = 7) { private val buf = ArrayDeque<Float>() fun push(v: Float): Float { buf.addLast(v) if (buf.size > size) buf.removeFirst() val sorted = buf.sorted() return sorted[sorted.size / 2] } } private val median = MedianFilter(size = medianSize) // 周波数の中央値フィルタ val hz = median.push(hzRaw)
- なぜ「平均」じゃなくて「中央値」なのか
直近7回の推定結果がこれだったとします:
[110, 110, 109, 220, 110, 111, 110]
220は倍音誤検出(よくある)- 本当は110Hz(A弦)を鳴らしている
平均値だと
(110+110+109+220+110+111+110)/7 ≈ 125Hz
→ 一発の誤検出に引っ張られる
中央値だと
ソート →[109,110,110,110,110,111,220] 中央値 →110Hz
→ 1〜2個の異常値を完全に無視できる
アタック音は雑音が多い
弦を弾いた直後はノイズが多いので、最初の数フレームを無視します。
// 弦を弾いた直後はノイズが多いため、 // 音が入り始めてから一定時間はピッチを出さない val stablePhase = stability.filter(hasSignal) if (!stablePhase) return null
UI側の問題
簡易メーターを入れる
テキストだとわかりにくいと感じたので、シンプルなメーターUIも作りました。内容はここでは省略します。
表示が揺れて、UIが落ち着かない
pitchは瞬間値なので常に揺れます。UIでは「いま取れている値」と「さっきまで取れていた値」を分けて扱います。
値が取れている瞬間だけ表示をすると、チューニングをしている間に表示が消えてしまい使いづらいです。
このため、最後に取得した音程をしばらくholdします。ここでいう hold は「音が途切れた瞬間に表示が消えないように、直前の推定結果を少しだけ保持する」という意味です。
var pitch by remember { mutableStateOf<PitchResult?>(null) } // しばらくの間pitchをholdしておく val heldPitch = rememberHeldPitch2Stage(pitch) // 表示用のpitch val displayPitch = pitch ?: heldPitch TunerInfoArea( displayPitch = displayPitch, displayCents = smoothCents, isHold = isHold, modifier = Modifier.padding(top = 8.dp) )
ズレ量が分かりにくい
centもそのまま出すと揺れるので、表示用だけ平滑化します。ここでいう平滑化は、ロジック側の値を変えるのではなく、あくまで「UIで見える値が揺れすぎないようにする」ための処理です。
val smoothCents = rememberSmoothedCents( rawCents = displayPitch?.cents ?: 0, hasSignal = displayPitch != null, alpha =0.25f ) AnalogTunerMeter( cents = smoothCents, isInTune = isInTune, needleAlpha = needleAlpha, modifier = Modifier .fillMaxWidth() .height(250.dp) .padding(top = 32.dp, bottom = 32.dp) .alpha(meterAlpha) ) TunerInfoArea( displayPitch = displayPitch, displayCents = smoothCents, isHold = isHold, modifier = Modifier.padding(top = 8.dp) )
3. でき上がったもの
- 動作イメージ

Githubにコード全体を載せていますが、チューナー側のコードと、UI側のコードは以下にまとめています。
/** * マイク入力 → ピッチ検出 → UI向けの PitchResult を Flow で流すクラス * * ・AudioRecord の生PCMを扱う * ・「揺れる」「途切れる」前提で安定化処理を挟む * ・UIが嘘をつかないようにするための調整レイヤ */ class MicPitchAnalyzer( private val context: Context, // Hz を推定するロジック(自己相関など) private val detector: PitchDetector, // Hz → note / cents に変換するマッパ private val mapper: NoteMapper = NoteMapper(), // 音声処理は重いのでバックグラウンドで実行 private val dispatcher: CoroutineDispatcher = Dispatchers.Default, // 1秒あたりのサンプル数(Hz計算の基準) private val sampleRate: Int = 48_000, // --- 安定化パラメータ(体感調整しやすい) --- // 弦を弾いた直後(アタック音)はノイズが多いので無視する時間 private val attackIgnoreMs: Long = 100L, // 周波数の中央値フィルタサイズ(外れ値除去) private val medianSize: Int = 3, // cents を 0 に吸着させる幅(±2cent 以内は 0 扱い) private val centsDeadband: Int = 2, // --- 検出フレーム設定 --- // ピッチ検出に使う固定長フレームサイズ // 低音ほど長い波形が必要なので固定にしている private val detectFrameSize: Int = 4096, // --- ゲート設定(環境依存) --- // 音が入ったと判断する音量(dB) private val openDb: Float = -72f, // 音が切れたと判断する音量(dB) private val closeDb: Float = -80f, ) { // ---- 状態を持つフィルタ群 ---- // Flow 内で毎回作り直さず、Analyzer に保持する // アタック音を除外するためのフィルタ private val stability = PitchStabilityFilter(attackIgnoreMs = attackIgnoreMs) // 周波数のブレを抑える中央値フィルタ private val median = MedianFilter(size = medianSize) // cents の小さな揺れを吸収するヒステリシス private val hysteresis = CentsHysteresis(snapToZeroCents = centsDeadband) // 音量に基づく開閉ゲート private val gate = GateState(openDb = openDb, closeDb = closeDb) /** * ピッチ検出結果を Flow として返す * * ・音が安定していないときは null * ・安定したときだけ PitchResult を流す */ @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) fun pitchFlow(): Flow<PitchResult?> = callbackFlow { // --- パーミッションチェック --- if (!hasRecordAudioPermission()) { trySend(null) close(SecurityException("RECORD_AUDIO permission not granted")) return@callbackFlow } var record: AudioRecord? = null // 音声処理は別スレッドで実行 val job = launch(dispatcher) { try { // --- AudioRecord 初期化 --- val config = AudioConfig(sampleRate = sampleRate) record = createAudioRecordOrNull(config) ?: run { trySend(null) close(IllegalStateException("AudioRecord init failed")) return@launch } // 端末依存の自動音声補正を無効化 // (チューナーでは予期しない変化になる) disableAudioEffects(record) // PCM16bit → ShortArray val shortBuf = ShortArray(config.bufferSizeInBytes / 2) // 固定長フレームを作るためのリングバッファ val ring = FloatRingBuffer(capacity = detectFrameSize) // --- 計測開始 --- record.startRecording() while (isActive) { // マイクから読み込み val read = record.read(shortBuf, 0, shortBuf.size) if (read <= 0) continue // PCM(short) → 正規化された float val floatBuf = pcm16ToFloat(shortBuf, read) // リングバッファに追加 ring.push(floatBuf) // フレームが溜まるまでは何も出さない if (!ring.isReady) { trySend(null) continue } // --- 音量ゲート判定 --- // 最新の read 分だけで判定することで反応を良くする val rmsDb = rmsDb(floatBuf) val hasSignal = gate.update(rmsDb) // アタック除外+ゲート val stablePhase = stability.filter(hasSignal) if (!stablePhase || !hasSignal) { trySend(null) continue } // --- ピッチ検出 --- // 固定長フレームを取得 val frame = ring.snapshot() val hzRaw = detector.estimateFrequencyHz(frame, sampleRate) if (hzRaw == null) { trySend(null) continue } // 周波数の中央値フィルタでブレを抑える val hz = median.push(hzRaw) // Hz → note / cents に変換 val mapped = mapper.map(hz) // cents のヒステリシス(小さな揺れを抑制) val stabilizedCents = hysteresis.apply(mapped.cents) // UI向けの最終結果を流す trySend( mapped.copy( frequencyHz = hz, cents = stabilizedCents ) ).isSuccess } } catch (_: Throwable) { // stop/release時の端末差例外は握る } } // Flow がキャンセルされたときの後始末 awaitClose { job.cancel() record?.let { runCatching { it.stop() } runCatching { it.release() } } } } }
/** * Autocorrelation(自己相関)を用いたピッチ検出器。 * * ・FFTを使わず、時間領域の周期性から基本周波数を推定する * ・単音(ギター等)用途を想定 * ・ノイズや無音時の誤検出を極力避ける設計 */ class AutocorrelationPitchDetector( private val minHz: Float = 60f, // 想定する最低周波数(Hz) private val maxHz: Float = 1000f, // 想定する最高周波数(Hz) private val minCorrelation: Float = 0.25f, // 周期性の信頼度下限 private val minRmsDb: Float = -45f, // 入力音量が小さすぎる場合は無視 private val maxHzMargin: Float = 0.95f // 上限付近の誤検出を落とすためのマージン ) : PitchDetector { /** * PCM サンプル配列から基本周波数(Hz)を推定する * * @return 推定Hz、または信頼できない場合は null */ override fun estimateFrequencyHz( samples: FloatArray, sampleRate: Int ): Float? { // 不正入力チェック if (samples.isEmpty()) return null if (sampleRate <= 0) return null // ------------------------------------------------------------------ // 1) DC除去(平均値を引く) // マイク入力に含まれる直流成分を除去し、 // 自己相関の lag=0 付近が異常に強くなるのを防ぐ // ------------------------------------------------------------------ val x = removeDc(samples) // ------------------------------------------------------------------ // 2) 簡易ゲート(RMS dB) // 音量が小さすぎる場合はピッチ検出を行わない // ------------------------------------------------------------------ val (energy, rms) = energyAndRms(x) // RMS を dB に変換 val rmsDb = 20f * log10(rms.coerceAtLeast(EPS)) if (rmsDb < minRmsDb) return null // ------------------------------------------------------------------ // 3) 探索範囲(lag)の決定 // lag = 周期(サンプル数) // Hz = sampleRate / lag // ------------------------------------------------------------------ val maxLag = (sampleRate / minHz) .toInt() .coerceAtMost(x.size - 1) val minLag = (sampleRate / maxHz) .toInt() .coerceAtLeast(1) if (minLag >= maxLag) return null // ------------------------------------------------------------------ // 4) 自己相関(非正規化) // 周期性の強さを lag ごとに算出 // ------------------------------------------------------------------ val corr = autocorrelation(x, minLag, maxLag) // ------------------------------------------------------------------ // 5) 最初の谷の後ろから最大ピークを探す // lag=0 の自己相関ピークを避け、 // 基音周期に対応するピークを選択する // ------------------------------------------------------------------ val best = findBestLagAfterFirstValley(corr, minLag, maxLag) ?: return null val bestLag = best.first val bestValue = best.second // lag → 周波数へ変換 val hz = sampleRate.toFloat() / bestLag.toFloat() // ------------------------------------------------------------------ // 6) 上限付近の誤検出を除外 // lag が小さすぎる場合に出やすい高音誤検出対策 // ------------------------------------------------------------------ if (hz > maxHz * maxHzMargin) return null // ------------------------------------------------------------------ // 7) 相関の強さで信頼性チェック // 自己相関ピークが全体エネルギーに対して // 十分に強いかを判定 // ------------------------------------------------------------------ val norm = bestValue / max(energy, EPS) if (norm < minCorrelation) return null return hz } }
/** * チューナー画面 */ @Composable fun TunerScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current // --------------------------------------------------------------------- // 1) マイク権限(RECORD_AUDIO) // --------------------------------------------------------------------- // 初期状態で権限があるかどうかを確認して State に保持します。 var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED ) } // 権限リクエスト用の launcher。 // 「権限許可ダイアログ」から帰ってきた結果(granted)で state を更新します。 val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> hasPermission = granted } // --------------------------------------------------------------------- // 2) ピッチ解析 // --------------------------------------------------------------------- // Analyzer は内部にフィルタ状態などを持つので、基本的に Screen と寿命を合わせます。 val analyzer = remember { MicPitchAnalyzer( context = context.applicationContext, detector = AutocorrelationPitchDetector() ) } // --------------------------------------------------------------------- // 3) 解析結果の State // --------------------------------------------------------------------- // pitchFlow() から流れてくる「生の推定結果」を保持します。 var pitch by remember { mutableStateOf<PitchResult?>(null) } // 権限が取れたら Flow の購読を開始します。 // collectLatest にしているので、UIが重い瞬間に古い値が溜まるのを避けられます。 LaunchedEffect(hasPermission) { if (!hasPermission) return@LaunchedEffect analyzer.pitchFlow().collectLatest { pitch = it } } // --------------------------------------------------------------------- // 4) hold // --------------------------------------------------------------------- val heldPitch = rememberHeldPitch2Stage( pitch = pitch, ) as PitchResult? // --------------------------------------------------------------------- // 5) 見た目のフェード制御 // --------------------------------------------------------------------- val meterAlpha = when { pitch != null -> 1f heldPitch != null -> 0.75f else -> 0.45f } // keepScreenOn(画面スリープ防止)は「計測中だけ」有効化。 val measuring = hasPermission && (pitch != null || heldPitch != null) Surface( modifier = modifier .fillMaxSize() .then(if (measuring) Modifier.keepScreenOn() else Modifier) ) { Column( modifier = Modifier .fillMaxSize() .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { // ----------------------------------------------------------------- // 権限がないとき:権限取得UIを出すだけ // ----------------------------------------------------------------- if (!hasPermission) { Text("マイク権限が必要です。") Button(onClick = { launcher.launch(Manifest.permission.RECORD_AUDIO) }) { Text("権限を許可") } return@Column } // ----------------------------------------------------------------- // 権限があるとき:チューナーUI // ----------------------------------------------------------------- val isHold = pitch == null && heldPitch != null val hasLive = pitch != null val hasHeld = heldPitch != null // 表示は「live が優先」。live がなければ hold を使います。 val displayPitch = pitch ?: heldPitch // メーターに渡す cents(±50 でクランプ) // cents は「近い音名からどれだけズレているか」の単位で、 // 0 がちょうど合ってる、+ が高い、- が低い、という UI 向けの指標です。 val meterCentsRaw = (displayPitch?.cents?.coerceIn(-50, 50)) ?: 0 // ----------------------------------------------------------------- // 表示用 cents の平滑化 // ----------------------------------------------------------------- // 生の cents は細かく揺れるので、UIでは少しだけなめらかにします。 // ロジック側の推定を変えるのではなく「見える値だけ」整えるイメージ。 val smoothCents = rememberSmoothedCents( rawCents = meterCentsRaw, hasSignal = displayPitch != null, alpha = 0.25f ) // ----------------------------------------------------------------- // 針の表示 // ----------------------------------------------------------------- // 「live のときは濃く」「hold だけなら薄く」「何もなければ消す」 val needleAlphaTarget = when { hasLive -> 1f hasHeld -> 0.6f else -> 0f } // animateFloatAsState で alpha を滑らかに遷移させます。 // ※「値が途切れた瞬間に針が消える」より体験が良くなる val needleAlpha by animateFloatAsState( targetValue = needleAlphaTarget, animationSpec = tween(durationMillis = 180), label = "needleAlpha" ) // ----------------------------------------------------------------- // メーター表示 // ----------------------------------------------------------------- val isInTune = hasLive && abs(smoothCents) <= IN_TUNE_CENTS AnalogTunerMeter( cents = smoothCents, isInTune = isInTune, needleAlpha = needleAlpha, modifier = Modifier .fillMaxWidth() .height(250.dp) .padding(top = 32.dp, bottom = 32.dp) .alpha(meterAlpha) ) // ----------------------------------------------------------------- // テキスト情報表示(音名 / Hz / cents など) // ----------------------------------------------------------------- TunerInfoArea( displayPitch = displayPitch, displayCents = smoothCents, isHold = isHold, modifier = Modifier.padding(top = 8.dp) ) } } }
4. まとめと今後の展望
音声処理というハードルの高い題材でも、AIを利用することで現実的な時間で「それなりに動くチューナー」を作ることができました。
今後の課題としては、pitchの追従性の向上や、周囲の雑音への耐性向上と、UIも触っていて楽しい方向に改善できたらと思います。
また、ギター専用として弦ロックやガイド表示など、チューニングに特化した機能も足していきたいと感じました。