← 图形原理 · 交互课 课题九 · 采样与重建 · 第 3 课

物理建模合成:几十行写一根弦

全课题骨架:采样不是"取点",是"用一串离散值 + 一个重建核,去猜那个连续的东西"。
前两课在采一个已经存在的东西:先看清"多采几个点为什么不是答案",再拆开"先滤再采"里那台滤波器。 这一课反过来 —— 不去采一根弦,而是用一条几十个采样长的环形延迟线,把一根弦"长"出来: 音高由"这一圈走几拍"决定,衰减由"环路里那个滤波器"决定。整件事在 1978 年被 Alex Strong 想出来, 1983 年由 Karplus 与 Strong 发表,当时的卖点是"只需要加与移位"—— 几十行、没有乘法。
四段路:环的长度定音高(18.1)→ 环路滤波器让高频先走(18.2)→ 滤波器的形状决定衰减时间(18.3)→ 拨的那一下决定哪些谐波根本没被激发(18.4)。
★ 本页同样不发声(判据必须能在这里逐样本复算):画面画的是环形缓冲里那一圈采样(弦的形状) 与频谱 / 单圈增益 / 衰减包络。想听的话,把 18.1 件一里那六行按 8000 Hz 塞进 AudioBuffer 播一遍即可 —— 但本页不声称页面上的数与"你会听到的数"逐位相同(浏览器会按上下文采样率重采样,这条在第 1 课已写明)。 保真度:真机可证(可测) —— 整根弦逐样本算,四张表、十条判据都能离线复算。 一手源:Karplus & Strong 1983(算法本体与"周期 = p + ½")、Jaffe & Smith 1983(同一期的配套论文:单圈增益、环长、T60)、 J. O. Smith《PASP》(初值取白噪声 / KS 是波导特例 / 反馈环对增益失真的敏感)、拨弦点的梳状零点(Penn State 与 Am. J. Phys. 84)。

分段:
环路延迟 p: p = 40 · 缓冲 41 槽 · 环长 40.5 拍 ← 18.1 的旋钮(缓冲槽数 M 与"这一圈走几拍"不是同一个数)
环路滤波器: ← 18.2 的旋钮(它对不同频率的增益不一样)
环路增益 g: g = 0.9990 ← 18.3 的旋钮(只拉 1% 就能把衰减时间拉走四倍)
初始形状: 拨弦点 x₁/L: x₁/L = 0.20 ← 18.4 的旋钮(初形只决定"哪些谐波被激发")
(四张表与十条判据在后台跑)

(脚本启动中…)

定点观察:(脚本启动中…)

(按「跑一遍读数」看当前这一档的四个谐波;按「扫一遍环路延迟」看六档 p 的实测音高)

本帧的读数

(脚本启动中…)

18.1 一根弦就是一个延迟线:环的长度定音高

