每日算法 — 使用java实现节奏大师:音频节拍检测与动态评分算法

一、游戏简介与算法目标

节奏大师是一款经典的音乐节奏游戏,玩家需要跟随音乐节奏,在音符到达判定线时准确点击对应按键。游戏核心挑战在于将音频信号转化为可交互的音符序列,并对玩家的操作时机进行精确评分。

本文的核心算法目标包括:
1. 音频节拍检测:通过频谱分析从音频信号中提取节拍位置(BPM检测)
2. 音符落点生成:将检测到的节拍映射为游戏音符序列
3. 动态评分判定:根据玩家点击时机与标准时机的偏差,计算Perfect/Good/Miss等级
4. 难度自适应:根据玩家历史表现动态调整音符密度和速度

二、核心算法原理

2.1 音频预处理与分帧

将音频信号分割为短时帧(通常20-40ms),对每帧进行加窗(汉宁窗)和快速傅里叶变换(FFT),得到频谱能量分布。

2.2 onset检测(音符起始检测)

onset是音符开始的时刻,通过检测频谱能量的突然增加来识别:

onset_strength(t) = Σ |flux(t, f)|

其中flux(t,f) = max(0, |X(t,f)| – |X(t-1,f)|) 表示相邻帧在频率f上的能量差。

2.3 节拍追踪(Beat Tracking)

使用自相关函数或动态规划在onset序列上寻找周期性最强的位置:

AC(lag) = Σ onset(t) × onset(t + lag)

峰值对应的lag即为节拍周期(以帧为单位),进而计算BPM。

2.4 动态评分算法

定义时间窗口:
– Perfect:|Δt| ≤ 50ms
– Good:50ms < |Δt| ≤ 120ms
– Miss:|Δt| > 120ms

得分计算:Perfect=100分,Good=50分,Miss=0分,连击加成按几何级数增长。

三、Java完整实现

import java.util.*;

/**
 * 节奏大师核心算法:音频节拍检测与动态评分系统
 * 核心算法:FFT频谱分析、onset检测、节拍追踪、动态评分
 */
public class RhythmMaster {

    // 音频参数
    private static final int SAMPLE_RATE = 44100;      // 采样率(Hz)
    private static final int FRAME_SIZE = 1024;        // FFT帧大小
    private static final int HOP_SIZE = 512;           // 帧移(50%重叠)
    private static final double FRAME_DURATION = (double) HOP_SIZE / SAMPLE_RATE;

    // 评分参数
    private static final int PERFECT_WINDOW_MS = 50;   // Perfect判定窗口(ms)
    private static final int GOOD_WINDOW_MS = 120;     // Good判定窗口(ms)
    private static final int PERFECT_SCORE = 100;      // Perfect得分
    private static final int GOOD_SCORE = 50;          // Good得分

    // 音符类型
    enum NoteType {
        TAP,       // 单击
        HOLD,      // 长按
        SLIDE      // 滑动
    }

    // 音符对象
    static class Note {
        double time;        // 标准命中时间(秒)
        int track;          // 轨道(0-3)
        NoteType type;      // 音符类型
        double duration;    // 持续时间(对HOLD音符)
        boolean hit;        // 是否已被击中
        Judgment judgment;  // 判定结果

        Note(double time, int track, NoteType type) {
            this.time = time;
            this.track = track;
            this.type = type;
            this.duration = 0;
            this.hit = false;
            this.judgment = null;
        }
    }

    // 判定结果
    enum Judgment {
        PERFECT, GOOD, MISS
    }

    // 玩家输入事件
    static class InputEvent {
        double time;    // 输入时间(秒)
        int track;      // 轨道
        boolean press;  // true=按下, false=释放

        InputEvent(double time, int track, boolean press) {
            this.time = time;
            this.track = track;
            this.press = press;
        }
    }

    // 频谱帧
    static class SpectrumFrame {
        double[] magnitude;  // 各频率幅度
        double totalEnergy;  // 总能量

        SpectrumFrame(double[] magnitude) {
            this.magnitude = magnitude;
            this.totalEnergy = 0;
            for (double m : magnitude) {
                this.totalEnergy += m * m;
            }
        }
    }

    private List<SpectrumFrame> spectrum;    // 音频频谱序列
    private List<Double> onsetFunction;       // onset强度函数
    private List<Note> noteSequence;          // 生成的音符序列
    private List<InputEvent> playerInputs;    // 玩家输入记录
    private double bpm;                       // 检测到的BPM
    private int combo;                        // 当前连击数
    private int maxCombo;                     // 最大连击数
    private int totalScore;                   // 总分
    private int perfectCount, goodCount, missCount;

