# 音频工具服务器集成指南

[English](./SERVER_INTEGRATION.en.md) | 简体中文

本文档提供了关于如何将 mstf-kit 音频工具与服务器端集成的详细指南和代码示例，特别适用于需要实时音频处理和语音识别的应用场景。

## 目录

- [集成概述](#集成概述)
- [REST API模式](#rest-api模式)
- [WebSocket模式](#websocket模式)
- [服务器端实现](#服务器端实现)
- [最佳实践](#最佳实践)
- [常见问题](#常见问题)

## 集成概述

将录音功能与服务器集成，可实现实时语音识别、音频处理等高级功能。我们支持两种主要的集成模式，各有优缺点：

### REST API模式 vs WebSocket模式

| 特性 | REST API | WebSocket |
|------|---------|-----------|
| 实时性 | 中等（取决于请求频率） | 高（持续连接） |
| 实现复杂度 | 低 | 中 |
| 服务器负载 | 每个请求都需要建立连接 | 保持长连接，较低的连接开销 |
| 防火墙友好性 | 高 | 中（可能需要特殊配置） |
| 双向通信 | 有限 | 完全支持 |
| 适用场景 | 短语音、非严格实时场景 | 长音频流、需要即时反馈 |

## REST API模式

REST API模式通过定期HTTP请求发送音频数据块，适合已有RESTful架构的系统集成。

### 客户端实现

```typescript
import { createRecorder, audioBlobToBase64 } from 'mstf-kit';

async function recordAndSendToServer() {
  const API_ENDPOINT = 'https://api.example.com/speech-recognition';
  let sessionId = null;
  
  // 创建流式录音器
  const recorder = await createRecorder({
    format: 'wav',
    sampleRate: 16000,  // 16kHz，适合语音识别
    isStreaming: true,
    timeslice: 500,     // 每500ms发送一次数据
    
    // 流式数据回调
    onDataAvailable: async (blob) => {
      try {
        // 创建表单数据
        const formData = new FormData();
        formData.append('audio', blob, 'chunk.wav');
        
        // 如果有会话ID，添加到请求中
        if (sessionId) {
          formData.append('sessionId', sessionId);
        }
        
        // 发送数据到服务器
        const response = await fetch(API_ENDPOINT, {
          method: 'POST',
          body: formData
        });
        
        if (!response.ok) {
          throw new Error(`服务器错误: ${response.status}`);
        }
        
        const result = await response.json();
        
        // 保存会话ID用于后续请求
        if (!sessionId && result.sessionId) {
          sessionId = result.sessionId;
        }
        
        // 处理服务器响应
        if (result.text) {
          console.log('识别结果:', result.text);
          updateTranscription(result.text);
        }
      } catch (error) {
        console.error('发送音频数据失败:', error);
      }
    }
  });
  
  // 开始录音
  await recorder.start();
  
  // UI更新函数
  function updateTranscription(text) {
    const transcriptionElement = document.getElementById('transcription');
    if (transcriptionElement) {
      transcriptionElement.textContent = text;
    }
  }
  
  // 停止按钮事件处理
  document.getElementById('stopButton').addEventListener('click', async () => {
    // 停止录音
    const finalBlob = await recorder.stop();
    
    // 发送最后的数据块，标记为结束
    const formData = new FormData();
    formData.append('audio', finalBlob, 'final.wav');
    formData.append('sessionId', sessionId);
    formData.append('isFinal', 'true');
    
    const response = await fetch(API_ENDPOINT, {
      method: 'POST',
      body: formData
    });
    
    if (response.ok) {
      const result = await response.json();
      updateTranscription(result.text);
      console.log('最终识别结果:', result.text);
    }
    
    // 释放资源
    recorder.release();
  });
}
```

### 实现细节

1. **会话管理**：使用`sessionId`跟踪连续的音频片段，确保服务器可以正确组合它们
2. **数据打包**：使用`FormData`封装音频数据，便于服务器处理
3. **错误处理**：实现了错误捕获和恢复机制
4. **最终标记**：在录音结束时发送特殊标记，通知服务器处理完整音频

## WebSocket模式

WebSocket模式使用持久连接实现真正的实时音频流传输，特别适合需要低延迟的应用场景。

### 客户端实现

```typescript
import { createRecorder, audioBlobToBase64 } from 'mstf-kit';

async function webSocketAudioStream() {
  // WebSocket 连接
  const socket = new WebSocket('wss://api.example.com/audio-stream');
  let isConnected = false;
  
  // 连接建立后开始录音
  socket.addEventListener('open', async () => {
    console.log('WebSocket 连接已建立');
    isConnected = true;
    
    // 发送初始化消息
    socket.send(JSON.stringify({
      type: 'init',
      format: 'wav',
      sampleRate: 16000,
      language: 'zh-CN'
    }));
    
    // 创建流式录音器
    const recorder = await createRecorder({
      format: 'wav',
      sampleRate: 16000,
      isStreaming: true,
      timeslice: 200,  // 更小的间隔，更实时的体验
      
      // 流式数据回调
      onDataAvailable: async (blob) => {
        if (isConnected) {
          try {
            // 将 Blob 转换为 Base64 并发送
            const base64 = await audioBlobToBase64(blob);
            
            // 发送音频数据
            socket.send(JSON.stringify({
              type: 'audio',
              data: base64
            }));
          } catch (error) {
            console.error('发送音频数据失败:', error);
          }
        }
      }
    });
    
    // 开始录音
    await recorder.start();
    
    // 处理来自服务器的消息
    socket.addEventListener('message', (event) => {
      try {
        const message = JSON.parse(event.data);
        
        // 处理不同类型的消息
        switch (message.type) {
          case 'result':
            // 临时识别结果
            updatePartialResult(message.text);
            break;
          case 'finalResult':
            // 最终识别结果
            updateFinalResult(message.text);
            break;
          case 'error':
            console.error('服务器错误:', message.error);
            break;
        }
      } catch (error) {
        console.error('处理服务器消息失败:', error);
      }
    });
    
    // 停止按钮事件处理
    document.getElementById('stopButton').addEventListener('click', async () => {
      // 发送结束消息
      socket.send(JSON.stringify({
        type: 'end'
      }));
      
      // 停止录音并释放资源
      await recorder.stop();
      recorder.release();
      
      // 关闭WebSocket连接
      socket.close();
      isConnected = false;
    });
    
    // 处理WebSocket错误和关闭
    socket.addEventListener('error', (error) => {
      console.error('WebSocket 错误:', error);
      isConnected = false;
    });
    
    socket.addEventListener('close', () => {
      console.log('WebSocket 连接已关闭');
      isConnected = false;
      recorder.stop().then(() => recorder.release());
    });
  });
  
  // UI更新函数
  function updatePartialResult(text) {
    const resultElement = document.getElementById('partialResult');
    if (resultElement) {
      resultElement.textContent = text;
    }
  }
  
  function updateFinalResult(text) {
    const resultElement = document.getElementById('finalResult');
    if (resultElement) {
      const previousText = resultElement.textContent;
      resultElement.textContent = previousText 
        ? `${previousText} ${text}` 
        : text;
    }
  }
}
```

### 实现细节

1. **协议设计**：使用JSON消息传递指令和数据，包括初始化、音频数据和结束信号
2. **连接管理**：处理WebSocket生命周期，包括开启、错误和关闭事件
3. **数据转换**：将二进制音频数据转换为Base64进行传输
4. **双向通信**：接收和处理服务器发送的实时识别结果

## 服务器端实现

以下提供了使用不同语言和框架实现的服务器端示例，支持上述两种集成模式。

### Node.js 实现 (基于百度语音识别)

```javascript
// Express 服务器示例
const express = require('express');
const multer = require('multer');
const { Readable } = require('stream');
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

// 用于存储会话数据
const sessions = new Map();

// 百度语音识别 API 配置
const baiduConfig = {
  apiKey: 'YOUR_BAIDU_API_KEY',
  secretKey: 'YOUR_BAIDU_SECRET_KEY',
  appId: 'YOUR_BAIDU_APP_ID',
  accessToken: null,
  expiresAt: 0
};

// 获取百度 API 访问令牌
async function getBaiduAccessToken() {
  const now = Date.now();
  
  // 如果令牌有效，直接返回
  if (baiduConfig.accessToken && now < baiduConfig.expiresAt) {
    return baiduConfig.accessToken;
  }
  
  // 获取新令牌
  try {
    const response = await axios.get(
      'https://aip.baidubce.com/oauth/2.0/token',
      {
        params: {
          grant_type: 'client_credentials',
          client_id: baiduConfig.apiKey,
          client_secret: baiduConfig.secretKey
        }
      }
    );
    
    baiduConfig.accessToken = response.data.access_token;
    // 设置过期时间（提前5分钟过期，确保安全）
    baiduConfig.expiresAt = now + (response.data.expires_in - 300) * 1000;
    
    return baiduConfig.accessToken;
  } catch (error) {
    console.error('获取百度访问令牌失败:', error);
    throw new Error('无法获取语音识别授权');
  }
}

// 调用百度语音识别 API
async function recognizeSpeech(audioBuffer, options = {}) {
  try {
    const accessToken = await getBaiduAccessToken();
    
    // 将音频转为 Base64
    const base64Audio = audioBuffer.toString('base64');
    
    // 设置默认参数
    const params = {
      format: options.format || 'wav',
      rate: options.sampleRate || 16000,
      channel: options.channelCount || 1,
      dev_pid: 1537,  // 普通话识别模型，根据需要可更改
      cuid: 'mstf-kit',
      speech: base64Audio,
      len: audioBuffer.length
    };
    
    // 调用 API
    const response = await axios.post(
      `https://vop.baidu.com/server_api?access_token=${accessToken}`,
      params
    );
    
    if (response.data.err_no !== 0) {
      throw new Error(`百度语音识别错误: ${response.data.err_msg}`);
    }
    
    return response.data.result[0];
  } catch (error) {
    console.error('语音识别失败:', error);
    throw error;
  }
}

// 处理音频数据的端点
app.post('/speech-recognition', upload.single('audio'), async (req, res) => {
  try {
    const audioBuffer = req.file.buffer;
    const sessionId = req.body.sessionId || Date.now().toString();
    const isFinal = req.body.isFinal === 'true';
    
    // 获取或创建会话
    let session = sessions.get(sessionId);
    if (!session) {
      session = {
        audioChunks: [],
        partialText: '',
      };
      sessions.set(sessionId, session);
    }
    
    // 添加新的音频数据
    session.audioChunks.push(audioBuffer);
    
    if (!isFinal) {
      // 如果不是最终块，直接识别当前音频块
      try {
        const recognitionResult = await recognizeSpeech(audioBuffer, {
          format: 'wav',
          sampleRate: 16000
        });
        
        session.partialText = recognitionResult;
        
        // 返回当前识别的文本和会话ID
        res.json({
          sessionId,
          text: session.partialText,
        });
      } catch (error) {
        console.error('部分识别失败:', error);
        // 失败时也返回会话ID，前端可以继续发送
        res.json({
          sessionId,
          text: session.partialText,
          error: '部分识别失败'
        });
      }
    } else {
      // 最终块，合并所有音频并进行完整识别
      // 注意：由于百度API对音频长度有限制，可能需要分段识别
      // 这里简化处理，实际应用中需要考虑长音频的分段识别
      const completeAudio = Buffer.concat(session.audioChunks);
      
      try {
        const finalResult = await recognizeSpeech(completeAudio, {
          format: 'wav',
          sampleRate: 16000
        });
        
        // 清理会话
        sessions.delete(sessionId);
        
        // 返回最终文本
        res.json({
          sessionId,
          text: finalResult,
          isFinal: true,
        });
      } catch (error) {
        console.error('最终识别失败:', error);
        res.status(500).json({ 
          error: '最终识别失败',
          details: error.message
        });
      }
    }
  } catch (error) {
    console.error('处理音频失败:', error);
    res.status(500).json({ error: '处理音频失败' });
  }
});

// WebSocket 处理
const WebSocket = require('ws');
const server = app.listen(3000);
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
  console.log('WebSocket 连接已建立');
  
  // 每个连接的状态
  const state = {
    format: 'wav',
    sampleRate: 16000,
    language: 'zh-CN',
    audioChunks: [],
  };
  
  // 处理消息
  ws.on('message', async (message) => {
    try {
      const msg = JSON.parse(message);
      
      switch (msg.type) {
        case 'init':
          // 初始化连接
          state.format = msg.format || 'wav';
          state.sampleRate = msg.sampleRate || 16000;
          state.language = msg.language || 'zh-CN';
          break;
          
        case 'audio':
          // 接收音频数据
          const audioBuffer = Buffer.from(msg.data, 'base64');
          state.audioChunks.push(audioBuffer);
          
          // 进行实时语音识别
          try {
            const recognitionResult = await recognizeSpeech(audioBuffer, {
              format: state.format,
              sampleRate: state.sampleRate
            });
            
            // 发送识别结果回客户端
            ws.send(JSON.stringify({
              type: 'result',
              text: recognitionResult,
            }));
          } catch (error) {
            console.error('实时识别失败:', error);
            ws.send(JSON.stringify({
              type: 'error',
              error: '实时识别失败',
              details: error.message
            }));
          }
          break;
          
        case 'end':
          // 客户端结束录音
          const completeAudio = Buffer.concat(state.audioChunks);
          
          try {
            // 进行最终识别
            const finalResult = await recognizeSpeech(completeAudio, {
              format: state.format,
              sampleRate: state.sampleRate
            });
            
            // 发送最终结果
            ws.send(JSON.stringify({
              type: 'finalResult',
              text: finalResult,
            }));
          } catch (error) {
            console.error('最终识别失败:', error);
            ws.send(JSON.stringify({
              type: 'error',
              error: '最终识别失败',
              details: error.message
            }));
          }
          break;
      }
    } catch (error) {
      console.error('处理WebSocket消息失败:', error);
      ws.send(JSON.stringify({
        type: 'error',
        error: '处理消息失败',
      }));
    }
  });
  
  ws.on('close', () => {
    console.log('WebSocket 连接已关闭');
    // 清理资源
  });
});

console.log('服务器运行在 http://localhost:3000');
```

### Python 实现 (基于 FastAPI)

```python
from fastapi import FastAPI, UploadFile, File, Form, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import asyncio
import base64
import json
import io
import os
import uuid
import httpx
import time
from typing import List, Dict, Optional, Any
from pydantic import BaseModel

app = FastAPI(title="语音识别服务器")

# 启用CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 生产环境中应该限制为特定域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 会话存储
sessions: Dict[str, Dict[str, Any]] = {}

# 百度语音识别API配置
BAIDU_API_KEY = "YOUR_BAIDU_API_KEY"
BAIDU_SECRET_KEY = "YOUR_BAIDU_SECRET_KEY"
BAIDU_APP_ID = "YOUR_BAIDU_APP_ID"
access_token = None
token_expires_at = 0

# 获取百度API访问令牌
async def get_baidu_access_token():
    global access_token, token_expires_at
    
    # 如果令牌有效，直接返回
    if access_token and time.time() < token_expires_at:
        return access_token
    
    # 获取新令牌
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                "https://aip.baidubce.com/oauth/2.0/token",
                params={
                    "grant_type": "client_credentials",
                    "client_id": BAIDU_API_KEY,
                    "client_secret": BAIDU_SECRET_KEY
                }
            )
            
            data = response.json()
            access_token = data["access_token"]
            # 设置过期时间（提前5分钟过期，确保安全）
            token_expires_at = time.time() + data["expires_in"] - 300
            
            return access_token
    except Exception as e:
        print(f"获取百度访问令牌失败: {e}")
        raise Exception("无法获取语音识别授权")