本段只增加一样东西:环路延迟 p(旋钮 = p;其余控件与本课起点相同,滤波器固定在"两点平均"、g = 1)。
上一段的缺口:前两课的滤波器都是"给 f₀ 与 Q、反解出五个系数"来的解析设计。可"拨响一根弦"这件事 —— 一根金属弦被拨片推了一下,然后自己响了十几秒 —— 能不能不设计任何滤波器、几十行就得到?
Karplus-Strong 的做法只有两步:① 往一条环形缓冲里填一段随机数("拨"的那一下);② 之后每走一步, 把缓冲里相邻两点加起来除以二,写回最老那一格("弦"自己往下传)。原论文原话:这"只需要加与移位"。
★ 于是 p 是这一课最像"弦长"的那个旋钮:缓冲要装 M = p + 1 个槽 —— 可这一圈真正走几拍,不是 M。原论文自己写着:音高"对应一个 p + ½ 个采样的周期"(f_s/(p + ½))。 那半个拍是那个"相邻两点平均"贡献的相位延迟(18.2 会把它单独量出来)。
★ 能拿笔核:f₁ = f_s/(p + ½)。p = 40、f_s = 8000 ⇒ 197.5309 Hz; 若按"缓冲槽数"算会得到 8000/41 = 195.1220 Hz —— 差 0.4%,而两条独立路线(自相关 / 加窗 FFT)都站在 197.5 这一边(差 ≤0.02%)。
★ 副观察(它是这一课最干净的一条"精确"判据):把滤波器换成"不滤",一圈恰好走 M 拍, 波形会每 M 个采样逐位重复(实测 lag = 41,差 0);而一加上两点平均,环长成了 40.5 —— 这是一个非整数,波形从此再也不会逐位重复(实测在 2…4M 内找不到任何 lag)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 一段噪声(±1 等概率) = 拨的那一下(原论文的初值) 旋钮:环路延迟 p 10 … 240(本段唯一新增) 缓冲 M = p + 1 个槽 滤波器固定"两点平均"、g = 1 ★ 本段只加"环长" // 环形缓冲:M 个槽,初值一段噪声 const b = new Float64Array(M); // 每走一步:读相邻两点求平均,写回最老那一格 out = 0.5 * (b[i] + b[(i + 1) % M]); b[i] = g * out; y[t] = out; i = (i + 1) % M; // 只有加与移位 // 于是"这一圈走几拍"(环长) // 不滤: 环长 = M = 41 // 两点平均: 环长 = M − 1/2 = 40.5 // ⇒ f1 = fs / (p + 1/2) p = 40 时: 缓冲 M = 41 个槽 环长 = 40.5 拍 f₁ = 197.5309 Hz 两条独立路线: 自相关 197.5099 加窗 FFT 197.5280 (对闭式 ≤ 0.02%) 若不滤:lag=41 逐位重复 加了平均:永不重复
定点观察:拖「环路延迟 p」→ 看读数行里的「缓冲槽数」「闭式环长」「实测周期」→ 缓冲装了 M 个槽,可这一圈只走 M − ½ 拍(p = 40:41 个槽 → 40.5 拍 → 197.5309 Hz,而不是 8000/41 = 195.1220 Hz)。
表① 环长与音高(f_s = 8000 Hz,两点平均档、g = 1)。「闭式」= f_s/(p + ½); 「自相关」「加窗 FFT」是两条不共享任何中间量的独立路线(前者在时域数周期、后者在频域找最低的那个显著峰)。
p缓冲 M环长 p+½闭式 f₁ (Hz)自相关 (Hz)偏差加窗 FFT (Hz)偏差
表①b 音准格:整数 p 只能落在 f_s/(p + ½) 这组离散频率上(Jaffe & Smith 原话:"the allowed pitches are quantized, especially at high frequency")。 ★ 越往高音越糟:E5 差了 51 cent —— 补救是在环里加一个"只贡献相位、不改增益"的一阶全通(见页末的一手源)。
目标音想要 (Hz)需要 p+½取整 p实际 (Hz)偏差 (cent)

18.2 每一次绕圈,高频先走:环路滤波器的形状

