# 实时语音转文字流程详解

> 本文档基于 `src/asr/realtime-asr-pipeline.js` 整理，通俗讲解录音后声音如何变成文字显示在页面上。

---

## 一、整体架构概览

```
┌─────────────────────────────────────────────────────────────────────────┐
│                         RealtimeAsrPipeline 类                           │
├─────────────────────────────────────────────────────────────────────────┤
│  音频采集层 → PCM编码 → WebSocket发送 → 后端ASR → LLM矫正 → 页面显示    │
└─────────────────────────────────────────────────────────────────────────┘
```

**双通道设计**：
- **Online 通道**（快但不准）：低延迟实时预览，用户边说边看到文字
- **Offline 通道**（慢但准）：完整句子+LLM矫正，最终保存的正式文本

---

## 二、录音启动流程

### 1. 用户点击"开始录音"

**入口**：`main.js` 调用 → `pipeline.start()`

```javascript
// realtime-asr-pipeline.js 第 184-192 行
start() {
  this.disposed = false;           // 重置销毁标记
  this.ensureRecorders();          // 创建音频录制器
  
  const onlineStarted = this.wsOnline?.wsStart() === 1;   // 启动 online websocket
  const offlineStarted = this.wsOffline?.wsStart("offline") === 1; // 启动 offline websocket
  this.setActive(onlineStarted || offlineStarted);
  return onlineStarted || offlineStarted ? 1 : 0;
}
```

### 2. 创建音频录制器（Recorder）

**位置**：`ensureRecorders()` 第 336-352 行

```javascript
ensureRecorders() {
  if (!this.recOnline) {
    this.recOnline = Recorder({
      type: "pcm",           // 输出原始PCM音频
      bitRate: 16,           // 16位深度
      sampleRate: 16000,     // 16kHz采样率（ASR要求）
      onProcess: (...args) => this.processRecorderFrame("online", ...args),  // 每60ms回调一次
    });
  }
  // offline 同理...
}
```

**通俗解释**：Recorder 像是一个"音频快递员"，每60毫秒把录到的声音打包成PCM格式，通过 `onProcess` 回调交给后续处理。

### 3. 启动 WebSocket 连接

**位置**：`createSockets()` 第 169-180 行

```javascript
createSockets() {
  this.wsOnline = new WebSocketConnectMethod({
    msgHandle: (event) => this.handleOnlineMessage(event),    // 收到消息处理
    stateHandle: (state) => this.handleOnlineState(state),    // 连接状态变化
    getScene: () => this.getScene(),                         // 获取场景（cg/yl等）
  });
  // offline 同理...
}
```

**状态处理**：
- `state === 0`：连接成功 → `openMixedStream()` 打开音频流
- `state === 2`：断开连接 → `stop()` 清理资源

### 4. 打开音频混合流

**位置**：`openMixedStream()` 第 417-478 行

```javascript
async openMixedStream(channel) {
  const micStream = this.getMicStream();        // 获取麦克风流
  const systemStream = this.getSystemStream();  // 获取系统音频流
  
  // 创建 AudioContext 合并两路音频
  const audioContext = createAudioContext();
  const destination = audioContext.createMediaStreamDestination();
  
  if (hasMic) audioContext.createMediaStreamSource(micStream).connect(destination);
  if (hasSystem) audioContext.createMediaStreamSource(systemStream).connect(destination);
  
  // 启动 Recorder 开始录制
  recorder.start(destination.stream);
}
```

---

## 三、音频处理与发送流程

### 1. 音频采集回调

**位置**：`processRecorderFrame()` 第 482-530 行

**触发时机**：Recorder 每60ms调用一次

