feat: 优化音频处理逻辑,支持降级到 ScriptProcessorNode,增强兼容性

This commit is contained in:
Pine
2026-08-18 08:56:17 +08:00
parent 71aa2bc3b6
commit 7e6b14c44a
+165 -73
View File
@@ -469,8 +469,8 @@ export class S2sWsRealtimeClient extends EventTarget {
// Prefer a context the caller already created + resumed inside the tap // Prefer a context the caller already created + resumed inside the tap
// gesture (required on iOS). Fall back to creating one here for callers // gesture (required on iOS). Fall back to creating one here for callers
// that don't (desktop is lenient about the gesture timing). // that don't (desktop is lenient about the gesture timing).
// Most desktops give us 48 kHz, mobiles can give 44.1/24/16 kHz; the // Most desktops give us 48 kHz, mobiles can give 44.1/24/16 kHz; both the
// capture worklet handles any rate (linear interp fallback). // worklet and the ScriptProcessor fallback handle any rate.
const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" }); const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" });
this._ctx = ctx; this._ctx = ctx;
@@ -484,62 +484,85 @@ export class S2sWsRealtimeClient extends EventTarget {
} }
} }
// AudioWorklet 加载(WebView2/打包兼容)——多策略 + 详细日志: // ---- 音频管线:优先 AudioWorklet;环境不支持则降级 ScriptProcessorNode ----
// 源码已由 Vite `?raw` 内联进 bundle。不同 WebView2 版本对 // 展播机 WebView2 上 addModule 整体 AbortError(机制坏,非代码问题)。此时
// blob: / data: / http: 三种 URL 的 addModule 支持不一致,逐一尝试 // 绝不能 throw —— 改用 ScriptProcessorNode(老 API,所有 WebView 都支持
// 并打印每次失败的真实 name/message,便于定位真正原因 // 无需加载任何模块),保证麦克风采集与播放一定能跑通
// 策略1 内联源码 → Blob URL(无网络,多数环境最稳) this._useScriptProcessor = false;
// 策略2 内联源码 → data: URL(部分 WebView2 拒绝 blob: 但接受 data: this._playbackQueue = [];
// 策略3 后端真实 HTTPIP 可达 + CORS *,直连 addModule let audioWorkletOk = false;
const attempts = []; try {
const tryModule = async (label, url) => { audioWorkletOk =
(await this._initAudioWorklet(ctx, "mic-capture", micWorkletSrc)) &&
(await this._initAudioWorklet(ctx, "audio-playback", playbackWorkletSrc));
} catch (err) {
console.warn("[ws] AudioWorklet 初始化异常,将降级 ScriptProcessor:", err);
audioWorkletOk = false;
}
if (!audioWorkletOk) {
console.warn("[ws] AudioWorklet 不可用 → 降级 ScriptProcessorNode 管线(兼容模式)");
this._useScriptProcessor = true;
}
// 麦克风源 + 输入分析器(两条路径共用;直接并行 tap 原始信号,不经 worklet
const micSrc = ctx.createMediaStreamSource(this.options.micStream);
this._micSrc = micSrc;
const micAnalyser = ctx.createAnalyser();
micAnalyser.fftSize = VIS_FFT_SIZE;
micAnalyser.smoothingTimeConstant = 0;
micSrc.connect(micAnalyser);
this._micAnalyser = micAnalyser;
// 输出分析器(共用;置于播放节点与扬声器之间)
const outAnalyser = ctx.createAnalyser();
outAnalyser.fftSize = VIS_FFT_SIZE;
outAnalyser.smoothingTimeConstant = 0.3;
this._outAnalyser = outAnalyser;
if (this._useScriptProcessor) {
this._setupScriptProcessor(ctx, micSrc, outAnalyser);
} else {
this._setupWorkletNodes(ctx, micSrc, outAnalyser);
}
this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
this._visualiser.start();
}
/** 尝试用内联源码把 worklet 加载进 ctx.audioWorklet(多策略:blob/data/http)。 */
async _initAudioWorklet(ctx, name, src) {
if (!ctx.audioWorklet || typeof ctx.audioWorklet.addModule !== "function") return false;
const tryModule = async (url) => {
try { try {
await ctx.audioWorklet.addModule(url); await ctx.audioWorklet.addModule(url);
return true; return true;
} catch (err) { } catch (err) {
const detail = `${err?.name ?? ""}: ${err?.message ?? String(err)}`; console.warn(`[ws] addModule(${name}) 失败:`, err?.name, err?.message);
attempts.push(`${label} -> ${detail}`);
console.warn(`[ws] worklet addModule 失败(${label}:`, err);
return false; return false;
} }
}; };
// 策略1:Blob URL(内联源码,无网络)
const addWorkletFromSrc = async (name, src) => { const blobUrl = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
// 策略1Blob URL let ok = await tryModule(blobUrl);
const blobUrl = URL.createObjectURL(new Blob([src], { type: "text/javascript" })); URL.revokeObjectURL(blobUrl);
let ok = await tryModule(`${name}@blob`, blobUrl); if (ok) return true;
URL.revokeObjectURL(blobUrl); // 策略2data: URL
try {
const b64 = btoa(unescape(encodeURIComponent(src)));
ok = await tryModule(`data:text/javascript;base64,${b64}`);
if (ok) return true;
} catch { /* 忽略编码错误 */ }
// 策略3:后端真实 HTTPIP 可达 + CORS *
const httpBase = window.VOICE_WORKLETS_BASE;
if (httpBase) {
ok = await tryModule(`${httpBase}${name}.js`);
if (ok) return true; if (ok) return true;
// 策略2data: URLbase64UTF-8 安全)
try {
const b64 = btoa(unescape(encodeURIComponent(src)));
ok = await tryModule(`${name}@data`, `data:text/javascript;base64,${b64}`);
if (ok) return true;
} catch (err) {
attempts.push(`${name}@data-encode -> ${err?.message ?? String(err)}`);
}
// 策略3:后端 HTTPIP 可达 + CORS *
const httpBase = window.VOICE_WORKLETS_BASE;
if (httpBase) {
ok = await tryModule(`${name}@http`, `${httpBase}${name}.js`);
if (ok) return true;
}
return false;
};
const workletOk =
(await addWorkletFromSrc("mic-capture", micWorkletSrc)) &&
(await addWorkletFromSrc("audio-playback", playbackWorkletSrc));
if (workletOk) {
console.info("[ws] AudioWorklet 加载成功(WebView2 兼容模式)");
} else {
throw new Error(
`AudioWorklet 模块加载失败。尝试详情:${attempts.join(" | ") || "无"}`,
);
} }
return false;
}
/** 正常路径:用 AudioWorkletNode 做采集与播放。 */
_setupWorkletNodes(ctx, micSrc, outAnalyser) {
const captureNode = new AudioWorkletNode(ctx, "mic-capture", { const captureNode = new AudioWorkletNode(ctx, "mic-capture", {
numberOfInputs: 1, numberOfInputs: 1,
numberOfOutputs: 0, numberOfOutputs: 0,
@@ -556,19 +579,8 @@ export class S2sWsRealtimeClient extends EventTarget {
}; };
// Push the initial gate config now that the worklet exists. // Push the initial gate config now that the worklet exists.
captureNode.port.postMessage({ kind: "gate", ...this._noiseGate }); captureNode.port.postMessage({ kind: "gate", ...this._noiseGate });
this._captureNode = captureNode;
const micSrc = ctx.createMediaStreamSource(this.options.micStream);
micSrc.connect(captureNode); micSrc.connect(captureNode);
this._micSrc = micSrc; this._captureNode = captureNode;
// Mic analyser: tap the mic in parallel with the worklet so we get the
// raw (un-resampled, un-clipped) signal for the visualiser.
const micAnalyser = ctx.createAnalyser();
micAnalyser.fftSize = VIS_FFT_SIZE;
micAnalyser.smoothingTimeConstant = 0;
micSrc.connect(micAnalyser);
this._micAnalyser = micAnalyser;
const playbackNode = new AudioWorkletNode(ctx, "audio-playback", { const playbackNode = new AudioWorkletNode(ctx, "audio-playback", {
numberOfInputs: 0, numberOfInputs: 0,
@@ -577,18 +589,88 @@ export class S2sWsRealtimeClient extends EventTarget {
}); });
playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE, muted: this._playbackMuted === true }); playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE, muted: this._playbackMuted === true });
playbackNode.port.onmessage = (e) => this._onPlaybackMessage(e.data); playbackNode.port.onmessage = (e) => this._onPlaybackMessage(e.data);
// Output analyser sits between the playback worklet and the speakers.
const outAnalyser = ctx.createAnalyser();
outAnalyser.fftSize = VIS_FFT_SIZE;
outAnalyser.smoothingTimeConstant = 0.3;
playbackNode.connect(outAnalyser); playbackNode.connect(outAnalyser);
outAnalyser.connect(ctx.destination); outAnalyser.connect(ctx.destination);
this._outAnalyser = outAnalyser;
this._playbackNode = playbackNode; this._playbackNode = playbackNode;
}
this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking); /** 降级路径:ScriptProcessorNode 重采样采集 + 播放(兼容所有 WebView,无模块加载)。 */
this._visualiser.start(); _setupScriptProcessor(ctx, micSrc, outAnalyser) {
const buf = 4096;
const targetRate = OUTPUT_SAMPLE_RATE; // 16000
const chunkSamples = Math.round((targetRate * MIC_CHUNK_MS) / 1000); // 640 (40ms)
const ratio = ctx.sampleRate / targetRate;
// ---- 麦克风采集:累积输入 → 重采样到 16k → 打包 Int16 分块 ----
const cap = ctx.createScriptProcessor(buf, 1, 1);
const sink = ctx.createGain();
sink.gain.value = 0;
sink.connect(ctx.destination); // ScriptProcessor 须连到 destination 才会触发回调
let scratch = new Float32Array(0);
cap.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const next = new Float32Array(scratch.length + input.length);
next.set(scratch, 0);
next.set(input, scratch.length);
scratch = next;
const need = Math.ceil(chunkSamples * ratio);
while (scratch.length >= need) {
const dec = new Float32Array(chunkSamples);
let sumSq = 0;
for (let i = 0; i < chunkSamples; i++) {
const idx = Math.floor(i * ratio);
const s = scratch[idx];
dec[i] = s;
sumSq += s * s;
}
const out = new Int16Array(chunkSamples);
for (let i = 0; i < chunkSamples; i++) {
const s = dec[i] < -1 ? -1 : dec[i] > 1 ? 1 : dec[i];
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
this.dispatchEvent(
new CustomEvent("input-level", { detail: { rms: Math.sqrt(sumSq / chunkSamples) } }),
);
this._onMicChunk(out.buffer);
scratch = scratch.slice(Math.floor(chunkSamples * ratio));
}
};
micSrc.connect(cap);
cap.connect(sink);
this._captureNode = cap;
// ---- 播放:从队列读取 Float32 16k 样本 → 升采样到 ctx.sampleRate 输出 ----
const pb = ctx.createScriptProcessor(buf, 0, 1);
const stepRatio = targetRate / ctx.sampleRate;
let frac = 0;
let readPos = 0;
pb.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
if (this._playbackMuted === true) {
for (let i = 0; i < out.length; i++) out[i] = 0;
return;
}
const q = this._playbackQueue;
for (let i = 0; i < out.length; i++) {
let s = 0;
if (q.length) {
const head = q[0];
const a = head[readPos];
let b;
if (readPos + 1 < head.length) b = head[readPos + 1];
else if (q.length > 1) b = q[1][0];
else b = a;
s = a + (b - a) * frac;
frac += stepRatio;
while (frac >= 1) { frac -= 1; readPos += 1; }
while (q.length && readPos >= q[0].length) { readPos -= q[0].length; q.shift(); }
}
out[i] = s;
}
};
pb.connect(outAnalyser);
outAnalyser.connect(ctx.destination);
this._playbackNode = pb;
} }
/** @param {string} connectUrl */ /** @param {string} connectUrl */
@@ -960,10 +1042,16 @@ export class S2sWsRealtimeClient extends EventTarget {
if (a > peak) peak = a; if (a > peak) peak = a;
} }
this._dbgPeak = Math.max(this._dbgPeak || 0, peak); this._dbgPeak = Math.max(this._dbgPeak || 0, peak);
// Clone (no transfer list): structured cloning across the main->worklet if (this._useScriptProcessor) {
// boundary is more robust than transferring the buffer — some browser // 降级路径:把 16k Float32 样本推入主线程播放队列,由 ScriptProcessor 读取。
// versions mis-shape the received object and the worklet would drop it. if (!this._playbackQueue) this._playbackQueue = [];
this._playbackNode.port.postMessage({ kind: "audio", samples }); this._playbackQueue.push(samples);
} else {
// Clone (no transfer list): structured cloning across the main->worklet
// boundary is more robust than transferring the buffer — some browser
// versions mis-shape the received object and the worklet would drop it.
this._playbackNode.port.postMessage({ kind: "audio", samples });
}
} }
/** @param {CloseEvent} ev */ /** @param {CloseEvent} ev */
@@ -1154,7 +1242,11 @@ export class S2sWsRealtimeClient extends EventTarget {
setPlaybackMuted(muted) { setPlaybackMuted(muted) {
this._playbackMuted = muted; this._playbackMuted = muted;
if (this._playbackNode) { if (this._playbackNode) {
this._playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE, muted }); if (this._useScriptProcessor) {
// 降级路径:无需通知;onaudioprocess 内读取 _playbackMuted。
} else {
this._playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE, muted });
}
} }
} }