本段只增加一样东西:环路滤波器档(旋钮 = 不滤 / 两点平均 / 四点平均;p 沿用上一段、g 仍为 1)。
上一段的缺口:18.1 量到"一圈走 p + ½ 拍"—— 那半个拍是从哪冒出来的?就是那个"相邻两点平均"贡献的相位延迟。 可它既然会改相位、那就一定还有另一半作用没看:它对不同频率的增益也不一样。
★ 一段噪声为什么会被听成"一根弦"?因为延迟线让每一圈都叠起来 —— 只有那些"一圈正好走整数倍波长"的频率 能站住(就是 f₁ 的整数倍),于是噪声成了一排谐波。而每一圈都要过一次那个滤波器, 于是每个谐波的幅度每圈就乘一次 |H_a(f)|:高频乘得少、低频乘得多,这就是"衰减"的全部来源。
★ 能拿笔核(Jaffe & Smith 1983):两点平均档 |H_a(f)| = |cos(π f T_s)|; 四点平均档(两级串联)cos²(π f T_s);不滤档恒为 1。 第 8 次谐波(1560.98 Hz)单圈 0.813552,绕 20 圈只剩 1.61e−2; 而同一档的第 1 次谐波 20 圈后还有 0.942 —— "高频先走"就是这一列数字。
★ 副观察(本段的彩蛋):不滤档 12 次谐波的"末/首"= 0.9775(一路平 —— 原论文原话说这样听起来像簧风琴), 两点平均 0.3228,四点平均 0.1483;而音高:不滤 195.1220 Hz、两点平均 197.5309 Hz、 四点平均又回到 195.1220 Hz —— 因为两个 ½ 拍的相位延迟相加正好是 1 拍,反把音高还回来了。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 同一圈缓冲 相邻两点 · 老的一格 旋钮:环路滤波器档 不滤 / 两点 / 四点 环路增益 g = 1 与上一段相同 ★ 本段只加"滤波器" // 环路滤波器的系数(K = 阶数,M = p + max(1, K)) // A 不滤 c = [1] // B 两点平均 c = [0.5, 0.5] // C 四点平均 c = [0.25, 0.5, 0.25] out = 0; for (k = 0; k <= K; k++) out += c[k] * b[(i + K - k) % M]; // 每绕一圈乘一次 ⇒ 单圈增益就是它的频响 // 环长 41 / 40.5 / 41 ⇒ 音高回到 195.1220 / // 197.5309 / 195.1220 Hz(两个 ½ 相加 = 1) 单圈增益 |H(f)|: A 不滤 1 B 两点 |cos(πfT_s)| C 四点 cos²(πfT_s) 实测 vs 闭式: 三档 × 八次谐波 差 ≤ 6.7e−16 12 次谐波末/首: 0.9775 / 0.3228 / 0.1483 (不滤那档=一路平)
定点观察:切「环路滤波器档」→ 看下方面板的单圈增益曲线与那 8 个实测格点、以及读数行里的「末/首」→ 不滤那一档 12 次谐波一路平(0.9775),两点平均压到 0.3228,四点平均压到 0.1483; 同时看音高:四点平均那一档又回到了 195.1220 Hz。
表② 三档环路滤波器(f_s = 8000 Hz、p = 40)。上半是三档的环长与音高;下半是单圈增益: 「实测」= 把该频率的整周期余弦记录(长度取 2×环长)用循环滤波过一次、量幅度比(这是唯一能与闭式吻合到 1e−16 的量法); 「闭式」= |H_a(f)|。「20 圈后」= |H(f_n)|²⁰。
档缓冲 M环长闭式 f₁ (Hz)实测 自相关 / FFT (Hz)
表②b 单圈增益(频率取环的真实模态 f_n = n·f₁)。★ 这一列是本课"三件同源"最硬的一处: 实测(循环滤波)与闭式逐项吻合到 6.7e−16,而 20 圈后的数字就是"高频先走"的定量形态。
谐波 nf (Hz)A 实测A 闭式B 实测B 闭式C 实测C 闭式B 档 20 圈后C 档 20 圈后

18.3 滤波器的形状决定衰减时间

