柏舟的博客

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

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

AI编程的智力提升非常迅速,很多以前想要开发的想法现在都可以完成了。我非常痴迷于物理建模,数值计算偏微分方程。之前课程作业也写过CFD,自动捕捉间断的格式,花了我一个月的时间。我现在都还记得边界处理非常痛苦,算着算着边界网格的速度就发散了,然后扩散到整个网格中。我也很喜欢把同样的思路(牛顿的思路)扩展到经济中、音乐中,直到所有的领域。这或许就是理性的精神?

这篇文章包含以下的内容:

  • 钢琴物理模拟的模型:钢琴的琴弦偏微分方程和音板。
  • 代码架构设计:抽象和并行计算架构。
  • AI编程实践分享:我是怎么确认关键设计的。

1 钢琴模型

输入:midi乐谱,即每个时刻按的音和力度。 输出:通过钢琴模型计算的wav文件,采样频率44.1 kHz的音频。

钢琴有三个部分组成:琴槌,琴弦和音板。其中一共有88个音符,每个音符一般对应3根琴弦。琴弦和音板相连,将琴弦的震动传递给音板,音板通过大面积与空气接触,驱动空气振动。

琴弦三根一组,它的频率十分接近但不完全相同,会产生拍音。

音板本身也有自己的频率响应曲线(可以画伯德图分析),它会选择性地增强某些频率的音符。多根弦的泛音相互叠加、干涉,再经过音板的“滤波”,最终形成乐器的独特音色。

1.1 线性模型

物理建模有两种方式,第一种是采用弦的达朗贝尔解(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}$太小了就从队列中删掉。这个方法唯一的问题是要求这个方程是线性的。

1.2 非线性模型

来都来了,怎么能用这么简单的方法呢。所以,琴弦采用非线性欧拉-伯努利梁方程(张力主导,含刚度、阻尼、几何非线性):

