技术
| 后端 |
Spring Boot + WebSocket + 阿里云 NLS |
实时音频采集、NLS 语音转文字、超时控制、数据推送 |
| 前端 |
HTML5 + Web Audio API + Canvas |
麦克风权限获取、实时波形可视化、识别结果显示 |
| 前后端通信 |
WebSocket |
实时传输「波形数据」和「识别结果」 |
| 音频格式 |
PCM 16K 16 位单声道 |
兼容阿里云 NLS 实时识别要求 |
- 初始化阶段:前端请求麦克风权限 → 建立 WebSocket 连接 → 后端初始化音频采集、NLS 客户端、30s 超时定时器。
- 实时交互阶段:
- 前端:通过 Web Audio API 分析麦克风流,生成波形数据;接收后端推送的「识别结果」并显示。
- 后端:采集麦克风音频 → 计算波形振幅数据(推给前端)→ 分块发送音频给 NLS → 接收 NLS 实时识别结果(推给前端)。
- 停止阶段:30s 超时或手动停止 → 后端关闭音频采集 / NLS 连接 → 前端停止波形绘制并提示。
后端实现(java+springboot)
maven地址
https://mvnrepository.com/artifact/clojure-interop/javax.sound/1.0.2
核心依赖
<!-- Spring WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 阿里云NLS -->
<dependency>
<groupId>com.aliyun.nls</groupId>
<artifactId>nls-sdk-java</artifactId>
<version>2.2.18</version>
</dependency>
<!-- 音频采集 -->
<dependency>
<groupId>javax.sound</groupId>
<artifactId>jsound</artifactId>
<version>1.0.2</version>
</dependency>
package com.whale.cloud.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* WebSocket配置:启用WebSocket端点
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
package com.whale.cloud.service;
import com.alibaba.nls.client.NlsClient;
import com.alibaba.nls.client.protocol.InputFormatEnum;
import com.alibaba.nls.client.protocol.SampleRateEnum;
import com.alibaba.nls.client.protocol.asr.SpeechTranscriber;
import com.alibaba.nls.client.protocol.asr.SpeechTranscriberListener;
import com.alibaba.nls.client.protocol.asr.SpeechTranscriberResponse;
import com.whale.cloud.config.NLSConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.sound.sampled.*;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 实时语音WebSocket服务:处理波形推送、实时转文字、超时控制
*/
@Slf4j
@Component
@ServerEndpoint("/ws/realtime-speech") // WebSocket端点地址
public class RealtimeSpeechWebSocket {
// 音频配置(必须与NLS一致:PCM 16K 16位单声道)
private static final AudioFormat AUDIO_FORMAT = new AudioFormat(
16000F, // 采样率:16K
16, // 位深度:16位
1, // 声道:单声道
true, // signed
false // little-endian
);
private static final int BUFFER_SIZE = 3200; // 音频块大小(与NLS匹配)
private static final int TIMEOUT_SECONDS = 30; // 超时时间:30s
// 注入NLS配置(单例NlsClient)
@Autowired
private NLSConfig nlsConfig;
@Autowired
private NlsClient nlsClient;
// 本地资源
private Session webSocketSession; // WebSocket会话
private TargetDataLine micLine; // 麦克风音频采集线
private SpeechTranscriber transcriber; // NLS实时识别实例
private ScheduledExecutorService timeoutExecutor; // 超时定时器
private boolean isRunning = false; // 运行状态标记
/**
* 1. 客户端连接WebSocket时触发
*/
@OnOpen
public void onOpen(Session session) {
this.webSocketSession = session;
this.isRunning = true;
log.info("WebSocket连接建立,会话ID:{}", session.getId());
try {
// 初始化麦克风采集
initMicCapture();
// 初始化NLS实时识别
initNlsTranscriber();
// 启动30s超时定时器
startTimeoutTimer();
// 启动音频采集+波形计算+NLS推送
startAudioProcess();
} catch (Exception e) {
log.error("初始化实时语音服务失败", e);
sendMessage("error: " + "初始化失败,请重试");
closeAllResources();
}
}
/**
* 2. 初始化麦克风采集
*/
private void initMicCapture() throws LineUnavailableException {
DataLine.Info micInfo = new DataLine.Info(TargetDataLine.class, AUDIO_FORMAT);
// 检查麦克风权限
if (!AudioSystem.isLineSupported(micInfo)) {
throw new LineUnavailableException("麦克风不支持当前音频格式(需PCM 16K 16位单声道)");
}
// 打开麦克风采集线
micLine = (TargetDataLine) AudioSystem.getLine(micInfo);
micLine.open(AUDIO_FORMAT);
micLine.start();
log.info("麦克风采集初始化完成");
}
/**
* 3. 初始化NLS实时识别(支持中英文混合)
*/
private void initNlsTranscriber() {
// 构建NLS监听器(实时接收识别结果)
SpeechTranscriberListener listener = new SpeechTranscriberListener() {
// 中英文混合识别结果(句子结束时推送)
@Override
public void onSentenceEnd(SpeechTranscriberResponse response) {
if ("20000000".equals(response.getStatus())) {
String result = response.getTransSentenceText(); // 中英文混合结果
sendMessage("result: " + result); // 推给前端显示
log.info("实时识别结果:{}", result);
}
}
// 识别失败
@Override
public void onFail(SpeechTranscriberResponse response) {
String errorMsg = "识别失败:" + response.getStatusText();
log.error(errorMsg);
sendMessage("error: " + errorMsg);
closeAllResources();
}
// 其他回调(简化,保留必要逻辑)
@Override public void onTranscriberStart(SpeechTranscriberResponse response) {}
@Override public void onSentenceBegin(SpeechTranscriberResponse response) {}
@Override public void onTranscriptionComplete(SpeechTranscriberResponse response) {}
@Override public void onTranscriptionResultChange(SpeechTranscriberResponse response) {}
};
// 创建NLS识别实例
transcriber = new SpeechTranscriber(nlsClient, listener);
transcriber.setAppKey(nlsConfig.getAppKey());
transcriber.setFormat(InputFormatEnum.PCM); // 音频格式PCM
transcriber.setSampleRate(SampleRateEnum.SAMPLE_RATE_16K); // 16K采样率
transcriber.setEnablePunctuation(true); // 保留标点(中英文标点)
transcriber.setEnableITN(false); // 关闭数字转换(避免"123"转"one hundred twenty-three")
transcriber.setEnableIntermediateResult(false); // 关闭中间结果,只推最终句子
try {
transcriber.start(); // 启动NLS识别
log.info("NLS实时识别初始化完成(支持中英文混合)");
} catch (IOException e) {
throw new RuntimeException("NLS启动失败", e);
}
}
/**
* 4. 启动30s超时定时器
*/
private void startTimeoutTimer() {
timeoutExecutor = new ScheduledThreadPoolExecutor(1);
timeoutExecutor.schedule(() -> {
if (isRunning) {
sendMessage("timeout: " + "30秒超时,已自动停止");
log.info("实时语音超时(30s),自动停止");
closeAllResources();
}
}, TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
/**
* 5. 核心:音频采集+波形计算+NLS推送
*/
private void startAudioProcess() {
new Thread(() -> {
byte[] audioBuffer = new byte[BUFFER_SIZE];
while (isRunning && micLine != null && micLine.isOpen()) {
try {
// 步骤1:采集音频块(从麦克风)
int readLen = micLine.read(audioBuffer, 0, BUFFER_SIZE);
if (readLen <= 0) break;
// 步骤2:计算波形数据(振幅归一化,用于前端可视化)
int[] waveData = calculateWaveData(audioBuffer, readLen);
sendMessage("wave: " + arrayToString(waveData)); // 推波形给前端
// 步骤3:将音频块推给NLS(实时识别)
if (transcriber != null && transcriber.isStarted()) {
transcriber.send(audioBuffer, readLen);
}
} catch (Exception e) {
log.error("音频处理异常", e);
break;
}
}
}, "Audio-Process-Thread").start();
}
/**
* 计算波形数据:将PCM音频块转为振幅数组(归一化到0-100,方便前端绘制)
*/
private int[] calculateWaveData(byte[] audioBuffer, int readLen) {
int[] waveData = new int[readLen / 2]; // 16位PCM:2字节=1个样本
for (int i = 0; i < readLen; i += 2) {
// 16位PCM数据:拼接2字节为有符号整数(振幅)
short sample = (short) ((audioBuffer[i] & 0xFF) | (audioBuffer[i + 1] << 8));
// 振幅取绝对值,归一化到0-100(前端绘制范围)
int amplitude = Math.min(Math.abs(sample) / 327, 100); // 32768(16位最大值)/100≈327
waveData[i / 2] = amplitude;
}
return waveData;
}
/**
* WebSocket发送消息给前端
*/
private void sendMessage(String message) {
if (webSocketSession != null && webSocketSession.isOpen()) {
try {
webSocketSession.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("WebSocket消息发送失败", e);
}
}
}
/**
* 客户端断开连接时触发
*/
@OnClose
public void onClose(SessionCloseReason reason) {
log.info("WebSocket连接关闭,原因:{}", reason.getReasonPhrase());
closeAllResources();
}
/**
* 关闭所有资源(麦克风、NLS、定时器、WebSocket)
*/
private void closeAllResources() {
this.isRunning = false;
// 1. 关闭麦克风
if (micLine != null && micLine.isOpen()) {
micLine.stop();
micLine.close();
}
// 2. 关闭NLS识别
if (transcriber != null) {
transcriber.stop();
transcriber.close();
}
// 3. 关闭超时定时器
if (timeoutExecutor != null) {
timeoutExecutor.shutdownNow();
}
// 4. 关闭WebSocket
if (webSocketSession != null && webSocketSession.isOpen()) {
try {
webSocketSession.close();
} catch (IOException e) {
log.error("WebSocket关闭异常", e);
}
}
log.info("所有实时语音资源已关闭");
}
/**
* 工具方法:数组转字符串(前端解析波形数据用)
*/
private String arrayToString(int[] array) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < array.length; i++) {
sb.append(array[i]);
if (i < array.length - 1) sb.append(",");
}
return sb.toString();
}
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>实时语音波形+转文字</title>
<style>
.container { width: 800px; margin: 50px auto; }
#waveCanvas { width: 100%; height: 150px; border: 1px solid #ccc; background: #f5f5f5; }
#resultBox { margin-top: 20px; padding: 15px; border: 1px solid #eee; min-height: 50px; font-size: 18px; }
.status { margin-top: 10px; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h2>麦克风实时语音(中英文混合)</h2>
<!-- 波形可视化Canvas -->
<canvas id="waveCanvas"></canvas>
<!-- 实时识别结果 -->
<div id="resultBox">识别结果:</div>
<!-- 状态提示 -->
<div class="status">状态:等待连接...(30秒后自动停止)</div>
<!-- 手动停止按钮 -->
<button id="stopBtn" style="margin-top:10px; padding:8px 16px; background:#ff4444; color:white; border:none; cursor:pointer;">
手动停止
</button>
</div>
<script>
// 1. 初始化DOM元素
const waveCanvas = document.getElementById('waveCanvas');
const ctx = waveCanvas.getContext('2d');
const resultBox = document.getElementById('resultBox');
const statusText = document.querySelector('.status');
const stopBtn = document.getElementById('stopBtn');
// 2. 调整Canvas实际分辨率(避免模糊)
waveCanvas.width = waveCanvas.offsetWidth * 2;
waveCanvas.height = waveCanvas.offsetHeight * 2;
ctx.scale(2, 2); // 缩放绘图上下文
// 3. WebSocket连接(替换为你的后端地址)
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/realtime-speech`;
let webSocket = new WebSocket(wsUrl);
// 4. 波形绘制配置
let waveData = []; // 存储实时波形数据
const waveColor = '#2196F3'; // 波形颜色
const waveWidth = 2; // 波形柱宽度
const waveGap = 1; // 波形柱间距
/**
* 5. WebSocket消息处理
*/
webSocket.onmessage = function(event) {
const message = event.data;
if (message.startsWith('wave:')) {
// 处理波形数据
const waveStr = message.split(':')[1];
waveData = waveStr.split(',').map(Number); // 转为数字数组
drawWaveform(); // 绘制波形
} else if (message.startsWith('result:')) {
// 处理识别结果(中英文混合)
const result = message.split(':')[1];
resultBox.innerHTML = `识别结果:${result}`;
} else if (message.startsWith('timeout:')) {
// 处理超时
const timeoutMsg = message.split(':')[1];
statusText.textContent = `状态:${timeoutMsg}`;
stopBtn.disabled = true;
} else if (message.startsWith('error:')) {
// 处理错误
const errorMsg = message.split(':')[1];
statusText.textContent = `状态:错误 - ${errorMsg}`;
resultBox.innerHTML = `识别结果:错误 - ${errorMsg}`;
stopBtn.disabled = true;
}
};
// WebSocket连接成功
webSocket.onopen = function() {
statusText.textContent = '状态:正在录音...(30秒后自动停止)';
};
// WebSocket连接关闭
webSocket.onclose = function() {
statusText.textContent = '状态:已停止';
stopBtn.disabled = true;
};
// WebSocket错误
webSocket.onerror = function(error) {
statusText.textContent = `状态:连接错误 - ${error.message}`;
};
/**
* 6. Canvas绘制实时波形
*/
function drawWaveform() {
// 清空Canvas
ctx.clearRect(0, 0, waveCanvas.offsetWidth, waveCanvas.offsetHeight);
const canvasHeight = waveCanvas.offsetHeight;
const canvasWidth = waveCanvas.offsetWidth;
// 计算波形柱总数(避免超出Canvas宽度)
const maxWaveCount = Math.floor(canvasWidth / (waveWidth + waveGap));
const drawData = waveData.slice(0, maxWaveCount); // 截取有效数据
// 绘制每个波形柱(垂直柱形)
drawData.forEach((amplitude, index) => {
const x = index * (waveWidth + waveGap); // 柱形X坐标
const柱形高度 = (amplitude / 100) * (canvasHeight / 2); // 振幅→高度(归一化)
const y = (canvasHeight / 2) - (柱形高度 / 2); // 柱形Y坐标(居中)
// 绘制柱形
ctx.fillStyle = waveColor;
ctx.fillRect(x, y, waveWidth, 柱形高度);
});
}
/**
* 7. 手动停止按钮点击事件
*/
stopBtn.addEventListener('click', function() {
if (webSocket.readyState === WebSocket.OPEN) {
webSocket.close();
}
statusText.textContent = '状态:已手动停止';
this.disabled = true;
});
</script>
</body>
</html>
- 阿里云 NLS 默认支持中英文混合输入,无需额外配置;
- 后端通过
transcriber.setEnableITN(false)关闭 “数字转换”(避免中文 “一百” 转英文 “one hundred”);
setEnablePunctuation(true)保留中英文标点(如 “Hello,世界!”)。
- 后端用
ScheduledExecutorService定时 30 秒,超时后触发closeAllResources();
- 超时前会向前端推送
timeout: 30秒超时,已自动停止消息,前端更新 UI。
- 后端采集音频时,将 16 位 PCM 数据转为 “振幅数组”(归一化到 0-100),通过 WebSocket 推给前端;
- 前端用 Web Audio API 辅助音频流处理,Canvas 绘制垂直柱形波形,振幅越大柱形越高。
-
麦克风权限:
- 前端需在 HTTPS 环境下运行(localhost除外),否则浏览器会拒绝麦克风权限;
- 测试时需点击浏览器的 “允许麦克风访问” 弹窗。
-
音频格式兼容性:
- 确保后端音频采集格式为
PCM 16K 16位单声道(NLS 强制要求),否则识别失败;
- 部分浏览器 / 麦克风可能不支持该格式,需在前端添加格式检测。
-
性能优化:
- 前端波形绘制频率控制在
30fps以内(通过截取波形数据长度避免卡顿);
- 后端音频采集线程单独运行,避免阻塞 WebSocket 主线程。
-
阿里云 NLS 配置:
- 确保
NLSConfig中的appKey、accessKeyId、accessKeySecret正确,且已开通 “实时语音识别” 服务;
- 若需更高识别准确率,可在 NLS 控制台配置 “自定义词汇表”(针对行业术语)。