本段只增加一样东西:环路增益 g(旋钮 = g,0.9000 … 0.9999;滤波器固定在"两点平均"、p 沿用上两段)。
上一段的缺口:18.2 说了"高频衰减得快",可具体多快?把整个环的增益写成 G(f) = g · |H_a(f)|,每绕一圈乘一次 G,于是每个谐波都是一条几何衰减, 它的时间常数与 −60 dB 时间有闭式(Jaffe & Smith 1983 式 3–5、9)。
★ 能拿笔核:τ(f) = −(p + ½)·T_s / ln G(f)、t60(f) = ln(1000)·τ ≈ 6.91 τ。 p = 40、g = 1 时基频 τ = 1.681006 s、t60 = 11.61198 s;第 8 次谐波只有 0.16948 s —— 相差 68 倍, 这正是原论文那句"第 n 次谐波的衰减时间大致 ∝ p³/n²"(页面下方把这条指数律做成了实测的 log-log 斜率:−2.04 与 +2.98)。
★ 本段最该带走的一条:τ ∝ 1/(−ln G) —— 这个 1/ln 把增益的小变化放大成千百倍 (g = 1 时 1/(−ln G) ≈ 330):g 只从 1 降到 0.99,基频 t60 就从 11.61 s 掉到 2.68 s(4.3 倍)。 J. O. Smith 在讲延迟线插值时专门警告过这件事("feedback loops can be very sensitive to gain distortions")—— 本页把那条警告量成了一个具体倍数。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 旋钮:环路增益 g 0.9000 … 0.9999 上一段的那个滤波器 两点平均:|cos(πfT_s)| 每个谐波各自衰减 一圈乘一次 G(f) ★ 本段只加"g" // 单圈增益(在频率 f 上):那个滤波器 × 那个增益 const G = g * magH(fi, f); // 每绕一圈乘一次 G ⇒ 时间常数与 −60 dB 时间 const tau = -(p + 0.5) * TS / Math.log(G); const t60 = Math.log(1000) * tau; // 小损耗极限(g = 1):−ln cos x ≈ x²/2 // ⇒ tau ∝ (p + 1/2)³ / n² // ⇒ 原论文的"第 n 次谐波 ∝ p³/n²" // ★ tau ∝ 1/(−ln G):1/ln 把 g 的小变化放大 // g = 1 时 1/(−ln G) ≈ 330 倍 基频 t60(p = 40): g = 1 11.61198 s g = 0.99 2.67729 s g = 0.95 0.64397 s 同一时刻各谐波: n=1 11.612 s n=4 0.71471 s n=8 0.16948 s 实测指数律: −2.04(n)/ +2.98(p)
定点观察:拖「环路增益 g」→ 看读数行里四个谐波的 t60 与下方面板的衰减包络(实测点 + 闭式直线)→ 同一个 g 下,基频的 t60 是第 8 次谐波的 68 倍;而 g 从 1 降到 0.99,基频 t60 从 11.61 s 掉到 2.68 s —— 因为 1/(−ln G) 把 g 的 1% 变化放大成了 4.3 倍。
表③ 单圈增益 → 衰减时间(p = 40、两点平均档)。「G 实测」= 循环滤波(与闭式差 ≤ 1e−16); 「τ 闭式」= −(p+½)T_s/ln G;「t60」= ln(1000)·τ。 ★ 注意右侧一列:同一行里"实测 G"与"闭式 G"在 9 位小数上完全相同,所以 τ 也逐位相同 —— 这正是"三件同源"的意思。
g谐波 nf (Hz)G 闭式G 实测τ (s)t60 (s)
表③b 端到端核验(基频):把整条波形跑出来,逐窗跟踪它的幅度、再对 ln A 做最小二乘拟合。 ★ g = 1 那一行的 +2.84% 不是"实现错了",而是这条路的固有精度:τ ∝ 1/(−ln G) 把增益误差放大约 330 倍, 而"从波形里估一个增益"本身只有 1e−5 量级的分辨率。这一列本身就说明了本段的重点。
g闭式 t60 (s)端到端实测 t60 (s)相对差1/(−ln G) 放大约数

18.4 拨的那一下:哪些谐波根本没被激发

