简介:这是一份面向Java开发者的讯飞语音转文字(ASR)实战示例资源,适合具备Java基础、希望快速接入讯飞RESTful API完成语音识别功能的初中级工程师。压缩包共8个文件,包含6个java源码与2个jar依赖,整体约143KB,源码覆盖控制器、服务接口与实现、上传词条及识别工具等模块,jar包则提供讯飞SDK与音视频处理支持。内容围绕APP ID与密钥配置、OAuth 2.0获取Access Token、HTTP请求与JSON解析、音频格式处理、Multipart上传、异步回调、错误重试及日志记录等关键环节展开,读者可据此理解从鉴权到结果提取的完整调用链路。目前已有3962人学习下载,可结合示例代码快速搭建可运行的语音转文字Demo,并在此基础上适配自身业务场景。
1. 从一段会议录音到结构化文本:Java 接入讯飞语音转写的真实路径
上周帮一个做在线教育后台的朋友处理课程录音归档,他们攒了三百多条 MP3,每条四五十分钟,运营同事手动听打根本扛不住。需求很明确:用 Java 把本地音频批量转成带时间戳的文字,最好还能区分说话人。我第一反应就是接讯飞听见的语音转写 WebAPI,原因很简单——中文识别准确率在通用场景下够用,Java 侧有官方 SDK,而且支持上传本地文件后异步轮询结果,不用自己搭模型。这套方案适合谁?手里有 Java 后端、需要把录音/视频音轨转成文字做检索、字幕或内容审核的团队。它不是实时麦克风场景,是文件转写场景,这个边界先划清楚,后面选接口才不会走弯路。
2. 接口选型与鉴权:为什么走 WebAPI 而不是实时流
2.1 三种接入方式的取舍
讯飞开放平台在语音转写这块,常见有三条路:实时语音转写(WebSocket 流式)、语音听写(短音频,一般 60 秒内)、语音转写(长音频文件,异步回调或轮询)。很多人一上来搜「java xunfei」看到示例代码就抄,结果抄的是语音听写的 demo,拿去传一个 40 分钟的 MP3,直接报错或者只出前几十秒。这是最典型的翻车点。
选型逻辑其实不复杂:
| 接入方式 | 适用时长 | 交互模式 | 典型场景 |
|---|---|---|---|
| 语音听写 | 60 秒以内 | 同步返回 | 语音指令、短语音消息 |
| 实时语音转写 | 持续流 | WebSocket 双向 | 直播字幕、会议实时记录 |
| 语音转写(长音频) | 5 小时以内 | 上传 + 轮询/回调 | 课程录音、访谈归档 |
我这次要处理的是本地文件批量转写,时长几十分钟,所以锁定「语音转写」的 WebAPI。它的流程是:先把音频上传拿到一个taskId,然后轮询查询任务状态,成功后返回一个结果 URL,再下载 JSON 解析。注意,这个接口不是同步返回文字的,是异步的,很多人卡在「上传完以为就完事了」,其实后面还有两步。
2.2 鉴权:HMAC-SHA256 签名怎么拼
讯飞 WebAPI 的鉴权不是简单塞一个 token,而是要在请求头里带一个签名。签名基于host、date、request-line三部分,用 APISecret 做 HMAC-SHA256,再 Base64。这块是纯体力活,但参数错一个字符就是 401。
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.Date; import java.util.Locale; import java.util.TimeZone; public class AuthUtil { // 生成鉴权 URL,host 固定为转写服务域名,path 为接口路径 public static String buildAuthUrl(String host, String path, String apiKey, String apiSecret) throws Exception { // date 必须是 RFC1123 格式,且用 GMT 时区,否则签名对不上 SimpleDateFormat sdf = new SimpleDateFormat( "EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US); sdf.setTimeZone(TimeZone.getTimeZone("GMT")); String date = sdf.format(new Date()); // 签名原文:host + date + request-line String requestLine = "POST " + path + " HTTP/1.1"; String signatureOrigin = "host: " + host + "\n" + "date: " + date + "\n" + requestLine; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] raw = mac.doFinal(signatureOrigin.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(raw); // 拼回 authorization,注意格式里的空格和逗号都不能省 String authorization = String.format( "api_key=\"%s\", algorithm=\"hmac-sha256\", " + "headers=\"host date request-line\", signature=\"%s\"", apiKey, signature); return "https://" + host + path + "?authorization=" + Base64.getEncoder() .encodeToString(authorization.getBytes(StandardCharsets.UTF_8)) + "&date=" + java.net.URLEncoder.encode(date, "UTF-8") + "&host=" + host; } }逻辑说明:signatureOrigin的换行顺序必须是 host、date、request-line,中间用\n连接,最后一行后面不加换行。date用 GMT,本地时区直接算会差 8 小时导致签名失效。authorization整体再做一次 Base64 拼到 URL 上,这是讯飞 WebAPI 的固定套路。
参数说明:apiKey和apiSecret从控制台应用详情里拿,别把apiSecret写进前端或提交到仓库,这是血泪经验,泄露了别人能直接刷你的额度。host用转写服务的域名,path用对应接口路径,两者要匹配,混用会 404。
3. 上传、轮询、解析:把长音频转写跑通
3.1 上传音频拿到 taskId
上传接口接收的是音频文件的二进制流,同时要在 body 里带一些参数,比如language、accent、domain。这里有个容易忽略的点:请求体是 JSON,音频内容要 Base64 编码后放在data字段里,不是直接 form-data 传文件。我第一次按 form-data 传,返回参数错误,查了半天文档才发现。
import com.alibaba.fastjson.JSONObject; import java.io.File; import java.nio.file.Files; import java.util.Base64; public class UploadService { // 上传本地音频,返回 taskId public String upload(String authUrl, File audio) throws Exception { byte[] bytes = Files.readAllBytes(audio.toPath()); String audioBase64 = Base64.getEncoder().encodeToString(bytes); JSONObject body = new JSONObject(); body.put("data", audioBase64); // 音频格式,支持 wav/mp3/m4a 等,要和实际文件一致 body.put("format", "mp3"); // 采样率,常见 16000 或 8000,和录音设备保持一致识别率更高 body.put("sample_rate", "16000"); // 语言:zh_cn 中文,en_us 英文 body.put("language", "zh_cn"); // 领域:general 通用,education 教育等,选错会影响专业词识别 body.put("domain", "general"); String resp = HttpUtil.postJson(authUrl, body.toJSONString()); JSONObject json = JSONObject.parseObject(resp); if (json.getIntValue("code") != 0) { throw new RuntimeException("上传失败: " + json.getString("message")); } return json.getJSONObject("data").getString("taskId"); } }逻辑说明:先把文件读成字节数组再 Base64,注意大文件 Base64 后体积会涨约三分之一,40 分钟 MP3 大概几十 MB,Base64 后可能上百 MB,要留意 JVM 堆和 HTTP 客户端超时。format、sample_rate、language、domain这几个参数直接影响识别效果,不是随便填。
参数说明:sample_rate如果音频实际是 44100,你填 16000,识别会变慢甚至出错,最好用 ffmpeg 先统一转成 16000 单声道。domain选education对课程场景里的专业术语更友好,但通用场景用general就行,别乱选。
3.2 轮询任务状态与结果下载
上传成功后拿到taskId,接下来要定时查询。查询接口返回status,常见值有0(任务创建)、1(处理中)、2(完成)、-1(失败)。完成后会带一个result字段,里面是结果文件的 URL,需要再发一次 GET 下载。
public class PollService { // 轮询直到完成,返回结果 JSON public JSONObject poll(String authUrl, String taskId) throws Exception { long deadline = System.currentTimeMillis() + 30 * 60 * 1000L; // 最多等 30 分钟 while (System.currentTimeMillis() < deadline) { JSONObject body = new JSONObject(); body.put("taskId", taskId); String resp = HttpUtil.postJson(authUrl, body.toJSONString()); JSONObject json = JSONObject.parseObject(resp); int status = json.getJSONObject("data").getIntValue("status"); if (status == 2) { // 完成,拿到结果文件地址 String resultUrl = json.getJSONObject("data") .getString("result"); return HttpUtil.getJson(resultUrl); } else if (status == -1) { throw new RuntimeException("转写失败: " + json.getJSONObject("data").getString("error")); } // 处理中,等 5 秒再查,别太频繁,接口有频率限制 Thread.sleep(5000); } throw new RuntimeException("轮询超时"); } }逻辑说明:轮询间隔别设太短,5 到 10 秒比较稳,太频繁可能触发限流。超时时间按音频时长估,一般转写耗时约为音频时长的 0.3 到 0.5 倍,40 分钟音频大概十几分钟出结果,留 30 分钟余量够用。
参数说明:taskId是上传返回的,别和别的任务混。结果 JSON 里通常有lattice或ws结构,里面是分词和置信度,解析时按cw数组取词,wp是标点。不同接口返回结构略有差异,拿到结果先打印一份看结构,别硬编码字段名。
3.3 结果 JSON 解析成纯文本
结果文件是 JSON,结构比较绕,核心是把每个词拼起来。下面是一个简化解析,实际字段以你拿到的结果为准。
public class ResultParser { // 把转写结果 JSON 拼成纯文本 public String toPlainText(JSONObject result) { StringBuilder sb = new StringBuilder(); // 结果里通常有 ws 数组,每个元素含 cw 词数组 com.alibaba.fastjson.JSONArray ws = result .getJSONArray("ws"); if (ws == null) { return ""; } for (int i = 0; i < ws.size(); i++) { JSONObject item = ws.getJSONObject(i); com.alibaba.fastjson.JSONArray cw = item.getJSONArray("cw"); if (cw != null && cw.size() > 0) { // 取第一个候选词,多个候选时按置信度选 sb.append(cw.getJSONObject(0).getString("w")); } } return sb.toString(); } }逻辑说明:ws是词块数组,每个词块里cw是候选词,一般取第一个。标点也在cw里,w字段就是标点符号,直接拼即可。如果要做时间戳对齐,ws里通常还有bg(开始时间)和ed(结束时间),单位毫秒,可以按需保留。
参数说明:候选词选择上,如果对准确率要求高,可以比较sc置信度字段,取最高分那个。但多数场景取第一个就够,别过度设计。
4. 避坑与排查:那些文档没写清楚的细节
4.1 签名 401:date 时区和换行顺序
现象:请求返回 401,提示签名校验失败。原因:date用了本地时区,或者signatureOrigin里换行顺序不对、末尾多了换行。解决:强制用 GMT 时区格式化,签名原文严格按 host、date、request-line 顺序,行间\n,末尾不加。我一般会先把签名原文打印出来,和官方示例逐字符比对。
4.2 上传报参数错误:body 格式搞混
现象:上传接口返回参数错误,但文件明明没问题。原因:把音频当 form-data 传了,或者 Base64 后没放进data字段。解决:确认请求体是 JSON,音频 Base64 放data,format和实际文件后缀一致。另外 Base64 不要带换行,标准编码器默认不带,但有些工具会加,注意检查。
4.3 轮询一直处理中:音频格式或采样率不匹配
现象:任务一直停在处理中,最后超时。原因:音频采样率和填的参数不一致,或者格式实际是 wav 却填了 mp3。解决:用 ffmpeg 统一转码,命令是ffmpeg -i input.mp3 -ar 16000 -ac 1 -f wav output.wav,转完再传,识别率和稳定性都会好很多。这个预处理步骤我后来固定加在流程最前面。
4.4 结果乱码或丢字:编码和解析字段
现象:拼出来的文本有乱码,或者少了一段。原因:下载结果时没按 UTF-8 读,或者解析时字段名写错。解决:HTTP 客户端统一设 UTF-8,拿到结果先原样打印,确认ws、cw、w这些字段真实存在再写解析逻辑。别照着旧文档硬编码。
4.5 额度与并发:别把 key 写死在前端
现象:跑批量任务时突然报额度不足或限流。原因:并发太高,或者 key 泄露被别人用了。解决:批量任务加队列,控制并发在 2 到 3 个,轮询间隔别低于 5 秒。apiSecret只放服务端,前端永远不碰。这是最容易被忽视又最致命的一条。
5. 批量转写的工程化收尾:队列、重试与结果落库
单条跑通之后,真正要落地的是批量。三百多条录音,不可能一条条手动跑。我一般的做法是:用一个固定大小的线程池,比如 3 个线程,每个线程处理一条,内部走「上传 → 轮询 → 下载 → 解析 → 入库」的完整链路。任务状态存一张表,字段包括task_id、file_path、status、retry_count、text_content,这样断了能续,失败了能重试。
重试策略上,网络类错误(超时、连接重置)重试 3 次,每次退避 10 秒;业务类错误(参数错、额度不足)不重试,直接标记失败并告警。这个区分很重要,不然额度不足时疯狂重试只会雪上加霜。
// 简化的批量处理骨架 ExecutorService pool = Executors.newFixedThreadPool(3); for (File audio : audioList) { pool.submit(() -> { int retry = 0; while (retry < 3) { try { String taskId = uploadService.upload(authUrl, audio); JSONObject result = pollService.poll(pollUrl, taskId); String text = parser.toPlainText(result); // 落库,text 存文本字段,taskId 存唯一索引防重复 dao.save(audio.getName(), taskId, text); break; } catch (Exception e) { retry++; // 只对网络异常重试,业务异常直接跳出 if (e instanceof BusinessException) { dao.markFailed(audio.getName(), e.getMessage()); break; } try { Thread.sleep(10000L * retry); } catch (InterruptedException ignored) {} } } }); } pool.shutdown();逻辑说明:线程池大小别超过账号并发上限,一般 2 到 3 个稳妥。taskId做唯一索引,防止重复提交同一条音频。重试退避用10 秒 * retry,避免瞬间打满。
参数说明:retry_count建议上限 3,再多意义不大。落库的text_content字段用TEXT或LONGTEXT,40 分钟录音转出来大概几千到上万字,普通VARCHAR装不下。
验证方法上,我习惯先拿一条 1 分钟以内的短音频跑通全流程,确认签名、上传、轮询、解析都没问题,再放批量。短音频出结果快,调试成本低。另外,转写结果和原始音频最好做一次抽样人工比对,尤其是专业术语多的场景,domain选对了能省很多校对时间。
从那以后我每次接新的语音转写需求,都强制先跑一条短音频验证全链路,再上批量队列,这个习惯帮我省了至少两次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取