|
@@ -109,11 +109,35 @@ export default class NlsAsr extends BaseEvent {
|
|
|
this.processor.connect(this.audioContext.destination);
|
|
this.processor.connect(this.audioContext.destination);
|
|
|
this.source.connect(this.processor);
|
|
this.source.connect(this.processor);
|
|
|
const sampleRate = this.audioContext.sampleRate;
|
|
const sampleRate = this.audioContext.sampleRate;
|
|
|
|
|
+ let capturedChunks = 0;
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `[nls] 开始采集:AudioContext=${this.audioContext.state} 采样率=${sampleRate}Hz ` +
|
|
|
|
|
+ `processor 缓冲=${this.processor.bufferSize}`
|
|
|
|
|
+ );
|
|
|
this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
|
|
this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
|
|
|
if (!this.processing || !this.stream || !this.stream.active) return;
|
|
if (!this.processing || !this.stream || !this.stream.active) return;
|
|
|
const channel = event.inputBuffer.getChannelData(0);
|
|
const channel = event.inputBuffer.getChannelData(0);
|
|
|
- this.pendingAudio.push(encodePcm16kMono(channel, sampleRate));
|
|
|
|
|
|
|
+ const encoded = encodePcm16kMono(channel, sampleRate);
|
|
|
|
|
+ capturedChunks += 1;
|
|
|
|
|
+ if (capturedChunks === 1) {
|
|
|
|
|
+ console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`);
|
|
|
|
|
+ }
|
|
|
|
|
+ this.pendingAudio.push(encoded);
|
|
|
};
|
|
};
|
|
|
|
|
+
|
|
|
|
|
+ // 诊断:AudioContext 有时会以 suspended 起步(那样 onaudioprocess 根本不会回调),
|
|
|
|
|
+ // 这里主动 resume 一次,并在 1.5s 后仍没采到任何数据时把状态打出来
|
|
|
|
|
+ if (this.audioContext.state === "suspended") {
|
|
|
|
|
+ void this.audioContext.resume().catch(() => undefined);
|
|
|
|
|
+ }
|
|
|
|
|
+ window.setTimeout(() => {
|
|
|
|
|
+ if (capturedChunks === 0 && this.processing) {
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ `[nls] ⚠️ 1.5s 内没有采集到任何音频(AudioContext=${this.audioContext?.state},` +
|
|
|
|
|
+ `stream.active=${this.stream?.active})—— 麦克风可能被别的程序占用或未真正启动`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 1500);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
|
|
/** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
|
|
@@ -126,6 +150,8 @@ export default class NlsAsr extends BaseEvent {
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
this.startResolve = resolve;
|
|
this.startResolve = resolve;
|
|
|
this.startReject = reject;
|
|
this.startReject = reject;
|
|
|
|
|
+ /** 诊断用:中间结果事件计数(日志里每 4 条打一条,避免刷屏) */
|
|
|
|
|
+ let midEvents = 0;
|
|
|
|
|
|
|
|
const url = `${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`;
|
|
const url = `${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`;
|
|
|
const socket = new WebSocket(url);
|
|
const socket = new WebSocket(url);
|
|
@@ -147,6 +173,19 @@ export default class NlsAsr extends BaseEvent {
|
|
|
const name = getNlsEventName(message);
|
|
const name = getNlsEventName(message);
|
|
|
if (!name) return;
|
|
if (!name) return;
|
|
|
|
|
|
|
|
|
|
+ // 诊断日志:把网关下行事件打出来(ResultChanged 很密,每 4 条打一条)。
|
|
|
|
|
+ // 排查「进录音态但不出字」时,这几行能直接区分「音频没上去」和「服务端没回/回了错」。
|
|
|
|
|
+ if (name === "TaskFailed") {
|
|
|
|
|
+ console.warn(`[nls] ← TaskFailed:${describeNlsFailure(message)}`);
|
|
|
|
|
+ } else if (name !== "TranscriptionResultChanged" && name !== "SentenceBegin") {
|
|
|
|
|
+ console.log(`[nls] ← ${name}${message?.payload?.result ? ` ${JSON.stringify(message.payload.result)}` : ""}`);
|
|
|
|
|
+ } else if (name === "TranscriptionResultChanged") {
|
|
|
|
|
+ midEvents += 1;
|
|
|
|
|
+ if (midEvents % 4 === 1) {
|
|
|
|
|
+ console.log(`[nls] ← 中间结果:${JSON.stringify(message?.payload?.result ?? "")}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if (name === "TranscriptionStarted") {
|
|
if (name === "TranscriptionStarted") {
|
|
|
settle();
|
|
settle();
|
|
|
this.startFlushTimer();
|
|
this.startFlushTimer();
|
|
@@ -290,27 +329,53 @@ export default class NlsAsr extends BaseEvent {
|
|
|
this.stopSendTimer();
|
|
this.stopSendTimer();
|
|
|
const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节 = 100ms
|
|
const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节 = 100ms
|
|
|
let carry = new Uint8Array(0);
|
|
let carry = new Uint8Array(0);
|
|
|
|
|
+ let sentFrames = 0;
|
|
|
|
|
+ let lastLoggedFrames = 0;
|
|
|
|
|
+ let stallWarnedAt = 0;
|
|
|
|
|
+ const flushStartedAt = Date.now();
|
|
|
|
|
|
|
|
this.sendTimer = window.setInterval(() => {
|
|
this.sendTimer = window.setInterval(() => {
|
|
|
- if (!this.processing && this.pendingAudio.length === 0) return;
|
|
|
|
|
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
|
|
|
- // 先把能凑整帧的缓存合并进 carry
|
|
|
|
|
- while (carry.length + (this.pendingAudio[0]?.length ?? 0) <= bytesPerFrame * 2 && this.pendingAudio.length) {
|
|
|
|
|
- const next = this.pendingAudio.shift() as Uint8Array;
|
|
|
|
|
- const merged = new Uint8Array(carry.length + next.length);
|
|
|
|
|
|
|
+ // 1) 把采集到的块**全部**并进 carry。
|
|
|
|
|
+ // ⚠️ 这里曾经写成「只在 carry + 下一块 ≤ 两帧时才合并」—— 一旦浏览器给的 buffer
|
|
|
|
|
+ // 比两帧还大(bufferSize 是浏览器自选的),那块就永远合并不进来,
|
|
|
|
|
+ // 结果**一帧音频都发不出去**,而界面看着仍在正常录音(用户 2026-09-24 报的
|
|
|
|
|
+ // 「进了录音状态但是不出字」就是这个形态)。合并**不设上限**才安全。
|
|
|
|
|
+ if (this.pendingAudio.length) {
|
|
|
|
|
+ const pending = this.pendingAudio;
|
|
|
|
|
+ this.pendingAudio = [];
|
|
|
|
|
+ const merged = new Uint8Array(carry.length + pending.reduce((n, c) => n + c.length, 0));
|
|
|
merged.set(carry, 0);
|
|
merged.set(carry, 0);
|
|
|
- merged.set(next, carry.length);
|
|
|
|
|
|
|
+ let offset = carry.length;
|
|
|
|
|
+ for (const chunk of pending) {
|
|
|
|
|
+ merged.set(chunk, offset);
|
|
|
|
|
+ offset += chunk.length;
|
|
|
|
|
+ }
|
|
|
carry = merged;
|
|
carry = merged;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 一个 tick 内最多发 NLS_MAX_FRAMES_PER_TICK 帧(有积压就多发烧掉)
|
|
|
|
|
|
|
+ // 2) 按帧发(有积压时一个 tick 最多发 NLS_MAX_FRAMES_PER_TICK 帧)
|
|
|
let sentThisTick = 0;
|
|
let sentThisTick = 0;
|
|
|
while (carry.length >= bytesPerFrame && sentThisTick < NLS_MAX_FRAMES_PER_TICK) {
|
|
while (carry.length >= bytesPerFrame && sentThisTick < NLS_MAX_FRAMES_PER_TICK) {
|
|
|
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
|
this.socket.send(carry.subarray(0, bytesPerFrame));
|
|
this.socket.send(carry.subarray(0, bytesPerFrame));
|
|
|
carry = carry.subarray(bytesPerFrame);
|
|
carry = carry.subarray(bytesPerFrame);
|
|
|
sentThisTick += 1;
|
|
sentThisTick += 1;
|
|
|
|
|
+ sentFrames += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sentFrames - lastLoggedFrames >= 20) {
|
|
|
|
|
+ lastLoggedFrames = sentFrames;
|
|
|
|
|
+ console.log(`[nls] 已推音频 ${Math.round((sentFrames * bytesPerFrame) / 32)}ms(${sentFrames} 帧)`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 诊断:录着音却 >=2 秒一帧都没发出去 → 明确报出来,别再"静默不出声"
|
|
|
|
|
+ if (this.processing && sentFrames === 0 && Date.now() - flushStartedAt > 2000 && Date.now() - stallWarnedAt > 5000) {
|
|
|
|
|
+ stallWarnedAt = Date.now();
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ `[nls] ⚠️ 录音中但音频一帧都没发出去(carry=${carry.length}B,缓存块=${this.pendingAudio.length})` +
|
|
|
|
|
+ ` —— 若持续如此,检查采集端是否真的在出数据(见上一条「采集开始」日志里的采样率)`
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
}, NLS_FLUSH_TICK_MS);
|
|
}, NLS_FLUSH_TICK_MS);
|
|
|
}
|
|
}
|