空间音频:一个方位角怎么变成两个增益
全课题骨架:采样不是"取点",是"用一串离散值 + 一个重建核,去猜那个连续的东西"。
前三课都在这条链上:先看清"多采几个点为什么不是答案",再拆开"先滤再采"里那台滤波器,
然后用一条环形延迟线把一根弦"长"出来。这一课换一个问法 ——
同一个声音,怎么被放到"左边 / 远处 / 身后"去?
答案比你想的朴素:所谓"放到空间里",就是给它算出几个增益 ——
方位角给你两个(左、右),距离给你一个,声源的朝向再给你一个;
最后把这三个乘到信号上。整页就是把这三件事各自的算式摆出来,并指出它们各自的坑。
四段路:一个方位角两个增益(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。
这台设备拿不到 canvas 2D 上下文,活例跑不了。
本页的原理与七张数值表照常可读(画面只是把同一份状态画出来)。
分段:
方位角:
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" ——
仰角算出来了,但被丢掉。所以"声音在上方还是下方"这件事,这套算法完全没有表达。
表① 方位角 → 左右两个增益(听者固定在原点、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。
| 设定方位角 | 折回后 | x | gainL | gainR | gainL²+gainR² | 说明 |
表①b 仰角对 equalpower 没有影响(方位角固定在 0°;规范原话:the elevation value is ignored)。
注意第二列:仰角算出来了,只是没被用。
| 设定仰角 | 算出的 el | 算出的 az | gainL | gainR | 与 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 会给负增益,那就是相位反了)。
表② 三种距离模型(dref = 1、dmax = 10、f = 1)。
注意 d ≤ 1 两行三列全是 1;d = 10 起 linear 停在 0 不动,
而另两条继续往下掉(但永不为 0)。末列给出每行的可手算依据。
| 距离 d | linear | inverse | exponential | 可手算依据 |
表②b 把 f 拨开,inverse 与 exponential 就分家了(距离固定在 4)。
f = 1 那一行两列逐位相同(都是 1/4);f = 0 那一行三列全是 1(完全不衰减)。
| 衰减强度 f | linear | inverse | exponential | 说明 |
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° 处恰好相同(那时连线的方向反不反都一样)。
表③ 声锥的分段(内锥 120°、外锥 240°、外锥增益 0 ⇒ 半角 60°/120°)。
第三列的"可手算依据"就是那条线性插值式。末两列是同一组几何下两种方向口径的读数 ——
它们互补,且在 θ = 90° 处相同。
★ θ 正好落在外锥半角(120°)那一格时,浮点上是 2.2e−16(表里显示 0)——
因为那里是"夹角恰好等于 120°"这个边界,`acos` 的舍入让它落在边界的哪一侧都合规。
| θ | absAngle | coneGain | 可手算依据 | 规范伪码口径 | 散文 / 两引擎口径 |
表③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] 里,所以相乘只会让声音更小,不会更大。
表④ 整条乘法链(方位角 +90° ⇒ 全右;距离 4;θ = 90°;内锥 120°、外锥 240°、外锥增益 0)。
三个模型只在 distanceGain 这一列不同,其余三列完全相同 —— 因为距离不改变"左右怎么分"。
| 距离模型 | gainL | gainR | distanceGain | coneGain | totalGain | outL | outR |
- ★ 规范里"四段式增益公式"并不存在:开工时以为方位角会按 0..90 / 90..180 / −90..0 / −180..−90
四段各给一条公式。逐字抄回规范之后是 clamp → 折回 [−90,90] → 一条 cos/sin。
这个"我以为的"反而成了 19.1 的正题(折叠 ⇒ 前后不分)。
- ★ 声锥那一步的方向口径,规范自己没统一:伪码(配合规范自己的
Vec3.diff(v) = this − v)
算的是 源 − 听者,而规范散文与 Chromium / Firefox 都用 听者 − 源。
本页把两支都摆出来(19.3),默认取与散文自洽的那一支。本轮没有检索到规范就此的勘误文本,
所以页面只陈述三处逐字证据与冲突本身,不下"谁对谁错"的定论。
- ★
gainL² + gainR² = 1 这条等式规范里没有 —— 规范只在算法名与描述里出现
"equal-power panning"。页面把它当推论写(判据①),并注明它是 cos² + sin² = 1。
- ★ 本页不发声、也不拿原生
PannerNode 当判据:本站的契约是"每条判据都要能在 Node 里复算",
而无头探针里没有 Web Audio。想核验真机的话,用 OfflineAudioContext + PannerNode
渲一小段、把输出与这里的数比一比即可 —— 但本页不声称两者逐位相同
(equalpower 是规范 MUST 的算法,可本页没有在本机核验任何具体实现)。