# 调用百度语音识别API
async def recognize_speech(audio_data: bytes, options: Dict = None):
    if options is None:
        options = {}
    
    try:
        token = await get_baidu_access_token()
        
        # 将音频转为Base64
        base64_audio = base64.b64encode(audio_data).decode('utf-8')
        
        # 设置默认参数
        params = {
            "format": options.get("format", "wav"),
            "rate": options.get("sampleRate", 16000),
            "channel": options.get("channelCount", 1),
            "dev_pid": 1537,  # 普通话识别模型，根据需要可更改
            "cuid": "mstf-kit-python",
            "speech": base64_audio,
            "len": len(audio_data)
        }
        
        # 调用API
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"https://vop.baidu.com/server_api?access_token={token}",
                json=params
            )
            
            result = response.json()
            
            if result["err_no"] != 0:
                raise Exception(f"百度语音识别错误: {result['err_msg']}")
            
            return result["result"][0]
    except Exception as e:
        print(f"语音识别失败: {e}")
        raise e

# REST API 路由
@app.post("/speech-recognition")
async def speech_recognition(
    audio: UploadFile = File(...),
    sessionId: Optional[str] = Form(None),
    isFinal: bool = Form(False)
):
    try:
        # 读取音频数据
        audio_data = await audio.read()
        
        # 创建或获取会话
        session_id = sessionId or str(uuid.uuid4())
        if session_id not in sessions:
            sessions[session_id] = {
                "audio_chunks": [],
                "partial_text": ""
            }
        
        session = sessions[session_id]
        
        # 添加新的音频数据
        session["audio_chunks"].append(audio_data)
        
        if not isFinal:
            # 如果不是最终块，直接识别当前音频块
            try:
                recognition_result = await recognize_speech(audio_data, {
                    "format": "wav",
                    "sampleRate": 16000
                })
                
                session["partial_text"] = recognition_result
                
                # 返回当前识别的文本和会话ID
                return {
                    "sessionId": session_id,
                    "text": session["partial_text"]
                }
            except Exception as e:
                # 失败时也返回会话ID，前端可以继续发送
                return {
                    "sessionId": session_id,
                    "text": session["partial_text"],
                    "error": f"部分识别失败: {str(e)}"
                }
        else:
            # 最终块，合并所有音频并进行完整识别
            complete_audio = b''.join(session["audio_chunks"])
            
            try:
                final_result = await recognize_speech(complete_audio, {
                    "format": "wav",
                    "sampleRate": 16000
                })
                
                # 清理会话
                del sessions[session_id]
                
                # 返回最终文本
                return {
                    "sessionId": session_id,
                    "text": final_result,
                    "isFinal": True
                }
            except Exception as e:
                return {
                    "error": "最终识别失败",
                    "details": str(e)
                }
    except Exception as e:
        return {"error": f"处理音频失败: {str(e)}"}