\[ \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{\partial^2 y}{\partial x^2}(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站搜一搜计算流体力学格式得到都有。

比较麻烦的处理是音板的实现,音板会将弦的震动传导到其它弦上,再传导回音板,形成比较丰富的声音。

为了模拟这个过程,我采用了以下的架构设计。

2 架构设计

采用事件驱动设计,类似于一个简化的CQRS架构。输入是一个事件源:产生音符Event,类似Spark的微批处理,拿到一个时间段的音符。

public interface IEventSource
{
    bool TryRead(Seconds horizon, [NotNullWhen(true)] out Event? sourceEvent);
 }

输出是一个writer,固定44.1 kHz的频率输出。

整体架构图:

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的两倍。

还有一个独立的Scheduler用于控制琴弦的并行计算,因为弹一个音不会立刻消失,在一帧的时间内可以看作是互相独立的,所以可以并行地推进每个音符上弦的计算。唯一麻烦的是即使一帧也是20微秒,即使是数值计算也太小了。

最后的结果就是合成音乐的时间是实际音乐的实际2倍左右。

2.1 Review:重新思考事件驱动架构、CSP模型和Actor模型

我现在认为事件驱动架构和CSP模型,Actor模型是层次不同的语义。事件驱动、MVC这些是上层架构;类似高级语言,而Actor,CSP是底层实现,类似汇编语言。原理也非常简单,软件中关于流程的抽象可以很容易的翻译为Actor和CSP的实现,但是很难从Actor和CSP重新翻译回原来的流程的抽象。

可为什么Actor和CSP很难重新翻译回原来的流程模型呢?因为流程的抽象就是原始业务的良好封装,改成Actor和CSP(特别是Actor)后就完全扁平了,之后业务的语义完全依靠文档完成,一旦遇到边缘场景,复杂的依赖关系,很难分析清楚。这还是没有考虑队列和网络通信本身的复杂度。

我觉得可以参考的解决方法是参考自动微分的思路,内部保存计算图。将流程class的相关动作重载,在Run的时候编译为底层的Actor和CSP模型进行计算。我感觉在事件驱动的架构上很容易实现,比如Scheduler的实现可以是进程,我将不同Note包装成计算任务委托给Scheduler完成,也可以底层通过其它分布式的抽象实现。由于没有实际尝试过,可能会存在很大的问题。

2.2 音板带来的耦合问题

架构图中设计的音板的状态采用零阶保持器ZOH进行交互,从而简化琴弦和音板的耦合。即音板读取琴弦一段周期的力,然后输入音板计算状态,然后更新对每个琴弦的阻抗,这是一种典型的松耦合的计算方法,CFD和材料力学耦合计算也常采用这种方法。

但是在当前实现中并没有实现音板。这是因为有计算机实现和物理的问题没有解决:

  1. 并行的粒度很难把握:我可以组件并行(不同弦分别计算),也可以音符并行(同音符的弦串行计算)。问题来源于存在串行环节,需要处理共享音板和上层输入,而采样频率是20微秒左右,thread的开销已经可以占到这个时间尺度的1/10了。
  2. ZOH传递函数的问题:ZOH并不是一个完美的滤波器,比如在下图中,如果音板和琴弦交互周期在44.1kHz,由于钢琴的频率范围在27.5 Hz ~ 4.2 kHz,ZOH在强度和相位上几乎没有延迟。但是如果选取了更长的交互周期,高频会产生问题。类似的,松耦合上采用离散的技巧对音效的影响需要仔细的分析。

10 2 10 3 10 4 10 5 10 6 −160−150−140−130−120−110−100−90Magnitude (dB)ZOH Bode Plot (44.1 kHz) 10 2 10 3 10 4 10 5 10 6 Frequency (rad/s)−180−160−140−120−100−80−60−40−200Phase (degrees)

当前的音板是直接跳过了物理音板,(主要是物理的音板需要调传递矩阵,类似于MIMO天线信道,很麻烦)。在合成器采用30个带通滤波器模拟音板的传递函数,在钢琴的音域高通,滤掉10kHz以上的声音,使声音更明亮。

−45−40−35−30−25−20−15Magnitude (dB)k1k2k3k4k5k6k7k8default dense comb (30) 10 2 10 3 10 4 Frequency (Hz)−400−300−200Phase (deg)default dense comb (30)

3 AI编程实践分享

开发目的:其中一个目的是之前没有编写过复杂的高性能事件驱动程序,探索一下事件驱动的实现方式。

这个程序我花了一天半的时间开发完成,开发速度超乎我的预料。如果我手写,我估计学习文献,写数值格式都要花一周。现在在AI的帮助下,只需要了解大致的原理,就能迅速实现剩余内容。但是AI存在以下的问题:

这段时间我尝试了不同的AI编程思路,有了以下的结论:

3.1 Agent使用观察

  1. Deepseek v4.1 flash相比之前v4有了非常显著的提高,明显感觉智力不一样。
  2. Deepseek相比其它模型特别喜欢不停地读文件,而GLM和Qwen明显要克制一些,只读相关的文件。
  3. 当模型开始不说人话,大概率就是你没有想清楚,模型也不能处理,这个时候一定要停下来重新思考架构。
  4. AI生成的文档非常垃圾,就如被AI写的文档给气笑了所说的,Deepseek也非常喜欢在文档中写phase1已完成这些,然后会在之后开发中疯狂塞入无关上下文,降低性能。其中最让我哭笑不得的是,Deepseek在编写ADR过程中,写没有采用XXX方案,因为XXX原因。然后在和我讨论设计的时候,直接引用了XXX原因,还说是设计决策。当时直接把我看恁了,我看了原始文档直接气晕。

以下是当前的开发经验推荐,估计能管6个月左右。

3.2 测试驱动开发

必须要求编程然后撰写相应测试。就像OpenAI能够证明数学问题一样,靠的是Lean形式化证明,有真实信号反馈,纯靠想是无法降低熵的。当然有一个很自然的推论:如果反馈的代价高昂,那不管什么AI都不好使。

3.3 Spec驱动慎用,需要定时清理文档

我最开始尝试的是文档驱动的编程,先撰写spec,然后根据spec实现,但不好使。AI写的spec真的是又臭又长,还喜欢塞代码。最重要的是,写了spec不代表你完成了架构设计,LLM解决的都是不重要的问题,它喜欢写正确的废话,你还反驳不了它。

到实际实现的时候,AI喜欢过度设计,写一堆用不上的class。人类在编写代码的时候,是会预估未来需求,先实现简单版本,适当预留接口。但是直接就实现了,不管了用不用的上。

3.4 确认接口即可

最麻烦的是纯AI编程,很快AI说的话看不懂,尤其是Deepseek和Qwen 3.8 flash next很明显,喜欢不说人话,没有任何上下文,全是黑话。人根本处理不了复杂性。

所以,之后的思路是我让它先写接口和数据结构,我确认后再实现。然后增量地修改。这在中等复杂度的非常有效,尤其是你之前写过类似的程序,有很大把握写对,这个时候写出来的结果和手写差别不大。

3.5 架构抽象问题

什么情况不好使呢?就是架构其实并没有想清楚,在实现过程中AI凭空蹦出一个抽象,看起来觉得很对,结果随着代码的演变不断发现抽象有问题。

比如我在自己实现的Agent里面,内层有AgentEvent,负责处理所有模型和对话产生的事件。在外层重新包装了一个TranscriptEvent,内部抽象为一个虚拟的显示器,而外层接cli和桌面应用。问题是最开始我没有想清楚TranscriptEvent到底是处理什么,只是将历史记录回放和实时更新分别处理,简单转发了AgentEvent,包装成ui的事件。后来Agent在编写桌面应用中发现有的状态获取不到,于是在前端class中同时接入了TranscriptEvent和AgentEvent,导致事件重复消费,有的对话甚至会重复两次,复现还不稳定,状态管理混乱。

所以Agent编程只确认接口也不够,必须确认每一个模块的功能设计是否清晰,到底管理什么状态,解决什么问题。在过去,我们边写边思考解决这个问题,但是现在AI编写后我们很难从对话和直接看代码中发现问题。让AI撰写文档并不会自然地理清架构,反而会让你觉得AI说得很有道理,然后写成意大利面条。这个问题没有什么好的解决方法,就像以前新人接手项目,即使有文档也会卡在莫名其妙的地方。

3.5 当前实践方式推荐

总的来说:

  1. 新增功能一定要先写接口和数据结构,确认后实现。
  2. 对于之前没写过的,不熟悉的,我打算还是手动编程,AI review,在此过程中完成架构问题的思考。否则最后根本处理不了复杂度。
  3. 什么情况可以完全不review:一次性的,方向探索性的,可行性验证的。如果发现程序有价值,则工程化实现。所以我觉得Vibe coding更适合快速试错,合格的工程代码写的速度就是快不起来,需要经常重构。
  4. 文档定期清理,只保留正向信息,清理任何对上下文无用的信息,决策信息禁止AI读取。
  5. 个人项目和公司的项目不一样,对于我来说快和便宜最重要,所以我使用flash模型。并且编码本身很难并行,最多一个review,一个编码。对于公司来说大部分时间在扯皮,而且工单之间基本是无关的,可以开多个Agent并行处理。

对于目前这个音乐程序,目前已经到达我能够处理的复杂度极限,我得手动编写一下音板,提升我对这个程序的理解。也可能暂时搁置,编写更有经济价值的代码。最近不打算再订opencode-go了,上个月花了价值60美元的Flash模型,其中90%是Deepseek,把我用力竭了。

注:初始版本9月26日发布,10月9日增加2.1事件驱动架构思考,2.2音板耦合问题,修改了AI编程经验分享。