Skip to content

使用 OpenAI ASR 实现语音识别接口(Java 后端示例) ​

客户端调用说明已独立到 java-openai 章节:WhisperClient 语音识别。本篇保留应用接口、缓存和业务流程。

本文介绍如何在 Java 后端快速实现一个“上传音频文件 → 调用 OpenAI ASR(Whisper)→ 返回识别文本”的接口,并加入缓存机制避免重复识别,降低成本与延迟。

整体流程可以理解为四步:

  1. 客户端通过 POST /api/v1/asr 上传音频文件(表单字段名为 file)。
  2. 后端接收文件后,先对文件内容做 MD5,去数据库查缓存。
  3. 如果缓存命中,直接返回历史识别结果。
  4. 如果未命中,则上传音频到对象存储(用于留档/追溯),随后调用 OpenAI Whisper 转写,把结果写入数据库缓存,再返回给客户端。

接口定义 ​

  • 请求方式:POST /api/v1/asr
  • 上传字段:file: binary
  • 返回:统一 JSON 结构,data.result 为识别文本

请求示例(概念描述):

POST /api/v1/asr
file:binary

响应示例:

json
{
  "data": {
    "id": null,
    "result": "......\n"
  },
  "code": 1,
  "msg": null,
  "ok": true,
  "error": null
}

核心实现思路(通俗版) ​

1)Handler 负责“接收请求 + 转交服务 + 返回响应” ​

Handler 做的事情很简单:从请求里把上传文件取出来,交给 ASR 服务处理,然后把结果包装成 HTTP 响应返回。

2)Service 负责“去重缓存 + 并发控制 + 调用 ASR + 落库” ​

Service 主要解决三个实际问题:

  • 重复识别浪费钱:同一个音频反复上传不应每次都调用 ASR,所以用 MD5 做缓存键。
  • 并发时重复调用:比如同一时间多个请求上传同一个文件,如果不控制,会同时调用 ASR 多次。这里用 Striped<Lock> 按 md5 做分段锁,保证同一个 md5 在同一时间只会有一个线程真正去调用 ASR。
  • 结果持久化:识别完成后保存到表 ai_audio_asr_cache,下次直接命中缓存。

后端代码 ​

下面是完整代码(保持原样展示,不拆分)。

java
package nexus.io.llm.handler;

import nexus.io.jfinal.aop.Aop;
import nexus.io.llm.service.AiAudioAsrService;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.model.upload.UploadFile;
import nexus.io.tio.http.server.handler.HttpRequestHandler;

public class ApiAsrHandler implements HttpRequestHandler {

  @Override
  public HttpResponse handle(HttpRequest httpRequest) throws Exception {
    UploadFile uploadFile = httpRequest.getUploadFile("file");
    RespBodyVo vo = Aop.get(AiAudioAsrService.class).asr(uploadFile);
    HttpResponse response = TioRequestContext.getResponse();
    return response.body(vo);
  }

}
java
package nexus.io.llm.service;

import java.util.concurrent.locks.Lock;

import com.google.common.util.concurrent.Striped;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.llm.consts.AgentTableNames;
import nexus.io.model.TaskResponse;
import nexus.io.model.body.RespBodyVo;
import nexus.io.model.http.response.ResponseVo;
import nexus.io.openai.whisper.WhisperClient;
import nexus.io.openai.whisper.WhisperResponseFormat;
import nexus.io.tio.boot.admin.services.storage.AliyunStorageService;
import nexus.io.model.upload.UploadResult;
import nexus.io.model.upload.UploadFile;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

public class AiAudioAsrService {
  private static final Striped<Lock> locks = Striped.lock(1024);

  public RespBodyVo asr(UploadFile uploadFile) {
    byte[] data = uploadFile.getData();
    String md5Hex = Md5Utils.md5Hex(data);
    String sql = "select content from %s where md5=? and deleted=0";
    sql = String.format(sql, AgentTableNames.ai_audio_asr_cache);
    String content = Db.queryStr(sql, md5Hex);
    if (content == null) {
      Lock lock = locks.get(md5Hex);
      lock.lock();
      try {
        content = Db.queryStr(sql, md5Hex);
        if (content == null) {
          content = parse0(uploadFile, md5Hex);
        }
      } finally {
        lock.unlock();
      }

    }

    TaskResponse taskResponse = new TaskResponse(content);
    return RespBodyVo.ok(taskResponse);
  }

  public String parse0(UploadFile uploadFile, String md5Hex) {
    UploadResult uploadFileResult = Aop.get(AliyunStorageService.class).uploadFile("audio", uploadFile);
    Long fileId = uploadFileResult.getId();
    byte[] data = uploadFile.getData();
    String name = uploadFile.getName();

    ResponseVo transcriptions = WhisperClient.transcriptions(name, data, WhisperResponseFormat.text);
    String content = transcriptions.getBodyString();

    long id = SnowflakeIdUtils.id();
    Row row = Row.by("id", id).set("md5", md5Hex).set("file_id", fileId)
        //
        .set("content", content);
    Db.save(AgentTableNames.ai_audio_asr_cache, row);
    return content;
  }
}

关键点解释(更容易踩坑的地方) ​

1)上传字段名必须一致 ​

的 handler 用的是:

  • httpRequest.getUploadFile("file")

所以客户端表单字段一定要叫 file,否则会取不到文件。

2)缓存表结构要能支撑查询条件 ​

代码查询条件是:

  • where md5=? and deleted=0

因此表里至少需要 md5、content、deleted 字段(以及保存的 file_id、id 等)。另外建议对 md5 建索引,不然音频多了会慢。

3)并发去重的“二次检查”很重要 ​

在加锁前查一次,锁内又查一次,这是典型的“双重检查”写法,目的就是避免:

  • 第一个线程锁内已经写入缓存
  • 第二个线程拿到锁后不再重复调用 ASR

这能显著降低高并发时的重复调用。

4)返回格式与前端约定 ​

最终返回是:

  • RespBodyVo.ok(new TaskResponse(content))

所以前端拿到结果一般在 data.result,与示例响应一致。


如何调用(简单说明) ​

用任意支持 multipart 的方式上传即可:

  • Postman:选择 form-data,key 填 file,类型选 File,上传音频。
  • curl:-F "file=@xxx.wav"

MIT Licensed