dsh-mpkg-wallpaper 3.13.1 → 3.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/audio-bus.js CHANGED
@@ -20,7 +20,55 @@
20
20
  // `createMediaStreamSource`(不吃 `track.enabled`)、`src`/`srcObject` setter(autoplay 绕过 `play()`)、
21
21
  // `start(when)` 的迟到分布(**负很多** = bfcache/节流恢复;**负一点点** = 正常抖动,不误杀)、
22
22
  // Shadow DOM 里的媒体元素、`speechSynthesis`、`RTCPeerConnection` 的 `track.enabled`;
23
- // ⑧ **跨 realm 各装一遍**(同源 iframe 的 `AudioNode.prototype` 与父页不是同一个)。
23
+ // ⑧ **跨 realm 各装一遍**(同源 iframe 的 `AudioNode.prototype` 与父页不是同一个);
24
+ // ⑨ **gain 自动化归因**(2026-09-23 补):第三方对 `AudioParam` 排的包络(`linearRampToValueAtTime` /
25
+ // `exponentialRampToValueAtTime` / `setValueAtTime` / `setTargetAtTime` / `setValueCurveAtTime`)与取消
26
+ // (`cancelScheduledValues` / `cancelAndHoldAtTime`)全部归因(**存在才拦**:不存在的方法不造)。
27
+ // 裁决口径(**近似,不假装逐位等价**):
28
+ // ① 打到 **bus 自己 master 参数**上的写值自动化 ⇒ **拒绝执行** + 立刻按宿主值收口。这是唯一一条真能
29
+ // "把静音抬回来"的通路:master 是**串联乘法**,上游作者节点自己的包络再大也乘不过 0;而公开 API
30
+ // 拿不到 master 引用 ⇒ 这条属**纵深防御**,不是日常可达路径(真机靠 `gainAutomation` 读数判读)。
31
+ // ② 作者**自己**节点的包络 **不改写、不重放**(改了会破坏第三方的淡入淡出/音乐自动化);但宿主正在
32
+ // 裁决时(静音档 + 静音中/刚从静音恢复)每次归因到自动化调用就**再收口一次** ⇒ 把"静音之后有人
33
+ // 重新调度包络"的窗口从 750ms 轮询收敛到同一拍。
34
+ // ③ 取消类调用**放行**(它们只删事件、不写值),放行后立刻收口:第三方 `cancelScheduledValues(0)`
35
+ // 能删掉宿主刚写的事件,同拍补回宿主值(否则 master 会退回内在值 ⇒ 静音自己"恢复")。
36
+ // ④ **近似边界(如实)**:真实 `AudioParam` 没有"参数 → 属主节点"的反向引用 ⇒ 只有我们自己登记过的
37
+ // 参数能判身份(master 的 gain,以及 `createGain()` / `new GainNode()` 返回的 `.gain`);其余参数
38
+ // 只进总计数、不打 gain 标签。`param.value = v` 直接赋值**未单独拦**(同一"要先拿到引用"的前提),
39
+ // 只靠既有的 750ms 周期重压兜着。
40
+ // ⑤ 我们自己的收口**走原始方法引用**(与 ② 号注释同款):否则会被自己的钩子二次归因,更糟的是
41
+ // "打在自己参数上的写值自动化"会被自己拒绝 ⇒ 静音永远写不进去。
42
+ // ⑥ `node.connect(master.gain)`(AudioParam 当 connect 目标 ⇒ 与我们的收口**加法**叠加):静音档
43
+ // 拒绝该连接(加法能抬起静音),非静音档只归因、不改第三方的图结构。
44
+ //
45
+ // ⑩ **周期重压的副作用台账 + 幂等化**(2026-09-23;用户现场:「静音是开着的,声音毫无规律地
46
+ // 响一段 / 响一下,而且播放出来的音频是卡卡的」):
47
+ // 诊断:问题不在"静音没生效",而在**周期重压本身有副作用**。旧实现每 750ms 真的做了四件事:
48
+ // ① `closeBus()` **无条件** `cancelScheduledValues(now) + setValueAtTime(v, now)`
49
+ // —— 值一个 bit 都没变也照写(这就是"卡"的正面形状:周期性去戳音频图);
50
+ // ② `applyMute()` 无条件遍历**全部**媒体元素并写 `muted`/`volume`、对正在播的调 `pause()`
51
+ // —— 与作者脚本每帧的 `play()/volume=` 互相踩 ⇒ "响一下/响一段";
52
+ // ③ `syncFrames()` 每拍 `querySelectorAll('iframe')` 并对**每个同源帧**重入
53
+ // `installAudioBus` ⇒ 帧内 `setMode` ⇒ 帧内再跑一遍 `syncFrames + applyMute`(放大效应,
54
+ // 与资源审计 #9 同一条);
55
+ // ④ `collectMedia()` 为找 shadow root 每拍做一次全文档 `querySelectorAll('*')`(审计 #9)。
56
+ // 本版把周期路径改成**幂等且非破坏**:
57
+ // · 值**真的不同**才写(`paramWritesSkipped` 是跳过读数);
58
+ // · 周期路径里**绝不**创建/连接/断开节点、**绝不** suspend/resume 上下文;
59
+ // · 帧枚举与 shadow root 改**定向监听**(`MutationObserver` + 帧注册表 + `attachShadow`
60
+ // 包装;全文档 `*` 扫描只在安装期跑**一次**,读数 `repatch.shadowScans`);
61
+ // · `setMode`/`setMuted` 值没变 ⇒ 一个副作用都不做(帧内重入因此消失);
62
+ // · 每次周期动作实际做了什么按 `where` 记账到 `window.__mpwAudioBusRepatch`:
63
+ // `n` = 有副作用的拍数(稳态必须 0 增长)、`ticks`/`idleTicks`、`byWhere`、
64
+ // `nodesCreated`/`connects`/`disconnects`/`ctxSuspends`/`ctxResumes`/`paramWrites`/…
65
+ // ⑩ 附加的一条真机缺口(静音"收不全"):安装到**同源帧**里的那份总线此前只继承 `mode`、
66
+ // 不继承 `muted` ⇒ 帧里 `state.muted` 恒 false(帧内 WebAudio 压不住)。现在 `syncFrames`
67
+ // 把 `muted` 一起下发(`installAudioBus(cw, { mode, muted })`)。
68
+ // ⚠ 诚实边界:`param.value = v` 的**直接赋值**依然**拦不到**(包 `AudioParam.prototype.value`
69
+ // 的 accessor 会伤热路径性能 ⇒ 只**检测**不拦截):下一拍采样发现"我们写下去的值 ≠ 现在读到的值"
70
+ // 就记一条 `report().gainAutomation.reclaims` 台账,但那条来源**拿不到写者的栈**(`traceable:false`,
71
+ // 如实标注)。自动化/连接注入那两条来源有真栈(钩子当场抓到)。
24
72
  //
25
73
  // 档位(`?mpwhardmute=` 或 `bus.setMode()`)—— 二分定位用,不是"总开关":
26
74
  // off 只归因,不动声音(缺省)
@@ -40,6 +88,11 @@ export const BUS_MSG = 'mpw:audio-bus';
40
88
  /** 迟到补播的判定阈值:负得比这个多 ⇒ 当"恢复后补播"(bfcache/节流),否则算正常抖动,不误杀。 */
41
89
  export const LATE_START_SEC = 1.0;
42
90
  export const JITTER_START_SEC = 0.05;
91
+ /** ⑨ 被归因的 `AudioParam` 自动化方法(**存在才拦**:不存在的方法不造)。写值类 ⇒ 打在我们自己的
92
+ * master 参数上时必须拒绝(宿主裁决优先)。 */
93
+ export const PARAM_WRITE_METHODS = ['setValueAtTime', 'linearRampToValueAtTime', 'exponentialRampToValueAtTime', 'setTargetAtTime', 'setValueCurveAtTime'];
94
+ /** ⑨ 取消类:只删事件、不写值 ⇒ 放行,但放行后立刻按宿主值收口(把被删掉的宿主事件补回来)。 */
95
+ export const PARAM_CANCEL_METHODS = ['cancelScheduledValues', 'cancelAndHoldAtTime'];
43
96
 
44
97
  const now = (win) => { try { return win.performance && win.performance.now ? win.performance.now() : Date.now() } catch (e) { return Date.now() } };
45
98
 