# WebSocket 连接管理
class ConnectionManager:
    def __init__(self):
        self.active_connections: Dict[WebSocket, Dict] = {}

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections[websocket] = {
            "format": "wav",
            "sampleRate": 16000,
            "language": "zh-CN",
            "audio_chunks": []
        }

    def disconnect(self, websocket: WebSocket):
        if websocket in self.active_connections:
            del self.active_connections[websocket]

    async def send_message(self, websocket: WebSocket, message: Dict):
        await websocket.send_text(json.dumps(message))

manager = ConnectionManager()

# WebSocket 路由
@app.websocket("/ws/audio-stream")
async def websocket_audio_stream(websocket: WebSocket):
    await manager.connect(websocket)
    
    try:
        while True:
            data = await websocket.receive_text()
            message = json.loads(data)
            
            state = manager.active_connections[websocket]
            
            if message["type"] == "init":
                # 初始化连接
                state["format"] = message.get("format", "wav")
                state["sampleRate"] = message.get("sampleRate", 16000)
                state["language"] = message.get("language", "zh-CN")
                await manager.send_message(websocket, {
                    "type": "info",
                    "message": "连接初始化成功"
                })
            
            elif message["type"] == "audio":
                # 接收音频数据
                audio_data = base64.b64decode(message["data"])
                state["audio_chunks"].append(audio_data)
                
                # 进行实时语音识别
                try:
                    recognition_result = await recognize_speech(audio_data, {
                        "format": state["format"],
                        "sampleRate": state["sampleRate"]
                    })
                    
                    # 发送识别结果回客户端
                    await manager.send_message(websocket, {
                        "type": "result",
                        "text": recognition_result
                    })
                except Exception as e:
                    await manager.send_message(websocket, {
                        "type": "error",
                        "error": "实时识别失败",
                        "details": str(e)
                    })
            
            elif message["type"] == "end":
                # 客户端结束录音
                if state["audio_chunks"]:
                    complete_audio = b''.join(state["audio_chunks"])
                    
                    try:
                        # 进行最终识别
                        final_result = await recognize_speech(complete_audio, {
                            "format": state["format"],
                            "sampleRate": state["sampleRate"]
                        })
                        
                        # 发送最终结果
                        await manager.send_message(websocket, {
                            "type": "finalResult",
                            "text": final_result
                        })
                    except Exception as e:
                        await manager.send_message(websocket, {
                            "type": "error",
                            "error": "最终识别失败",
                            "details": str(e)
                        })
    
    except WebSocketDisconnect:
        manager.disconnect(websocket)
    except Exception as e:
        await manager.send_message(websocket, {
            "type": "error",
            "error": f"处理消息失败: {str(e)}"
        })
        manager.disconnect(websocket)

