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

空间音频:一个方位角怎么变成两个增益

全课题骨架:采样不是"取点",是"用一串离散值 + 一个重建核,去猜那个连续的东西"。
前三课都在这条链上:先看清"多采几个点为什么不是答案",再拆开"先滤再采"里那台滤波器, 然后用一条环形延迟线把一根弦"长"出来。这一课换一个问法 —— 同一个声音,怎么被放到"左边 / 远处 / 身后"去?
答案比你想的朴素:所谓"放到空间里",就是给它算出几个增益 —— 方位角给你两个(左、右),距离给你一个,声源的朝向再给你一个; 最后把这三个乘到信号上。整页就是把这三件事各自的算式摆出来,并指出它们各自的坑。
四段路:一个方位角两个增益(19.1)→ 远一点该轻多少(19.2)→ 声源也有正面和背面(19.3)→ 三件事相乘(19.4)。
★ 本页同样不发声(判据必须能在这里逐样本复算):画面画的是俯视图(听者、声源、方位角、 距离环、声锥)与几个增益的条。算式的每一行都逐字取自 W3C《Web Audio API》1.1 规范的 §6.2(方位角)、§6.3.1(equalpower)、§1.27.2(三种距离模型)、§6.5(声锥)与 Common Definitions 里的 class Vec3。 保真度:示意 · 等价重现 —— 算式按规范逐字实现、在纯 JS 里逐样本复算, 四段共 7 张表、十条判据都能离线复算(页面现场跑的数与离线定稿 sp4.mjs 逐位相同); 但本页没有在本机核验任何浏览器实现(无头探针里没有 Web Audio)。 ★ 本课还如实带出一处规范自身的不一致:声锥那一步的方向口径,规范伪码与它自己的散文、以及 Chromium / Firefox 两个实现相反 —— 详见 19.3。

分段:
方位角: az = −45° ← 19.1 的旋钮
仰角: el = 0° ← 19.1 的第二眼(它不影响两个增益)
距离: d = 1.00 ← 19.2 的旋钮(对数刻度 0.5…100,参考距离 1、上限 10)
距离模型: 衰减强度 f: f = 1.00 ← 19.2 的旋钮(规范给 linear 的名义范围是 [0,1],所以这里也只到 1)
声源朝向与连线的夹角 θ: θ = 0° ← 19.3 的旋钮
内锥 / 外锥(整角): 内 360° 外 360° 外锥增益: 外锥增益 0.00
声锥那一步的方向口径: ← 19.3 的正题:谁减谁,会把"正对听者"与"背对听者"换个个儿
输入幅值: 输入 = 1.00 ← 19.4 的旋钮(它只被乘一次,不会改任何一个增益)
(七张表与十条判据在后台跑)

(脚本启动中…)

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

(按「跑一遍读数」看当前这一档的全部中间量;按「扫一遍方位角」看十二档方位角的折叠与两个增益)

本帧的读数

(脚本启动中…)

19.1 一个方位角,两个增益

本段只增加一样东西:方位角(旋钮 = 方位角;其余控件与本课起点相同 —— 距离固定在参考距离 1 上、没有声锥、仰角 0)。
上一段的缺口:前三课把声音当"一串数"处理。可"这个声音在左边"这件事,最后必须落成两个数 —— 落成哪两个?为什么偏偏是这两个?
规范给的路只有两跳:① 先把"两个位置"变成一个方位角(听者与声源各有一个位置; 规范用听者的 forward / up 造出一套"右 / 上",再把"源 − 听者"这个方向投影上去,得到方位角与仰角); ② 再把方位角变成一个归一化量 x,然后左边取 cos、右边取 sin。
★ 这里有一个几乎所有人事先都想不到的细节:方位角先被 clamp 到 [−180,180], 然后超过 ±90° 的那两半折回来(`if (az < -90) az = -180 - az;`)。 于是 只有 [−90°, 90°] 这一段是"真"的:正前方(0°)与正后方(180°)算出来的两个增益 逐位相同 —— equalpower 根本分不出前后。这不是 bug,是这套算法的定义。
★ 还有一条:规范原话说 equalpower 用的时候 "the elevation value is ignored" —— 仰角算出来了,但被丢掉。所以"声音在上方还是下方"这件事,这套算法完全没有表达。

