一、游戏简介与算法目标
节奏大师是一款经典的音乐节奏游戏,玩家需要跟随音乐节奏,在音符到达判定线时准确点击对应按键。游戏核心挑战在于将音频信号转化为可交互的音符序列,并对玩家的操作时机进行精确评分。
本文的核心算法目标包括:
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) | 遍历音符序列找最近匹配 |
五、扩展思考
- 梅尔频谱与MFCC:使用更符合人类听觉感知的梅尔频率刻度替代线性频率,可提升onset检测在低频区的准确性。
- 动态时间规整(DTW):对于变速音乐或玩家节奏偏移,可用DTW对齐标准节拍序列和实际演奏序列。
- 机器学习节拍检测:训练CRNN(卷积循环神经网络)直接从音频波形预测节拍位置,准确率显著高于传统方法。
- 谱面生成优化:引入遗传算法或强化学习,生成既符合音乐结构又具有可玩性的音符编排。
六、总结
本文通过节奏大师游戏讲解了音频信号处理中的FFT频谱分析、onset检测、节拍追踪和动态评分四个核心算法。Java实现从合成音频出发,完整演示了从原始波形到游戏评分的全链路处理流程。该框架可扩展至更复杂的音乐游戏和实时音频分析场景。