[from CS61B 24sp project1C GuitarHero]
核心算法 (Karplus-Strong算法) 的三个主要步骤:
-
初始化:用随机噪声填充 Deque61B(值在 -0.5 到 0.5 之间的随机双精度数)
-
播放:播放 Deque61B 前端的数值
-
更新:
- 移除队列前端的数值
- 将其与下一个数值进行平均
- 将结果乘以能量衰减因子 0.996
- 将新的数值添加到队列末尾
- 重复步骤2
Karplus-Strong算法两个主要组成部分是环形缓冲反馈机制和平均运算。
环形缓冲区反馈机制(The ring buffer feedback mechanism.):
- 模拟弦在两端固定时能量的来回传递
- 缓冲区的长度决定了产生声音的基本频率
- 能量衰减因子(0.996)模拟了能量在琴弦中往返时的轻微损耗
平均操作(The averaging operation):
- 作为一个低通滤波器(去除较高频率,同时允许较低频率通过,因此得名)
- 逐渐减弱高频谐波,保留低频谐波
- 这种效果与真实吉他拨弦的声音特性相似
这张图展示了卡尔普斯-斯特朗算法的一个具体更新步骤:
在 time t 时刻,我们有一个双端队列(数组)包含了一系列数值:[.2, .4, .5, .3, -.2, .4, .3, .0, -.1, -.3]
在 time t+1 时刻发生了以下操作:
- 移除第一个元素 (.2)
- 获取新的第一个元素 (.4)
- 计算: 0.996 × (.2 + .4)/2 = 0.2988
- 将计算结果 0.2988 添加到队列末尾
通过实际吉他演奏的过程来解释 Karplus-Strong 算法和这段代码。
- 真实吉他弦的物理过程:
1. 静止状态的吉他弦:
━━━━━━━━━━━━━━━━ (一根绷直的弦)
2. 拨动瞬间:
╭╮ (弦被拉动,形成初始扰动)
━╯━━━━━━━━━━━━
3. 振动过程:
时刻1: ╭╮━━━━━━ (波向右传播)
时刻2: ━━╭╮━━━━
时刻3: ━━━━╭╮━━
时刻4: ━━━━━━╭╮
时刻5: ━━━━━╮╯━ (波反射并逐渐衰减)
2. 代码模拟这个过程:
/* 构造函数:设置吉他弦的基本属性 */
public GuitarString(double frequency) {
int capacity = (int) Math.round(SR / frequency);
// 例如:如果frequency是440Hz(标准A音)
// capacity = 44100/440 ≈ 100
// 这100个点就模拟了弦上的100个位置
buffer = new ArrayDeque61B<>();
// 初始状态:所有点都是0(静止的弦)
for (int i = 0; i < capacity; i++) {
buffer.addLast(0.0);
}
}
/* pluck:模拟拨动吉他弦 */
public void pluck() {
// 1. 清空当前状态
int size = buffer.size();
for (int i = 0; i < size; i++) {
buffer.removeFirst();
}
// 2. 用随机值填充(模拟拨弦时的混乱状态)
for (int i = 0; i < size; i++) {
double r = Math.random() - 0.5;
buffer.addLast(r);
}
}
3. Karplus-Strong 算法核心(tic方法):
public void tic() {
// 1. 获取当前位置的值(相当于弦的某个点)
double first = buffer.removeFirst();
// 2. 获取下一个位置的值
double second = buffer.get(0);
// 3. 计算新值(模拟能量传递和衰减)
double newValue = DECAY * (first + second) / 2;
// 4. 将新值放到末尾(形成循环)
buffer.addLast(newValue);
}
用图解释这个过程:
初始状态(拨弦后):
[0.4] [0.2] [-0.3] [0.1] [0.5] ...
↓ ↓
取平均: (0.4 + 0.2)/2 = 0.3
应用衰减: 0.3 * 0.996 = 0.2988
更新后:
[0.2] [-0.3] [0.1] [0.5] ... [0.2988]
4. 双端队列(ArrayDeque)的作用:
想象吉他弦是一个圆环:
[0.4]
[0.5] [0.2]
[0.3] [-0.1]
[0.1]
每次tic:
1. 从前端取值(removeFirst)
2. 看下一个值(get(0))
3. 计算新值
4. 放到末端(addLast)
ArrayDeque 提供了理想的操作:
removeFirst():快速获取并移除前端值get(0):快速访问下一个值addLast():快速添加新值到末尾
这就像是一个传递能量的圆环,完美模拟了:
- 波在弦上的传播
- 能量的循环往复
- 振动的逐渐衰减
5. 整个过程的类比:
真实吉他弦 程序模拟
━━━━━━━━━━ <--> [0][0][0][0] (静止状态)
╭╮━━━━━━━ <--> [0.4][0.2][-0.3] (拨动瞬间)
━╭╮━━━━━━ <--> [0.2][-0.3][0.299] (波的传播)
━━╭╮━━━━━ <--> [-0.3][0.299][0.25] (继续传播)
6. 为什么听起来像吉他:
- 缓冲区大小决定音高
- 随机初始值提供丰富的泛音
- 平均操作模拟波的传播
- 衰减因子模拟能量损失
这样设计的巧妙之处在于:用简单的数据结构(双端队列)和基本运算(平均和衰减),就实现了复杂的物理现象模拟。