我们可以先基于 web端语音识别 这个功能点,提出两个问题
- 语音如何采集。即在程序中,我们如何获取到我们说的话(音频数据)
- 如何传输语音。如何将采集到的音频数据交给 whisper 进行识别
这两个问题其实也很简单,都有成熟的解决方案
对于 语音采集,在 web端 我们可以用浏览器基于 WebRTC 技术提供的流媒体相关接口调用麦克风来完成
对于 语音传输,当然还是走传统的 http,架设一个服务端提供 语音识别 接口来供前端调用
技术选型
基于上述简要的分析,web应用部分比较简单
- 构建工具:
vite
- 框架:
React
- 组件库:
antd
- 语音采集:recordrtc,
webm-to-wav-converter
由于 whisper 需要通过 python 调用,因此服务端基于 python 技术栈来做
实现过程
做好了相关技术栈的选定后,我们就开始着手搭建环境和编码
web应用
为了测试 语音识别 这个功能,我们简单设计一下 UI
- 做一个
按钮
来控制 开始录音 和 结束录音 - 做一个
列表
来展示每次录音的 识别结果
OK,有了基本的设计,我们用 vite 的 react-ts 模板来初始化项目
pnpm create vite whisper-demo-for-web --template react-ts
创建好项目之后,安装依赖,启动环境
这些过程就不赘述了,我们直接进入编码阶段
首先来实现按钮部分,我们设计一个 RecordButtom
组件
- 将 WebRTC 的调用以及 语音识别接口 的调用一并封装在内
- 提供一个 onResult 回调,用于返回识别结果
import React, { useState } from 'react';
import { Button, Spin } from 'antd';
import AudioRTC from "../sdk/AudioRTC";
import AudioAI from '../sdk/AudioAI';
// 定义识别结果
export type Result = {
// 识别内容 or 错误信息
text: string
// 识别耗时
transcribe_time?: number
}
type Props = {
// 识别完成事件
onResult?: (result: Result) => void;
}
// 状态枚举
enum Status {
// 空闲
IDLE = 'idle',
// 记录中
RECORDING = 'recording',
}
// 文本映射
const labelMapper = {
[Status.IDLE]: '开始录音',
[Status.RECORDING]: '停止录音',
}
// 过程转换映射
const processStatusMapper = {
[Status.IDLE]: Status.RECORDING,
[Status.RECORDING]: Status.IDLE,
}
// 初始化AudioAI
const audioAI = new AudioAI();
// 初始化AudioRTC
const audioRTC = new AudioRTC();
export default function RecordButton(props: Props) {
const [status, setStatus] = useState(Status.IDLE);
const [loading, setLoading] = useState(false);
// 点击事件
const onClick = async () => {
if (status === Status.IDLE) {
// 开始录制
audioRTC.startRecording();
}
if (status === Status.RECORDING) {
// 结束录制
await audioRTC.stopRecording();
// 获取 wav 格式的 blob
const waveBlob = await audioRTC.getWaveBlob();
try {
setLoading(true);
// 调用接口 - 语音识别
const response = await audioAI.toText(waveBlob)
props.onResult?.(response);
} catch (error) {
props.onResult?.({ text: `${error}` });
} finally {
setLoading(false)
}
}
setStatus(processStatusMapper[status]);
}
return (
<>
<Button onClick={onClick}>{labelMapper[status]}</Button>
{
loading &&
<Spin tip="识别中..." size="small">
<span className="content" />
</Spin>
}
</>
)
}
在按钮的实现过程中,我们又抽象了两个模块出来
AudioAI
提供 AI 的处理能力,由于功能比较简单,只实现了一个 toText 方法,本质上是调用服务端接口,获取识别结果后返回
// AudioAI.ts
import { fetchAudioToText } from "./service";
export default class AudioAI {
async toText(audio: Blob) {
const response = await fetchAudioToText(audio)
return response.data
}
}
// service.ts
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
})
export const fetchAudioToText = async (audio: Blob) => {
const formData = new FormData()
formData.append('audio', audio)
formData.append('timestamp', String(+new Date()))
return api.post('/audioToText', formData)
}
AudioRTC
封装调用流媒体的操作,提供开始录制,结束录制,获取音频流
等相关 API
// AudioRTC.ts
import RecordRTC from 'recordrtc';
import { getWaveBlob } from 'webm-to-wav-converter'
export default class AudioRTC {
stream!: MediaStream;
recorder!: RecordRTC
/**
* 开始录制
*/
async startRecording() {
if (!this.recorder) {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true })
this.recorder = new RecordRTC(this.stream, {
type: 'audio'
})
}
this.recorder.startRecording()
}
/**
* 结束录制
* @returns
*/
stopRecording(): Promise<Blob> {
if (!this.recorder) {
return Promise.reject('Recorder is not initialized')
}
return new Promise((resolve) => {
this.recorder.stopRecording(() => {
const blob = this.recorder.getBlob()
resolve(blob)
})
})
}
/**
* 获取 blob
* @returns
*/
getWaveBlob() {
const blob = this.recorder.getBlob()
return getWaveBlob(blob, false);
}
}
OK,按钮部分的编码至此结束,接下来就是列表部分的实现,由于比较简单,我们就直接写在 根组件 中,大致逻辑就是
- 存储一个 识别结果 列表
- 注册一个 onResult 事件,在收到识别结果时将其推入列表
- 渲染列表,列表项中展示
序号,识别耗时及内容
等信息
import { useState } from 'react'
import './App.css'
import RecordButton, { Result } from './components/RecordButton'
import { Divider, List, Typography } from 'antd'
function App() {
const [list, setList] = useState<Result[]>([])
const onResult = (result: Result) => {
setList((prev) => [...prev, result])
}
return (
<div className="App">
<RecordButton onResult={onResult} />
<Divider orientation="left">识别记录</Divider>
<List
bordered
dataSource={list}
renderItem={(item, index) => (
<List.Item style={{ justifyContent: 'flex-start' }}>
{/* 序号 */}
<span>[{index + 1}]</span>
{/* 耗时 */}
<Typography.Text mark style={{ margin: '0 6px' }}>
识别耗时:{item.transcribe_time}s
</Typography.Text>
{/* 内容 */}
<span style={{ margin: '0 6px' }}>{item.text}</span>
</List.Item>
)}
/>
</div>
)
}
export default App