以下の内容はhttps://okayaan.hatenablog.com/entry/2026/01/12/215304より取得しました。


それなりに動くギターチューナーのAndroidアプリを作ってみる

これは はてなエンジニア - 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も触っていて楽しい方向に改善できたらと思います。

また、ギター専用として弦ロックやガイド表示など、チューニングに特化した機能も足していきたいと感じました。




以上の内容はhttps://okayaan.hatenablog.com/entry/2026/01/12/215304より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14