@@ -71,8 +124,81 @@ export function frameModeFor(m) { const s = normalizeMode(m); return s === 'redi
71
124
  */
72
125
  export function installAudioBus(win, opts = {}) {
73
126
  if (!win || typeof win !== 'object') return null;
127
+ /* ── ⑩ 周期重压的副作用台账(探针直接读 `window.__mpwAudioBusRepatch`,不需要用户操作) ──────────
128
+ 要回答的不是"静音设置生效没有",而是"**周期重压到底做了什么**":
129
+ · `ticks` 周期定时器总拍数;`idleTicks` 其中"一个副作用都没有"的拍数;
130
+ · `n` 有副作用的拍数(**修好后稳态必须 0 增长**;旧实现每拍 +1);
131
+ · `byWhere` 按动作点分;`entries`(≤64)只记有副作用那几拍的 delta;
132
+ · 四类"周期路径里绝不该出现"的副作用单独计数:`nodesCreated`/`connects`/`disconnects`/
133
+ `ctxSuspends`+`ctxResumes`(任一非 0 ⇒ 就是卡顿/漏音的成因,见文件头 ⑩)。
134
+ 每个 realm 一份(帧内那份在自己的 window 上);`report().framesRepatch` 汇总同源帧读数。 */
135
+ const REPATCH_WATCH = ['nodesCreated', 'connects', 'disconnects', 'ctxSuspends', 'ctxResumes', 'modeWrites', 'paramWrites', 'mediaWrites', 'mediaPauses', 'mediaUnmutes', 'speechCancels', 'frameReentries', 'frameDisposals'];
136
+ const repatch = (() => {
137
+ const prev = win.__mpwAudioBusRepatch;
138
+ if (prev && typeof prev === 'object') return prev;
139
+ const rec = {
140
+ n: 0, ticks: 0, idleTicks: 0, lastAt: 0, lastWhere: '', lastDid: null, lastStack: '', lastStackAt: 0,
141
+ byWhere: {}, entries: [],
142
+ nodesCreated: 0, connects: 0, disconnects: 0, ctxSuspends: 0, ctxResumes: 0,
143
+ modeWrites: 0, modeNoops: 0, paramWrites: 0, paramWritesSkipped: 0,
144
+ mediaWrites: 0, mediaPauses: 0, mediaUnmutes: 0, speechCancels: 0,
145
+ frameReentries: 0, frameNoops: 0, frameDisposals: 0,
146
+ shadowScans: 0, observers: 0,
147
+ };
148
+ try { win.__mpwAudioBusRepatch = rec } catch (e) {}
149
+ return rec;
150
+ })();
151
+ /** 记一次"真的做了事"(有副作用)。`field` = REPATCH_WATCH 里的计数器名(无副作用则传 null)。 */
152
+ const did = (where, field) => {
153
+ try {
154
+ const A = repatch;
155
+ A.byWhere[where] = (A.byWhere[where] || 0) + 1;
156
+ if (field) A[field] = (A[field] || 0) + 1;
157
+ A.lastAt = now(win); A.lastWhere = where;
158
+ } catch (e) {}
159
+ };
160
+ /** 周期动作包装:只把这一拍**实际产生的副作用**记账(稳态 ⇒ 一个 delta 都没有 ⇒ `idleTicks++`)。 */
161
+ const periodic = (where, work) => {
162
+ const A = repatch;
163
+ if (disposed) return {};
164
+ const t = now(win);
165
+ const before = {};
166
+ for (const k of REPATCH_WATCH) before[k] = A[k] || 0;
167
+ A.ticks++; A.byWhere['tick:' + where] = (A.byWhere['tick:' + where] || 0) + 1;
168
+ try { work() } catch (e) {}
169
+ const diff = {}; let any = 0;
170
+ for (const k of REPATCH_WATCH) { const d = (A[k] || 0) - before[k]; if (d) { diff[k] = d; any += d } }
171
+ if (!any) { A.idleTicks++; return diff }
172
+ A.n++; A.lastAt = t; A.lastWhere = where; A.lastDid = diff;
173
+ /* 栈只在"这一拍真的做了事"时抓一次(节流 1s):周期路径不为归因付抓栈开销。 */
174
+ if (!A.lastStack || (t - A.lastStackAt) > 1000) { A.lastStack = stackOf(); A.lastStackAt = t }
175
+ if (A.entries.length > 63) A.entries.shift();
176
+ A.entries.push({ at: t, where, did: diff });
177
+ return diff;
178
+ };
179
+ /** 把台账拷成**普通对象**(跨 realm 读帧内那份时用;`report()` 直接返回它)。 */
180
+ const snapshotOf = (A) => (A && typeof A === 'object') ? {
181
+ n: A.n, ticks: A.ticks, idleTicks: A.idleTicks, lastAt: A.lastAt, lastWhere: A.lastWhere,
182
+ lastDid: A.lastDid, lastStack: A.lastStack, byWhere: Object.assign({}, A.byWhere),
183
+ nodesCreated: A.nodesCreated, connects: A.connects, disconnects: A.disconnects,
184
+ ctxSuspends: A.ctxSuspends, ctxResumes: A.ctxResumes,
185
+ modeWrites: A.modeWrites, modeNoops: A.modeNoops,
186
+ paramWrites: A.paramWrites, paramWritesSkipped: A.paramWritesSkipped,
187
+ mediaWrites: A.mediaWrites, mediaPauses: A.mediaPauses, mediaUnmutes: A.mediaUnmutes,
188
+ speechCancels: A.speechCancels, frameReentries: A.frameReentries, frameNoops: A.frameNoops,
189
+ frameDisposals: A.frameDisposals, shadowScans: A.shadowScans, observers: A.observers,
190
+ entries: (A.entries || []).slice(-8),
191
+ } : null;
192
+ const repatchSnapshot = () => snapshotOf(repatch);
193
+
74
194
  if (win.__mpwAudioBus && win.__mpwAudioBus.__installed) { // 幂等:同一 realm 只装一遍
195
+ /* ⑩ 读数:这行 = "有人从外面重入这个 realm 的总线"(旧写法每 750ms 对每个同源帧来一次)。
196
+ 修好之后周期路径不该走到这里(帧集合没变 ⇒ `syncFrames` 直接返回)⇒ 稳态帧重入 = 0。 */
197
+ did('frame-reentry', 'frameReentries');
198
+ /* ⑩ 值没变时 `setMode`/`setMuted` 都是**零副作用**的(见 api 里那两条幂等守卫)⇒ 周期重入
199
+ (旧写法每 750ms 对每个同源帧重入一次)变成空操作,而不是"每拍重压一遍帧内音频图"。 */
75
200
  if (opts.mode !== undefined) win.__mpwAudioBus.setMode(opts.mode);
201
+ if (opts.muted !== undefined) win.__mpwAudioBus.setMuted(opts.muted);
76
202
  return win.__mpwAudioBus;
77
203
  }
78
204
  const log = [];
@@ -91,7 +217,33 @@ export function installAudioBus(win, opts = {}) {
91
217
  masters: new WeakMap(), // ctx -> masterGain
92
218
  adopted: new Set(), // 被我们接管过的 ctx(**含**安装前就存在的老 ctx)——静音要遍历这一份
93
219
  media: new Set(), // 媒体元素(含 new Audio() 与 shadow DOM 里的)
220
+ mediaMutedByUs: new WeakSet(),// ⑩ 被**我们**按下的静音(解除时只撤销我们自己按的,见 release())
94
221
  tappedMedia: new WeakSet(), // 被 createMediaElementSource 接管的元素(不能 pause:analyser 要数据)
222
+ masterParams: new WeakSet(), // ⑨ 我们 bus 自己的 gain 参数(AudioParam)—— 身份只能靠自己登记
223
+ gainParams: new WeakSet(), // ⑨ 已知是 `GainNode.gain` 的参数(作者自己的包络)
224
+ hostVolume: 1, // ⑨ 宿主管的音量(0..1,缺省 1 = 与改动前逐位一致)
225
+ hostVerdict: new WeakMap(), // ⑩ ctx → 我们最后写下去的裁决值(判"被外部改动/覆盖"的基线)
226
+ gainAutomation: { // ⑨ 自动化归因面(探针/diag 直接读;见文件头 ⑨ 的近似边界)
227
+ calls: 0, // 归因到的自动化调用总数
228
+ authorGain: 0, // 其中打在"作者自己的 GainNode.gain"上的
229
+ masterWrites: 0, // 其中打在 bus 自己 master 参数上的
230
+ suppressed: 0, // 被拒绝执行的(master 参数 + 写值类)
231
+ reclosed: 0, // 因自动化调用触发的"按宿主值再收口一次"次数
232
+ paramInputs: 0, // 往 master 参数 connect 信号(加法叠加)的次数
233
+ paramInputsRefused: 0, // 其中静音档被拒绝的
234
+ byMethod: {}, lastMethod: '', lastAt: 0, lastStack: '', lastStackAt: 0,
235
+ /* ⑩ "谁把静音抬回来"台账(沿用本对象的命名风格 ⇒ **不新建平行结构**;探针读
236
+ `report().gainAutomation.reclaims`)。bySource 的取值:
237
+ automation-suppressed —— 第三方对 bus 自己的 gain 排包络(钩子当场拒绝;栈可得)
238
+ param-connect-injection —— 往 bus 自己的 gain connect 信号(加法叠加;栈可得)
239
+ media-element —— 静音期间元素 `muted`/`volume` 被抬回(volumechange 当场抓;栈可得)
240
+ media-element-resample —— 周期重压时**发现**元素又被抬回了(采样点栈)
241
+ value-assign-or-unknown —— `param.value = v` 直接赋值:**拦不到**,下一拍采样才发现(无写者栈) */
242
+ reclaims: {
243
+ n: 0, lastAt: 0, lastWhere: '', lastSource: '', lastExpected: null, lastActual: null,
244
+ lastStack: '', lastStackAt: 0, bySource: {}, byWhere: {}, entries: [],
245
+ },
246
+ },
95
247
  redirects: 0,
96
248
  bypassSuspected: [], // 能观测到出声但不在 bus 上的 ctx
97
249
  uncontrollable: [], // 跨域帧(拿不到 contentDocument)
@@ -99,6 +251,8 @@ export function installAudioBus(win, opts = {}) {
99
251
  };
100
252
  let ctxSeq = 0;
101
253
  let timer = 0;
254
+ let lastCloseWrites = 0; // 最近一次 closeBus 实际**写下去**的参数个数(如实进 emit/report)
255
+ let disposed = false; // ⑩ dispose 之后不再接受任何周期动作
102
256
  /* 本 realm 的**原始 connect**。必须是 `AudioNode.prototype.connect` —— 真实浏览器里
103
257
  `AudioContext.prototype` 上**没有** connect(我第一版写错过,那样一条都拦不到)。 */
104
258
  let realmOrigConnect = null;
@@ -116,6 +270,17 @@ export function installAudioBus(win, opts = {}) {
116
270
  const rest = Array.prototype.slice.call(arguments, 1);
117
271
  let ctx = null;
118
272
  try { ctx = this && this.context } catch (e) { /* 某些桩没有 context */ }
273
+ did('connect-observed', 'connects'); // ⑩ 读数:被观察到的 connect 调用(周期路径里必须 0 增长)
274
+ /* ⑨ 往 **bus 自己的 gain 参数**连信号(AudioParam 作 connect 目标 ⇒ 与我们的收口**加法**叠加):
275
+ 静音档拒绝该连接(加法能抬起静音),非静音档只归因、**不动第三方的图结构**。 */
276
+ if (dest && state.masterParams.has(dest)) {
277
+ const holding = busHolds();
278
+ try { state.gainAutomation.paramInputs++; if (holding) state.gainAutomation.paramInputsRefused++ } catch (e) {}
279
+ /* ⑩ "谁把静音抬回来":这是一条**有栈可得**的抬回通路(加法叠加能抬起静音)⇒ 入台账。 */
280
+ if (holding) noteDrift('param-input', 'param-connect-injection', 0, null, { stack: stackOf(), method: 'connect' });
281
+ emit('param-input', { kind: nodeKind(this), refused: holding, stack: stackOf() });
282
+ if (holding) { try { closeBus() } catch (e) {} ; return dest } // 真 API:connect 返回传入的目标
283
+ }
119
284
  // 只拦"直连 destination";用**引用比较**(instanceof 跨 realm 会失败)
120
285
  if (ctx && dest && dest === ctx.destination && !isOfflineCtx(ctx)) {
121
286
  let master = state.masters.get(ctx);
@@ -148,11 +313,18 @@ export function installAudioBus(win, opts = {}) {
148
313
  const origCreate = ctx.__origCreateGain || (ctx.createGain && ctx.createGain.bind(ctx));
149
314
  const master = origCreate ? origCreate() : null;
150
315
  if (!master) return null;
316
+ did('create-master', 'nodesCreated'); // ⑩ 读数:节点创建(只应在"首次接管一个 ctx"时发生)
151
317
  try {
152
- if (master.gain) { master.gain.value = state.muted ? 0 : 1 }
318
+ if (master.gain) {
319
+ try { state.masterParams.add(master.gain) } catch (e) {} // ⑨ 身份登记(见 ③′ 段)
320
+ master.gain.value = initialMasterValue();
321
+ /* ⑩ 裁决基线:初值就是"我们写下去的值" ⇒ 之后读到的任何差异都是外部改动(见 closeBus 的检测)。 */
322
+ try { state.hostVerdict.set(ctx, initialMasterValue()) } catch (e) {}
323
+ }
153
324
  const origConnect = realmOrigConnect;
154
325
  if (origConnect) origConnect.call(master, ctx.destination); // 原始引用,绕开 hook
155
326
  else if (master.connect) master.connect(ctx.destination);
327
+ did('master-connect', 'connects'); // ⑩ 读数:我们自己那一次 master→destination(只此一次)
156
328
  } catch (e) { emit('master-connect-fail', { err: String(e && e.message || e) }) }
157
329
  return master;
158
330
  };
@@ -165,6 +337,167 @@ export function installAudioBus(win, opts = {}) {
165
337
  const ctxId = (ctx) => { const f = state.contexts.find((c) => c.ctx === ctx); return f ? f.id : -1 };
166
338
  const nodeKind = (n) => { try { return String((n && n.constructor && n.constructor.name) || 'AudioNode') } catch (e) { return 'AudioNode' } };
167
339
 
340
+ /* ── ③′ 宿主裁决值 + gain 自动化归因钩子(文件头 ⑨;语义/近似边界都在那里) ────────────────────
341
+ 真实 `AudioParam` **没有"参数 → 属主节点"的反向引用** ⇒ 身份只能靠自己登记:① master 的 gain
342
+ (`state.masterParams`);② `createGain()` / `new GainNode()` 返回的 `.gain`(`state.gainParams`)。 */
343
+ const realmParamOrig = {}; // 原始 AudioParam 方法:我们自己的收口必须走它(否则被自己拦/自己归因)
344
+ let patchedParams = []; // [{ proto, fns }],卸载时还原
345
+ /** 宿主此刻是否真的在裁决总线值(静音档 + 静音中,或刚从静音恢复的那一次)。 */
346
+ const busHolds = () => modeMutes(state.mode) && (state.muted || state.wasMuted);
347
+ /** 宿主裁决的目标值:静音 ⇒ 0;否则宿主管的音量(缺省 1 ⇒ 与改动前逐位一致)。 */
348
+ const hostTarget = () => ((modeMutes(state.mode) && state.muted) ? 0 : state.hostVolume);
349
+ /** 新建 master 的初值:裁决档(`1`/`all`)按宿主值起手;其余档维持 1(**不碰**,与档位语义一致)。 */
350
+ const initialMasterValue = () => (modeMutes(state.mode) ? hostTarget() : 1);
351
+ /**
352
+ * 把宿主的裁决值写到所有已接管的 master 上。**走原始方法引用**(见 ⑤ 号注释)。
353
+ * ⑩ **值真的不同才写**(`force` 除外):稳态下这一句一个音频事件都不排、一次 `.value` 都不写
354
+ * (跳过次数记 `repatch.paramWritesSkipped`)。旧写法每 750ms 无条件
355
+ * `cancelScheduledValues(now) + setValueAtTime(v, now)` —— 用户听到的"卡/响一下"正是这种
356
+ * "周期性去戳音频图"的形状(读数见文件头 ⑩ 与 `window.__mpwAudioBusRepatch`)。
357
+ * 顺带做 ⑩ 的**检测**:读到的值与"我们上次写下去的值"不一致 ⇒ 记一条 reclaims(谁把静音抬回来)。
358
+ * @returns {number} 被**核对/收口**的 master 参数个数(`force=false` 且值相等时只核对不写)
359
+ */
360
+ const closeBus = (force) => {
361
+ let written = 0, covered = 0;
362
+ const v = hostTarget();
363
+ for (const ctx of state.adopted) {
364
+ const master = state.masters.get(ctx);
365
+ const p = master && master.gain;
366
+ if (!p) continue;
367
+ covered++;
368
+ let cur = NaN;
369
+ try { cur = Number(p.value) } catch (e) { cur = NaN }
370
+ const wr = state.hostVerdict.has(ctx) ? state.hostVerdict.get(ctx) : null;
371
+ /* ⑩ 检测"宿主裁决值被外部改动/覆盖":直接赋值(`param.value = v`)**拦不到**(不包 accessor)
372
+ ⇒ 只能在这里采样发现;台账如实标 `traceable:false`(拿不到写者的栈)。 */
373
+ if (wr !== null && cur === cur && cur !== wr) {
374
+ noteDrift('close-bus-resample', 'value-assign-or-unknown', wr, cur, { ctxId: ctxId(ctx) });
375
+ }
376
+ if (!force && cur === cur && cur === v) { did('close-bus', 'paramWritesSkipped'); continue }
377
+ try {
378
+ const t = ctx.currentTime;
379
+ if (realmParamOrig.cancelScheduledValues) realmParamOrig.cancelScheduledValues.call(p, t);
380
+ if (realmParamOrig.setValueAtTime) realmParamOrig.setValueAtTime.call(p, v, t);
381
+ else p.value = v;
382
+ written++;
383
+ } catch (e) { try { p.value = v; written++ } catch (e2) {} }
384
+ try { state.hostVerdict.set(ctx, v) } catch (e) {}
385
+ did('close-bus', 'paramWrites');
386
+ }
387
+ lastCloseWrites = written;
388
+ return covered;
389
+ };
390
+ /** 归因一次自动化调用(计数 + 末次栈;探针读 `report().gainAutomation`)。**不许抛**:归因失败绝不影响音频。 */
391
+ const noteAutomation = (fn, mine, author) => {
392
+ try {
393
+ const A = state.gainAutomation;
394
+ A.calls++; A.byMethod[fn] = (A.byMethod[fn] || 0) + 1;
395
+ A.lastMethod = fn; A.lastAt = now(win);
396
+ /* 栈**不每次抓**:自动化可能是热路径(每个音符几十次调用)⇒ 只在"打到我们自己的参数上"或
397
+ 距上次抓栈 >1s 时抓一次(诊断面够用;热路径不为归因付出抓栈开销)。 */
398
+ if (mine || !A.lastStack || (A.lastAt - A.lastStackAt) > 1000) { A.lastStack = stackOf(); A.lastStackAt = A.lastAt }
399
+ if (mine) A.masterWrites++; else if (author) A.authorGain++;
400
+ } catch (e) {}
401
+ };
402
+ /** ⑩ 记一条"宿主裁决值被外部改动/覆盖"的台账(时间戳 + 来源 + 节流栈)。**不许抛**。
403
+ * `extra.stack` 给了就是**当场抓到的真写者栈**(钩子路径);没给 = 采样点栈(拦不到直接赋值那条)。 */
404
+ const noteDrift = (where, source, expected, actual, extra) => {
405
+ try {
406
+ const R = state.gainAutomation.reclaims;
407
+ const t = now(win);
408
+ const stack = (extra && extra.stack) ? String(extra.stack) : '';
409
+ R.n++; R.lastAt = t; R.lastWhere = where; R.lastSource = source;
410
+ R.lastExpected = expected; R.lastActual = actual;
411
+ R.bySource[source] = (R.bySource[source] || 0) + 1;
412
+ R.byWhere[where] = (R.byWhere[where] || 0) + 1;
413
+ /* 栈**节流**(>1s 才抓一次,与 noteAutomation 同款):采样可能是热路径。
414
+ 直接赋值那条**没有写者栈**(不包 accessor)⇒ `traceable:false`,只留采样点读数。 */
415
+ const traceable = !!stack;
416
+ if (traceable) R.lastStack = stack;
417
+ else if (!R.lastStack || (t - R.lastStackAt) > 1000) R.lastStack = stackOf();
418
+ R.lastStackAt = t;
419
+ if (R.entries.length > 31) R.entries.shift();
420
+ R.entries.push({
421
+ at: t, where, source, expected, actual, traceable,
422
+ method: (extra && extra.method) || '', ctxId: (extra && extra.ctxId !== undefined) ? extra.ctxId : '',
423
+ stack: traceable ? stack : '',
424
+ });
425
+ } catch (e) {}
426
+ };
427
+ /** 登记"这是 GainNode 的 gain 参数":`createGain()` 与 `new GainNode()` 两条造节点的路都覆盖。 */
428
+ const patchGainRegistration = () => {
429
+ const ACproto = protoOf(win.AudioContext || win.webkitAudioContext);
430
+ if (ACproto && typeof ACproto.createGain === 'function' && !ACproto.__mpwBusGain) {
431
+ try {
432
+ const orig = ACproto.createGain;
433
+ ACproto.__mpwBusGain = true;
434
+ ACproto.createGain = function (...a) {
435
+ const node = orig.apply(this, a);
436
+ try { if (node && node.gain) state.gainParams.add(node.gain) } catch (e) {}
437
+ return node;
438
+ };
439
+ } catch (e) {}
440
+ }
441
+ try {
442
+ const Orig = win.GainNode;
443
+ if (typeof Orig === 'function' && !Orig.__mpwBusGainCtor) {
444
+ const Wrapped = function (...a) {
445
+ const node = new Orig(...a);
446
+ try { if (node && node.gain) state.gainParams.add(node.gain) } catch (e) {}
447
+ return node;
448
+ };
449
+ try { Wrapped.prototype = Orig.prototype } catch (e) {}
450
+ Wrapped.__mpwBusGainCtor = true;
451
+ win.GainNode = Wrapped;
452
+ }
453
+ } catch (e) {}
454
+ };
455
+ /** ⑨ `AudioParam.prototype` 的自动化方法:归因 + "宿主值是最终裁决"。 */
456
+ const patchParamAutomation = () => {
457
+ const proto = protoOf(win.AudioParam);
458
+ if (!proto || proto.__mpwBusParam) return 0;
459
+ const done = [];
460
+ for (const fn of PARAM_WRITE_METHODS.concat(PARAM_CANCEL_METHODS)) {
461
+ let orig = null;
462
+ try { orig = proto[fn] } catch (e) { orig = null }
463
+ if (typeof orig !== 'function') continue; // **存在才拦**:不存在的方法不造
464
+ realmParamOrig[fn] = orig;
465
+ const isWrite = PARAM_WRITE_METHODS.indexOf(fn) >= 0;
466
+ try {
467
+ proto[fn] = function mpwBusParamAutomation() {
468
+ let mine = false, author = false;
469
+ try { mine = state.masterParams.has(this); author = !mine && state.gainParams.has(this) } catch (e) {}
470
+ noteAutomation(fn, mine, author);
471
+ if (mine && isWrite) {
472
+ /* ① 宿主裁决优先:**拒绝**打到 bus 自己 gain 上的写值自动化,并立刻按宿主值收口 —— 否则
473
+ "旧包络 / 新排的 ramp"会在 750ms 轮询的空隙里把静音抬起来(正是报障的形状)。 */
474
+ try { state.gainAutomation.suppressed++; emit('gain-automation-suppressed', { method: fn, stack: stackOf() }) } catch (e) {}
475
+ /* ⑩ 台账:这是一条**有真栈**的"想改宿主裁决值"通路(谁在抬静音,一眼可见)。 */
476
+ try { noteDrift('param-automation', 'automation-suppressed', state.hostVerdict.has(hostCtxOf(this)) ? state.hostVerdict.get(hostCtxOf(this)) : null, null, { stack: stackOf(), method: fn }) } catch (e) {}
477
+ try { closeBus() } catch (e) {}
478
+ return undefined; // 真 API:这几个方法返回 undefined
479
+ }
480
+ const out = orig.apply(this, arguments); // 作者自己的包络:**原样放行**(不改第三方语义)
481
+ /* ② 收口:打到 bus 参数上的**取消类**调用(可能删掉宿主刚写的事件)、或宿主正在裁决时的任何
482
+ 自动化调用 ⇒ 立刻把宿主值补成"最后生效"的那个(不等 750ms 轮询)。
483
+ ⑩ 取消类**强制**收口(第三方刚删掉宿主事件,master 会退回内在值);其余按"值不同才写"核对
484
+ ⇒ 稳态零写入(`paramWritesSkipped` 涨、`paramWrites` 不涨)。 */
485
+ if (mine || busHolds()) { try { if (closeBus(mine && !isWrite)) state.gainAutomation.reclosed++ } catch (e) {} }
486
+ return out;
487
+ };
488
+ done.push(fn);
489
+ } catch (e) {}
490
+ }
491
+ try { proto.__mpwBusParam = true } catch (e) {}
492
+ if (done.length) patchedParams.push({ proto, fns: done });
493
+ return done.length;
494
+ };
495
+ /** ⑩ 从 master 的 gain 参数找回它的 ctx(只为我们登记过的参数服务;找不到返回 null)。 */
496
+ const hostCtxOf = (param) => {
497
+ try { for (const ctx of state.adopted) { const m = state.masters.get(ctx); if (m && m.gain === param) return ctx } } catch (e) {}
498
+ return null;
499
+ };
500
+
168
501
  /* ── ② AudioContext 构造拦截:登记 + 归因(接管本身靠 connect,构造只登记) ───── */
169
502
  const patchCtor = (winObj, key) => {
170
503
  try {
@@ -284,6 +617,8 @@ export function installAudioBus(win, opts = {}) {
284
617
  const el = e && e.target;
285
618
  if (!el || !state.muted || !modeMutes(state.mode)) return;
286
619
  if (!el.muted || Number(el.volume) > 0) {
620
+ /* ⑩ 台账:元素级"把静音抬回来"——**当场抓到写者栈**(谁在静音期间把 muted/volume 抬回去)。 */
621
+ try { noteDrift('media-element-volume', 'media-element', 0, Number(el.volume), { stack: stackOf(), method: el.muted ? 'volume' : 'muted' }) } catch (e) {}
287
622
  emit('volume-raised-while-muted', { muted: !!el.muted, volume: Number(el.volume), stack: stackOf() });
288
623
  suppress(el);
289
624
  }
@@ -329,69 +664,256 @@ export function installAudioBus(win, opts = {}) {
329
664
 
330
665
  const shortSrc = (s) => String(s || '').replace(/token=[^&]*/, 'token=…').slice(0, 120);
331
666
 
667
+ /* ── ⑩ 定向监听:媒体元素 / 帧 / shadow root 的**增量**登记(替代每拍的 `querySelectorAll('*')`) ────
668
+ 旧写法(资源审计 #9):`collectMedia()` 每次为找 shadow root 做一次全文档 `querySelectorAll('*')`;
669
+ `syncFrames()` 每 750ms 重新枚举 iframe 并对**每个同源帧**重入 `installAudioBus`。这里改成:
670
+ · `roots`:已知可查询根(document + 各 shadow root),**只在新增时**登记一次;
671
+ · `MutationObserver`(childList + subtree)只看新增/移除节点 ⇒ 登记媒体元素 / 置帧脏位;
672
+ · `Element.prototype.attachShadow` 包装(返回值原样透传)⇒ 新 shadow root 立刻登记;
673
+ · 安装期**一次**全文档扫描,把"装之前就已经存在"的 shadow root 收进来(读数 `repatch.shadowScans`)。
674
+ 周期路径里**没有**全文档扫描(门禁:同一状态连跑 N 拍 `shadowScans` 不增长)。 */
675
+ const roots = [];
676
+ const rootSeen = new WeakSet();
677
+ const observers = [];
678
+ const frameEls = []; // 帧注册表(替代每拍 `querySelectorAll('iframe')`)
679
+ const frameBus = []; // [{ el, win }]:帧内那条总线的登记(换壁纸/帧卸载时 dispose 它)
680
+ let framesDirty = true; // MutationObserver 命中帧增删时置位;周期路径只在置位时才重枚举
681
+ let shadowScanned = false;
682
+ const patchedDisconnect = [], patchedCtxLife = [], patchedShadow = [];
683
+
684
+ const safeQuery = (root, sel) => { try { return (root && typeof root.querySelectorAll === 'function') ? root.querySelectorAll(sel) : [] } catch (e) { return [] } };
685
+ const isMediaEl = (n) => { try { return !!(n && n.nodeType === 1 && /^(audio|video)$/i.test(String(n.tagName || ''))) } catch (e) { return false } };
686
+ const isFrameEl = (n) => { try { return !!(n && n.nodeType === 1 && /^iframe$/i.test(String(n.tagName || ''))) } catch (e) { return false } };
687
+
332
688
  const registerMedia = (el) => {
333
689
  if (!el || typeof el !== 'object') return false;
334
690
  if (state.media.has(el)) return false;
335
691
  state.media.add(el);
336
692
  return true;
337
693
  };
338
- /** 收集媒体元素:document + **Shadow DOM**(querySelectorAll 抓不到 shadow root 里的)+ 同源 iframe。 */
339
- const collectMedia = (doc, out) => {
694
+ /** 只看**新增节点**(含其子树里定向查出来的媒体/帧)——这就是"只盯新增"的那一半。 */
695
+ const scanNode = (n) => {
340
696
  try {
341
- for (const el of doc.querySelectorAll('audio,video')) out.push(el);
342
- for (const host of doc.querySelectorAll('*')) { if (host.shadowRoot) collectMedia(host.shadowRoot, out) }
697
+ if (!n || n.nodeType !== 1) return;
698
+ if (isMediaEl(n)) registerMedia(n);
699
+ if (isFrameEl(n)) framesDirty = true;
700
+ try { if (n.shadowRoot) addRoot(n.shadowRoot) } catch (e) {}
701
+ for (const el of safeQuery(n, 'audio,video')) registerMedia(el);
702
+ if (safeQuery(n, 'iframe').length) framesDirty = true;
343
703
  } catch (e) {}
704
+ };
705
+ const observeRoot = (root) => {
706
+ try {
707
+ const MO = win.MutationObserver;
708
+ if (typeof MO !== 'function' || !root || typeof root.querySelectorAll !== 'function') return false;
709
+ const node = (root === win.document && (root.documentElement || root)) || root;
710
+ const mo = new MO((muts) => {
711
+ try {
712
+ for (const m of muts) {
713
+ const added = m && m.addedNodes;
714
+ if (added) for (let i = 0; i < added.length; i++) scanNode(added[i]);
715
+ const removed = m && m.removedNodes;
716
+ if (removed) for (let i = 0; i < removed.length; i++) if (isFrameEl(removed[i])) framesDirty = true;
717
+ }
718
+ } catch (e) {}
719
+ });
720
+ mo.observe(node, { childList: true, subtree: true });
721
+ observers.push(mo); repatch.observers = observers.length;
722
+ return true;
723
+ } catch (e) { return false }
724
+ };
725
+ /** 登记一个可查询根(document / shadow root):装一次 `audio,video` 定向查询 + 挂 observer。 */
726
+ const addRoot = (root) => {
727
+ if (!root || rootSeen.has(root) || typeof root.querySelectorAll !== 'function') return false;
728
+ try { rootSeen.add(root) } catch (e) { return false }
729
+ roots.push(root);
730
+ for (const el of safeQuery(root, 'audio,video')) registerMedia(el);
731
+ observeRoot(root);
732
+ return true;
733
+ };
734
+ /** 安装期**一次**:把装之前就存在的 shadow root 收进来(`querySelectorAll('*')` **只在这里**出现)。 */
735
+ const shadowScanOnce = () => {
736
+ if (shadowScanned) return 0;
737
+ shadowScanned = true;
738
+ did('shadow-scan-once', 'shadowScans');
739
+ let n = 0;
740
+ try { for (const host of safeQuery(win.document, '*')) { try { if (host && host.shadowRoot && addRoot(host.shadowRoot)) n++ } catch (e) {} } } catch (e) {}
741
+ return n;
742
+ };
743
+ /** 新 shadow root:包 `attachShadow`(返回值**原样透传**,作者/框架依赖 `=== this.shadowRoot`)。 */
744
+ const patchAttachShadow = () => {
745
+ try {
746
+ const E = win.Element && win.Element.prototype;
747
+ if (!E || typeof E.attachShadow !== 'function' || E.__mpwBusShadow) return 0;
748
+ const orig = E.attachShadow;
749
+ E.attachShadow = function mpwBusAttachShadow() { const sr = orig.apply(this, arguments); try { addRoot(sr) } catch (e) {} ; return sr };
750
+ E.__mpwBusShadow = true;
751
+ patchedShadow.push({ proto: E, orig });
752
+ return 1;
753
+ } catch (e) { return 0 }
754
+ };
755
+ /** 只**计数**的旁路钩子(不改语义、返回值原样透传):为"周期重压有没有副作用"提供可读读数。
756
+ * `disconnect` / `suspend` / `resume` 在本模块里**从不主动调用**(⑥ 明确不 suspend)——但"没调用"
757
+ * 这件事此前无法自证 ⇒ 装上计数器后 `repatch.disconnects/ctxSuspends/ctxResumes` 稳态必须恒为 0
758
+ * (有读数 ⇒ 是**别人**在戳音频图,那正是卡顿/漏音的成因)。 */
759
+ const patchNodeCounters = (proto) => {
760
+ if (!proto || typeof proto.disconnect !== 'function' || proto.__mpwBusDisc) return false;
761
+ const orig = proto.disconnect;
762
+ proto.disconnect = function mpwBusDisconnect() { did('disconnect-observed', 'disconnects'); return orig.apply(this, arguments) };
763
+ proto.__mpwBusDisc = true;
764
+ patchedDisconnect.push({ proto, orig });
765
+ return true;
766
+ };
767
+ const patchCtxLifecycle = () => {
768
+ for (const proto of [protoOf(win.AudioContext), protoOf(win.webkitAudioContext)]) {
769
+ if (!proto || proto.__mpwBusCtxLife) continue;
770
+ proto.__mpwBusCtxLife = true;
771
+ for (const fn of ['suspend', 'resume']) {
772
+ let orig = null; try { orig = proto[fn] } catch (e) { orig = null }
773
+ if (typeof orig !== 'function') continue; // **存在才拦**:不存在的方法不造(与 ⑨ 同款纪律)
774
+ proto[fn] = function mpwBusCtxLife() { did(fn + '-observed', fn === 'suspend' ? 'ctxSuspends' : 'ctxResumes'); return orig.apply(this, arguments) };
775
+ patchedCtxLife.push({ proto, fn, orig });
776
+ }
777
+ }
778
+ };
779
+ /** 收集媒体元素:已知根(document + shadow root)里定向查 `audio,video` + 登记表 + 调用方给的根。
780
+ * **不再**做全文档 `querySelectorAll('*')`(那是每拍一次的全文档遍历,见文件头 ⑩④)。 */
781
+ const collectMedia = (out) => {
782
+ for (const root of roots) { for (const el of safeQuery(root, 'audio,video')) out.push(el) }
344
783
  return out;
345
784
  };
346
785
  const allMedia = () => {
347
786
  const out = [];
348
- try { collectMedia(win.document, out) } catch (e) {}
787
+ try { collectMedia(out) } catch (e) {}
349
788
  for (const el of state.media) out.push(el);
350
789
  if (typeof opts.mediaRoots === 'function') { try { for (const el of opts.mediaRoots() || []) out.push(el) } catch (e) {} }
351
790
  return out;
352
791
  };
353
- /** 压一个媒体元素:**被 createMediaElementSource 接管的不要 pause**(analyser 要数据,暂停 = 频谱恒 0)。 */
792
+ /** 压一个媒体元素:**被 createMediaElementSource 接管的不要 pause**(analyser 要数据,暂停 = 频谱恒 0)。
793
+ * ⑩ **幂等**:已经是目标状态就一个字节都不写(旧写法每拍无条件 `muted/volume/pause` ⇒ 与作者脚本
794
+ * 每帧的 `play()/volume=` 互相踩,听感就是"响一下/响一段、卡卡的")。 */
354
795
  const suppress = (el) => {
355
- try { el.muted = true } catch (e) {}
796
+ let changed = false;
797
+ try { if (el.muted !== true) { el.muted = true; try { state.mediaMutedByUs.add(el) } catch (e) {} ; did('suppress', 'mediaWrites'); changed = true } } catch (e) {}
356
798
  try {
357
799
  if (state.tappedMedia.has(el)) { /* 只总线归零,保留元素输出给 analyser */ }
358
- else { el.volume = 0; if (typeof el.pause === 'function' && !el.paused) el.pause() }
800
+ else {
801
+ if (Number(el.volume) !== 0) { el.volume = 0; did('suppress', 'mediaWrites'); changed = true }
802
+ if (typeof el.pause === 'function' && !el.paused) { el.pause(); did('suppress', 'mediaPauses'); changed = true }
803
+ }
359
804
  } catch (e) {}
805
+ return changed;
806
+ };
807
+ /** 解除静音:**只撤销我们自己按下去的**(`mediaMutedByUs`)——作者/NP/别的插件自己 muted 的元素不动。
808
+ * 为什么必须收窄:旧写法无条件 `el.muted = false`,会把"别人自己静的"一起抬起来(用户现场:
809
+ * NP 播放器明明是静音状态却仍然出声)。 */
810
+ const release = (el) => {
811
+ let changed = false;
812
+ try {
813
+ if (state.mediaMutedByUs.has(el)) {
814
+ if (el.muted !== false) { el.muted = false; did('release', 'mediaUnmutes'); changed = true }
815
+ state.mediaMutedByUs.delete(el);
816
+ }
817
+ } catch (e) {}
818
+ return changed;
819
+ };
820
+ /** ⑩ 静音时按需 `speechSynthesis.cancel()`:环境**能**报状态(`speaking` 是布尔)且确实没在说话 ⇒
821
+ * 跳过(周期路径零副作用);报不了状态的实现保留旧行为(无条件 cancel)——不在观测能力不足的地方省这一下。 */
822
+ const speechWorthCancelling = () => {
823
+ try {
824
+ const ss = win.speechSynthesis;
825
+ if (!ss || typeof ss.cancel !== 'function') return false;
826
+ if (typeof ss.speaking !== 'boolean') return true;
827
+ return !!(ss.speaking || ss.pending || ss.paused);
828
+ } catch (e) { return false }
360
829
  };
361
830
 
362
831
  /* ── ④ 总线静音:只动我们自己的 masterGain(不 suspend:冻结 currentTime 会破坏调度/可视化) ── */
363
- const applyMute = () => {
832
+ /** @param {string} where 调用点(周期路径传 `'tick'`:`tick` 拍**不写日志环**,读数只看 repatch 台账)。 */
833
+ const applyMute = (where) => {
364
834
  const mute = state.muted && modeMutes(state.mode);
365
835
  /* 非静音档(off/report/redirect)**不碰 gain**:我们的 master 初值就是 1,没人会去改它;
366
836
  反过来"每次都写 1"会盖掉第三方自己对 master 的调整,也会让 redirect 诊断档失去意义
367
- (它要能回答"bus 到底收没收全",而不是顺手改了音量)。只有**从静音恢复**那一次必须写回 1。 */
368
- if (!mute && !state.wasMuted) return;
837
+ (它要能回答"bus 到底收没收全",而不是顺手改了音量)。只有**从静音恢复**那一次必须写回 1
838
+ (⑨ 起改成"写回**宿主值**":`hostVolume` 缺省 1 ⇒ 缺省行为与改动前逐位一致)。 */
839
+ if (!mute && !state.wasMuted) return 0;
369
840
  state.wasMuted = mute;
370
- for (const ctx of state.adopted) {
371
- const master = state.masters.get(ctx);
372
- if (!master || !master.gain) continue;
373
- try {
374
- const t = ctx.currentTime;
375
- if (master.gain.cancelScheduledValues) master.gain.cancelScheduledValues(t);
376
- if (master.gain.setValueAtTime) master.gain.setValueAtTime(mute ? 0 : 1, t);
377
- else master.gain.value = mute ? 0 : 1;
378
- } catch (e) { try { master.gain.value = mute ? 0 : 1 } catch (e2) {} }
841
+ const covered = closeBus();
842
+ const wrote = lastCloseWrites;
843
+ let mediaN = 0;
844
+ for (const el of allMedia()) { try { if (mute) { if (suppress(el)) mediaN++ } else if (release(el)) mediaN++ } catch (e) {} }
845
+ if (mute && speechWorthCancelling()) { try { win.speechSynthesis.cancel(); did('speech-cancel', 'speechCancels') } catch (e) {} }
846
+ /* ⑩ 周期拍**安静**:没有实际改动就不往日志环里塞记录(读数在 `repatch` 台账里,见文件头 ⑩)。 */
847
+ if (where !== 'tick' || mediaN || wrote || covered) {
848
+ emit('apply-mute', { muted: state.muted, mode: state.mode, where: where || 'apply-mute', contexts: state.contexts.length, media: state.media.size, closed: covered, closeWrites: wrote, mediaWrites: mediaN, hostVolume: state.hostVolume });
379
849
  }
380
- for (const el of allMedia()) { try { if (mute) suppress(el); else { el.muted = false } } catch (e) {} }
381
- if (mute) { try { win.speechSynthesis && win.speechSynthesis.cancel && win.speechSynthesis.cancel() } catch (e) {} }
382
- emit('apply-mute', { muted: state.muted, mode: state.mode, contexts: state.contexts.length, media: state.media.size });
850
+ return mediaN;
383
851
  };
384
852
 
385
853
  /* ── ⑤ 跨域帧:压不动就**如实报不可控**;同源帧各装一遍(跨 realm) ── */
386
- const syncFrames = () => {
854
+ /** 帧注册表:只在 MutationObserver 置脏(或调用方明确要求)时重新枚举 —— 周期路径不再每拍枚举。 */
855
+ const frameList = (force) => {
856
+ if (force) framesDirty = true;
857
+ if (framesDirty) {
858
+ framesDirty = false;
859
+ frameEls.length = 0;
860
+ for (const f of safeQuery(win.document, 'iframe')) frameEls.push(f);
861
+ }
862
+ return frameEls;
863
+ };
864
+ /** 记住"这个帧里装着一条总线"(同一个 iframe 换了 window ⇒ 旧 window 那条先释放)。 */
865
+ const rememberFrameBus = (el, cw) => {
866
+ try {
867
+ for (let i = frameBus.length - 1; i >= 0; i--) {
868
+ const rec = frameBus[i];
869
+ if (!rec || rec.el !== el) continue;
870
+ if (rec.win === cw) return;
871
+ disposeFrameBus(rec, 'frame-rewindow');
872
+ frameBus.splice(i, 1);
873
+ }
874
+ frameBus.push({ el, win: cw });
875
+ } catch (e) {}
876
+ };
877
+ const disposeFrameBus = (rec, why) => {
878
+ try {
879
+ const b = rec && rec.win && rec.win.__mpwAudioBus;
880
+ if (b && typeof b.dispose === 'function') { b.dispose(); did('frame-dispose:' + String(why || ''), 'frameDisposals') }
881
+ } catch (e) {}
882
+ };
883
+ /** 帧卸载/换壁纸:把**已经不在文档里**的帧内总线和它的 750ms 重压定时器一起释放。 */
884
+ const pruneFrameBus = (alive) => {
885
+ const set = new WeakSet();
886
+ for (const f of alive) { try { if (f) set.add(f) } catch (e) {} }
887
+ for (let i = frameBus.length - 1; i >= 0; i--) {
888
+ const rec = frameBus[i];
889
+ let gone = true;
890
+ try { gone = !rec || !rec.el || rec.el.isConnected === false || !set.has(rec.el) } catch (e) { gone = true }
891
+ if (!gone) continue;
892
+ disposeFrameBus(rec, 'frame-gone');
893
+ frameBus.splice(i, 1);
894
+ }
895
+ };
896
+ const syncFrames = (o) => {
897
+ const periodicCall = !!(o && o.periodic);
898
+ /* ⑩ 幂等:帧集合没变(MutationObserver 没置脏位)⇒ 周期路径**不枚举、不重入任何帧**。
899
+ 旧写法每 750ms 重新 `querySelectorAll('iframe')` 并对**每个同源帧** `installAudioBus` ⇒
900
+ 帧内 `setMode` ⇒ 帧内再跑一遍 `syncFrames + applyMute`(放大效应 = 资源审计 #9)。 */
901
+ if (periodicCall && !framesDirty) { did('sync-frames', 'frameNoops'); return state.frames }
387
902
  state.uncontrollable = [];
388
- let frames = [];
389
- try { frames = Array.prototype.slice.call(win.document.querySelectorAll('iframe')) } catch (e) {}
903
+ const frames = Array.prototype.slice.call(frameList(!periodicCall));
904
+ /* ⑩ 换壁纸/帧卸载:**先**释放已经消失的那些帧里的总线(它有自己的 750ms 重压定时器与 observer)。 */
905
+ pruneFrameBus(frames);
390
906
  state.frames = frames.map((f, i) => {
391
907
  let sameOrigin = false, cw = null;
392
908
  try { cw = f.contentWindow; sameOrigin = !!(cw && cw.document) } catch (e) { sameOrigin = false }
393
- if (sameOrigin && cw) { try { installAudioBus(cw, { mode: frameModeFor(state.mode), onEvent: opts.onEvent, mediaRoots: opts.mediaRoots }) } catch (e) {} }
394
- else state.uncontrollable.push({ i, src: shortSrc(f.src || f.getAttribute && f.getAttribute('src')) });
909
+ if (sameOrigin && cw) {
910
+ try {
911
+ /* ⑩ `muted` 必须**一起下发**:旧写法只传 `mode` ⇒ 帧内那份总线 `state.muted` 恒 false
912
+ (帧内 WebAudio 根本压不住 —— 真机上就是"设置里静音了、帧里照样出声")。 */
913
+ installAudioBus(cw, { mode: frameModeFor(state.mode), muted: state.muted, onEvent: opts.onEvent, mediaRoots: opts.mediaRoots });
914
+ rememberFrameBus(f, cw);
915
+ } catch (e) {}
916
+ } else state.uncontrollable.push({ i, src: shortSrc(f.src || f.getAttribute && f.getAttribute('src')) });
395
917
  return { i, sameOrigin, src: shortSrc(f.src || '') };
396
918
  });
397
919
  if (modeMutesAll(state.mode)) {
@@ -404,25 +926,77 @@ export function installAudioBus(win, opts = {}) {
404
926
  __installed: true,
405
927
  get mode() { return state.mode },
406
928
  get muted() { return state.muted },
407
- setMode(m) { state.mode = normalizeMode(m); syncFrames(); applyMute(); emit('mode', { mode: state.mode }); return state.mode },
929
+ setMode(m) {
930
+ const next = normalizeMode(m);
931
+ /* ⑩ 幂等:值没变 ⇒ 不回帧、不重压、不记账(旧写法每次被重入都会跑一遍 `syncFrames + applyMute`,
932
+ 那正是"每 750ms 重挂一遍"的放大源)。 */
933
+ if (next === state.mode) { did('set-mode', 'modeNoops'); return state.mode }
934
+ state.mode = next;
935
+ did('set-mode', 'modeWrites');
936
+ syncFrames();
937
+ applyMute('set-mode');
938
+ emit('mode', { mode: state.mode });
939
+ return state.mode;
940
+ },
408
941
  setMuted(v) {
409
- state.muted = !!v;
942
+ const next = !!v;
943
+ /* ⑩ 幂等:值没变 ⇒ 一个副作用都不做(定时器不重装、帧不重入、gain 不重写)。 */
944
+ if (next === state.muted) { did('set-muted', 'mutedNoops'); return state.muted }
945
+ state.muted = next;
410
946
  syncFrames();
411
- applyMute();
947
+ applyMute('set-muted');
412
948
  if (state.muted && modeMutes(state.mode)) { // "偶发" ⇒ 周期性重压(新节点/自动化都要压住)
413
- if (!timer) timer = win.setInterval(() => { try { applyMute(); syncFrames() } catch (e) {} }, 750);
949
+ /* ⑩ 每一拍**按台账记账**:稳态(同一状态连跑 N 拍)必须 0 副作用(见 `periodic`)。
950
+ 周期路径里没有节点创建/连接/断开、没有 suspend/resume、没有全文档扫描。 */
951
+ if (!timer) timer = win.setInterval(() => { periodic('750ms', () => { applyMute('tick'); syncFrames({ periodic: true }) }) }, 750);
414
952
  } else if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
415
953
  return state.muted;
416
954
  },
417
- installFrame(cw, o) { return installAudioBus(cw, Object.assign({ mode: state.mode, onEvent: opts.onEvent }, o || {})) },
955
+ installFrame(cw, o) { return installAudioBus(cw, Object.assign({ mode: state.mode, muted: state.muted, onEvent: opts.onEvent }, o || {})) },
418
956
  syncFrames,
957
+ /** ⑨ 宿主管的音量(0..1,缺省 1)。**只在裁决档(`1`/`all`)写下去**;`off`/`report`/`redirect`
958
+ * 不碰 gain(诊断档口径不被破坏)。写下去之后,任何打到 bus 参数上的自动化都扳不动它。 */
959
+ setHostVolume(v) {
960
+ const n = Number(v);
961
+ state.hostVolume = isFinite(n) ? Math.min(1, Math.max(0, n)) : 1;
962
+ if (modeMutes(state.mode)) { try { closeBus() } catch (e) {} }
963
+ emit('host-volume', { hostVolume: state.hostVolume, mode: state.mode });
964
+ return state.hostVolume;
965
+ },
966
+ get hostVolume() { return state.hostVolume },
967
+ /** `dispose()` 是否已经跑过(探针据此判"定时器/监听是不是真的清了")。 */
968
+ get disposed() { return disposed },
419
969
  /** 自上报快照(探针/diag 直接读它,**不需要用户操作**)。 */
420
970
  report() {
971
+ const R = state.gainAutomation.reclaims;
421
972
  return {
422
- mode: state.mode, muted: state.muted, redirects: state.redirects,
973
+ mode: state.mode, muted: state.muted, redirects: state.redirects, disposed,
974
+ /* ⑩ 资源/监听侧的可观测计数:`dispose()` 之后必须全部归零。 */
975
+ timers: timer ? 1 : 0, observers: observers.length, roots: roots.length, frameBus: frameBus.length,
976
+ /* ⑩ 周期重压台账(与 `window.__mpwAudioBusRepatch` 同一份数据的快照)。 */
977
+ repatch: repatchSnapshot(),
978
+ framesRepatch: frameBus.slice(0, 8).map((r) => ({ src: shortSrc(r.el && r.el.getAttribute && r.el.getAttribute('src')), repatch: snapshotOf(r.win && r.win.__mpwAudioBusRepatch) })),
423
979
  contexts: state.contexts.map((c) => Object.assign({}, c, { ctx: undefined, adopted: state.masters.has(c.ctx), state: safeState(c.ctx) })),
424
980
  masters: state.contexts.filter((c) => state.masters.has(c.ctx)).length,
425
981
  media: allMedia().length, registered: state.media.size, tapped: countTapped(),
982
+ hostVolume: state.hostVolume,
983
+ /* ⑨ gain 自动化归因面(探针读这几个数判"第三方有没有在动包络、有没有被拒绝")。 */
984
+ gainAutomation: {
985
+ calls: state.gainAutomation.calls, authorGain: state.gainAutomation.authorGain,
986
+ masterWrites: state.gainAutomation.masterWrites, suppressed: state.gainAutomation.suppressed,
987
+ reclosed: state.gainAutomation.reclosed, paramInputs: state.gainAutomation.paramInputs,
988
+ paramInputsRefused: state.gainAutomation.paramInputsRefused,
989
+ byMethod: Object.assign({}, state.gainAutomation.byMethod),
990
+ lastMethod: state.gainAutomation.lastMethod, lastAt: state.gainAutomation.lastAt,
991
+ lastStack: state.gainAutomation.lastStack,
992
+ /* ⑩ "谁把静音抬回来"台账(时间戳 + 来源 + 节流栈;有界 ≤32 条)。 */
993
+ reclaims: {
994
+ n: R.n, lastAt: R.lastAt, lastWhere: R.lastWhere, lastSource: R.lastSource,
995
+ lastExpected: R.lastExpected, lastActual: R.lastActual, lastStack: R.lastStack,
996
+ bySource: Object.assign({}, R.bySource), byWhere: Object.assign({}, R.byWhere),
997
+ entriesKept: R.entries.length, entries: R.entries.slice(-8),
998
+ },
999
+ },
426
1000
  bypassSuspected: state.bypassSuspected.slice(0, 12),
427
1001
  uncontrollableFrames: state.uncontrollable.slice(0, 12),
428
1002
  frames: state.frames.slice(0, 12),
@@ -430,9 +1004,26 @@ export function installAudioBus(win, opts = {}) {
430
1004
  };
431
1005
  },
432
1006
  log: () => log.slice(-100),
1007
+ /** 释放:定时器 + 全部钩子/observer 还原(**旧实现全仓无人调用** ⇒ 750ms 重压定时器与原型钩子
1008
+ * 跟着页面走到底;现在由接线侧在"插件卸载 / 换壁纸卸载帧"时调用,见 `tools/build-audio-bus.mjs`)。 */
433
1009
  dispose() {
1010
+ disposed = true;
434
1011
  if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
435
1012
  for (const p of patched) { try { p.proto.connect = p.origConnect; p.proto.__mpwBusPatched = false } catch (e) {} }
1013
+ for (const rec of patchedParams) {
1014
+ for (const fn of rec.fns) { try { if (realmParamOrig[fn]) rec.proto[fn] = realmParamOrig[fn] } catch (e) {} }
1015
+ try { rec.proto.__mpwBusParam = false } catch (e) {}
1016
+ }
1017
+ for (const rec of patchedDisconnect) { try { rec.proto.disconnect = rec.orig; rec.proto.__mpwBusDisc = false } catch (e) {} }
1018
+ for (const rec of patchedCtxLife) { try { rec.proto[rec.fn] = rec.orig; rec.proto.__mpwBusCtxLife = false } catch (e) {} }
1019
+ for (const rec of patchedShadow) { try { rec.proto.attachShadow = rec.orig; rec.proto.__mpwBusShadow = false } catch (e) {} }
1020
+ for (const mo of observers) { try { mo.disconnect() } catch (e) {} }
1021
+ /* ⑩ 帧内那些总线也是本次安装长出来的:一起释放(否则父实例 dispose 后,帧内的 750ms 重压
1022
+ 定时器 + observer + 原型钩子继续活着 —— 旧实现里 `dispose()` 全仓无人调用,连这一层都没有)。 */
1023
+ for (const rec of frameBus.slice()) { try { disposeFrameBus(rec, 'parent-dispose') } catch (e) {} }
1024
+ observers.length = 0; roots.length = 0; frameEls.length = 0; frameBus.length = 0;
1025
+ try { repatch.observers = 0 } catch (e) {}
1026
+ emit('disposed', { ticks: repatch.ticks, idleTicks: repatch.idleTicks, sideEffectTicks: repatch.n });
436
1027
  try { delete win.__mpwAudioBus } catch (e) { win.__mpwAudioBus = null }
437
1028
  },
438
1029
  };
@@ -443,19 +1034,28 @@ export function installAudioBus(win, opts = {}) {
443
1034
  /* ④ `AudioNode.prototype` 每 realm 各装一遍(同源 iframe 的 prototype 与父页不是同一个);
444
1035
  用 `AudioNode` 而不是 `AudioContext` —— 后者身上没有 connect。 */
445
1036
  if (!patchConnect(protoOf(win.AudioNode))) patchConnect(protoOf(win.AudioContext));
1037
+ patchNodeCounters(protoOf(win.AudioNode)); // ⑩ 只计数:disconnect(周期路径里必须恒为 0)
1038
+ patchCtxLifecycle(); // ⑩ 只计数:suspend/resume(本模块永不调用;别人调了要看得见)
1039
+ patchGainRegistration(); // ⑨ 先登记"哪些参数是 GainNode.gain",自动化钩子才分得清身份
1040
+ patchParamAutomation(); // ⑨ 再装自动化归因/裁决钩子
446
1041
  patchAttribution();
1042
+ patchAttachShadow(); // ⑩ 新 shadow root ⇒ 立刻登记(替代每拍全文档扫 `*`)
1043
+ addRoot(win.document); // ⑩ 定向登记 document 根 + 挂 MutationObserver
1044
+ shadowScanOnce(); // ⑩ 只此一次:把"装之前就存在"的 shadow root 收进来
447
1045
  try { win.__mpwAudioBus = api } catch (e) {}
448
1046
  try {
449
1047
  const doc = win.document;
450
1048
  if (doc && doc.addEventListener) {
451
- const onVis = () => { emit('visibility', { state: doc.visibilityState }); if (state.muted && modeMutes(state.mode)) applyMute() };
1049
+ const onVis = () => { emit('visibility', { state: doc.visibilityState }); if (state.muted && modeMutes(state.mode)) applyMute('visibility') };
452
1050
  doc.addEventListener('visibilitychange', onVis, true);
453
- win.addEventListener && win.addEventListener('pageshow', (e) => { emit('pageshow', { persisted: !!(e && e.persisted) }); if (state.muted && modeMutes(state.mode)) applyMute() }, true);
1051
+ win.addEventListener && win.addEventListener('pageshow', (e) => { emit('pageshow', { persisted: !!(e && e.persisted) }); if (state.muted && modeMutes(state.mode)) applyMute('pageshow') }, true);
454
1052
  win.addEventListener && win.addEventListener('message', (e) => { // 跨域帧配合时的入口
455
1053
  try { if (e && e.data && e.data.type === BUS_MSG) api.setMuted(!!e.data.muted) } catch (err) {}
456
1054
  }, false);
457
1055
  }
458
1056
  } catch (e) {}
459
- emit('installed', { mode: state.mode });
1057
+ /* ⑩ 帧内那份总线:**继承父实例的 muted**(旧写法只继承 mode ⇒ 帧内静音压不住,见文件头 ⑩)。 */
1058
+ if (opts.muted !== undefined) { try { api.setMuted(!!opts.muted) } catch (e) {} }
1059
+ emit('installed', { mode: state.mode, muted: state.muted });
460
1060
  return api;
461
1061
  }