    public RhythmMaster() {
        this.spectrum = new ArrayList<>();
        this.onsetFunction = new ArrayList<>();
        this.noteSequence = new ArrayList<>();
        this.playerInputs = new ArrayList<>();
        this.combo = 0;
        this.maxCombo = 0;
        this.totalScore = 0;
        this.perfectCount = 0;
        this.goodCount = 0;
        this.missCount = 0;
    }

    /**
     * 快速傅里叶变换(FFT) - 迭代实现
     * 时间复杂度: O(n log n),n为帧大小
     */
    public static void fft(double[] real, double[] imag) {
        int n = real.length;
        if (n <= 1) return;

        // 位反转置换
        int j = 0;
        for (int i = 0; i < n; i++) {
            if (i < j) {
                double temp = real[i]; real[i] = real[j]; real[j] = temp;
                temp = imag[i]; imag[i] = imag[j]; imag[j] = temp;
            }
            int bit = n >> 1;
            while (j >= bit && bit > 0) {
                j -= bit;
                bit >>= 1;
            }
            j += bit;
        }

        // Cooley-Tukey迭代FFT
        for (int len = 2; len <= n; len <<= 1) {
            double ang = 2 * Math.PI / len;
            double wlenReal = Math.cos(ang);
            double wlenImag = Math.sin(ang);
            for (int i = 0; i < n; i += len) {
                double wReal = 1, wImag = 0;
                for (int k = 0; k < len / 2; k++) {
                    int evenIdx = i + k;
                    int oddIdx = i + k + len / 2;
                    double evenReal = real[evenIdx];
                    double evenImag = imag[evenIdx];
                    double oddReal = real[oddIdx] * wReal - imag[oddIdx] * wImag;
                    double oddImag = real[oddIdx] * wImag + imag[oddIdx] * wReal;

                    real[evenIdx] = evenReal + oddReal;
                    imag[evenIdx] = evenImag + oddImag;
                    real[oddIdx] = evenReal - oddReal;
                    imag[oddIdx] = evenImag - oddImag;

                    double nextWReal = wReal * wlenReal - wImag * wlenImag;
                    double nextWImag = wReal * wlenImag + wImag * wlenReal;
                    wReal = nextWReal;
                    wImag = nextWImag;
                }
            }
        }
    }

