
上周五下班的时候,产品经理对我说,我们的项目需要加一个语音控制的功能,我说具体要怎样实现?她说:你跟后端沟通一下,我笑了笑说:好~
其实是有点无奈的,在哪个页面加,加了怎么控制,具体的逻辑是啥,你都没跟我说清楚就让我加,但是回头又想了一下,她说不清楚也很正常。
我这周一回来跟后端小哥捋了一下,产品的意思应该在首页和聊天页面加,那废话不多说,直接开干!
当然,了解了干啥之后肯定是需要去网上调研一下的,别人的语音传输功能是怎么做的?甚至还在技术交流群里问了其他的一些大佬们,看看他们是怎么做的。

其中,小陈同学也提供了非常中肯的建议。

简单的看了一下,发现这个文档写的确实还挺不错的,但是仔细分析需求,我们好像也不用这么复杂。前端只需要完成录音就行,而且正常的浏览器都是支持的,最终转换文字可以直接交给后端来处理。总结一下:核心的功能就包括四个部分:
1、前端录音
2、转PCM Base64编码
3、后端转写
4、回填输入框
整体的流程图如下:

那前端主要的实现过程主要也就是这四个步骤:
1、录音控制
const clearVoiceState = () => {
mediaRecorderRef.current = null;
audioChunksRef.current = [];
if (autoStopTimerRef.current) {
window.clearTimeout(autoStopTimerRef.current);
autoStopTimerRef.current = null;
}
if (audioStreamRef.current) {
audioStreamRef.current.getTracks().forEach(track => track.stop());
audioStreamRef.current = null;
}
setIsVoiceRecording(false);
};
const stopRecording = () => {
const recorder = mediaRecorderRef.current;
if (!recorder || recorder.state === 'inactive') return;
recorder.stop();
};
const resamplePcm = (source: Float32Array, sourceRate: number, targetRate: number): Float32Array => {
if (sourceRate === targetRate) return source;
const ratio = sourceRate / targetRate;
const targetLength = Math.max(1, Math.round(source.length / ratio));
const result = new Float32Array(targetLength);
for (let i = 0; i < targetLength; i += 1) {
const sourceIndex = i * ratio;
const left = Math.floor(sourceIndex);
const right = Math.min(left + 1, source.length - 1);
const weight = sourceIndex - left;
result[i] = source[left] * (1 - weight) + source[right] * weight;
}
return result;
};
const pcmToBase64 = (pcm: Float32Array): string => {
const buffer = new ArrayBuffer(pcm.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < pcm.length; i += 1) {
const sample = Math.max(-1, Math.min(1, pcm[i]));
view.setInt16(i * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
}
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
};
const blobToPcmBase64 = async (audioBlob: Blob): Promise<string> => {
const arrayBuffer = await audioBlob.arrayBuffer();
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('AudioContext not supported');
}
const audioContext = new AudioContextClass();
try {
const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const mono = decoded.numberOfChannels > 1
? (() => {
const left = decoded.getChannelData(0);
const right = decoded.getChannelData(1);
const mixed = new Float32Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) {
mixed[i] = (left[i] + right[i]) * 0.5;
}
return mixed;
})()
: decoded.getChannelData(0);
const pcm16k = resamplePcm(mono, decoded.sampleRate, 16000);
return pcmToBase64(pcm16k);
} finally {
await audioContext.close();
}
};
const transcribeAudioBlob = async (audioBlob: Blob) => {
try {
setIsVoiceProcessing(true);
const pcmBase64 = await blobToPcmBase64(audioBlob);
const result = await post<any>('speech/transcribe', {
pcm_base64: pcmBase64,
sample_rate: 16000
}, { showError: false });
const text = typeof result?.text === 'string' ? result.text.trim() : '';
if (!text) {
message.warning('未识别到有效语音内容');
return;
}
setInputValue(prev => (prev ? `${prev}\n${text}` : text));
} catch (error) {
message.error('语音识别失败,请重试');
} finally {
setIsVoiceProcessing(false);
}
};
const handleVoiceClick = async () => {
if (isVoiceProcessing) return;
if (isVoiceRecording) {
stopRecording();
return;
}
if (!navigator?.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
message.warning('当前浏览器不支持语音输入');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStreamRef.current = stream;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
audioChunksRef.current = [];
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data && event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
recorder.onerror = () => {
clearVoiceState();
message.error('录音失败,请重试');
};
recorder.onstop = () => {
const chunks = audioChunksRef.current;
clearVoiceState();
if (!chunks.length) return;
const audioBlob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
transcribeAudioBlob(audioBlob);
};
recorder.start();
setIsVoiceRecording(true);
autoStopTimerRef.current = window.setTimeout(() => {
stopRecording();
}, 60000);
} catch (error) {
message.warning('请允许麦克风权限后重试');
clearVoiceState();
}
};
useEffect(() => {
return () => {
clearVoiceState();
};
}, []);上述代码仅包含核心hooks,页面结构就不给大家展示了。
2、处理音频格式文件
这里主要有这四个重点:重采样 + PCM16 + Base64 + Blob解码串联
首先来看重采样的函数
const resamplePcm = (source: Float32Array, sourceRate: number, targetRate: number): Float32Array => {
if (sourceRate === targetRate) return source;
const ratio = sourceRate / targetRate;
const targetLength = Math.max(1, Math.round(source.length / ratio));
const result = new Float32Array(targetLength);
for (let i = 0; i < targetLength; i += 1) {
const sourceIndex = i * ratio;
const left = Math.floor(sourceIndex);
const right = Math.min(left + 1, source.length - 1);
const weight = sourceIndex - left;
result[i] = source[left] * (1 - weight) + source[right] * weight;
}
return result;
};那为什么要重采样呢?
因为在我们录音的时候浏览器默认的频率是:44100Hz 或者 48000Hz
但后端语音接口文档明确说了只接收16000Hz的语音流(大家知道这里为什么要有这个限制吗?后端为什么不收到文件之后自己去转,非要让前端转好之后再传给他?),那我们就需要压缩。
那上面这个函数干的就是这件事儿,
const pcmToBase64 = (pcm: Float32Array): string => {
const buffer = new ArrayBuffer(pcm.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < pcm.length; i += 1) {
const sample = Math.max(-1, Math.min(1, pcm[i]));
view.setInt16(i * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
}
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
};把 Float32 音频(-1~1)
转成 16 位整型(int16)
按 小端(little-endian) 写入二进制
转成二进制字符串
最后
btoa 编码成 Base64const blobToPcmBase64 = async (audioBlob: Blob): Promise<string> => {
const arrayBuffer = await audioBlob.arrayBuffer();
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('AudioContext not supported');
}
const audioContext = new AudioContextClass();
try {
const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const mono = decoded.numberOfChannels > 1
? (() => {
const left = decoded.getChannelData(0);
const right = decoded.getChannelData(1);
const mixed = new Float32Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) {
mixed[i] = (left[i] + right[i]) * 0.5;
}
return mixed;
})()
: decoded.getChannelData(0);
const pcm16k = resamplePcm(mono, decoded.sampleRate, 16000);
return pcmToBase64(pcm16k);
} finally {
await audioContext.close();
}
};这个函数的核心实现逻辑如下:
1)、 读入二进制: Blob -> ArrayBuffer
2)、解码音频: ArrayBuffer -> AudioBuffer(Float32 PCM, 可能多声道, 原采样率不固定)
3)、规范化格式:混单声道 + 重采样到 16000Hz
4)、编码输出: Float32 PCM -> Int16 PCM -> Base64
我们录音之后的音频文件在上述代码中的数据流如下:

为什么要这样做呢?
其实是考虑到了几个方面:
首先:后端要求的格式是确定的,我们必须要使用 PCM Base64 + 16k
其次,浏览器录音并不是天生的 PCM,所以我们需要先解码
第三,因为不同的人说话的语速,音量是不一样的,导致采集到的数据频率不统一,所以我们需要在前端做标准化处理。
当然,上述方案可能也只是完成了功能,肯定会有做得不够完美的地方,也欢迎大家一起交流交流。
最后,我们需要调用后端的接口将识别成文字然后回填到聊天框内
const transcribeAudioBlob = async (audioBlob: Blob) => {
try {
setIsVoiceProcessing(true);
const pcmBase64 = await blobToPcmBase64(audioBlob);
const result = await post<any>('speech/transcribe', {
pcm_base64: pcmBase64,
sample_rate: 16000
}, { showError: false });
const text = typeof result?.text === 'string' ? result.text.trim() : '';
if (!text) {
message.warning('未识别到有效语音内容');
return;
}
setInputValue(prev => (prev ? `${prev}\n${text}` : text));
} catch (error) {
message.error('语音识别失败,请重试');
} finally {
setIsVoiceProcessing(false);
}
};如果录音时间太短,或者用户一句话也没说的话,就会导致识别失败,前端也会提醒用户再次录音。
下面我们看看整体的效果:
用户点击麦克风图标后:
首先浏览器会请求权限

点击允许后,系统开始录音
如果我没有说话的话,

停止录音后,会有上述提醒。
如果正常说话的话,系统就会识别到语音,并且将内容填充到输入框内,点击发送按钮,后端进行后续的流程。
好啦,以上就是本期文章的全部内容,感谢大家的阅读,我们下期再见~
另外,如果本期文章对你有帮助,也欢迎一键三连~