输入 声源位置 (x, y, z) 例:源在 (4, 0, 0) = 正右方、距 4 听者(固定不动) 位置 (0, 0, 0) forward (0, 0, −1) up (0, 1, 0) 仰角 el 算出来了,被丢掉 右 = forward × up = (1, 0, 0) 方位角 az 的含义: 0 = 正前,+90 = 正右 ① 方位角(规范 §6.2) ② 方位角 → 两个增益(规范 §6.3.1) function azEl(sp) { // §6.2:听者固定在原点 const u = unit(sub(sp, LPOS)); // 源 − 听者 const R = cross(LFWD, LUP); // 听者的“右” const up = cross(unit(R), unit(LFWD)); // 听者的“上” const P = unit(sub(u, mul(up, dot(u, up)))); // 压到水平面 let az = Math.acos(cl1(dot(P, unit(R)))) * 180 / Math.PI; if (dot(u, unit(LFWD)) < 0) az = 360 - az; // 在前还是在后 az = (az <= 270) ? 90 - az : 450 - az; // 再相对“正前” let el = 90 - Math.acos(cl1(dot(u, up))) * 180 / Math.PI; if (el > 90) el = 180 - el; // 折回 [−90, 90] else if (el < -90) el = -180 - el; return { az: az, el: el }; } function panGains(az) { // §6.3.1 az = Math.min(180, Math.max(-180, az)); // clamp 到 [−180,180] if (az < -90) az = -180 - az; // ★ 折回来 else if (az > 90) az = 180 - az; // ★ 折回来 const x = (az + 90) / 180; // 归一化 const gL = Math.cos(x * Math.PI / 2); const gR = Math.sin(x * Math.PI / 2); return { wrap: az, x: x, gainL: gL, gainR: gR }; } 输出 方位角 az [−180°, 180°] 折回后 wrap 只有 [−90°, 90°] 归一化 x (wrap + 90) / 180 gainL = cos(x·π/2) gainR = sin(x·π/2) az = 0° 与 180°: 两个增益逐位相同 az = ±90°:一侧落到浮点零 gainL² + gainR² 恒为 1 (这条规范没写,是推论)
表① 方位角 → 左右两个增益(听者固定在原点、forward = (0,0,−1)、up = (0,1,0); 源方向 = (sin az, 0, −cos az)。第三列是"折回后"的方位角 —— 只有 [−90°,90°] 落在它里面; 末列是等功率核对:gainL² + gainR² 应当恒为 1)。 ★ ±90° 那一侧的 0 是浮点零(cos(π/2) 在双精度下是 6.12e−17),不是数学上的 0。
设定方位角折回后xgainLgainRgainL²+gainR²说明
表①b 仰角对 equalpower 没有影响(方位角固定在 0°;规范原话:the elevation value is ignored)。 注意第二列:仰角算出来了,只是没被用。
设定仰角算出的 el算出的 azgainLgainR与 el = 0° 那档之差

19.2 远一点该轻多少

本段只增加一样东西:距离(旋钮 = 距离 + 距离模型;方位角回到 0°、仍然没有声锥)。
上一段的缺口:19.1 只按角度给增益 —— 站在正前方的源,离 1 米和离 100 米,那两个增益完全一样。 距离还没着落。
规范给的是三个模型(distanceModel),公式都很短:
linear:1 − f·(clamp(d, dr, dm) − dr) / (dm − dr); inverse:dref / (dref + f·(max(d, dref) − dref)); exponential:(max(d, dref)/dref)^(−f)。
★ 三条曲线的第一个共同点是:在 d ≤ dref 处都恰好等于 1(不是"接近 1")—— 参考距离以内不衰减。 第二个共同点是:它们对"很远"的态度完全不同 —— linear 有地板:`d ≥ d'max` 之后它恒等于 1 − f,再远也不掉(`f = 1` 时就是 0); inverse 与 exponential 没有地板,但也不会精确到 0 (`d = 10⁹` 时还剩 `1.0e−9`)。
★ 还有一件"看着像笔误、其实是代数"的事:f = 1、dref = 1 时, inverse 与 exponential 是同一条曲线 —— 前者化成 1/(1 + (d−1)) = 1/d,后者化成 (d/1)^(−1) = 1/d。 表③ 的中间两列在这一档参数下逐位相同;把 f 拨到 0.5 或 0.25,它们立刻分开(见表③b)。
★ 本页把衰减强度 f 的滑块只开到 1:规范给 linear 的 rolloffFactor 名义范围是 [0, 1],而超范围时怎么夹,规范只留了一句"夹在距离计算里发生"、没有逐字伪码 —— 本页不猜那件事(拿 `f = 4` 代进去,linear 会给负增益,那就是相位反了)。

输入 声源位置 (x, y, z) 听者位置 (0, 0, 0) 参数 dref 参考距离 = 1 dmax 上限 = 10 f 衰减强度 ∈ [0,1] 选哪个模型 linear / inverse / exponential 规范默认值: dref 1 / dmax 10000 rolloffFactor 1 distanceModel 默认 是 "inverse" 距离 → 一个总增益(规范 §6.4 + §1.27.2) function distOf(sp) { // §6.4:距离 = |源 − 听者| return len(sub(sp, LPOS)); } function distanceGain(m, d, dref, dmax, f) { // §1.27.2 if (m === 'linear') { // 有地板的那个 const dr = Math.min(dref, dmax), dm = Math.max(dref, dmax); if (dr === dm) return 1 - f; const dc = Math.min(Math.max(d, dr), dm); // 把 d 夹进 [dr, dm] return 1 - f * (dc - dr) / (dm - dr); } if (m === 'inverse') { // 1/d 那一族 if (dref === 0) return 0; return dref / (dref + f * (Math.max(d, dref) - dref)); } if (dref === 0) return 0; return Math.pow(Math.max(d, dref) / dref, -f); // exponential } dref = 0 时:inverse 与 exponential 都返回 0 (规范原话:the value … is taken to be 0) 输出 距离 d |源 − 听者| distanceGain 一个数,乘到左右 0 ≤ 增益 ≤ 1 d ≤ dref 时恰为 1 linear 的地板: 1 − f(f=1 时是 0) 反过来说:只有 linear 能精确到 0 f=1、dref=1 时 inverse ≡ exponential (两条都是 1/d)
表② 三种距离模型(dref = 1、dmax = 10、f = 1)。 注意 d ≤ 1 两行三列全是 1;d = 10 起 linear 停在 0 不动, 而另两条继续往下掉(但永不为 0)。末列给出每行的可手算依据。
距离 dlinearinverseexponential可手算依据
表②b 把 f 拨开,inverse 与 exponential 就分家了(距离固定在 4)。 f = 1 那一行两列逐位相同(都是 1/4);f = 0 那一行三列全是 1(完全不衰减)。
衰减强度 flinearinverseexponential说明

19.3 声源也有正面和背面

本段只增加一样东西:声源的朝向(旋钮 = θ 与两个锥角;方位角 0°、距离 1)。
上一段的缺口:19.1 与 19.2 都假设声源四面八方一样响(全向)。可现实里的喇叭、说话的人, 是朝一个方向发的:站在喇叭背面,听到的当然不一样。
规范把这叫 sound cone:给声源一个"朝向向量",再给两个张角与一个外锥增益。 计算只有三种情况:那条连线落在内锥里 → 增益 1;落在最外锥之外 → 增益 = 外锥增益; 夹在两者之间 → 线性插值(`gain = (1 − x) + outerGain·x`,x 从 0 到 1)。
★ 两个容易踩的地方:① 两个角度是"整角"不是"半角" —— 规范伪码里专门有一句注释 "Divide by 2 here since API is entire angle (not half-angle)", 所以 `coneInnerAngle = 120°` 意味着两侧各 60°;② θ 不是"声源朝向与听者朝向的夹角", 而是"声源朝向"与"声源和听者之间那条连线"的夹角。
★ 而这里有一件规范自己没统一的事,本课就把它摆在台面上: 那条连线取哪个方向?
· 规范 §6.5 的伪码写的是 sourcePosition.diff(listenerPosition);而规范自己在 Common Definitions 里 定义了 diff(v) { return new Vec3(this.x - v.x, …); } ⇒ 它算出来的是 源 − 听者。
· 可同一份规范的散文说:"a sound source pointing directly at the listener will be louder than if it is pointed off-axis"(正对听者的源更响)。
· 两个实现也都是 听者 − 源:Chromium 的 source_to_listener = listener_position - source_position;、 Firefox 的 sourceToListener = mListenerEngine->Position() - position;。
三者里,伪码那一步与另外两处相反。后果很直观:两个方向的夹角互补(`θ ↔ 180 − θ`), 于是 "正对听者"与"背对听者"整个换个个儿 —— 伪码那一支会把正对着你的喇叭静音。
★ 本页的处置:把口径做成两个按钮,默认取"散文 / 两个引擎"(与散文自洽的那一支); 另一支给出规范伪码的数。表③ 末两列把两边的读数并列摆出来,判据⑧ 断言它们互补、 且在 θ = 90° 处恰好相同(那时连线的方向反不反都一样)。

输入 声源朝向向量 o "喇叭朝哪边" 内锥 / 外锥(整角) 默认都是 360° (= 没有锥,增益 1) 外锥增益 默认 0(外面听不到) θ:朝向与连线的夹角 本页直接给你这个角 (真实场景里它由朝向 与位置一起合出来) ★ 连线取哪个方向: 伪码 源−听者;散文与 两个引擎 听者−源 朝向 → 又一个总增益(规范 §6.5) function coneGain(sp, cfg, dir) { // §6.5 const none = len(cfg.o) === 0; const flat = (cfg.inner === 360 && cfg.outer === 360); // 没有指定锥 if (none || flat) return { gain: 1, a: NaN }; // ⇒ 单位增益 // ★ 那条连线取哪个方向:伪码用 源−听者;散文与两个引擎用 听者−源 const v = dir === 1 ? sub(sp, LPOS) : sub(LPOS, sp); const d0 = unit(v), o0 = unit(cfg.o); const a = Math.abs(Math.acos(cl1(dot(d0, o0))) * 180 / Math.PI); const ai = Math.abs(cfg.inner) / 2, ao = Math.abs(cfg.outer) / 2; if (a <= ai) return { gain: 1, a: a }; // 内锥里:不衰减 if (a >= ao) return { gain: cfg.og, a: a }; // 外锥外:只剩外锥增益 const x = (a - ai) / (ao - ai); // 中间:线性插值 return { gain: (1 - x) + cfg.og * x, a: a }; } θ ≤ 内锥半角 → 1 ;θ ≥ 外锥半角 → 外锥增益 中间那一段是直线(不是钟形曲线) ★ 整角不是半角:API 给的是整个锥的张角,代码里要 /2 特例:朝向模长 0、或内外锥都是 360° → 1 (规范原话:no cone specified — unity gain) 输出 absAngle |θ|,0…180° coneGain 一个数,乘到左右 外锥增益 0 时: 能精确压到 0 内锥 120°/外锥 240° = 半角 60°/120° θ = 90° 是两口径 唯一相同的点 换口径 ⇒ 楔形翻到 听者的另一侧
表③ 声锥的分段(内锥 120°、外锥 240°、外锥增益 0 ⇒ 半角 60°/120°)。 第三列的"可手算依据"就是那条线性插值式。末两列是同一组几何下两种方向口径的读数 —— 它们互补,且在 θ = 90° 处相同。 ★ θ 正好落在外锥半角(120°)那一格时,浮点上是 2.2e−16(表里显示 0)—— 因为那里是"夹角恰好等于 120°"这个边界,`acos` 的舍入让它落在边界的哪一侧都合规。
θabsAngleconeGain可手算依据规范伪码口径散文 / 两引擎口径
表③b 外锥增益不是 0 的时候(θ = 90°,仍在中间段)。 中间段的公式 (1 − x) + outerGain·x 在这里 x = 0.5 ⇒ 增益是两端的中点。
外锥增益coneGain可手算依据

19.4 三件事相乘

本段只增加一样东西:把三者连起来(旋钮 = 输入幅值;三个增益的旋钮都在)。
上一段的缺口:三个增益各自都算出来了,可它们怎么合到一路信号上 —— 相加?取平均?还是相乘?
规范 §6.3.1 的第 6 步只有两行:let totalGain = coneGain() * distanceGain(); 然后 outputL = totalGain * outputL;、outputR = totalGain * outputR;。 是乘。理由也很朴素:每一个增益都在回答同一个问题 —— "从声源到我的耳朵,这一路还剩多少"; 一路经过三件事,剩下的自然是连乘。
★ 乘法链有两个立刻能看出来的性质:① 一个 0 吃掉全部 —— 只要有一环是 0,输出就是 0; ② 顺序无所谓(实数乘法可交换)—— 但不是"逐位相同",因为浮点乘法不满足结合律 (规范那句是数学上的可交换,不是比特上的)。
★ 最后把"三个旋钮各自能不能把声音压到 0"摊开 —— 这是它们最实用的差别: 方位角能把一侧压到浮点零(±90° 时 cos(π/2) = 6.12e−17);linear 能在大距离上精确压到 0(地板 `1−f`,`f=1` 时就是 0); inverse / exponential 不能(只会越来越小);声锥在外锥增益 0 时能精确压到 0。 而它们的共同点是:都在 [0, 1] 里,所以相乘只会让声音更小,不会更大。

输入 单声道信号 (本页是那个幅值) 三个已经算好的增益 gainL / gainR distanceGain coneGain 三者都在 [0, 1] 里 ⇒ 乘只会更小 方位角那一对不是"总" 增益:它是左右分配 (平方和 = 1) 另两个才是"总量" (都作用在两个通道上) 三个增益 → 两个通道(规范 §6.3.1 第 6 步) function spatial(sp, cfg) { // §6.3.1 第 6 步 const p = panGains(azEl(sp).az); // 左右两个 const d = distOf(sp); // 距离 const gD = distanceGain(cfg.model, d, cfg.dref, cfg.dmax, cfg.f); const gC = coneGain(sp, cfg, cfg.dir).gain; // 声锥 const totalGain = gC * gD; // ★ 相乘,不是相加 return { p: p, d: d, dg: gD, cg: gC, totalGain: totalGain, outL: p.gainL * totalGain, // 左通道 outR: p.gainR * totalGain }; // 右通道 } 注意左右两个增益用的是同一个 totalGain —— 距离与声锥不改变“左右怎么分” 本页输入幅值只被乘这一次,不会回头 改任何增益 输出(立体声) outL = 输入 · gainL · total outR = 输入 · gainR · total totalGain coneGain × distanceGain 一个 0 吃掉全部 相乘顺序可交换 但浮点上不逐位相同 上限:外侧一路乘下来 总不会超过输入
表④ 整条乘法链(方位角 +90° ⇒ 全右;距离 4;θ = 90°;内锥 120°、外锥 240°、外锥增益 0)。 三个模型只在 distanceGain 这一列不同,其余三列完全相同 —— 因为距离不改变"左右怎么分"。
距离模型gainLgainRdistanceGainconeGaintotalGainoutLoutR
运行时自检 等待脚本…