盯着 AI 一个字一个字往外吐,我起了个念头:让这个吐字的动作本身发出声音。

不是朗读,那是把文字念出来。我想要的是让吐字的节奏和形态变成音乐。模型在推理、在写正文、在调工具,听起来应该不一样;它卡住了、又恢复正常,耳朵也能听出来。

真正动手之后才发现,这件事比"把字符映射成音符"复杂得多,也已经有五十年的积累可以抄。

先听一段吧,下面是一次真实对话被音乐化后的结果,没有任何后期处理。留意整体的疏密起伏,以及每次回答结束时的收束:

28 轮真实对话的音乐化结果(约 68 秒)。低频的鼓点对应工具调用,明亮的短促音对应代码块,其余是推理与正文的旋律。

先去找论文:这不是新问题

这个领域叫 sonification,把数据变成声音。翻了十几篇论文之后,有三条结论对我影响最大。

一、乱弹琴是噪音,但在框子里乱弹就是音乐

你闭着眼睛在钢琴上乱拍,出来的是噪音。但如果只让你按那七个白键,怎么按都不太难听。

论文里有个很直接的对照实验:同样生成一段旋律,一组完全随机取音,另一组只用一条最傻的规则,比如"下一个音参考上一个音"。结果是前者几乎不落在调内、完全没有动机重复,后者立刻有了调内比例和结构。

只要加一点点规则,音乐性就上了一个台阶。关键不是消除随机,是给随机套个框。

二、别让数据决定"什么时候响"

这是最反直觉、也最要紧的一条。

有个项目要让管理员"听"出超级计算机在干什么。它的做法是:音乐骨架是死的,数据只决定在这个骨架上额外点亮多少个位置。底鼓永远落在固定的节拍位置上,数据只控制"再多敲几下"。

说白了:数据控制密度,不控制位置。

为什么这对 AI 吐字尤其关键?因为 token 是突发的。开头可能憋好几秒,然后哗哗往外冒。要是"来一个字就响一声",节奏就被上游的缓冲行为劫持了,听起来像机器故障而不像音乐。

换成"踩在固定拍子上"就没这个问题:你吐字快我就多敲几下,慢我就少敲几下,但永远踩在点上。

三、要区分两件事,就一次改四个维度

有个早期系统想用"声音往上滑"来模仿说话时扬起的疑问语气。论文原话是,听起来"像滑稽的船笛"。

问题在于它只在音高这一个维度上做区分。人耳对单一维度的细微变化不敏感,对多个维度同时变化才敏感。

后来我做"正常结束"和"出错了"两个提示音时,直接照抄了这个教训。不是改个音高就完事,而是让两者在形态、方向、落点、时值四个维度上同时不同:一个是和弦、往上走、落回主音、拖得长,另一个是旋律、往下走、停在半路、很急促。

四样同时不同,才听得出来。而且这个差别是可以测的:正常结束的频谱里有主音,错误动机里没有。

音阶不是审美问题,是结构问题

我原本以为用哪个音阶是个口味问题,后来发现它会改变输出的结构性质。

有篇论文记录了一个坑:他们给数据图表做音乐化,用了明亮的大调。但和弦连着弹下来,自动形成了"大调转小调"的进行,听起来莫名压抑、悬疑。作者说这是"直到坐下来即兴演奏时才意识到的坑"。

有些音符组合自带情绪,会不小心传递你没想传的意思。

解决办法很朴素:用五声音阶,就是钢琴黑键那五个音。理由很实在,这五个音里随便挑两个一起按都不会难听。它们之间没有半音(do 和 si 那种紧挨着的),也没有三全音,那个著名的"魔鬼音程"。

这是个被反复验证过的安全框子。印尼的 gamelan 音乐也用五声,道理是一样的。

试出来的几条经验

静默应该是默认状态

我一开始的实现是"就算没输入也响着",理由是不想有突然的安静。挂机听了一下午之后改了主意。

一直响会让人分不清"模型在思考"和"根本没在跑"。而且长时间挂着听本身就是负担。论文里反复提到这类系统的头号失败模式不是难听,是听久了腻烦。

改法很简单:持续没有输入超过若干拍之后彻底安静,有新内容立刻恢复。注意是按拍计而不是按秒,这样换速度时行为一致。

顺带一个细节:恢复时要渐入,而不是突然炸响。我让密度这个变量从 0 缓慢爬升,听感上就是自然的淡入。

什么时候该响、什么时候该安静

这里有个容易忽略的点:不同的参数,可听性不一样。

