柏舟的博客

战战兢兢,如临深渊,如履薄冰

文章
最新文章
基于PDE的钢琴物理模拟和Agent开发实践分享
柏舟   09-26 阅读次数 2
作者:柏舟
发布日期:09-26
阅读次数 2

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. 当模型开始不说人话,大概率就是你没有想清楚,模型也不能处理,这个时候一定要停下来重新思考架构。 \]