```javascript
processRecorderFrame(channel, buffer, _powerLevel, _duration, sampleRate) {
  if (!this.active || this.disposed) return;   // 已停止/销毁则忽略
  
  const sourceChunk = buffer[buffer.length - 1];  // 取最新音频块
  
  // 重采样到16kHz（ASR要求）
  const data16k = Recorder.SampleData([sourceChunk], sampleRate, 16000).data;
  
  // 追加到缓冲区
  sampleBuf = appendInt16(sampleBuf, data16k);
  
  // 内存保护：只保留最近5秒音频
  if (sampleBuf.length > DEFAULT_BUFFER_HARD_LIMIT_SAMPLES) {
    sampleBuf = sampleBuf.slice(-DEFAULT_BUFFER_HARD_LIMIT_SAMPLES);
  }
  
  // 切成960样本的小块发送（约60ms）
  while (sampleBuf.length >= DEFAULT_CHUNK_SIZE) {
    const sendBuf = sampleBuf.slice(0, DEFAULT_CHUNK_SIZE);
    ws?.wsSend(sendBuf);           // 发送到后端ASR
    sampleBuf = sampleBuf.slice(DEFAULT_CHUNK_SIZE);
  }
}
```

**关键常数**：
- `DEFAULT_CHUNK_SIZE = 960`：每包960个样本（60ms @ 16kHz）
- `DEFAULT_BUFFER_HARD_LIMIT_SAMPLES = 80000`：最多5秒缓冲（16kHz × 5s）

---

## 四、后端返回消息处理流程

### 1. Online 消息处理（低延迟预览）

**位置**：`handleOnlineMessage()` 第 1047-1086 行

```javascript
handleOnlineMessage(event) {
  const parsedMessage = JSON.parse(event.data);
  const { text, mode, timestamp } = parsedMessage;
  
  if (mode === "2pass-online") {
    // 检查是否是旧录音周期的消息（暂停/恢复后可能收到旧消息）
    if (this.isStaleRealtimeMessage(currentTimestamp, messageEpoch)) return;
    
    // 停顿3秒以上且以句号结尾 → 换行
    let shouldBreak = false;
    if (timeDiff >= 3 && /。\s*$/.test(text)) {
      shouldBreak = true;
    }
    
    // 累积到预览文本
    this.recTextOnline += shouldBreak ? text + "\n" : text;
    
  } else if (mode === "2pass-offline") {
    // online 通道偶尔也能收到 offline，作为临时交接文本保存
    this.offlineSentenceHandoffText = text;
    this.recTextOnline = "";  // 清空 online 预览
  }
  
  this.syncRealtimePreview();  // 刷新预览区显示
}
```

### 2. Offline 消息处理（正式句子+矫正）

**位置**：`handleOfflineMessage()` 第 1090-1205 行

这是**核心正式流程**，分为两种情况：

#### 情况A：收到完整句子（2pass-offline）

```javascript
if (mode === "2pass-offline") {
  // 1. 创建 pending 队列项（等待LLM矫正）
  const pendingSentence = this.ensurePendingSentence(text, timestamp, messageEpoch);
  
  // 2. 刷新预览（显示 rawText）
  this.syncRealtimePreview();
  
  // 3. 调用 LLM 矫正接口
  const response = await fetch(this.correctUrl, {
    method: "POST",
    body: JSON.stringify({
      text: pendingSentence.rawText,
      scene: this.getScene(),    // 【行为差异#3】新增场景字段
    }),
  });
  
  const data = await response.json();
  const correctedText = data.corrected_text || pendingSentence.rawText;
  
  // 4. 矫正完成，进入正式 segmentList
  this.finalizePendingSentence(pendingSentence.id, correctedText, messageEpoch);
}
```

#### 情况B：收到说话人标签（offline-speaker）

```javascript
else if (mode === "offline-speaker") {
  // 找到对应时间戳的 segment，回填说话人
  const speaker = parsedMessage.segments[0].speaker;
  
  // 倒序查找最近的无说话人 segment
  for (let i = this.segmentList.length - 1; i >= 0; i--) {
    if (segment.timestampMs === messageTimestampMs && segment.speaker === null) {
      segment.speaker = speaker;
      break;
    }
  }
  
  this.renderFullText();  // 刷新页面显示（加上 SPEAKER_00: 标签）
}
```

---

## 五、核心数据结构

### 1. Pending 队列（等待矫正）