本段只增加一样东西:初始形状(旋钮 = 初形档「噪声 / 三角」+ 拨弦点 x₁/L,0.05 … 0.5;前四段的旋钮全部沿用)。
上一段的缺口:前三段把"环"讲完了 —— 音高、衰减都算得出来。可"拨"的那一下呢? KS 用一段随机数(那是"用噪声敲一下"),而真弦的初形是拨片位置决定的一条折线: 拨在离端点 x₁ 的地方,弦被拉成一个三角形。
★ 于是有一样东西被整片拿掉了:三角初形在拨弦点处是一个"波节",所有把节点落在拨弦点上的谐波 一次都没被激发。理想弦的模态幅度是 b_n ∝ sin(nπx₁/L)/n² (Penn State 的推导原文:"the coefficients An will be zero when n is an integer multiple of L/d")。
★ 能拿笔核:拨在 1/5 处 ⇒ 第 5、10、15… 次谐波整个不存在;拨在 1/3 处 ⇒ 第 3、6、9、12 次; 拨在正中 ⇒ 所有偶次都不存在。表④ 的 12 次谐波逐项差 ≤ 1.9e−3(差的那一点点是"41 个采样去逼近一条折线"的离散化)。
★ 而音高与衰减一位都不变:初形只决定"激发了多少",不改环长、也不改单圈增益 —— 这一条把前三段与本段干脆地分开了:环管"能站住哪些频率",初形管"往这些频率里各放多少"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 初形:噪声 或 三角 = 怎么"拨"这一下 旋钮:拨弦点 x₁/L 0.05 … 0.5(本段新增) 环、g、p 全部沿用 音高与衰减一位不变 ★ 本段只加"初形" // 三角初形:拨在 x₁ 处,两侧各一条直线 const d = x1 * M; // 拨弦点在缓冲里的格子 for (let i = 0; i < M; i++) b[i] = i <= d ? i / d : (M - i) / (M - d); // 之后照常跑环 —— 初形只决定"哪些谐波被激发" // 理想弦的模态幅度(Penn State 的推导): // b_n ∝ sin(n π x₁/L) / n² // ⇒ n 是 L/x₁ 的整数倍时,b_n = 0(节点在那里) // ⇒ 拨弦点不移动音高,只移动"音色" 被削掉的谐波: x₁/L = 0.2 → 5, 10 x₁/L = 1/3 → 3, 6, 9, 12 x₁/L = 1/2 → 全部偶次 实测 vs 闭式: 12 次谐波逐项差 ≤ 1.9e−3 音高 / 衰减: 与噪声初形逐位相同 ★ 环管频率,初形管份量
定点观察:拖「拨弦点 x₁/L」→ 看频谱里掉到零的那几根柱与叠在上面的闭式包络 → 拨在 1/5 处第 5、10 次谐波整个不存在;拨在 1/3 处第 3、6、9、12 次不存在;拨在正中则所有偶次都不存在; 再看读数行:音高与衰减一位没变。
表④ 拨弦点与"被激发的谐波"(p = 40、M = 41、三角初形)。「实测」= 初形那一圈 41 个采样的 DFT(各列按自己的最大值归一); 「闭式」= sin(nπx₁/L)/n²(理想弦的模态幅度)。★ 归一化之后两行应当逐项相等 —— 这就是"初形决定音色"的可验形态。
x₁/L123456789101112

这一课带走了什么

三行:① 一根弦可以就是一条环形延迟线 —— 音高由"一圈走几拍"(p + ½)决定, 而那个 ½ 是环路滤波器贡献的相位延迟(所以"缓冲槽数"不是"弦长");② 衰减不是另外设计的, 它是"每一圈乘一次的那个增益"(G(f) = g·|H_a(f)|)的必然结果, 衰减时间对 g 的敏感度被 1/(−ln G) 放大成千百倍;③ 音色由"拨的那一下"决定 —— 初形只往那些能站住的频率里分配份量,末了还会按拨弦点削掉一整族谐波。
★ 前两课的"滤波器"是给形状反解系数;这一课的滤波器只有两项(`[½,½]`), 却是从一个物理过程里长出来的:它既是弦的损耗,又顺带把音高挪了半拍。
下一课指针:到这一课为止,声音都是"在一条时间线上"算的(一个采样接一个采样)。 下一课换一个维度:把一个方位角变成左右两个增益 —— 同样是一行式子,同样能拿笔核, 同样能在这页之外的任何播放器里听出来。

本页用到的做法,出处在哪

运行时自检 等待脚本…