if __name__ == "__main__":
    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
```

### 关键服务器功能

1. **会话管理**：使用Map/Dict存储会话状态，包括累积的音频数据
2. **API集成**：与百度语音识别API的集成，包括令牌管理和API调用
3. **流式处理**：支持实时音频流处理和结果返回
4. **多平台支持**：提供Node.js和Python两种实现，支持不同的开发环境

## 最佳实践

### 音频质量与性能平衡

- 对于语音识别，通常16kHz、单声道、16位的音频格式是最佳选择
- `timeslice`参数设置过小会导致过多的网络请求，过大则影响实时性
- 考虑在客户端进行简单的降噪处理，提高识别准确率

### 安全考虑

- 实现适当的身份验证和授权机制，防止未授权访问
- 考虑对音频数据进行加密，特别是包含敏感信息的内容
- 实现速率限制，防止DoS攻击

### 可扩展性设计

- 使用消息队列处理高流量场景下的音频处理请求
- 考虑使用微服务架构分离录音和识别功能
- 实现重试机制和断点续传功能，提高系统稳定性

## 常见问题

### 1. 录音无法正常工作

- 确保在HTTPS环境下或localhost运行，现代浏览器要求安全上下文才能访问麦克风
- 检查是否已获得麦克风访问权限
- 确认录音参数（格式、采样率等）设置正确

### 2. 服务器连接问题

- 检查网络连接和CORS配置
- WebSocket连接可能需要特殊的代理设置，特别是在企业网络环境中
- 实现连接重试和自动重连机制

### 3. 语音识别准确率低

- 提高音频质量，使用更好的麦克风或降噪设置
- 调整采样率和位深度，确保与识别服务兼容
- 考虑专业的语音识别服务，如Google Speech-to-Text、阿里云、百度等

### 4. 实时性能不佳

- 减小音频数据块大小（timeslice参数）
- 使用WebSocket而非REST API获得更低的延迟
- 优化服务器处理逻辑，减少不必要的计算

---

本指南提供了基础框架，您可以根据具体需求进行调整和扩展。如有特定问题，请参考官方文档或联系我们的支持团队。
