dsh-mpkg-wallpaper 3.12.0 → 3.13.1
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 +461 -0
- package/lib/client.js +873 -98
- package/lib/now-playing-math.js +14 -5
- package/lib/now-playing.js +11 -3
- package/package.json +1 -1
package/lib/audio-bus.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// audio-bus.js —— 「跨出口的总线级静音」内核(宿主侧,纯逻辑,可在桩环境单测)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要它(事故链):用户反复报「设置里静音了,但还是会偶发卡顿地响一声」。根因不是"静音按钮没生效",
|
|
4
|
+
// 而是**声音出口不止一个**:我们自己的音乐控件走 `<audio>`(muted 够用),但壁纸/角色语音/可视化可能走
|
|
5
|
+
// Web Audio——`new AudioContext()` 是**别人**建的、`source.connect(ctx.destination)` 也是**别人**连的,
|
|
6
|
+
// 我们连"谁在响"都看不见。上几轮我们只做到了**记录**(`mpwInstallAudioAudit` 的钩子),
|
|
7
|
+
// 记录不等于接管:只要有一个节点直连 `destination`,它就绕过了我们的静音。
|
|
8
|
+
//
|
|
9
|
+
// 本模块做**接管**,而且是**总线级**的(逐条对应审阅意见里点出的坑):
|
|
10
|
+
// ① `AudioNode.prototype.connect` 原型级拦截:任何节点连 `ctx.destination` ⇒ 改接到该 ctx 的
|
|
11
|
+
// **懒创建 masterGain**。原型级 ⇒ "比我们更早创建的 AudioContext"也能被收进来(只要它**还没**连);
|
|
12
|
+
// ② masterGain 的创建与它到 destination 的连接**走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归);
|
|
13
|
+
// ③ `connect` 的**返回值原样透传**(`a.connect(b).connect(c)` 链式依赖它);
|
|
14
|
+
// ④ 判定用 `dest === ctx.destination`(**引用比较**;`instanceof` 跨 realm 会失败);
|
|
15
|
+
// ⑤ `OfflineAudioContext` **放行**(它也有 destination;误挂 master 会改掉离线渲染结果);
|
|
16
|
+
// ⑥ 静音 = `master.gain.cancelScheduledValues(now) + setValueAtTime(0, now)`(**不用 `ctx.suspend()`**:
|
|
17
|
+
// 冻结 `currentTime` 会破坏第三方的调度与可视化);analyser 若接在 master **之前**,频谱仍有数据
|
|
18
|
+
// ⇒ 这同时修掉我们"元素被 muted ⇒ 音条恒 0"的老限制;
|
|
19
|
+
// ⑦ 归因补齐:`new Audio()`、`createMediaElementSource`(元素 muted 只影响输入源,输出在 Web Audio)、
|
|
20
|
+
// `createMediaStreamSource`(不吃 `track.enabled`)、`src`/`srcObject` setter(autoplay 绕过 `play()`)、
|
|
21
|
+
// `start(when)` 的迟到分布(**负很多** = bfcache/节流恢复;**负一点点** = 正常抖动,不误杀)、
|
|
22
|
+
// Shadow DOM 里的媒体元素、`speechSynthesis`、`RTCPeerConnection` 的 `track.enabled`;
|
|
23
|
+
// ⑧ **跨 realm 各装一遍**(同源 iframe 的 `AudioNode.prototype` 与父页不是同一个)。
|
|
24
|
+
//
|
|
25
|
+
// 档位(`?mpwhardmute=` 或 `bus.setMode()`)—— 二分定位用,不是"总开关":
|
|
26
|
+
// off 只归因,不动声音(缺省)
|
|
27
|
+
// redirect **只重定向不静音**:还能听见 ⇒ 有出口绕过 connect(历史遗留直连 / 跨 realm);
|
|
28
|
+
// 声音变闷变小 ⇒ 有节点被**重复挂** master(多次 connect destination)
|
|
29
|
+
// 1 重定向 + master 归零 + 媒体压制 + speechSynthesis.cancel + WebRTC track.enabled=false
|
|
30
|
+
// all 1 + 对跨域帧 postMessage 请求静音 + 连未归因的 ctx 也压
|
|
31
|
+
// report 只汇报不压(产出"不可控清单")
|
|
32
|
+
//
|
|
33
|
+
// 诚实边界:hook 之前**已经**连上 destination 的节点无法追溯(没有任何 API 能枚举已有连接)⇒
|
|
34
|
+
// report() 会把"能观测到出声但不在我们 bus 上"的 ctx 单列为 `bypassSuspected`,不假装收全了。
|
|
35
|
+
|
|
36
|
+
export const BUS_MODES = ['off', 'redirect', '1', 'all', 'report'];
|
|
37
|
+
export const BUS_DEFAULT_MODE = 'off';
|
|
38
|
+
export const BUS_ATTR = 'data-mpw-audio-bus';
|
|
39
|
+
export const BUS_MSG = 'mpw:audio-bus';
|
|
40
|
+
/** 迟到补播的判定阈值:负得比这个多 ⇒ 当"恢复后补播"(bfcache/节流),否则算正常抖动,不误杀。 */
|
|
41
|
+
export const LATE_START_SEC = 1.0;
|
|
42
|
+
export const JITTER_START_SEC = 0.05;
|
|
43
|
+
|
|
44
|
+
const now = (win) => { try { return win.performance && win.performance.now ? win.performance.now() : Date.now() } catch (e) { return Date.now() } };
|
|
45
|
+
|
|
46
|
+
/** 规范化档位(未知值 ⇒ 缺省;不抛)。 */
|
|
47
|
+
export function normalizeMode(m) {
|
|
48
|
+
const s = String(m === undefined || m === null ? '' : m).trim().toLowerCase();
|
|
49
|
+
return BUS_MODES.indexOf(s) >= 0 ? s : BUS_DEFAULT_MODE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 该档是否真的**压**声音(report/off/redirect 都不压)。 */
|
|
53
|
+
export function modeMutes(m) {
|
|
54
|
+
const s = normalizeMode(m);
|
|
55
|
+
return s === '1' || s === 'all';
|
|
56
|
+
}
|
|
57
|
+
/** 该档是否要连"未归因的 ctx"一起压。 */
|
|
58
|
+
export function modeMutesAll(m) { return normalizeMode(m) === 'all'; }
|
|
59
|
+
/** 该档是否只重定向(诊断档:验证 bus 收没收全)。 */
|
|
60
|
+
export function modeRedirectOnly(m) { return normalizeMode(m) === 'redirect'; }
|
|
61
|
+
/** ①(2026-09-22 真机探针抓到的 bug) **帧内模式**:`redirect` 只对**顶层**有意义(顶层只归因、不压宿主提示音),
|
|
62
|
+
* 壁纸帧要的是"压住" ⇒ 帧内把它提升为 `1`;其余档原样下传。真机证据:修前同源帧里 `__mpwAudioBus.mode`
|
|
63
|
+
* 是 `redirect`(继承顶层)⇒ 帧内**根本没静音**。 */
|
|
64
|
+
export function frameModeFor(m) { const s = normalizeMode(m); return s === 'redirect' ? '1' : s; }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 在一个 window 上装总线。**幂等**(同一 win 多次调用只装一遍)。
|
|
68
|
+
* @param {object} win 目标 window(可传 iframe 的 contentWindow ⇒ 跨 realm 各装一遍)
|
|
69
|
+
* @param {object} opts { mode, onEvent(name, detail), mediaRoots: ()=>Element[] }
|
|
70
|
+
* @returns {object|null} api
|
|
71
|
+
*/
|
|
72
|
+
export function installAudioBus(win, opts = {}) {
|
|
73
|
+
if (!win || typeof win !== 'object') return null;
|
|
74
|
+
if (win.__mpwAudioBus && win.__mpwAudioBus.__installed) { // 幂等:同一 realm 只装一遍
|
|
75
|
+
if (opts.mode !== undefined) win.__mpwAudioBus.setMode(opts.mode);
|
|
76
|
+
return win.__mpwAudioBus;
|
|
77
|
+
}
|
|
78
|
+
const log = [];
|
|
79
|
+
const emit = (name, detail) => {
|
|
80
|
+
const rec = { t: now(win), name, detail: detail || null };
|
|
81
|
+
log.push(rec);
|
|
82
|
+
if (log.length > 300) log.shift();
|
|
83
|
+
try { if (typeof opts.onEvent === 'function') opts.onEvent(name, rec.detail) } catch (e) { /* 上报失败不许影响音频 */ }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const state = {
|
|
87
|
+
mode: normalizeMode(opts.mode),
|
|
88
|
+
muted: false,
|
|
89
|
+
wasMuted: false, // 上一次是否处于"真的压着"的状态(决定恢复时要不要写回 1)
|
|
90
|
+
contexts: [], // { id, ctx, adopted, offline, createAt, createStack, frames }
|
|
91
|
+
masters: new WeakMap(), // ctx -> masterGain
|
|
92
|
+
adopted: new Set(), // 被我们接管过的 ctx(**含**安装前就存在的老 ctx)——静音要遍历这一份
|
|
93
|
+
media: new Set(), // 媒体元素(含 new Audio() 与 shadow DOM 里的)
|
|
94
|
+
tappedMedia: new WeakSet(), // 被 createMediaElementSource 接管的元素(不能 pause:analyser 要数据)
|
|
95
|
+
redirects: 0,
|
|
96
|
+
bypassSuspected: [], // 能观测到出声但不在 bus 上的 ctx
|
|
97
|
+
uncontrollable: [], // 跨域帧(拿不到 contentDocument)
|
|
98
|
+
frames: [],
|
|
99
|
+
};
|
|
100
|
+
let ctxSeq = 0;
|
|
101
|
+
let timer = 0;
|
|
102
|
+
/* 本 realm 的**原始 connect**。必须是 `AudioNode.prototype.connect` —— 真实浏览器里
|
|
103
|
+
`AudioContext.prototype` 上**没有** connect(我第一版写错过,那样一条都拦不到)。 */
|
|
104
|
+
let realmOrigConnect = null;
|
|
105
|
+
let realmNodeProto = null;
|
|
106
|
+
|
|
107
|
+
const protoOf = (Ctor) => { try { return Ctor && Ctor.prototype ? Ctor.prototype : null } catch (e) { return null } };
|
|
108
|
+
const stackOf = () => { try { return String(new win.Error().stack || '').split('\n').slice(2, 5).join(' | ').slice(0, 400) } catch (e) { return '' } };
|
|
109
|
+
|
|
110
|
+
/* ── ① 原型级 connect 拦截(含"懒创建 masterGain") ───────────────────────────── */
|
|
111
|
+
const patched = []; // [{ proto, origConnect }],卸载时还原
|
|
112
|
+
const patchConnect = (proto) => {
|
|
113
|
+
if (!proto || typeof proto.connect !== 'function' || proto.__mpwBusPatched) return false;
|
|
114
|
+
const origConnect = proto.connect;
|
|
115
|
+
proto.connect = function mpwBusConnect(dest) {
|
|
116
|
+
const rest = Array.prototype.slice.call(arguments, 1);
|
|
117
|
+
let ctx = null;
|
|
118
|
+
try { ctx = this && this.context } catch (e) { /* 某些桩没有 context */ }
|
|
119
|
+
// 只拦"直连 destination";用**引用比较**(instanceof 跨 realm 会失败)
|
|
120
|
+
if (ctx && dest && dest === ctx.destination && !isOfflineCtx(ctx)) {
|
|
121
|
+
let master = state.masters.get(ctx);
|
|
122
|
+
if (!master) {
|
|
123
|
+
master = createMaster(ctx); // 内部全走原始引用
|
|
124
|
+
state.masters.set(ctx, master);
|
|
125
|
+
if (master) {
|
|
126
|
+
state.adopted.add(ctx);
|
|
127
|
+
/* 比我们更早创建、我们没登记过的 ctx(历史遗留):**采用时补登记**,
|
|
128
|
+
否则 applyMute 遍历的名单里没有它 ⇒ 它照样出声(这正是"假绿"的来源)。 */
|
|
129
|
+
if (ctxId(ctx) < 0) state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: true, legacy: true, createAt: now(win), createStack: stackOf(), frames: 0 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (this !== master) state.redirects++;
|
|
133
|
+
emit('reroute', { ctxId: ctxId(ctx), kind: nodeKind(this), stack: stackOf() });
|
|
134
|
+
origConnect.call(this, master, ...rest);
|
|
135
|
+
/* ③ 真 API 语义:`connect(dest)` 返回**调用方传进来的那个目标节点**(链式 `a.connect(b).connect(c)` 依赖它)。
|
|
136
|
+
若把 master 返回出去,第三方的 `a.connect(ctx.destination).connect(y)` 会变成 `master→y`(语义被改)。 */
|
|
137
|
+
return dest;
|
|
138
|
+
}
|
|
139
|
+
return origConnect.call(this, dest, ...rest);
|
|
140
|
+
};
|
|
141
|
+
proto.__mpwBusPatched = true;
|
|
142
|
+
patched.push({ proto, origConnect });
|
|
143
|
+
if (!realmOrigConnect) { realmOrigConnect = origConnect; realmNodeProto = proto }
|
|
144
|
+
return true;
|
|
145
|
+
};
|
|
146
|
+
/** ② masterGain 的创建与连接**必须走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归)。 */
|
|
147
|
+
const createMaster = (ctx) => {
|
|
148
|
+
const origCreate = ctx.__origCreateGain || (ctx.createGain && ctx.createGain.bind(ctx));
|
|
149
|
+
const master = origCreate ? origCreate() : null;
|
|
150
|
+
if (!master) return null;
|
|
151
|
+
try {
|
|
152
|
+
if (master.gain) { master.gain.value = state.muted ? 0 : 1 }
|
|
153
|
+
const origConnect = realmOrigConnect;
|
|
154
|
+
if (origConnect) origConnect.call(master, ctx.destination); // 原始引用,绕开 hook
|
|
155
|
+
else if (master.connect) master.connect(ctx.destination);
|
|
156
|
+
} catch (e) { emit('master-connect-fail', { err: String(e && e.message || e) }) }
|
|
157
|
+
return master;
|
|
158
|
+
};
|
|
159
|
+
const isOfflineCtx = (ctx) => {
|
|
160
|
+
try {
|
|
161
|
+
const OAC = win.OfflineAudioContext || win.webkitOfflineAudioContext;
|
|
162
|
+
return !!(OAC && ctx instanceof OAC); // ⑤ 离线渲染放行
|
|
163
|
+
} catch (e) { return false }
|
|
164
|
+
};
|
|
165
|
+
const ctxId = (ctx) => { const f = state.contexts.find((c) => c.ctx === ctx); return f ? f.id : -1 };
|
|
166
|
+
const nodeKind = (n) => { try { return String((n && n.constructor && n.constructor.name) || 'AudioNode') } catch (e) { return 'AudioNode' } };
|
|
167
|
+
|
|
168
|
+
/* ── ② AudioContext 构造拦截:登记 + 归因(接管本身靠 connect,构造只登记) ───── */
|
|
169
|
+
const patchCtor = (winObj, key) => {
|
|
170
|
+
try {
|
|
171
|
+
const Orig = winObj[key];
|
|
172
|
+
if (typeof Orig !== 'function' || Orig.__mpwBusWrapped) return;
|
|
173
|
+
const Wrapped = function (...args) {
|
|
174
|
+
const ctx = new Orig(...args);
|
|
175
|
+
try {
|
|
176
|
+
state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: false, offline: false, createAt: now(win), createStack: stackOf(), frames: 0 });
|
|
177
|
+
emit('ctx-created', { ctor: key, state: ctx && ctx.state });
|
|
178
|
+
} catch (e) {}
|
|
179
|
+
return ctx;
|
|
180
|
+
};
|
|
181
|
+
try { Wrapped.prototype = Orig.prototype } catch (e) {}
|
|
182
|
+
Wrapped.__mpwBusWrapped = true;
|
|
183
|
+
winObj[key] = Wrapped; // 构造只负责**登记/归因**;接管靠下面的 AudioNode.prototype
|
|
184
|
+
} catch (e) {}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/* ── ③ 归因钩子:MediaElement / MediaStream / src / srcObject / start(when) / speech / WebRTC ── */
|
|
188
|
+
const patchAttribution = () => {
|
|
189
|
+
const ACproto = protoOf(win.AudioContext || win.webkitAudioContext);
|
|
190
|
+
if (ACproto && !ACproto.__mpwBusAttr) {
|
|
191
|
+
ACproto.__mpwBusAttr = true;
|
|
192
|
+
for (const [fn, kind] of [['createMediaElementSource', 'media-element'], ['createMediaStreamSource', 'media-stream'], ['createMediaStreamDestination', 'media-stream-dest']]) {
|
|
193
|
+
try {
|
|
194
|
+
const orig = ACproto[fn];
|
|
195
|
+
if (typeof orig !== 'function') continue;
|
|
196
|
+
ACproto[fn] = function (...a) {
|
|
197
|
+
const node = orig.apply(this, a);
|
|
198
|
+
try {
|
|
199
|
+
if (kind === 'media-element' && a[0]) { state.tappedMedia.add(a[0]); registerMedia(a[0]); }
|
|
200
|
+
emit('source-node', { kind, ctxId: -1, stack: stackOf() });
|
|
201
|
+
} catch (e) {}
|
|
202
|
+
return node;
|
|
203
|
+
};
|
|
204
|
+
} catch (e) {}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// start(when):迟到分布(负很多 = bfcache/节流恢复;负一点点 = 抖动)
|
|
208
|
+
for (const [key, kind] of [['AudioBufferSourceNode', 'buffer'], ['OscillatorNode', 'oscillator'], ['ConstantSourceNode', 'constant'], ['AudioWorkletNode', 'worklet']]) {
|
|
209
|
+
try {
|
|
210
|
+
const Ctor = win[key]; const np = protoOf(Ctor);
|
|
211
|
+
if (!np || typeof np.start !== 'function' || np.__mpwBusStart) continue;
|
|
212
|
+
const origStart = np.start;
|
|
213
|
+
np.start = function (when) {
|
|
214
|
+
const rest = Array.prototype.slice.call(arguments, 1);
|
|
215
|
+
try {
|
|
216
|
+
const ctx = this.context;
|
|
217
|
+
const cur = ctx && ctx.currentTime;
|
|
218
|
+
const delta = (typeof when === 'number' && typeof cur === 'number') ? (when - cur) : null;
|
|
219
|
+
/* 只**记录**、绝不阻止/丢弃 start("误杀会掉音符"):负得多 ⇒ burst(bfcache/节流恢复后的补播);
|
|
220
|
+
负一点点 ⇒ jitter(正常调度抖动,进分布供人判读)。未来时刻不记。 */
|
|
221
|
+
if (delta !== null && delta < 0) {
|
|
222
|
+
emit(delta < -LATE_START_SEC ? 'start-late-burst' : 'start-late-jitter', { kind, delta: Number(delta.toFixed(3)), mode: state.mode, muted: state.muted, stack: stackOf() });
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
return origStart.apply(this, arguments);
|
|
226
|
+
};
|
|
227
|
+
np.__mpwBusStart = true;
|
|
228
|
+
} catch (e) {}
|
|
229
|
+
}
|
|
230
|
+
// new Audio() / src / srcObject(autoplay + src 赋值绕过 play())
|
|
231
|
+
try {
|
|
232
|
+
const OrigAudio = win.Audio;
|
|
233
|
+
if (typeof OrigAudio === 'function' && !OrigAudio.__mpwBusAudio) {
|
|
234
|
+
const Wrapped = function (...a) { const el = new OrigAudio(...a); try { registerMedia(el); emit('new-audio', { src: shortSrc(el && el.src) }) } catch (e) {} return el };
|
|
235
|
+
try { Wrapped.prototype = OrigAudio.prototype } catch (e) {}
|
|
236
|
+
Wrapped.__mpwBusAudio = true;
|
|
237
|
+
win.Audio = Wrapped;
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {}
|
|
240
|
+
const HME = win.HTMLMediaElement && win.HTMLMediaElement.prototype;
|
|
241
|
+
if (HME && !HME.__mpwBusMedia) {
|
|
242
|
+
HME.__mpwBusMedia = true;
|
|
243
|
+
for (const prop of ['src', 'srcObject']) {
|
|
244
|
+
try {
|
|
245
|
+
const d = Object.getOwnPropertyDescriptor(HME, prop);
|
|
246
|
+
if (!d || !d.set) continue;
|
|
247
|
+
const origSet = d.set, origGet = d.get;
|
|
248
|
+
Object.defineProperty(HME, prop, {
|
|
249
|
+
configurable: true, enumerable: d.enumerable,
|
|
250
|
+
get: origGet,
|
|
251
|
+
set(v) {
|
|
252
|
+
try { if (state.muted && modeMutes(state.mode)) suppress(this) } catch (e) {}
|
|
253
|
+
emit(prop === 'src' ? 'src-set' : 'srcobject-set', { src: prop === 'src' ? shortSrc(v) : '[stream]', muted: !!(this && this.muted) });
|
|
254
|
+
return origSet.call(this, v);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch (e) {}
|
|
258
|
+
}
|
|
259
|
+
// play():静音期间**压制**且返回 resolved Promise(不排队——排队会在取消静音瞬间多声齐发)
|
|
260
|
+
try {
|
|
261
|
+
const origPlay = HME.play;
|
|
262
|
+
if (typeof origPlay === 'function' && !HME.__mpwBusPlay) {
|
|
263
|
+
HME.__mpwBusPlay = true;
|
|
264
|
+
HME.play = function () {
|
|
265
|
+
try {
|
|
266
|
+
if (state.muted && modeMutes(state.mode)) {
|
|
267
|
+
suppress(this);
|
|
268
|
+
emit('play-suppressed', { src: shortSrc(this && this.src), stack: stackOf() });
|
|
269
|
+
return win.Promise ? win.Promise.resolve() : undefined; // 必须 resolved,否则调用方 await 卡住
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {}
|
|
272
|
+
return origPlay.apply(this, arguments);
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {}
|
|
276
|
+
// volumechange:静音下有人抬 volume/muted ⇒ 立刻归零 + 记栈
|
|
277
|
+
try {
|
|
278
|
+
if (!HME.__mpwBusVol) {
|
|
279
|
+
HME.__mpwBusVol = true;
|
|
280
|
+
const origAdd = HME.addEventListener;
|
|
281
|
+
if (typeof origAdd === 'function') {
|
|
282
|
+
const self = win;
|
|
283
|
+
origAdd.call(self.document || self, 'volumechange', (e) => {
|
|
284
|
+
const el = e && e.target;
|
|
285
|
+
if (!el || !state.muted || !modeMutes(state.mode)) return;
|
|
286
|
+
if (!el.muted || Number(el.volume) > 0) {
|
|
287
|
+
emit('volume-raised-while-muted', { muted: !!el.muted, volume: Number(el.volume), stack: stackOf() });
|
|
288
|
+
suppress(el);
|
|
289
|
+
}
|
|
290
|
+
}, true);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch (e) {}
|
|
294
|
+
}
|
|
295
|
+
// speechSynthesis(角色语音可能走它,完全不受 <audio>/Web Audio 控制)
|
|
296
|
+
try {
|
|
297
|
+
const ss = win.speechSynthesis;
|
|
298
|
+
if (ss && typeof ss.speak === 'function' && !ss.__mpwBusSpeech) {
|
|
299
|
+
const origSpeak = ss.speak.bind(ss);
|
|
300
|
+
ss.__mpwBusSpeech = true;
|
|
301
|
+
ss.speak = function (u) {
|
|
302
|
+
if (state.muted && modeMutes(state.mode)) { try { ss.cancel() } catch (e) {} ; emit('speech-suppressed', { text: String(u && u.text || '').slice(0, 60) }); return }
|
|
303
|
+
emit('speech', { text: String(u && u.text || '').slice(0, 60) });
|
|
304
|
+
return origSpeak(u);
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
} catch (e) {}
|
|
308
|
+
// WebRTC:outbound 的 track.enabled 是唯一能压它的开关
|
|
309
|
+
try {
|
|
310
|
+
const RTC = win.RTCPeerConnection;
|
|
311
|
+
if (typeof RTC === 'function' && !RTC.__mpwBusRtc) {
|
|
312
|
+
const Wrapped = function (...a) {
|
|
313
|
+
const pc = new RTC(...a);
|
|
314
|
+
try {
|
|
315
|
+
pc.addEventListener('track', (ev) => {
|
|
316
|
+
const tracks = (ev && ev.streams && ev.streams[0] && ev.streams[0].getTracks) ? ev.streams[0].getTracks() : [];
|
|
317
|
+
for (const t of tracks) { try { if (t.kind === 'audio' && state.muted && modeMutes(state.mode)) t.enabled = false } catch (e) {} }
|
|
318
|
+
emit('rtc-track', { n: tracks.length });
|
|
319
|
+
});
|
|
320
|
+
} catch (e) {}
|
|
321
|
+
return pc;
|
|
322
|
+
};
|
|
323
|
+
try { Wrapped.prototype = RTC.prototype } catch (e) {}
|
|
324
|
+
Wrapped.__mpwBusRtc = true;
|
|
325
|
+
win.RTCPeerConnection = Wrapped;
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const shortSrc = (s) => String(s || '').replace(/token=[^&]*/, 'token=…').slice(0, 120);
|
|
331
|
+
|
|
332
|
+
const registerMedia = (el) => {
|
|
333
|
+
if (!el || typeof el !== 'object') return false;
|
|
334
|
+
if (state.media.has(el)) return false;
|
|
335
|
+
state.media.add(el);
|
|
336
|
+
return true;
|
|
337
|
+
};
|
|
338
|
+
/** 收集媒体元素:document + **Shadow DOM**(querySelectorAll 抓不到 shadow root 里的)+ 同源 iframe。 */
|
|
339
|
+
const collectMedia = (doc, out) => {
|
|
340
|
+
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) }
|
|
343
|
+
} catch (e) {}
|
|
344
|
+
return out;
|
|
345
|
+
};
|
|
346
|
+
const allMedia = () => {
|
|
347
|
+
const out = [];
|
|
348
|
+
try { collectMedia(win.document, out) } catch (e) {}
|
|
349
|
+
for (const el of state.media) out.push(el);
|
|
350
|
+
if (typeof opts.mediaRoots === 'function') { try { for (const el of opts.mediaRoots() || []) out.push(el) } catch (e) {} }
|
|
351
|
+
return out;
|
|
352
|
+
};
|
|
353
|
+
/** 压一个媒体元素:**被 createMediaElementSource 接管的不要 pause**(analyser 要数据,暂停 = 频谱恒 0)。 */
|
|
354
|
+
const suppress = (el) => {
|
|
355
|
+
try { el.muted = true } catch (e) {}
|
|
356
|
+
try {
|
|
357
|
+
if (state.tappedMedia.has(el)) { /* 只总线归零,保留元素输出给 analyser */ }
|
|
358
|
+
else { el.volume = 0; if (typeof el.pause === 'function' && !el.paused) el.pause() }
|
|
359
|
+
} catch (e) {}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/* ── ④ 总线静音:只动我们自己的 masterGain(不 suspend:冻结 currentTime 会破坏调度/可视化) ── */
|
|
363
|
+
const applyMute = () => {
|
|
364
|
+
const mute = state.muted && modeMutes(state.mode);
|
|
365
|
+
/* 非静音档(off/report/redirect)**不碰 gain**:我们的 master 初值就是 1,没人会去改它;
|
|
366
|
+
反过来"每次都写 1"会盖掉第三方自己对 master 的调整,也会让 redirect 诊断档失去意义
|
|
367
|
+
(它要能回答"bus 到底收没收全",而不是顺手改了音量)。只有**从静音恢复**那一次必须写回 1。 */
|
|
368
|
+
if (!mute && !state.wasMuted) return;
|
|
369
|
+
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) {} }
|
|
379
|
+
}
|
|
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 });
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/* ── ⑤ 跨域帧:压不动就**如实报不可控**;同源帧各装一遍(跨 realm) ── */
|
|
386
|
+
const syncFrames = () => {
|
|
387
|
+
state.uncontrollable = [];
|
|
388
|
+
let frames = [];
|
|
389
|
+
try { frames = Array.prototype.slice.call(win.document.querySelectorAll('iframe')) } catch (e) {}
|
|
390
|
+
state.frames = frames.map((f, i) => {
|
|
391
|
+
let sameOrigin = false, cw = null;
|
|
392
|
+
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')) });
|
|
395
|
+
return { i, sameOrigin, src: shortSrc(f.src || '') };
|
|
396
|
+
});
|
|
397
|
+
if (modeMutesAll(state.mode)) {
|
|
398
|
+
for (const f of frames) { try { f.contentWindow && f.contentWindow.postMessage({ type: BUS_MSG, muted: state.muted, mode: state.mode }, '*') } catch (e) {} }
|
|
399
|
+
}
|
|
400
|
+
return state.frames;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const api = {
|
|
404
|
+
__installed: true,
|
|
405
|
+
get mode() { return state.mode },
|
|
406
|
+
get muted() { return state.muted },
|
|
407
|
+
setMode(m) { state.mode = normalizeMode(m); syncFrames(); applyMute(); emit('mode', { mode: state.mode }); return state.mode },
|
|
408
|
+
setMuted(v) {
|
|
409
|
+
state.muted = !!v;
|
|
410
|
+
syncFrames();
|
|
411
|
+
applyMute();
|
|
412
|
+
if (state.muted && modeMutes(state.mode)) { // "偶发" ⇒ 周期性重压(新节点/自动化都要压住)
|
|
413
|
+
if (!timer) timer = win.setInterval(() => { try { applyMute(); syncFrames() } catch (e) {} }, 750);
|
|
414
|
+
} else if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
|
|
415
|
+
return state.muted;
|
|
416
|
+
},
|
|
417
|
+
installFrame(cw, o) { return installAudioBus(cw, Object.assign({ mode: state.mode, onEvent: opts.onEvent }, o || {})) },
|
|
418
|
+
syncFrames,
|
|
419
|
+
/** 自上报快照(探针/diag 直接读它,**不需要用户操作**)。 */
|
|
420
|
+
report() {
|
|
421
|
+
return {
|
|
422
|
+
mode: state.mode, muted: state.muted, redirects: state.redirects,
|
|
423
|
+
contexts: state.contexts.map((c) => Object.assign({}, c, { ctx: undefined, adopted: state.masters.has(c.ctx), state: safeState(c.ctx) })),
|
|
424
|
+
masters: state.contexts.filter((c) => state.masters.has(c.ctx)).length,
|
|
425
|
+
media: allMedia().length, registered: state.media.size, tapped: countTapped(),
|
|
426
|
+
bypassSuspected: state.bypassSuspected.slice(0, 12),
|
|
427
|
+
uncontrollableFrames: state.uncontrollable.slice(0, 12),
|
|
428
|
+
frames: state.frames.slice(0, 12),
|
|
429
|
+
log: log.slice(-40),
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
log: () => log.slice(-100),
|
|
433
|
+
dispose() {
|
|
434
|
+
if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
|
|
435
|
+
for (const p of patched) { try { p.proto.connect = p.origConnect; p.proto.__mpwBusPatched = false } catch (e) {} }
|
|
436
|
+
try { delete win.__mpwAudioBus } catch (e) { win.__mpwAudioBus = null }
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
const safeState = (ctx) => { try { return ctx.state } catch (e) { return null } };
|
|
440
|
+
const countTapped = () => { let n = 0; for (const el of state.media) if (state.tappedMedia.has(el)) n++; return n };
|
|
441
|
+
|
|
442
|
+
patchCtor(win, 'AudioContext'); patchCtor(win, 'webkitAudioContext');
|
|
443
|
+
/* ④ `AudioNode.prototype` 每 realm 各装一遍(同源 iframe 的 prototype 与父页不是同一个);
|
|
444
|
+
用 `AudioNode` 而不是 `AudioContext` —— 后者身上没有 connect。 */
|
|
445
|
+
if (!patchConnect(protoOf(win.AudioNode))) patchConnect(protoOf(win.AudioContext));
|
|
446
|
+
patchAttribution();
|
|
447
|
+
try { win.__mpwAudioBus = api } catch (e) {}
|
|
448
|
+
try {
|
|
449
|
+
const doc = win.document;
|
|
450
|
+
if (doc && doc.addEventListener) {
|
|
451
|
+
const onVis = () => { emit('visibility', { state: doc.visibilityState }); if (state.muted && modeMutes(state.mode)) applyMute() };
|
|
452
|
+
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);
|
|
454
|
+
win.addEventListener && win.addEventListener('message', (e) => { // 跨域帧配合时的入口
|
|
455
|
+
try { if (e && e.data && e.data.type === BUS_MSG) api.setMuted(!!e.data.muted) } catch (err) {}
|
|
456
|
+
}, false);
|
|
457
|
+
}
|
|
458
|
+
} catch (e) {}
|
|
459
|
+
emit('installed', { mode: state.mode });
|
|
460
|
+
return api;
|
|
461
|
+
}
|