背景: 在使用百度网盘观看视频时,下面会出现一些字幕以及ai分析等,所以准备自己实现以下
建议先下载本篇文章完整代码库,然后进行阅读,按照文档步骤,可以从0到1实现全部内容
- 通过本篇文档,你可以了解如下信息
- 前端如何提取视频中的音频
- 如何在window环境部署开源大模型FunASR
- 本地启用简单http服务
- 如何实现语音分析以及AI分析
准备工作
- 创建脚手架
- 安装docker
- 了解wasmWebAssembly | MDN
一.提取视频中的音频
先说明方案,使用wasm版的ffmpeg
wasm ffmpeg架构图
1.项目中引入
先从官网下载核心文件,下载地址:
- coreURL:unpkg.com/@ffmpeg/cor…
- wasmURL:unpkg.com/@ffmpeg/cor…
下载完后放在项目public目录下的wasm目录
vite.config.ts,需要配置headers头,不然使用时报错
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
// 排除依赖分析
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
},
server: {
// 安全性和隔离性
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
2.开发提取语音的方法
开发为工具类
import { FFmpeg } from '@ffmpeg/ffmpeg';
import type { LogEvent } from '@ffmpeg/ffmpeg/dist/esm/types';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
import EventEmitter from 'eventemitter3';
import { TranscodeErrorType, TranscodeEventCollection } from './ffmpegEvent';
class TranscodeManage extends EventEmitter {
config = {};
ffmpeg = null;
isLoaded = false; // 核心库是否已经加载
constructor(config = {}) {
super();
Object.assign(this.config, config);
this.ffmpeg = new FFmpeg();
}
load = async () => {
try {
const baseURL = '/wasm'; // 本地加载
this.ffmpeg.on('log', ({ message: msg }: LogEvent) => {
console.log('msg', msg);
});
this.ffmpeg.on('progress', ({ progress }: LogEvent) => {
this.emit(TranscodeEventCollection.PROGRESS, progress);
});
// toBlobURL is used to bypass CORS issue, urls with the same
// domain can be used directly.
await this.ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
this.isLoaded = true;
this.emit(TranscodeEventCollection.LOAD_CORE_SUCCESS);
} catch (err) {
this.emit(TranscodeEventCollection.ERROR, TranscodeErrorType.LOAD_CORE_ERROR);
}
};
/**
* 从视频中读取mp3
* @param {string | File | Blob} file 文件
*/
extractAudio = async (file) => {
try {
await this.ffmpeg.writeFile('input.mp4', await fetchFile(file));
await this.ffmpeg.exec(['-i', 'input.mp4', '-map', '0:a', '-c:a', 'libmp3lame', 'output.mp3']);
const data = await this.ffmpeg.readFile('output.mp3');
// blobData
const blobData = new Blob([data.buffer], { type: 'audio/mp3' });
this.emit(TranscodeEventCollection.TRANSCODE_END_DATA, blobData);
} catch (err) {
this.emit(TranscodeEventCollection.ERROR, TranscodeErrorType.TRANSCODE_ERROR);
}
};
}
3.上传组件中引用
上传文件组件中引入转码工具 UploadFile.vue
<template>
<div>
<t-upload
ref="uploadRef"
v-model="fileList"
:multiple="false"
:auto-upload="true"
:is-batch-upload="false"
:before-upload="beforeUpload"
:request-method="requestMethod"
action=""
draggable
@fail="handleError"
@success="handleSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import TranscodeManage from '../utils/ffmpeg/TranscodeManage';
import { TranscodeEventCollection } from '../utils/ffmpeg/ffmpegEvent';
import { KeyValue } from '../global';
defineOptions({ name: 'UploadFile' });
const emits = defineEmits(['videoInfo', 'audioInfo', 'transcodeProgress']);
const fileList = ref([]);
const uploadRef: KeyValue = ref(null);
const transcodeInstance: KeyValue = ref(null);
// 手动上传
const requestMethod = async (file) => {
emits('videoInfo', file);
// 开始提取音频
await transcodeInstance.value.transcodeMp3(file.raw, 'm4a');
return {
status: 'success',
response: { url: '' },
};
};
// 初始化转码
const initTranscode = async () => {
transcodeInstance.value = new TranscodeManage();
console.log(`initTranscode===>`, transcodeInstance.value);
registerTranscodeEvent();
await transcodeInstance.value.load();
};
// 注册转码事件
const registerTranscodeEvent = () => {
transcodeInstance.value.on(TranscodeEventCollection.LOAD_CORE_SUCCESS, () => {
console.log(`registerTranscodeEvent===> LOAD_CORE_SUCCESS`);
});
transcodeInstance.value.on(TranscodeEventCollection.TRANSCODE_END_DATA, (blobData) => {
console.log(`registerTranscodeEvent===> TRANSCODE_END_DATA`, blobData);
const url = URL.createObjectURL(blobData);
emits('audioInfo', url);
});
transcodeInstance.value.on(TranscodeEventCollection.PROGRESS, (val) => {
emits('transcodeProgress', val);
});
};
onMounted(() => {
initTranscode();
});
</script>
4.demo页面中使用
App.vue
<template>
<div class="wrapper">
<div class="wrapper-header">
<upload-file
@video-info="handleVideoInfo"
@audio-info="handleAudioInfo"
@transcode-progress="handleTranscodeProgress"
></upload-file>
<t-space class="wrapper-header-audio" direction="vertical">
<div class="wrapper-header-audio__progress">
<div class="wrapper-header-audio__progress--tip">提取音频进度:</div>
<div class="wrapper-header-audio__progress--value">
<t-progress theme="plump" :percentage="percentage" />
</div>
</div>
<div class="wrapper-header-audio__player">
<audio :src="audioSrc" controls autoplay />
</div>
</t-space>
</div>
<div class="wrapper-body">
<div class="wrapper-body-video">
<video :src="videoSrc" controls autoplay />
</div>
<div class="wrapper-body-detail">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import UploadFile from './components/UploadFile.vue';
import { AudioIcon, FlagIcon, ListIcon, TreeRoundDotVerticalIcon } from 'tdesign-icons-vue-next';
const percentage = ref(0); // 视频提取音频进度
const audioSrc = ref(''); // 视频提取音频地址
const videoSrc = ref(''); // 视频地址
const handleVideoInfo = (file) => {
videoSrc.value = URL.createObjectURL(file.raw);
};
// 提取音频成功的事件
const handleAudioInfo = (blobData) => {
audioSrc.value = URL.createObjectURL(blobData);
};
const handleTranscodeProgress = (val: number) => {
percentage.value = parseInt((val * 100).toFixed(2), 10);
};
</script>
上效果图
二.本地环境部署FunASR离线转写服务
FunASR离线文件转写软件包,提供了一款功能强大的语音离线文件转写服务。拥有完整的语音识别链路,结合了语音端点检测、语音识别、标点等模型,可以将几十个小时的长音频与视频识别成带标点的文字,而且支持上百路请求同时进行转写。输出为带标点的文字,含有字级别时间戳,支持ITN与用户自定义热词等。服务端集成有ffmpeg,支持各种音视频格式输入。软件包提供有html、python、c++、java与c#等多种编程语言客户端,用户可以直接使用与进一步开发。
这里参考官网给步骤github.com/modelscope/…
1.拉取镜像
docker安装成功以后,在cmd窗口执行(管理员)
docker pull registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.6
2.本地手动创建模型的映射目录
如下图,本地新建目录:E:\docker-map\funasr-docker\funasr-runtime-resources\models
3.创建一个容器
docker run -p 10095:10095 -it --privileged=true --name funasr-container -v "E:\docker-map\funasr-docker\funasr-runtime-resources\models":/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.6
4.启动服务
如下是进入到容器中,启动服务
cd /workspace/FunASR/runtime 执行如下命令
nohup bash run_server.sh \
--download-model-dir /workspace/models \
--keyfile 0 \
--certfile 0 \
--vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx \
--model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx \
--punc-dir damo/punc_ct-transformer_cn-en-common-vocab471067-large-onnx \
--lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst \
--itn-dir thuduj12/fst_itn_zh > log.txt 2>&1 &
请注意--keyfile 0 和--certfile 0 是关闭ssl,不关闭需要使用wss协议
执行后需要等待下载模型,进度查看日志文件
cat log.txt
服务成功启动的标识
也可以使用自带的客户端进行测试服务是否搭建成功
三.将提取的语音送到大模型进行语音识别
1.websocket连接工具
wsHandler.ts
class Api {
ws: KeyValue | null = null;
url = '';
eventCollection: KeyValue = {};
supportEvent = ['open', 'close', 'message', 'error'];
constructor(url: string, options: KeyValue, event: KeyValue) {
this.options = { ...this.options, ...options };
this.url = url;
this.registerEvent(event);
}
registerEvent(eventCollection: KeyValue) {
const eventLen = this.supportEvent.length;
for (let i = 0; i < eventLen; i++) {
const eventName = this.supportEvent[i];
const customCallback = eventCollection[eventName];
if (customCallback && isFunction(customCallback)) {
this.eventCollection[eventName] = customCallback;
} else {
this.eventCollection[eventName] = (e: KeyValue) => {
window.dispatchEvent(new CustomEvent(EVENT_NAME[eventName], { detail: e }));
};
}
}
}
connect() {
this.ws = new ReconnectingWebSocket(this.url, [], this.options);
this.addEvent();
}
onOpen(e: KeyValue) {
this.eventCollection.open(e);
}
onMessage(e: KeyValue) {
const message = e.data;
try {
const data = JSON.parse(message);
this.eventCollection.message(data);
} catch (error) {
console.log();
}
}
onClose(e: KeyValue) {
this.eventCollection.close(e);
this.removeEvent();
}
onError(e: KeyValue) {
this.eventCollection.error(e);
this.removeEvent();
}
send(message: KeyValue) {
return this.ws.send(message);
}
close(reason: KeyValue) {
return this.ws.close(reason);
}
addEvent() {
this.ws.addEventListener('message', this.onMessage.bind(this));
this.ws.addEventListener('open', this.onOpen.bind(this));
this.ws.addEventListener('close', this.onClose.bind(this));
this.ws.addEventListener('error', this.onError.bind(this));
}
removeEvent() {
this.ws.removeEventListener('message', this.onMessage.bind(this));
this.ws.removeEventListener('open', this.onOpen.bind(this));
this.ws.removeEventListener('close', this.onClose.bind(this));
this.ws.removeEventListener('error', this.onError.bind(this));
}
getState() {
return this.ws?.readyState;
}
}
2.准备发送消息和数据
App.vue,点击开始识别事件触发
// 开始语音识别
const handleAudioAnalysis = async () => {
await initWs();
};
// 开始消息
const startTranscriptionMessage = () => {
return {
chunk_size: new Array(5, 10, 5),
wav_name: 'h5',
wav_format: 'mp3',
is_speaking: true,
chunk_interval: 10,
itn: false,
mode: 'offline',
};
};
// 停止消息
const stopTranscriptionMessage = () => {
const request = {
chunk_size: new Array(5, 10, 5),
wav_name: 'h5',
is_speaking: false,
chunk_interval: 10,
mode: 'offline',
};
wsClient.value.send(JSON.stringify(request));
};
// 发送音频数据,切片后发送
const sendAudioData = async () => {
if (!audioBlobData.value || !wsClient.value) return;
// 将音频数据转换为 Uint8Array
const arrayBuffer = await audioBlobData.value.arrayBuffer();
const sampleBuf = new Uint8Array(arrayBuffer);
const chunk_size = 960; // for asr chunk_size [5, 10, 5]
let offset = 0;
// 循环截取并发送数据
while (offset < sampleBuf.length) {
const end = Math.min(offset + chunk_size, sampleBuf.length);
const sendBuf = sampleBuf.slice(offset, end);
// 发送截取的数据
wsClient.value.send(sendBuf);
totalSend.value += sendBuf.length;
offset = end;
}
// 发送停止转写消息
stopTranscriptionMessage();
};
// 连接ws
const initWs = async () => {
wsClient.value = wsHandler.connect(
PAGE_VARS.FunASRUrl,
{
connectionTimeout: 3000,
maxRetries: 0,
},
{
open: () => {
console.log('ws:onOpen===>');
wsClient.value.send(JSON.stringify(startTranscriptionMessage()));
setTimeout(() => {
sendAudioData();
}, 2 * 1000);
},
error: (err) => {
console.log('ws:onOpen===>', err);
},
message: (msg) => {
console.log('on message,msg', msg);
// 返回结果赋值
audioTextData.value = msg?.stamp_sents || [];
},
close: (err) => {
console.log('ws:onclose===>', err);
},
},
);
};
3.返回结果展示
AudioList.vue
<template>
<t-space direction="vertical" size="large">
<t-list :split="true">
<t-list-item v-for="(item, index) in props.list" :key="index">
<t-list-item-meta :title="formatVideoTime(item.start/ 1000)" :description="item.text_seg" />
</t-list-item>
</t-list>
</t-space>
</template>
<script setup>
import { formatVideoTime } from '../utils/time';
defineOptions({ name: 'AudioList' });
const props = defineProps({
list: {
type: Array,
default: () => [],
},
});
</script>
App.vue
<audio-list :list="audioTextData"></audio-list>
最终结果上图:
四.对识别信息进行AI分析
方案:语音识别是自己本地部署服务,AI分析的功能选用调用第三方提供的resful api进行;
选用Kimi(新用户送15),可以掉用好多次了,kimi官方平台 platform.moonshot.cn/docs/api/ch…
1.获取api key
2.本地node服务
需要先安装依赖
npm install openai
简单版node server
const OpenAI = require("openai");
const http = require("http"); // 引入 http 模块

const client = new OpenAI({
apiKey : "", // 在这里将 MOONSHOT_API_KEY 替换为你从 Kimi 开放平台申请的 API Key
baseURL: "https://api.moonshot.cn/v1",
});
// 对话
async function chat(input) {}
// 创建 HTTP 服务器
const server = http.createServer(async (req, res) => {
// 添加 CORS 响应头
res.setHeader('Access-Control-Allow-Origin', '*'); // 允许所有域名跨域访问,生产环境建议指定具体域名
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// 处理 OPTIONS 请求(预检请求)
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.method === 'POST' && req.url === '/chat') {
let body = '';
// 监听数据接收事件
req.on('data', chunk => {
body += chunk.toString();
});
// 监听请求结束事件
req.on('end', async () => {
try {
const { question } = JSON.parse(body);
const answer = await chat(question);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ answer }));
} catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
官方chat示例
在server中完善chat代码
async function chat(input) {
console.log('input',input);
messages.push({
role: "user",
content: input,
});
const completion = await client.chat.completions.create({
model: "moonshot-v1-8k",
messages: messages,
temperature: 0.3,
});
const assistantMessage = completion.choices[0].message;
//messages.push(assistantMessage);
return assistantMessage.content;
}
启用服务
3.vue项目调用分析
1.开发引导词(问题)
引导词的核心是基于视频语音转成的文字,进行业务需求的提问
将语音文字数组数据合成字符串
App.vue
// 获取语音识别上下文
const getAudioContext = () => {
const list = audioTextData.value.map((item) => {
return `开始时间:${item.start} 结束时间:${item.end} 内容:${item?.text_seg?.replace(/\s/g, '')}`;
});
return list.join(',');
};
开发引导词
// 生成全文总结的引导词
const getAiAnalysisSummary = () => {
return `-以下是一段音频中的语音内容,每句话包含开始时间,结束时间以及内容,请你根据文本内容生成全文总结,返回格式要求为纯文本。语音内容为:${getAudioContext()}`;
};
// 生成段落的引导词
const getAiAnalysisParagraph = () => {
return `-以下是一段音频中的语音内容,每句话包含开始时间,结束时间以及内容,请你根据全文内容进行分章节, 不能超过5个章节,要求返回格式如下:{"a":{start: 0, end: 10, text: '这是第一个段落'}, "b":{start: 10, end: 20, text: '这是第二个段落'}}。-语音内容为:${getAudioContext()}`;
};
2.处理大模型返回的结果展示
调用本地http服务接口
...
const PAGE_VARS = {
AiAnalysisUrl: 'http://127.0.0.1:3000',
};
// 开始ai分析
const handleAiAnalysis = async () => {
const resultSummary = await axios.post(`${PAGE_VARS.AiAnalysisUrl}/chat`, { question: getAiAnalysisSummary() });
const resultParagraph = await axios.post(`${PAGE_VARS.AiAnalysisUrl}/chat`, { question: getAiAnalysisParagraph() });
aiAnalysisResult.summary = resultSummary.data?.answer;
aiAnalysisResult.paragraph = handleParagraphData(resultParagraph.data?.answer);
};
展示到页面中 App.vue
...
<t-tabs :value="tabActive" :theme="theme" @change="handlerChange">
<t-tab-panel value="first">
<template #label> <AudioIcon class="tabs-icon-margin" />语音转文字 </template>
<audio-list :list="audioTextData"></audio-list>
</t-tab-panel>
<t-tab-panel value="second">
<template #label> <FlagIcon class="tabs-icon-margin" /> 全文总结 </template>
<p style="padding: 25px" v-html="aiAnalysisResult.summary"></p>
</t-tab-panel>
<t-tab-panel value="third">
<template #label> <ListIcon class="tabs-icon-margin" /> 段落 </template>
<t-collapse :default-value="[0]">
<t-collapse-panel
:header="`${formatVideoTime(item.start / 1000)}-${formatVideoTime(item.end / 1000)}`"
v-for="(item, index) in aiAnalysisResult.paragraph"
:key="index"
>
{{ item.text }}
</t-collapse-panel>
</t-collapse>
</t-tab-panel>
<t-tab-panel value="four">
<template #label> <TreeRoundDotVerticalIcon class="tabs-icon-margin" /> 思维导图 </template>
<mind-map></mind-map>
</t-tab-panel>
</t-tabs>
结果示例
生成总结:
分章节:
生成思维导图:
五.聊一聊如果要在生产环境实现
以上功能如果要在生产环境中实现,有哪些注意事项呢
1.语音识别服务选择
文中选中本地部署的cpu版FunASR,生产环境根据项目需求来选择本地部署大模型,还是选择调用第三方api
比如阿里的api
优缺点
| 对比维度 | 本地部署大模型 | 调用第三方付费服务 |
|---|---|---|
| 成本 | ✅ 长期成本较低(无持续调用费用) ❌ 初期硬件/部署成本高 | ✅ 初期成本低(无需硬件投入) ❌ 长期高频调用成本可能较高 |
| 数据隐私 | ✅ 数据完全本地处理,安全性高 | ❌ 需上传数据到第三方,存在隐私风险 |
| 维护责任 | ❌ 需自行维护服务器/模型更新 | ✅ 由服务商负责维护和升级 |
| 灵活性 | ✅ 可深度定制模型和功能 | ❌ 受限于API功能,无法底层修改 |
| 网络依赖 | ✅ 离线可用,无网络要求 | ❌ 必须保持网络连接 |
| 性能表现 | ❌ 受本地硬件限制(计算速度/并发能力) | ✅ 高性能服务器集群,响应更快 |
| 扩展性 | ❌ 扩容需升级硬件 | ✅ 自动弹性扩展,按需使用 |
| 技术门槛 | ❌ 需要专业AI/运维团队 | ✅ 开箱即用,仅需集成API |
| 合规性 | ✅ 完全自主控制数据合规 | ❌ 需依赖服务商合规认证 |
| 模型时效性 | ❌ 需手动更新模型版本 | ✅ 自动获得最新模型改进 |
| 灾备能力 | ❌ 需自行设计容灾方案 | ✅ 服务商通常提供高可用保障 |
2.语音转文字显示优化
大视频文件一般识别出语音文字较多,可能列表长达几千条,需要使用虚拟滚动来实现展示
这里推荐一个虚拟滚动库: github.com/Akryum/vue-…
3.AI分析服务选择
同“语音识别服务选择”
4.引导词如何开发
推荐一个很火的github库: github.com/x1xhlol/sys…
原创不易,动动小手,一键三连噢!
完整代码见如下git仓库:
参考文档
- ffmpeg.wasm官网Usage | ffmpeg.wasm
- FunASR github.com/modelscope/…
- 摩搭社区 www.modelscope.cn/models
- kimi官网platform.moonshot.cn/docs/intro#…
- 虚拟滚动库: github.com/Akryum/vue-…