Use single FunASR 2pass stream for realtime preview

This commit is contained in:
Xulilong 2026-07-16 09:15:16 +08:00
parent a85e8938d4
commit f3d207a6fb
6 changed files with 89 additions and 38 deletions

View File

@ -835,7 +835,12 @@ async def forward_audio(websocket):
logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
# 发送 ASR 配置双通道模式PCM 音频格式)
config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
config = {
"mode": "2pass",
"chunk_size": [5, 10, 5],
"chunk_interval": 5,
"wav_format": "pcm",
}
await target_ws.send(json.dumps(config))
logger.debug(f"发送 ASR 配置: {config}")

View File

@ -56,10 +56,10 @@ def _hotwords_payload(logger, scene=SCENE_CG):
def _asr_init_payload(mode, hotwords):
return {
'chunk_size': [10, 10, 10],
'chunk_size': [5, 10, 5],
'wav_name': 'h5',
'is_speaking': True,
'chunk_interval': 10,
'chunk_interval': 5,
'itn': True,
'mode': mode,
'hotwords': hotwords,
@ -82,9 +82,10 @@ def live_asr_proxy(client_ws):
hotwords = _hotwords_payload(logger, scene)
upstream_open = threading.Event()
upstream_closed = threading.Event()
stop_requested = threading.Event()
upstream_holder = {'ws': None}
upstream_closed = threading.Event()
stop_requested = threading.Event()
upstream_holder = {'ws': None}
upstream_message_count = {'value': 0}
def on_open(upstream):
upstream_holder['ws'] = upstream
@ -92,11 +93,45 @@ def live_asr_proxy(client_ws):
upstream_open.set()
_send_json(client_ws, {'type': 'state', 'state': 'connected', 'mode': mode, 'scene': scene})
def on_message(_upstream, message):
try:
client_ws.send(message)
except Exception:
upstream_closed.set()
def on_message(_upstream, message):
upstream_message_count['value'] += 1
outbound_message = message
if upstream_message_count['value'] <= 20:
try:
payload = json.loads(message)
if payload.get('mode') == 'online':
payload['mode'] = '2pass-online'
outbound_message = json.dumps(payload, ensure_ascii=False)
elif payload.get('mode') == 'offline':
payload['mode'] = '2pass-offline'
outbound_message = json.dumps(payload, ensure_ascii=False)
logger.info(
'Realtime ASR upstream message #%s: mode=%s text=%r',
upstream_message_count['value'],
payload.get('mode'),
(payload.get('text') or '')[:80],
)
except Exception:
logger.info(
'Realtime ASR upstream message #%s: %r',
upstream_message_count['value'],
str(message)[:160],
)
else:
try:
payload = json.loads(message)
if payload.get('mode') == 'online':
payload['mode'] = '2pass-online'
outbound_message = json.dumps(payload, ensure_ascii=False)
elif payload.get('mode') == 'offline':
payload['mode'] = '2pass-offline'
outbound_message = json.dumps(payload, ensure_ascii=False)
except Exception:
outbound_message = message
try:
client_ws.send(outbound_message)
except Exception:
upstream_closed.set()
def on_error(_upstream, error):
logger.warning('FunASR WebSocket 错误:%s', error)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -187,10 +187,9 @@ export class RealtimeAsrPipeline {
start() {
this.disposed = false;
const onlineStarted = this.wsOnline?.wsStart() === 1;
const offlineStarted = this.wsOffline?.wsStart("offline") === 1;
this.setActive(onlineStarted || offlineStarted);
return onlineStarted || offlineStarted ? 1 : 0;
const onlineStarted = this.wsOnline?.wsStart("2pass") === 1;
this.setActive(onlineStarted);
return onlineStarted ? 1 : 0;
}
// 立即停止:用于暂停或离开录音视图。关闭 socket 和 Recorder 回调,
@ -752,11 +751,7 @@ export class RealtimeAsrPipeline {
// 预览文本 = 待矫正 offline 文本 + online/offline 交接文本 + 当前 online 临时文本。
syncRealtimePreview() {
// 【兜底去重】无论前面逻辑是否产生重复标点,最终渲染前统一清理
const previewText = this._deduplicateConsecutivePunctuation(
this.getPendingSentencePreviewText() +
this.offlineSentenceHandoffText +
this.recTextOnline
);
const previewText = this._deduplicateConsecutivePunctuation(this.recTextOnline);
this.onPreviewTextChange(previewText);
}
@ -1202,7 +1197,7 @@ export class RealtimeAsrPipeline {
const timestamp = parsedMessage.timestamp;
const messageEpoch = this.liveTranscriptionEpoch;
if (mode === "2pass-online") {
if (mode === "2pass-online" || mode === "online") {
// 低延迟预览文本可能不完整,因此不进入正式 segmentList。
const currentTimestamp = this.extractTimestampMs(timestamp);
if (this.isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
@ -1224,13 +1219,25 @@ export class RealtimeAsrPipeline {
this.recTextOnline += shouldBreak ? text + "\n" : text;
}
this.lastOnlineTimestamp = currentTimestamp;
} else if (mode === "2pass-offline") {
} else if (mode === "2pass-offline" || mode === "offline") {
// online 通道也可能回显 offline 句子,这里只作为交接预览保存。
// 正式句子只通过 offline 通道进入矫正流程。
this.recTextOnline = "";
this.offlineSentenceHandoffText = text || this.recTextOnline || "";
this.recTextOnline = "";
this.lastOnlineTimestamp = null;
if (text.trim()) {
const pendingSentence = this.ensurePendingSentence(
text,
timestamp,
messageEpoch
);
if (
pendingSentence &&
!pendingSentence.processed &&
!pendingSentence.correctionRequested
) {
pendingSentence.correctionRequested = true;
this.setPendingCorrectionCount(this.pendingCorrectionCount + 1);
this._requestCorrection(pendingSentence);
}
}
}
this.syncRealtimePreview();
@ -1249,16 +1256,18 @@ export class RealtimeAsrPipeline {
const messageEpoch = this.liveTranscriptionEpoch;
const messageTimestampMs = this.extractTimestampMs(timestamp);
if (mode === "2pass-offline") {
if (mode === "2pass-offline" || mode === "offline") {
// 文本先到:先放入 pending 队列刷新预览,再调用 LLM 矫正接口,
// 矫正完成后才进入正式 segmentList。
if (this.isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
return;
}
const text = "" + parsedMessage.text;
this.recTextOnline = "";
this.lastOnlineTimestamp = null;
const text = "" + parsedMessage.text;
if (!text.trim()) {
this.syncRealtimePreview();
return;
}
const pendingSentence = this.ensurePendingSentence(
text,
timestamp,

View File

@ -54,10 +54,12 @@ export function WebSocketConnectMethod(config) {
if (speechSokt) {
speechSokt.close();
}
if (e === "offline" || (e && e !== "online")) {
modeType = "offline";
} else {
modeType = "online";
if (e === "2pass") {
modeType = "2pass";
} else if (e === "offline" || (e && e !== "online")) {
modeType = "offline";
} else {
modeType = "online";
}
var Uri = getBackendAsrWebSocketUrl(modeType, getScene());
if (Uri.match(/wss:\S*|ws:\S*/)) {