    /**
     * 汉宁窗函数
     */
    private double[] hannWindow(int size) {
        double[] window = new double[size];
        for (int i = 0; i < size; i++) {
            window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (size - 1)));
        }
        return window;
    }

    /**
     * 音频分帧与频谱分析
     * 输入: 音频采样数组
     * 输出: 频谱帧序列
     */
    public void analyzeSpectrum(double[] audioSamples) {
        double[] window = hannWindow(FRAME_SIZE);
        int numFrames = (audioSamples.length - FRAME_SIZE) / HOP_SIZE + 1;

        System.out.println("开始频谱分析: " + numFrames + " 帧");

        for (int i = 0; i < numFrames; i++) {
            int start = i * HOP_SIZE;
            double[] frameReal = new double[FRAME_SIZE];
            double[] frameImag = new double[FRAME_SIZE];

            // 加窗
            for (int j = 0; j < FRAME_SIZE && start + j < audioSamples.length; j++) {
                frameReal[j] = audioSamples[start + j] * window[j];
            }

            // FFT
            fft(frameReal, frameImag);

            // 计算幅度谱(只取前半部分,后半为共轭对称)
            double[] magnitude = new double[FRAME_SIZE / 2];
            for (int j = 0; j < FRAME_SIZE / 2; j++) {
                magnitude[j] = Math.sqrt(frameReal[j] * frameReal[j] 
                                       + frameImag[j] * frameImag[j]);
            }

            spectrum.add(new SpectrumFrame(magnitude));
        }

        System.out.println("频谱分析完成: " + spectrum.size() + " 帧");
    }

    /**
     * Onset检测:计算频谱通量(Spectral Flux)
     * 检测相邻帧之间能量增加的总量
     */
    public void detectOnset() {
        if (spectrum.size() < 2) return;

        onsetFunction.add(0.0); // 第一帧没有前一个帧对比

        for (int i = 1; i < spectrum.size(); i++) {
            SpectrumFrame curr = spectrum.get(i);
            SpectrumFrame prev = spectrum.get(i - 1);

            double flux = 0;
            for (int j = 0; j < curr.magnitude.length; j++) {
                double diff = curr.magnitude[j] - prev.magnitude[j];
                if (diff > 0) {
                    flux += diff; // 只累加正向变化
                }
            }
            onsetFunction.add(flux);
        }

        System.out.println("Onset检测完成,最大强度: " + 
            onsetFunction.stream().mapToDouble(Double::doubleValue).max().orElse(0));
    }

    /**
     * 节拍追踪:使用自相关检测周期性
     * 在onset序列中寻找最强周期
     */
    public void trackBeats() {
        if (onsetFunction.size() < 10) {
            bpm = 120; // 默认值
            return;
        }

        // 计算自相关函数
        int maxLag = Math.min(onsetFunction.size() / 2, 
            (int) (SAMPLE_RATE / HOP_SIZE * 2)); // 最大2秒周期(30BPM)
        int minLag = (int) (SAMPLE_RATE / HOP_SIZE * 0.2); // 最小0.2秒周期(300BPM)

        double bestCorr = -1;
        int bestLag = minLag;

        for (int lag = minLag; lag <= maxLag; lag++) {
            double corr = 0;
            int count = 0;
            for (int i = 0; i + lag < onsetFunction.size(); i++) {
                corr += onsetFunction.get(i) * onsetFunction.get(i + lag);
                count++;
            }
            if (count > 0) {
                corr /= count;
                if (corr > bestCorr) {
                    bestCorr = corr;
                    bestLag = lag;
                }
            }
        }

        // BPM = 60 / (lag * 帧持续时间)
        double beatPeriod = bestLag * FRAME_DURATION;
        bpm = 60.0 / beatPeriod;

        // 限制在合理范围
        bpm = Math.max(60, Math.min(200, bpm));

        System.out.printf("节拍追踪完成: 最强周期lag=%d, BPM=%.1f%n", bestLag, bpm);
    }

    /**
     * 生成音符序列
     * 根据检测到的节拍位置,在4条轨道上生成音符
     */
    public void generateNotes(double songDuration) {
        noteSequence.clear();

        double beatInterval = 60.0 / bpm; // 节拍间隔(秒)
        double currentTime = 0;
        Random rand = new Random(42); // 固定种子保证可重复性

        while (currentTime < songDuration) {
            // 每个节拍生成1-2个音符
            int notesPerBeat = 1 + rand.nextInt(2);

            for (int i = 0; i < notesPerBeat; i++) {
                int track = rand.nextInt(4); // 4条轨道
                NoteType type = rand.nextDouble() < 0.8 ? NoteType.TAP : NoteType.HOLD;

                Note note = new Note(currentTime, track, type);
                if (type == NoteType.HOLD) {
                    note.duration = beatInterval * (0.5 + rand.nextDouble());
                }
                noteSequence.add(note);
            }

            currentTime += beatInterval;
        }

        // 按时间排序
        noteSequence.sort(Comparator.comparingDouble(n -> n.time));

        System.out.println("音符序列生成完成: " + noteSequence.size() + " 个音符");
    }

    /**
     * 动态评分核心算法
     * 根据玩家输入时机与标准时机的偏差计算判定等级
     * @param note 目标音符
     * @param inputTime 玩家实际输入时间
     */
    public Judgment judgeTiming(Note note, double inputTime) {
        double deltaMs = Math.abs(inputTime - note.time) * 1000;

        if (deltaMs <= PERFECT_WINDOW_MS) {
            return Judgment.PERFECT;
        } else if (deltaMs <= GOOD_WINDOW_MS) {
            return Judgment.GOOD;
        } else {
            return Judgment.MISS;
        }
    }

    /**
     * 处理玩家输入并更新分数
     * 算法:为每个输入找到最近的未命中音符进行匹配
     */
    public void processInput(InputEvent input) {
        playerInputs.add(input);

        // 找到该轨道上最近的未命中音符
        Note bestNote = null;
        double minDelta = Double.POSITIVE_INFINITY;

        for (Note note : noteSequence) {
            if (note.track != input.track || note.hit) continue;

            double delta = Math.abs(input.time - note.time);
            if (delta < minDelta && delta * 1000 <= GOOD_WINDOW_MS * 1.5) {
                minDelta = delta;
                bestNote = note;
            }
        }

        if (bestNote != null) {
            Judgment judgment = judgeTiming(bestNote, input.time);
            bestNote.hit = true;
            bestNote.judgment = judgment;

            int baseScore = 0;
            switch (judgment) {
                case PERFECT:
                    baseScore = PERFECT_SCORE;
                    combo++;
                    perfectCount++;
                    break;
                case GOOD:
                    baseScore = GOOD_SCORE;
                    combo++;
                    goodCount++;
                    break;
                case MISS:
                    combo = 0;
                    missCount++;
                    break;
            }

            // 连击加成:分数 = 基础分 * (1 + combo / 50)
            int finalScore = (int) (baseScore * (1.0 + combo / 50.0));
            totalScore += finalScore;
            maxCombo = Math.max(maxCombo, combo);
        }
    }

    /**
     * 检查并标记遗漏的音符(超时未命中)
     */
    public void checkMissedNotes(double currentTime) {
        for (Note note : noteSequence) {
            if (!note.hit && !note.type.equals(NoteType.HOLD)) {
                double deltaMs = (currentTime - note.time) * 1000;
                if (deltaMs > GOOD_WINDOW_MS * 1.5) {
                    note.hit = true;
                    note.judgment = Judgment.MISS;
                    combo = 0;
                    missCount++;
                }
            }
        }
    }

    /**
     * 计算准确率
     */
    public double calculateAccuracy() {
        int totalJudged = perfectCount + goodCount + missCount;
        if (totalJudged == 0) return 0;
        return (perfectCount * 1.0 + goodCount * 0.5) / totalJudged * 100;
    }

    /**
     * 打印评分统计
     */
    public void printScoreStats() {
        System.out.println("\n=== 评分统计 ===");
        System.out.println("Perfect: " + perfectCount);
        System.out.println("Good: " + goodCount);
        System.out.println("Miss: " + missCount);
        System.out.println("Max Combo: " + maxCombo);
        System.out.println("Total Score: " + totalScore);
        System.out.printf("Accuracy: %.2f%%%n", calculateAccuracy());
    }

    /**
     * 模拟完整游戏流程
     */
    public void simulateGame() {
        System.out.println("=== 节奏大师节拍检测与评分模拟 ===\n");

        // 模拟音频:生成一个120BPM的正弦波(简化音频)
        double duration = 10.0; // 10秒歌曲
        int numSamples = (int) (duration * SAMPLE_RATE);
        double[] audio = new double[numSamples];
        double beatFreq = 2.0; // 2Hz = 120BPM

        for (int i = 0; i < numSamples; i++) {
            double t = i / (double) SAMPLE_RATE;
            // 合成一个带有明显节拍感的信号
            audio[i] = Math.sin(2 * Math.PI * 440 * t) * 
                       (0.5 + 0.5 * Math.sin(2 * Math.PI * beatFreq * t));
        }

        // 1. 频谱分析
        analyzeSpectrum(audio);

        // 2. Onset检测
        detectOnset();

        // 3. 节拍追踪
        trackBeats();

        // 4. 生成音符
        generateNotes(duration);

        // 5. 模拟玩家输入(带随机误差)
        Random rand = new Random(123);
        System.out.println("\n模拟玩家输入中...");
        for (Note note : noteSequence) {
            // 模拟玩家有90%概率点击,时机误差±80ms
            if (rand.nextDouble() < 0.9) {
                double errorMs = (rand.nextDouble() - 0.5) * 160;
                double inputTime = note.time + errorMs / 1000.0;
                processInput(new InputEvent(inputTime, note.track, true));
            }
            checkMissedNotes(note.time + 0.2);
        }

        // 6. 输出结果
        printScoreStats();

        // 7. 显示部分音符判定详情
        System.out.println("\n=== 前10个音符判定详情 ===");
        System.out.printf("%-8s %-6s %-10s %-10s %-10s%n",
            "时间(s)", "轨道", "类型", "判定", "误差(ms)");
        System.out.println("-".repeat(50));
        for (int i = 0; i < Math.min(10, noteSequence.size()); i++) {
            Note n = noteSequence.get(i);
            String judgmentStr = n.judgment != null ? n.judgment.toString() : "未判定";
            System.out.printf("%-8.3f %-6d %-10s %-10s%n",
                n.time, n.track, n.type, judgmentStr);
        }
    }

    public static void main(String[] args) {
        RhythmMaster game = new RhythmMaster();
        game.simulateGame();
    }
}