**位置**：`pendingSentenceQueue` 数组，第 145 行定义

```javascript
const pendingItem = {
  id: 1,                          // 自增ID，用于LLM回调定位
  sourceText: "原始ASR文本",       // FunASR原始返回
  rawText: "裁剪后文本",            // 去除编辑重叠后的文本
  timestamp: "[[0,100],[100,200]]", // 字符级时间戳
  timestampMs: 200,                // 结束时间（毫秒）
  correctedText: "",               // LLM矫正后文本（空表示未矫正）
  processed: false,                // 是否已进入 segmentList
  correctionRequested: false,      // 是否已发起矫正请求（防止重复）
  epoch: 0,                        // 录音周期标识
};
```

### 2. Segment 列表（正式转录）

**位置**：`segmentList` 数组，第 143 行定义

```javascript
const segment = {
  text: "矫正后的句子",            // 最终显示的文本
  sourceText: "原始ASR文本",       // 用于去重比对
  timestamp: "[[0,100]]",          // 时间戳
  timestampMs: 100,                // 结束时间（毫秒）
  speaker: "SPEAKER_00",           // 说话人标识
  processed: true,                 // 已完成矫正
  epoch: 0,                        // 录音周期
};
```

---

## 六、关键算法详解

### 1. Pending 队列有序插入

**位置**：`ensurePendingSentence()` 第 777-786 行

**目的**：保持队列按音频时间顺序排列，LLM乱序返回也能正确排序

```javascript
// 按 timestampMs 顺序插入
const insertIndex = this.pendingSentenceQueue.findIndex(
  (item) => item.timestampMs > timestampMs
);
if (insertIndex === -1) {
  this.pendingSentenceQueue.push(pendingItem);
} else {
  this.pendingSentenceQueue.splice(insertIndex, 0, pendingItem);
}
```

### 2. 队首顺序 Flush（正式落句）

**位置**：`flushProcessedPendingSentences()` 第 795-856 行

**规则**：只有队首已矫正的项才能进入 segmentList（保持句子顺序）

```javascript
while (this.pendingSentenceQueue.length > 0) {
  if (!this.pendingSentenceQueue[0].processed) break;  // 队首未矫正，等待
  
  const item = this.pendingSentenceQueue.shift();      // 取出队首
  const segment = { text: item.correctedText, ... };
  
  // 按 timestampMs 有序插入 segmentList
  const insertIndex = this.segmentList.findIndex(
    (seg) => seg.timestampMs > segment.timestampMs
  );
  this.segmentList.splice(insertIndex === -1 ? this.segmentList.length : insertIndex, 0, segment);
}
```

### 3. 编辑重叠裁剪

**位置**：`trimOfflineTextWithCarryover()` 第 651-681 行

**场景**：用户手动编辑后，ASR返回的句子可能与编辑内容重复

```javascript
// 保存编辑后文本的最后80个字符作为"尾巴"
this.realtimeEditCarryover = { tailText: "...编辑后文本的最后80字" };

// 新ASR返回时，查找与尾巴的重叠部分
for (let size = maxOverlapLength; size >= minOverlapLength; size--) {
  const overlapText = tailText.slice(-size);
  const matchIndex = incomingText.lastIndexOf(overlapText);
  if (matchIndex !== -1) {
    // 只保留不重叠的后缀
    return incomingText.slice(matchIndex + overlapText.length);
  }
}
```

### 4. 时间停顿换行

**位置**：`handleWithTimestamp()` 第 919-967 行

**逻辑**：两句话间隔超过6秒（`timeThreshold`参数），自动换行

```javascript
if (previousTime > 0 && nowTime - previousTime > timeThreshold) {
  const lastChar = textWithTime.slice(-1) || prevChar;
  const endsWithPunc = /[。！？.!?，、；：,;:]/.test(lastChar);
  
  // 如果前一句已有标点，直接换行；否则先加句号再换行
  textWithTime += (endsWithPunc ? "\n" : "。\n") + processedSegment;
}
```