像混响这种均匀施加的效果,听一次就懂了;而"密度"“音高走向"这类,必须听完整段才能把握。设计时得知道这个差别,不能一视同仁。

实现选了 Go

调音色那阵子,我用的是最方便试错的方式:改一行直接跑,能交互式地反复听。

但音色定稿之后,那种迭代优势就不值钱了。常驻后台跑要的是别的东西:单二进制、没有环境依赖、崩溃了自己扛。于是重写成了 Go。

音频库选了 oto。它有个我很喜欢的性质:在 macOS 上不需要 cgo,通过 purego 动态加载系统框架,所以交叉编译和 CI 都很干净。

更妙的是它的 pull 模型天然契合前面那条"时钟独立"的设计:声卡按自己的节奏来拉数据,我只管填。token 的突发性根本进不了音频时序,这不是我硬做出来的,是架构本身给的。

状态传递用不可变快照加原子指针:事件线程构造新的状态整体替换,音频回调开头读一次引用。渲染路径因此不需要任何锁。

两个只有 Go 版才遇到的坑

一、播放器要保活。

第一次测真实素材时,日志显示事件只消费了几个就不动了,而播放明明持续了十几秒。加诊断后发现:喂数据的 Read 只被调用了两次,就再也没被调用。

查文档才看到 oto 明写着,A player must be kept reachable as long as it should keep playing。播放器在不可达时会被自动关闭。而我的播放器恰好是个局部变量,在阻塞等待退出信号的期间被 GC 判定成了死对象。

修法是在阻塞之后加一句 runtime.KeepAlive。这种坑不看文档根本想不到。

二、状态行不能用回调里的 wall clock 判断。

我原本想在喂数据的函数里顺便"每秒打一行状态”,结果一行都没有。因为 pull 模型会提前缓冲,那个函数的调用速率和音频时长完全不成比例,时间判断要么不触发、要么瞬间刷屏。

改成独立的定时器就好了。

给鼓点找音色,试了五轮

这篇文章发出来之前,我写过一句"听感上能分出模型在推理、在写正文、在调工具"。

那句话我没验证过。我是根据素材构成(推理片段占 65%、正文 32%)和设计意图推出来的,把"我设计成这样"当成了"听起来是这样"。

我自己听了也说不出来,才去查。结果这一查就是五轮。

第一件查出来的事:有个音从来没响过

三类声音里,“工具调用"那一类,在整段素材里一次都没出声。

素材里有 4510 个工具调用增量,但那个音色从未被触发。原因是我把它做成了"相位”,而相位切换有个防抖设计,需要连续命中若干次才生效。

工具调用在流式输出里只有第一个分片带函数名,后面全是参数碎片。这些信号稀疏到永远凑不够连续命中数,于是整个相位形同虚设。

修法是换一层抽象:工具调用是瞬时事件,不是持续状态。是"调用了一次工具",不是"正在工具中"。它不该走相位切换,而该像打击乐一样叠加在任何旋律之上,来一次响一次。改完从 0 次变成 56 次。

然后是五轮音色试错

我以为这是审美问题,结果它更像个工程问题。

试过 查出的原因
白噪声 宽频,和五声音阶的旋律打架;密集时像放鞭炮
低音区 和推理音域重叠,而且正好是每小节固定低音,同音同区
高音区 频段分开了,但音色材质不对,像敲铁片,不是鼓皮
加音高下滑 方向对了,真实鼓的膜张力在击打瞬间最高,但听起来在远处
独立短反射 贴脸了

每一轮都不是"换个音色试试",而是查出一个具体的物理原因。

让鼓"近"的不是音量

第四轮之后我一直在加音量、加深下滑,但始终"在远处"。真正的原因是空间。

真实混音里底鼓几乎总是干的。混响会给它贴上一层空间,把瞬态抹开。而我们的混响湿声比例在密集素材下能到 35%,等于给鼓强行套了个大厅。

改成"旋律走长混响、鼓点走独立的短反射"之后,鼓立刻贴脸。短反射的延迟只有 19 / 27 毫秒,人耳不把它解读成"空间",而是当作鼓声本身的"体"。

决定远近的旋钮是延迟,不是音量。

还有个更基础的疏漏

音色调对之后,我又感觉“鼓点节奏似乎没规律”。

我这才发现自己在自己写的原则上开了例外。前面那条"数据控制密度,不控制位置",旋律是严格遵守的,音符只落在网格点上。但鼓点是事件一到就敲,等于让 token 的到达时刻决定了节奏位置。