四、算法复杂度分析

操作 时间复杂度 空间复杂度 说明
FFT频谱分析 O(m × n log n) O(n) m为帧数,n为帧大小
Onset检测 O(m × n) O(m) 遍历所有帧和频率bin
节拍追踪(自相关) O(m × L) O(1) L为lag搜索范围
音符生成 O(k log k) O(k) k为音符数,含排序
动态评分 O(1) O(1) 单次判定为常数时间
输入匹配 O(k) O(1) 遍历音符序列找最近匹配

五、扩展思考

  1. 梅尔频谱与MFCC:使用更符合人类听觉感知的梅尔频率刻度替代线性频率,可提升onset检测在低频区的准确性。
  2. 动态时间规整(DTW):对于变速音乐或玩家节奏偏移,可用DTW对齐标准节拍序列和实际演奏序列。
  3. 机器学习节拍检测:训练CRNN(卷积循环神经网络)直接从音频波形预测节拍位置,准确率显著高于传统方法。
  4. 谱面生成优化:引入遗传算法或强化学习,生成既符合音乐结构又具有可玩性的音符编排。

六、总结

本文通过节奏大师游戏讲解了音频信号处理中的FFT频谱分析、onset检测、节拍追踪和动态评分四个核心算法。Java实现从合成音频出发,完整演示了从原始波形到游戏评分的全链路处理流程。该框架可扩展至更复杂的音乐游戏和实时音频分析场景。