### 5. 相邻句首尾标点去重

**位置**：`flushProcessedPendingSentences()` 第 830-846 行

**场景**：LLM给前一句加了句号，后一句ASR开头也有句号 → "你好。。世界"

```javascript
const prevEndingPunct = prevText.match(/[。！？.!?]+$/)?.[0];        // 前句末尾标点
const currentStartingPunct = segment.text.match(/^[，。！？、；：,.!?;:]+/)?.[0]; // 当前句首标点

if (prevEndingPunct && currentStartingPunct) {
  segment.text = segment.text.replace(/^[，。！？、；：,.!?;:]+/, "");  // 移除当前句首标点
}
```

---

## 七、文本渲染流程

### 1. 预览区更新（低延迟）

**位置**：`syncRealtimePreview()` 第 707-715 行

```javascript
const previewText = this._deduplicateConsecutivePunctuation(
  this.getPendingSentencePreviewText() +      // 等待矫正的句子
  this.offlineSentenceHandoffText +         // offline交接文本
  this.recTextOnline                         // online实时预览
);
this.onPreviewTextChange(previewText);      // 回调到 main.js 更新页面
```

### 2. 主转录区更新（正式文本）

**位置**：`renderFullText()` 第 1027-1044 行

```javascript
const displayText = this.buildSegmentText(
  this.segmentList.slice(this.segmentRenderStartIndex),  // 只渲染新segment
  initialSpeaker
);

this.recText = displayText;
const mainText = this._deduplicateConsecutivePunctuation(
  this.getPausedTranscription() + this.recText   // 历史文本 + 新文本
);
this.onMainTextChange(mainText);
```

---

## 八、录音控制流程

### 1. 暂停录音

**调用**：`pipeline.stop()` 第 196-201 行

```javascript
stop() {
  this.setActive(false);      // 标记非活跃，忽略后续 onProcess 回调
  this.wsOnline?.wsStop();    // 关闭 websocket
  this.wsOffline?.wsStop();
  this.closeRecorders();      // 关闭 Recorder
}
```

### 2. 恢复录音

**调用**：`pipeline.start()` 重新启动

**关键**：`liveTranscriptionEpoch++`（第 279 行），旧消息被标记为 stale 丢弃

### 3. 结束录音（保存）

**调用**：`await pipeline.finish(timeoutMs)` 第 208-228 行

```javascript
async finish(timeoutMs = 3000) {
  this.setActive(false);
  this.closeRecorders();
  
  // 发送 stop 帧，等待后端 flush 最后的 offline 结果
  await Promise.allSettled([
    this.wsOnline?.wsFinish(timeoutMs),
    this.wsOffline?.wsFinish(timeoutMs)
  ]);
}
```

### 4. 离开录音页（销毁）

**调用**：`pipeline.dispose()` 第 235-240 行

```javascript
dispose() {
  this.disposed = true;       // 标记销毁，后续回调全部忽略
  this.stop();
  this.wsOnline = null;
  this.wsOffline = null;
}
```

---

## 九、关键行为差异（对比旧代码）

| 差异点 | 旧代码 (main-old.js) | 新代码 (pipeline.js) | 影响 |
|--------|---------------------|---------------------|------|
| **#1 WebSocket断连** | 调用未定义的 `stop()` 抛错 | 优雅关闭ASR通道 | 不再报错，但录音不自动停 |
| **#2 结束录音关Recorder** | 切页才关 | `finish()`立即关 | 恢复时重建，逻辑自洽 |
| **#3 LLM矫正传scene** | 只传 `{text}` | 传 `{text, scene}` | 后端需兼容 |
| **#4 恢复录音清理** | 用flag延迟清 | 立即 `resetRealtimeTranscript()` | 时序更干净 |
| **#5 草稿加载清理** | 只重置索引 | 清空pending队列 | 更彻底 |
| **#6 场景切换** | 无场景概念 | 按scene路由不同ASR URL | 新增能力 |
| **#7 性能优化** | 展开运算符复制数组 | `appendInt16`直接拼接 | 高频回调性能更好 |
| **#8 迟到回调防护** | 无防护 | `disposed`屏障忽略迟到消息 | 避免脏数据 |

