ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Vue+SpringBoot的离线语音识别系统:MP3批量转文字实践

基于Vue+SpringBoot的离线语音识别系统:MP3批量转文字实践 你是不是也有这种需求手里一堆录音、会议纪要、采访音频都是MP3格式想转成文字但又不想把文件传到第三方云服务上——保密要求高、网络不稳定、或者纯粹不想为每次转写付费。我最早做这块是因为内部培训音频需要归档几百个MP3文件外包给云转写费用不低而且涉及内部业务内容上传到外部平台心理上不踏实。后来我基于 Vue SpringBoot 搭了一套完全离线的 MP3 转文字服务前端负责上传和展示后端负责解码和识别整条链路不依赖任何外部网络请求模型全部本地部署。这篇文章把我从选型到落地的完整过程、踩过的坑、以及可以直接抄走的代码思路都写出来希望能给同样有离线语音识别需求的人省点时间。1. 技术选型与总体架构设计1.1 为什么坚持走离线方案先说结论离线不等于技术落后在线也不一定体验更好。语音识别的在线方案比如各类云平台的录音文件识别接口优势是模型大、准确率高、调用简单但问题也很明显——音频文件要上传到云端识别结果经过网络回传整个过程不可控。企业内部场景里培训记录、客户沟通纪要、访谈录音往往包含敏感信息不允许外传。另外还有一类场景是数据量不大一天几十个文件但频率稳定长期购买云端API的累计费用其实不低。离线方案一次性投入硬件和搭建成本后续没有按量计费的问题。我最终的架构选择是前端 Vue 负责交互SpringBoot 负责接口调度和业务处理Python 负责音频解码和模型推理。三个组件全部部署在内网或本机浏览器通过局域网访问外网断了也不影响使用。1.2 识别引擎选型对比离线语音识别引擎目前值得考虑的方案就几个我把调研结果整理成了一张表省得你重复踩坑。引擎方案中文效果硬件要求部署难度许可证风险适合场景Vosk中等短句还行长文本一般低CPU即可低模型加载即用Apache 2.0友好嵌入式、轻量化工具FunASR阿里开源优秀中文是强项中推荐带GPUCPU也能跑中依赖较多模型许可需确认中文会议、录音转写faster-whisper优秀多语言中高CPU勉强GPU流畅中MIT 模型需注意多语言混合场景PaddleSpeech良好中文可中中高Apache 2.0中文垂直场景我实际测试下来Vosk 部署最省心模型文件小中文模型大约 40MB 左右Java 还有官方绑定库但识别长录音时经常出现断句混乱、同音字错误偏多的情况。FunASR 的 Paraformer 模型在中文长音频上表现明显更好而且自带时间戳输出做字幕对齐非常方便缺点是 Python 环境依赖比较多纯 CPU 环境下转写速度大概是音频时长的 1/3 到 1/2比如 10 分钟音频需要 3-5 分钟处理。1.3 前后端职责划分与调用链路整个项目的调用链路是这样的浏览器(Vue) → SpringBoot API → 本地磁盘存储 → FFmpeg 转码 → Python ASR 服务 → 识别结果 → SpringBoot 组装 → 浏览器展示前端只做三件事选文件、传文件、展示结果。SpringBoot 负责接收文件、调度转码、调用识别服务、管理转写任务状态。Python ASR 服务独立运行通过 HTTP 接口接收 WAV 文件路径或 PCM 数据流返回识别文本和分段时间戳。为什么拆成 SpringBoot 和 Python 两个后端服务而不是在 Java 里直接调用模型原因很实际语音识别模型生态基本都在 Python 这边Java 的语音识别库要么效果差要么封装不完整。SpringBoot 和 Python 之间用 HTTP 通信两边各自演进互不影响Python 服务崩了也不影响主流程的稳定性。2. 环境准备与工程初始化2.1 后端依赖与项目骨架SpringBoot 项目我用的 2.7.x 版本JDK 1.8 就够用了不需要追新。核心依赖只有 Web 和文件上传相关的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency项目不需要引入数据库任务状态我直接存在内存里用 ConcurrentHashMap 维护一个任务池。原因很简单这个工具就是内网几个人用没必要为临时任务上数据库。如果你要支持并发量大、要历史记录再考虑引入 MySQL。2.2 前端工程搭建前端用 Vue 3 ViteUI 库选的 Element Plus。Vite 启动快开发体验比 Webpack 时代好一个档次。npm create vuelatest npm install element-plus npm install axiosVue 3 的组合式 API 写起来清爽上传组件用 Element Plus 的 el-upload 封装一下加自定义的进度条后面会详细说。2.3 FFmpeg 解码器的准备这是最容易忽略的环节。语音识别模型要求输入音频是 16kHz 采样率、单声道、16bit 位深的 WAV 格式而用户上传的是各种码率、各种采样率的 MP3必须做一次统一转码。FFmpeg 有 Windows 和 Linux 两种部署方式。Windows 直接下载编译好的 exe 放到项目指定目录Linux 用 apt install ffmpeg 或者 yum install ffmpeg。重点是把 FFmpeg 的路径配置到 application.yml 里不要写死在代码中audio: ffmpeg-path: /usr/bin/ffmpeg upload-dir: /data/mp3-upload/ temp-dir: /data/mp3-temp/为什么单独强调 FFmpeg因为 Java 原生处理 MP3 解码实在太痛苦了。javax.sound.sampled 对 MP3 的支持需要在 JDK 里额外装插件JLayer 只能解 MP3 但不能重采样。与其自己造轮子不如直接调用 FFmpeg一句话的事稳定可靠。3. SpringBoot 后端核心实现3.1 MP3 上传与存储上传接口我用了两个一个上传文件返回任务ID另一个轮询任务状态。为什么不走 WebSocket 或者 SSE因为内网小工具轮询最简单前端一个 setInterval 就能搞定没必要引入消息推送增加复杂度。上传接口的核心代码思路PostMapping(/api/upload) public Result upload(RequestParam(file) MultipartFile file) { String taskId UUID.randomUUID().toString().replace(-, ); String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); File dest new File(uploadDir taskId ext); file.transferTo(dest); // 提交到线程池处理立即返回 taskId asrTaskService.process(taskId, dest.getAbsolutePath()); return Result.success(taskId); }注意两点文件名一定要重新生成不要直接使用用户上传的文件名。一方面是防止路径穿越攻击另一方面是避免不同用户上传同名文件互相覆盖。文件后缀校验也建议做一下只允许 .mp3、.wav、.m4a 几个常见格式。3.2 转码为 16kHz 单声道 WAV这里我把整个转码流程拆成了三步每一步都有明确目的。第一步用 FFmpeg 探测原始音频信息。有些 MP3 的采样率是 44.1kHz有些是 48kHz甚至有的录音笔输出 32kHz。不同采样率直接喂给识别模型识别率会明显下降。ffmpeg -i input.mp3 -ar 16000 -ac 1 -f wav output.wav这个命令的作用是把任意采样率的音频重采样到 16000Hz声道合并成单声道输出 WAV 格式。为什么是 16kHz因为绝大多数语音识别模型训练时就用的 16kHz 采样率太高了浪费计算资源低了信息量不足影响识别准确率。第二步处理音量。录音环境千差万别有的声音极小有的爆音。FFmpeg 支持内置音量归一化ffmpeg -i input.mp3 -af loudnormI-16:TP-1.5:LRA11 -ar 16000 -ac 1 output.wavloudnorm 滤镜会把音频响度统一到 -16 LUFS这个值在语音处理中比较常用。加了这一步之后不同录音的识别效果差异会小很多。第三步判断时长。如果音频超过 10 分钟建议先做 VAD语音活动检测切分把长音频切成一个一个小片段再识别。直接用模型识别长音频一方面显存可能不够另一方面模型对超长序列的处理效果会衰减。3.3 对接 Python 识别服务SpringBoot 调用 Python 服务是用 HTTP 还是进程间通信我选了 HTTP。理由很简单Python 服务加载模型需要几秒到几十秒如果每次转写都通过 Java 拉起 Python 进程模型加载时间无法接受。让 Python 服务常驻内存SpringBoot 只是发个请求过去响应时间能控制在秒级。调用代码很简单public AsrResponse recognize(String wavPath) { RestTemplate restTemplate new RestTemplate(); // Python 服务接收文件的绝对路径识别后拼接结果返回 MapString, String body new HashMap(); body.put(wavPath, wavPath); String url asrServerUrl /asr/recognize; JSONObject result restTemplate.postForObject(url, body, JSONObject.class); return parseResult(result); }这里有个安全细节Java 传入的是本地文件路径Python 服务不能放在公网上否则别人可以通过路径遍历漏洞读取服务器任意文件。我这边是纯内网部署防火墙只允许 SpringBoot 所在机器访问 Python 服务的端口。3.4 字幕导出与结果组织识别结果不是简单的纯文本。FunASR 接口默认返回带时间戳的分段结果我把它整理成三个维度全文文本用于复制粘贴分段文本按句子拆分每句带开始时间和结束时间字幕格式支持 SRT 和 VTT直接导入剪辑软件做视频字幕SRT 格式的生成逻辑很简单就是按时间戳拼接1 00:00:00,000 -- 00:00:03,500 大家好今天我们来讲一下离线语音识别方案SpringBoot 后端把识别结果和音频基本信息封装成一个 VO 对象前端拿这个对象做展示互不干扰。4. Python 离线识别服务构建4.1 FunASR 方案中文长音频优先如果音频内容以中文为主我推荐直接用 FunASR。阿里开源的这套工具链Paraformer 模型在中文识别上确实有两把刷子集成的 VAD 标点 时间戳整套流程非常完善。安装过程如下pip install funasr modelscope torchaudio模型会自动从 ModelScope 下载如果服务器完全离线需要在有网机器上先下载模型再拷贝到离线机器的 modelscope 缓存目录。我给个示例配置from funasr import AutoModel model AutoModel( modeliic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch, vad_modeliic/speech_fsmn_vad_zh-cn-16k-common-pytorch, punc_modeliic/punc_ct-transformer_cn-en-common-vocab471067-large, devicecpu # 有GPU的话改成 cuda:0 )识别调用result model.generate(inputwav_path, batch_size_s300) text result[0][text] # 识别的全文 sentence_info result[0][sentence_info] # 带时间戳的句子列表CPU 环境下转 10 分钟音频大约需要 2-4 分钟GPU哪怕入门的 T4能缩短到 30 秒以内。如果只是内部用CPU 可以接受毕竟不追求实时。FunASR 有一点要注意模型下载第一次要联网如果你是完全隔离的内网环境需要准备离线模型包。ModelScope 的模型下载之后会存到本地直接拷贝对应目录到离线服务器设置环境变量 MODELSCOPE_CACHE 指向模型目录即可。4.2 Vosk 方案轻量快速入门如果你的需求只是偶尔转几个短文件不想折腾 Python 环境Vosk 是更轻的选择。它提供 Java 原生绑定甚至可以直接在 SpringBoot 里调用不用单独起 Python 服务。xml dependency groupIdcom.alphacephei/groupId artifactIdvosk/artifactId version0.3.47/version /dependencyJava 调用代码VoskRecognizer recognizer new VoskRecognizer(model, 16000.0f); InputStream audioStream new FileInputStream(wavFile); byte[] buffer new byte[4096]; int len; while ((len audioStream.read(buffer)) 0) { if (recognizer.getAcceptance() 0.5) { System.out.println(recognizer.getResult()); } else { recognizer.partialResult(buffer, len); } } JSONObject finalResult new JSONObject(recognizer.getFinalResult());Vosk 的模型文件放在项目资源目录下加载一次可以复用。如果你用这个方案SpringBoot 单项目搞定不需要 Python。缺点前面说了——中文长文本识别准确率一般比较适合做指令识别、语音控制这类场景。4.3 服务接口与并发考量Python 服务我只是简单用 FastAPI 包了一层并发控制在 1 个同时识别任务。因为模型推理本身是 CPU/GPU 密集型并发多了反而互相抢占资源性能下降。直接加一个 asyncio.Lockasyncer asyncio.Lock() app.post(/asr/recognize) async def recognize(req: AsrRequest): async with asyncer: loop asyncio.get_running_loop() result await loop.run_in_executor(None, model.generate, req.wav_path) return result并发为 1 看起来不够高级但实际使用中很合理文件上传是异步的队列里排队就行。SpringBoot 那边只要把任务状态改为“排队中”、“识别中”、“已完成”用户就知道大概是等多久。5. Vue 前端界面与交互实现5.1 上传组件封装前端 UI 不需要花哨核心就两个功能上传 MP3、展示文字结果。我用 Element Plus 的 el-upload 做拖拽上传el-upload drag action/api/upload :on-successhandleSuccess :on-progresshandleProgress :before-uploadbeforeUpload div拖拽或点击上传MP3文件/div /el-uploadbeforeUpload 里做了两件事校验文件后缀限制文件大小。我这边限制单个文件不超过 500MB超过的直接提示用户自行处理。上传之后拿到 taskId启动一个轮询定时器每 2 秒拉取一次任务状态直到状态变为完成或失败。5.2 转写进度实时展示进度条怎么实现因为识别的是本地文件没有网盘那种上传进度可以用所以进度条展示的是转写状态而不是传输进度。我定义四个状态UPLOADING、QUEUED、RECOGNIZING、DONE。前端根据状态切换不同的提示文案和动画。这里给个小技巧虽然总体进度是假的但可以展示已识别的分段数量。FunASR 返回的时间戳是分段的后端每完成一段就更新一次进度比如“已完成 5/15 段”这种真实反馈比假进度条更让人信任。5.3 结果预览与导出结果页面做了三栏布局左栏音频文件信息和播放器中栏全文识别文本可以点击定位到音频对应时间点右栏分段列表每段显示时间和文本导出功能就两个按钮一个导出 TXT一个导出 SRT。前端直接拼接字符串生成 Blob 下载const blob new Blob([srtContent], { type: text/plain;charsetutf-8 }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download taskId .srt; a.click();这里注意导出文件的编码用 UTF-8 with BOM否则 Windows 下直接用记事本打开会乱码。这个小问题我第一次做的时候没注意交付出去被同事吐槽了。6. 常见问题排查与避坑实录6.1 转写结果为空或乱码遇到最多的情况是识别完成但返回的 text 是空的。排查路径先看 FFmpeg 转码后的 WAV 文件是否存在且非空用播放器听一下 WAV 是否有声音检查 WAV 采样率是否是 16kHz声道是否是单声道我遇到过一次比较隐蔽的问题是个别 MP3 文件编码不规范FFmpeg 转出来的 WAV 是 48kHz 的但命令参数写的是 16000FFmpeg 没有报错但实际输出质量极差。后来我在转码命令后面加上了-vn -y再强制指定-sample_fmt s16才彻底解决。如果识别结果是中文乱码检查 Python 服务的返回编码。FastAPI 默认 JSON 编码没问题但如果自己手动做了 encode/decode很容易翻车。6.2 长音频导致内存飙升用 FunASR 处理 1 小时以上的长音频默认模式下很容易撑爆内存。因为模型会把整段音频加载到内存里。解决方案是分块识别。我的做法是先用 VAD 模型把长音频切成多个小片段每段 10-60 秒然后逐段识别最后合并结果。FunASR 的 VAD 模型本身就能做切分不需要额外写代码vad_result vad_model.generate(inputwav_path) for segment in vad_result[0][value]: # 每段是 [start_ms, end_ms] 的列表 start_ms, end_ms segment[0], segment[1]切分后单段识别的内存占用只有原来的十分之一还可以顺便做并发多段并行处理速度反而更快。6.3 并发上传与资源竞争内网工具看起来只有几个人用但踩过一次坑两个人同时上传文件都转码到同一个临时目录文件名冲突了结果 A 的转写结果变成了 B 的内容。解决方式就是前面提到的文件一律用 UUID 重命名任务状态用 ConcurrentHashMap 管理不同任务的中间文件目录完全隔离。这不算什么高深技术但真能避免线上事故。还有一点临时文件清理。任务完成后把中间 WAV 文件删掉只保留原始 MP3 和最终转写结果。不清理的话跑上一个月磁盘会被几十 GB 的临时文件塞满。7. 项目落地后的实际效果与体验最终交付给同事用的版本UI 是 Vue 3 Element Plus后端是 SpringBoot 2.7Python 用的是 FunASR Paraformer-large 模型。服务器是一台普通的 i5 主机16GB 内存没有 GPU。实测下来 10 分钟的中文会议录音CPU 上转写耗时约 2 分半到 4 分钟识别准确率在 90% 左右偶尔有同音字错误但作为检索和非正式归档用途完全够用。提速方面如果后续觉得慢优先考虑加一张 GPU 显卡。同样的模型在 T4 上速度能提升 5 倍以上显存占用大概 2GB 左右门槛不高。使用层面同事们反馈最好的功能就是 SRT 导出——内部做视频培训材料的时候直接导入剪辑软件生成字幕再也不用人工对了。8. 后续扩展方向系统上线稳定之后有几个方向值得继续深入供你参考。第一是分角色识别。现有的语音识别只做转写不区分说话人。如果会议录音是多个人聊可以接入说话人分离模型在时间戳基础上再叠加说话人标签整理纪要的效率还会提升一大截。第二是关键词修正。模型识别错的专业术语可以通过自定义词典在输出后做一次规则替换。比如公司内部系统名称、人名地名做成一个同义词表识别结果出来后自动匹配替换准确率会明显改观。第三是批处理能力。现在是一次上传一个文件如果积累了大量历史音频可以在 SpringBoot 里做一个轮询扫描文件夹的功能自动处理新出现的 MP3 文件转写结果按日期归档。再往后可以接入大模型做摘要。识别出的文字调用本地部署的对话模型生成摘要和待办事项。整个链路仍然是离线的适合企业内网环境。以上是这套系统的完整落地过程。说句实在话离线语音识别本身没有太多高不可攀的新技术难点主要在于把几个成熟组件组合起来、踩平工程化路上的坑。我文章里写的这些问题基本覆盖了搭建过程中的大部分坑照着一遍做下来你也能跑通自己的离线转写工具。
返回列表