改了两层。

先是吸附到网格:事件到达只累加计数,等到最近的网格点才发声。

这还不够。我查了素材,56 次工具调用里有 27 处间隔小于一拍。落在 16 分音符网格上就是"一拍两声",听起来碎。于是再限制到拍级:一拍最多一声。

现在最密的情况下,鼓点的最小间隔也是严格的一拍。它和旋律踩同一个拍子,是"打拍子"而不是"乱敲"。

最后

回头看,这个项目最有意思的地方不是"AI 能生成音乐",那不新鲜,而是把一种抽象的信号翻译成另一种感官能直接感知的形式。

论文里有个说法我很喜欢:这类系统的目标不是"把数据念出来",而是让人能被动地感知。你不用盯着屏幕,耳朵会告诉你事情在往哪个方向走。

约束不是敌人,是让输出变得可听的原因。五声音阶是约束,固定节拍是约束,静默阈值也是约束。每加一条,音乐性就好一点。

开源

项目已开源 github weaming/llm-music

论文来源

按对本项目的影响排序。前六篇决定了设计,后面是读过、影响较小或者最终没采纳的。

  1. Alunno & Bientinesi, Real-time, EDM-inspired sonification of the activity of a supercomputer。整个骨架来自这里:底鼓固定落在第 0/8/16/24 个十六分音符上,数据只决定"再多点亮几个"。另外两条经验也出自这篇:均匀施加的效果听一次就懂,密度和音高走向得听完整段;信息过载时让各声部轮流站到前台。
  2. Alty & Vickers, The CAITLIN Auralization System: Hierarchical Leitmotif Design as a Clue to Program Comprehension。动机族的分层设计,异类之间完全不同,同类之间只改节奏、不改音高集合。它第一版的失败被逐字记了下来:不同构造配不同音阶,像把不同歌曲的片段拼在一起。
  3. Zhang, Grellscheid & Garrison, Data Melodification FM: Where Musical Rhetoric Meets Sonification。他们用大调给柱状图配乐,和弦连着弹下来自动转成小调,听出莫名的压抑。映射"数据特征"而不是"数据值"也出自这篇。
  4. Vickers & Alty, The Well-tempered Compiler? The Aesthetics of Program Auralization。两个反面教材:用"声音往上滑"表示疑问语气,被试说像滑稽的船笛;以及约一半被试觉得音频化中等程度烦人。
  5. Browne, The Shape of Surprise: Structured Uncertainty and Co-Creativity in AI Music Tools。把"给随机套个框"形式化为结构化不确定性。那条最傻规则的对照实验也在这里。
  6. Herho et al., Preliminary sonification of ENSO using traditional Javanese gamelan scales。gamelan 用五声。结论是音阶选择属于结构变量而非审美装饰,换一套音阶会改变输出里亮度与能量的耦合关系。
  7. Nakamichi, Uesaka & Morikawa, Dynamic Synchronization and Resonance as a Universal Origin of 1/f Fluctuations。1/f 不在原始波形里,在解调之后的幅度包络里。所以要雕琢的是密度、响度这类慢变量。
  8. McDonough & Herczyński, Fractal Patterns in Music。给纯统计指标的一记重击:DFA/Hurst 指数对音符顺序完全不敏感,一首曲子倒着弹指数一模一样,但听起来是噪声。
  9. Rossi & Rodolà, Communicating Sound Through Natural Language。实时场景付不起闭环精修的代价:合成前准确率 100%,合成后掉到 74%。
  10. Gonzalez de la Maza et al., Noise-Driven Instrument Based on Coherent Quantum and Stochastic Oscillator Models。白噪声之所以听起来还像乐器,是因为琴弦的物理共振充当了约束层。又一次印证框子决定一切。
  11. Kim, Kim & Hullman, Erie: A Declarative Grammar for Data Sonification。声明式的"数据变声音"语法,17 个编码通道。将来做通用工具时可以借鉴,本项目用不上。
  12. St.Clair & Berezovsky, Rhythm as an ordered phase of sound: how musical meter emerges in a statistical mechanical model。用自由能最小化生成有层次的节奏,温度低出规整节拍,温度高出泊松过程,中间是相变。更适合长曲式。
  13. Gómez Aíza, The Ising model and random fields of scales。把音阶集合当作自旋位点,用模拟退火搜。
  14. Lugo & Alatriste-Contreras, Musical composition and 2D cellular automata based on music intervals。用音程规则让随机音符阵列在 5 次迭代内自动归位到调内。