feat: 优化音频处理逻辑,支持降级到 ScriptProcessorNode,增强兼容性
This commit is contained in:
+165
-73
@@ -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 后端真实 HTTP(IP 可达 + 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" }));
|
||||||
// 策略1:Blob 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);
|
// 策略2:data: URL
|
||||||
|
try {
|
||||||
|
const b64 = btoa(unescape(encodeURIComponent(src)));
|
||||||
|
ok = await tryModule(`data:text/javascript;base64,${b64}`);
|
||||||
|
if (ok) return true;
|
||||||
|
} catch { /* 忽略编码错误 */ }
|
||||||
|
// 策略3:后端真实 HTTP(IP 可达 + CORS *)
|
||||||
|
const httpBase = window.VOICE_WORKLETS_BASE;
|
||||||
|
if (httpBase) {
|
||||||
|
ok = await tryModule(`${httpBase}${name}.js`);
|
||||||
if (ok) return true;
|
if (ok) return true;
|
||||||
|
|
||||||
// 策略2:data: URL(base64,UTF-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:后端 HTTP(IP 可达 + 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user