柏舟 09-26
2
2
作者:柏舟
发布日期:09-26
AI编程的智力提升非常迅速,很多以前想要开发的想法现在都可以完成了。我非常痴迷于物理建模,数值计算偏微分方程。之前课程作业也写过CFD,自动捕捉间断的格式,花了我一个月的时间。我现在都还记得边界处理非常痛苦,算着算着边界网格的速度就发散了,然后扩散到整个网格中。我也很喜欢把同样的思路(牛顿的思路)扩展到经济中、音乐中,直到所有的领域。这或许就是理性的精神?
这篇文章包含以下的内容:
- 钢琴物理模拟的模型:钢琴的琴弦偏微分方程和音板。
- 代码架构设计:抽象和并行计算架构。
- AI编程实践分享:我是怎么确认关键设计的。
钢琴模型
输入:midi乐谱,即每个时刻按的音和力度。 输出:通过钢琴模型计算的wav文件,采样频率44.1 kHz的音频。
钢琴有三个部分组成:琴槌,琴弦和音板。其中一共有88个音符,每个音符一般对应3根琴弦。琴弦和音板相连,将琴弦的震动传递给音板,音板通过大面积与空气接触,驱动空气振动。
琴弦三根一组,它的频率十分接近但不完全相同,会产生拍音。
音板本身也有自己的频率响应曲线(可以画伯德图分析),它会选择性地增强某些频率的音符。多根弦的泛音相互叠加、干涉,再经过音板的“滤波”,最终形成乐器的独特音色。
线性模型
物理建模有两种方式,第一种是采用弦的达朗贝尔解(PianoGame),可以看成是一个波沿弦的方向传播。
\[
\frac{\partial^2 y}{\partial t^2}
+ 2\lambda \frac{\partial y}{\partial t}
= c^2 \frac{\partial^2 y}{\partial x^2}
\]
其中y是弦的位移,c是波速。
它的解是:
\[
y = e^{-\lambda t}\cos(\omega t + kx + \phi_0)
\]
其中 \(\omega\) 和k要满足色散关系:
\[
\omega^2 = c^2 k^2 - \lambda^2
\]
然后我们可以记录琴槌和每个解的时间关系,叠加起来,如果$e^{-\lambda t}$太小了就从队列中删掉。这个方法唯一的问题是要求这个方程是线性的。
非线性模型
来都来了,怎么能用这么简单的方法呢。所以,琴弦采用非线性欧拉-伯努利梁方程(张力主导,含刚度、阻尼、几何非线性):
\[
\rho \frac{\partial^2 y}{\partial t^2} =
\frac{\partial}{\partial x}\left( T(x,t) \frac{\partial y}{\partial x} \right)
- EI \frac{\partial^4 y}{\partial x^4}
- \delta(\omega) \frac{\partial y}{\partial t}
+ F_{\text{hammer}}(x,t)
\]
- 左端(压弦条):近似固支 $$ y(0,t) = 0, \quad \frac{\partial2 y}{\partial x2}(0,t) = 0
\[
- **右端(琴码)**:阻抗边界(能量泄漏到音板)
$$
T \frac{\partial y}{\partial x}(L,t) = -Z_{\text{bridge}} \frac{\partial y}{\partial t}(L,t)
$$
其中 $Z_{\text{bridge}}$ 是琴码输入阻抗。
实际求解时将琴弦离散化,采用kick–drift 蛙跳格式计算。离散化计算就不展开了,AI或者B站搜一搜计算流体力学格式得到都有。
比较麻烦的处理是音板的实现,目前我的实现是解耦的,即每组弦对应一个独立的音板,这个在线性和弦独立的假设下是合理的。但是实际上,音板会将弦的震动传导到其它弦上,再传导回音板,形成比较丰富的声音。为了模拟这个过程,我采用了以下的架构设计。
## 架构设计
输入是一个事件源:产生音符Event,类似Spark的微批处理,拿到一个时间段的音符。
```C#
public interface IEventSource
{
bool TryRead(Seconds horizon, [NotNullWhen(true)] out Event? sourceEvent);
}
```
输出是一个writer,固定44.1 kHz的频率输出。
整体架构图:
```mermaid
flowchart TD;
IEventSource-->Synthesizer;
subgraph Synthesizer;
VoiceManager --> N1[Note C4] & N2[Note D4] & N3[Other notes] --> 音板 --> 合成器;
end;
合成器-->ISink;
```
**合成器Synthesizer**控制循环,调用VoiceManager输入音符,得到一组结果。
**VoiceManager**的时间频率(帧)也是44.1 kHz,存储多个Note。实际计算时,Note**并行**计算弦的音强,完成后和音板交互,合成音板的状态。当Note的音强很小的时候,VoiceManager认为Note已经听不见了,释放资源。
每个**Note**的琴弦模拟频率大约是10倍的44.1 kHz,这是因为奈奎斯特采样定律:离散的采样频率至少是44.1 kHz的两倍。需要注意的是音板的状态采用零阶保持器ZOH进行交互,从而简化琴弦和音板的耦合。这是一种典型的松耦合的计算方法,CFD和材料力学耦合计算也常采用这种方法。
还有一个独立的**Scheduler**用于控制琴弦的并行计算,因为弹一个音不会立刻消失,在一帧的时间内可以看作是互相独立的,所以可以并行地推进每个音符上弦的计算。唯一麻烦的是即使一帧也是20微秒,即使是数值计算也太小了。
最后的结果就是合成音乐的时间是实际音乐的实际2倍左右。
## AI编程实践分享
这个程序我花了一天半的时间开发完成,开发速度超乎我的预料。如果我手写,我估计学习文献,写数值格式都要花一周。现在在AI的帮助下,只需要了解大致的原理,就能迅速实现剩余内容。
这段时间我尝试了不同的AI编程思路,有了以下的结论:
### 测试驱动开发
必须要求编程然后撰写相应测试。就像OpenAI能够证明数学问题一样,靠的是Lean形式化证明,有真实信号反馈,纯靠想是无法降低熵的。当然有一个很自然的推论:如果反馈的代价高昂,那不管什么AI都不好使。
### 确认接口即可
我最开始尝试的是文档驱动的编程,先撰写完整的spec,然后根据spec实现,但我发现不好使。首先是中间会出现各种各样新的问题,最麻烦的是Deepseek和Qwen 3.8 flash它说的话我听不懂。不说人话,没有任何上下文,全是黑话。人在其中根本处理不了复杂性。
所以,之后的思路是我让它先写接口和数据结构,我确认后再实现。然后增量地修改。这在中等复杂度的非常有效,尤其是你之前写过类似的程序,有很大把握写对,这个时候写出来的结果和手写差别不大。
### 架构抽象问题
什么不好使呢?就是架构其实并没有想清楚,在实现过程中AI凭空蹦出一个抽象,看起来觉得很对,结果随着代码的演变不断发现抽象有问题。
比如我在自己实现的Agent里面,内层有AgentEvent,负责处理所有模型和对话产生的事件。在外层重新包装了一个TranscriptEvent,内部抽象为一个虚拟的显示器,而外层接cli和桌面应用。问题是最开始我没有想清楚TranscriptEvent到底是处理什么,只是将历史记录回放和实时更新分别处理,简单转发了AgentEvent,包装成ui的事件。后来Agent在编写桌面应用中发现有的状态获取不到,于是在前端class中同时接入了TranscriptEvent和AgentEvent,导致事件重复消费,有的对话甚至会重复两次,复现还不稳定,状态管理混乱。
所以Agent编程只确认接口也不够,必须确认每一个模块的功能设计是否清晰,到底管理什么状态,解决什么问题。在过去,我们边写边思考解决这个问题,但是现在AI编写后我们很难从对话和直接看代码中发现问题。**让AI撰写文档并不会自然地理清架构,反而会让你觉得AI说得很有道理,然后写成意大利面条**。这个问题没有什么好的解决方法,就像以前新人接手项目,即使有文档也会卡在莫名其妙的地方。
### 当前实践方式推荐
总的来说:
1. 新增功能一定要先写接口和数据结构,确认后实现。
2. 对于之前没写过的,不熟悉的,我打算还是手动编程,AI review,在此过程中完成架构问题的思考。否则最后根本处理不了复杂度。
3. 什么情况可以完全不review:一次性的,方向探索性的,可行性验证的。如果发现程序有价值,则工程化实现。所以我觉得Vibe coding更适合快速试错,合格的工程代码写的速度就是快不起来,需要经常重构。
对于目前这个音乐程序,目前已经到达我能够处理的复杂度极限,我得手动编写一下音板,提升我对这个程序的理解。
一些观察:
1. Deepseek v4.1 flash相比之前v4有了非常显著的提高,明显感觉智力不一样。
2. Deepseek相比其它模型特别喜欢不停地读文件,而GLM和Qwen明显要克制一些,只读相关的文件。
3. 当模型开始不说人话,大概率就是你没有想清楚,模型也不能处理,这个时候一定要停下来重新思考架构。
\]