---

## 十、代码位置速查表

| 功能 | 方法/属性 | 行号 |
|------|----------|------|
| 类定义 | `RealtimeAsrPipeline` | 90 |
| 构造函数 | `constructor` | 91-163 |
| 启动录音 | `start()` | 184-192 |
| 暂停录音 | `stop()` | 196-201 |
| 结束录音 | `finish()` | 208-228 |
| 销毁 | `dispose()` | 235-240 |
| 创建 WebSocket | `createSockets()` | 169-180 |
| 创建 Recorder | `ensureRecorders()` | 336-352 |
| 音频处理回调 | `processRecorderFrame()` | 482-530 |
| 打开音频流 | `openMixedStream()` | 417-478 |
| Online消息处理 | `handleOnlineMessage()` | 1047-1086 |
| Offline消息处理 | `handleOfflineMessage()` | 1090-1205 |
| 创建 Pending 项 | `ensurePendingSentence()` | 741-790 |
| Flush Pending 队列 | `flushProcessedPendingSentences()` | 795-856 |
| 完成矫正 | `finalizePendingSentence()` | 859-877 |
| 渲染主文本 | `renderFullText()` | 1027-1044 |
| 构建 Segment 文本 | `buildSegmentText()` | 981-1023 |
| 时间停顿换行 | `handleWithTimestamp()` | 919-967 |
| 预览更新 | `syncRealtimePreview()` | 707-715 |
| 提取时间戳 | `extractTimestampMs()` | 576-605 |
| 编辑重叠裁剪 | `trimOfflineTextWithCarryover()` | 651-681 |
| 去重标点 | `_deduplicateEndingPunctuation()` | 884-900 |
| 兜底去重 | `_deduplicateConsecutivePunctuation()` | 904-913 |
| 旧消息过滤 | `isStaleRealtimeMessage()` | 684-694 |
| 等待矫正 | `waitForCorrections()` | 262-271 |

---

## 十一、时序图

```
用户操作          Pipeline内部              后端ASR                页面显示
   │                  │                        │                     │
   │──点击开始录音──>│                        │                     │
   │                  │──start()─────────────>│                     │
   │                  │                        │                     │
   │                  │◄────websocket连接─────│                     │
   │                  │                        │                     │
   │                  │──ensureRecorders()────>│                     │
   │                  │                        │                     │
   │                  │◄────onProcess(60ms)────│                     │
   │                  │                        │                     │
   │                  │──wsSend(PCM)──────────>│                     │
   │                  │                        │                     │
   │                  │◄────2pass-online───────│                     │
   │                  │                        │                     │
   │                  │──recTextOnline += text─┼────────────────────>│预览区更新
   │                  │                        │                     │
   │                  │◄────2pass-offline──────│                     │
   │                  │                        │                     │
   │                  │──ensurePendingSentence()                     │
   │                  │                        │                     │
   │                  │──fetch(/asr/correct)──>│                     │
   │                  │                        │                     │
   │                  │◄────corrected_text─────│                     │
   │                  │                        │                     │
   │                  │──finalizePendingSentence()                     │
   │                  │                        │                     │
   │                  │──renderFullText()────────────────────────────>│主转录区更新
   │                  │                        │                     │
   │◄────────────────────────────────────────────────────────────────用户看到最终文本
```

---

## 十二、调试技巧

1. **查看 pending 队列**：`console.log(pipeline.pendingSentenceQueue)`
2. **查看 segment 列表**：`console.log(pipeline.segmentList)`
3. **检查 epoch**：`console.log(pipeline.liveTranscriptionEpoch)`
4. **查看实时预览文本**：`console.log(pipeline.recTextOnline)`
5. **查看主转录文本**：`console.log(pipeline.recText)`

---

*文档生成时间：2026-06-30*
*对应代码版本：realtime-asr-pipeline.js (1207行)*
