全民AI的时代,Vue3+ffmpeg+FunASR+Kimi,百度网盘视频AI功能开整!

1,205 阅读9分钟

背景: 在使用百度网盘观看视频时,下面会出现一些字幕以及ai分析等,所以准备自己实现以下

建议先下载本篇文章完整代码库,然后进行阅读,按照文档步骤,可以从0到1实现全部内容

imageonline-co-gifimage.gif

  • 通过本篇文档,你可以了解如下信息
    • 前端如何提取视频中的音频
    • 如何在window环境部署开源大模型FunASR
    • 本地启用简单http服务
    • 如何实现语音分析以及AI分析

准备工作

一.提取视频中的音频

先说明方案,使用wasm版的ffmpeg

wasm ffmpeg架构图 ffmpeg-arch.jpg

1.项目中引入

先从官网下载核心文件,下载地址:

下载完后放在项目public目录下的wasm目录

wasm目录.jpg

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>

上效果图

提取音频进度.jpg

音频提取完成.jpg

二.本地环境部署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 创建模型映射目录.jpg

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

服务成功启动的标识

日志中服务成功启动标识.jpg

也可以使用自带的客户端进行测试服务是否搭建成功 自带客户端连接服务成功.jpg

三.将提取的语音送到大模型进行语音识别

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>

最终结果上图:

调用本地语音识别成功.jpg

四.对识别信息进行AI分析

方案:语音识别是自己本地部署服务,AI分析的功能选用调用第三方提供的resful api进行;

选用Kimi(新用户送15),可以掉用好多次了,kimi官方平台 platform.moonshot.cn/docs/api/ch…

1.获取api key

2.本地node服务

需要先安装依赖

npm install openai

kimi开始对话.jpg

kimi新用户注册送.jpg

简单版node server

const OpenAI = require("openai");
const http = require("http"); // 引入 http 模块

![kimi新用户注册送.jpg](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/23c97a80f8a94d64a6bbcb2c360c92e5~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgU3lubWJyZg==:q75.awebp?rk3s=f64ab15b&x-expires=1772243918&x-signature=%2FwDtSbT2DCLRfEZNZIgK%2BypEEwY%3D)
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示例

kimi开始对话.jpg

在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;
}

启用服务 启用本地node服务.jpg

3.vue项目调用分析

1.开发引导词(问题)

引导词的核心是基于视频语音转成的文字,进行业务需求的提问

将语音文字数组数据合成字符串 语音文字数据结构.jpg

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>

结果示例

全文总结提问.jpg

生成总结: 全文总结结果展示.jpg

分章节: 段落结果展示.jpg

生成思维导图:

思维导图demo数据.jpg

五.聊一聊如果要在生产环境实现

以上功能如果要在生产环境中实现,有哪些注意事项呢

1.语音识别服务选择

文中选中本地部署的cpu版FunASR,生产环境根据项目需求来选择本地部署大模型,还是选择调用第三方api

比如阿里的api

阿里语音识别服务.jpg

优缺点

对比维度本地部署大模型调用第三方付费服务
成本✅ 长期成本较低(无持续调用费用)
❌ 初期硬件/部署成本高
✅ 初期成本低(无需硬件投入)
❌ 长期高频调用成本可能较高
数据隐私✅ 数据完全本地处理,安全性高❌ 需上传数据到第三方,存在隐私风险
维护责任❌ 需自行维护服务器/模型更新✅ 由服务商负责维护和升级
灵活性✅ 可深度定制模型和功能❌ 受限于API功能,无法底层修改
网络依赖✅ 离线可用,无网络要求❌ 必须保持网络连接
性能表现❌ 受本地硬件限制(计算速度/并发能力)✅ 高性能服务器集群,响应更快
扩展性❌ 扩容需升级硬件✅ 自动弹性扩展,按需使用
技术门槛❌ 需要专业AI/运维团队✅ 开箱即用,仅需集成API
合规性✅ 完全自主控制数据合规❌ 需依赖服务商合规认证
模型时效性❌ 需手动更新模型版本✅ 自动获得最新模型改进
灾备能力❌ 需自行设计容灾方案✅ 服务商通常提供高可用保障

2.语音转文字显示优化

大视频文件一般识别出语音文字较多,可能列表长达几千条,需要使用虚拟滚动来实现展示

这里推荐一个虚拟滚动库: github.com/Akryum/vue-…

3.AI分析服务选择

同“语音识别服务选择”

4.引导词如何开发

推荐一个很火的github库: github.com/x1xhlol/sys…

引导词github库.jpg

引导词简介.jpg

原创不易,动动小手,一键三连噢!

完整代码见如下git仓库:

github.com/yc-lm/docum…

参考文档

  1. ffmpeg.wasm官网Usage | ffmpeg.wasm
  2. FunASR github.com/modelscope/…
  3. 摩搭社区 www.modelscope.cn/models
  4. kimi官网platform.moonshot.cn/docs/intro#…
  5. 虚拟滚动库: github.com/Akryum/vue-…