☰
Java接入讯飞语音转文字:从鉴权到落库的完整工程实践
2026/10/18 11:07:08 网站建设 项目流程

简介:这份资源面向具备一定Java基础的开发者,聚焦在Java环境中调用讯飞语音转文字(ASR)接口的完整实现,帮助读者打通从音频输入到文字输出的技术链路。压缩包共8个文件,以6个java源码为主,另含2个jar依赖包,整体约143KB,源码覆盖业务控制、服务接口与实现、音频处理及上传等模块,jar包则提供讯飞SDK与音频转换支持。内容涉及HTTP请求与JSON解析、OAuth 2.0授权获取Access Token、音频格式处理、Multipart上传、异步回调、错误重试与日志记录等关键知识点,示例代码结构清晰,便于对照理解接口调用流程。目前已有3964人学习下载,适合想快速上手讯飞ASR、需要可运行参考实现或排查授权与上传问题的开发者借鉴。

1. 讯飞语音转文字 Java 接入:从一段会议录音到可检索文本的完整落地

手里有一批会议录音、客服通话或者课堂音频,想用 Java 把它们批量转成文字,还要能落到自己的业务库里做检索——这是很多后端开发者会碰到的真实需求。讯飞开放平台的语音听写和语音转写能力,正好能覆盖「短音频实时转」和「长音频离线转」两类场景,而 Java 作为服务端主力语言,天然适合把它封装成一个可复用的工具模块。这个项目就是围绕「Java 调用讯飞语音转文字接口」展开的工程化实现,适合需要做音频转写、会议纪要生成、字幕文件产出的开发者。它不依赖前端,纯后端跑通,核心要解决的是鉴权签名、音频格式适配、长音频分片和结果落库这几件事。

2. 项目结构与技术选型:为什么用 Maven 单模块而不是 Spring Boot 全家桶

2.1 目录结构长什么样

这个项目我一般会按「接口层 + 核心转写层 + 工具层」来切,不搞过度分层。一个能跑起来的目录大概是这样:

xunfei-asr-java/ ├── pom.xml ├── src/main/java/com/example/asr/ │ ├── AsrApplication.java // 启动入口,非必须,测试用 │ ├── config/ │ │ └── XunfeiConfig.java // 存放 appId、apiKey、apiSecret │ ├── core/ │ │ ├── AuthBuilder.java // 鉴权签名生成 │ │ ├── IatClient.java // 语音听写(短音频) │ │ └── LfasrClient.java // 语音转写(长音频) │ ├── model/ │ │ ├── AsrRequest.java │ │ └── AsrResult.java │ └── util/ │ ├── AudioUtil.java // 音频格式、采样率处理 │ └── FileUtil.java └── src/main/resources/ └── application.properties // 配置项

之所以不套 Spring Boot,是因为转写本身是个「输入音频、输出文本」的纯计算任务,引入容器只会让依赖变重。用 Maven 单模块,打成一个 jar,丢到服务器上跑批处理或者被其他服务以依赖方式引入都方便。常见做法是把配置抽到application.properties,用Properties类加载,避免硬编码密钥。

2.2 依赖选型与版本注意

pom.xml 里真正需要的依赖不多,核心是 HTTP 客户端和 JSON 解析:

<dependencies> <!-- 讯飞 WebSocket 接口需要,短音频听写用 --> <dependency> <groupId>org.java-websocket</groupId> <artifactId>Java-WebSocket</artifactId> <version>1.5.4</version> </dependency> <!-- 长音频转写走 HTTP,用 OkHttp 足够 --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <!-- JSON 解析,Fastjson 或 Jackson 都行 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.43</version> </dependency> <!-- 音频重采样,把任意格式转成 16k/16bit 单声道 --> <dependency> <groupId>ws.schild</groupId> <artifactId>jave-all-deps</artifactId> <version>3.5.0</version> </dependency> </dependencies>

这里有个血泪经验:讯飞的语音听写接口对音频格式极其挑剔,必须是 16k 采样率、16bit 位深、单声道的 PCM 或 WAV。你直接拿手机录的 m4a 丢进去,大概率返回「音频格式错误」。所以jave-all-deps这个库不是可选项,是必需品,它底层封装了 ffmpeg,能在 Java 里直接做转码。

2.3 鉴权签名:整个接入里最容易翻车的一步

讯飞的鉴权不是简单的 API Key 放在 Header 里,而是要用 HMAC-SHA256 对特定字符串做签名,再拼成 URL。这一步写错,后面全白搭。核心代码:

public class AuthBuilder { // 生成鉴权 URL,host 和 path 由具体接口决定 public static String buildUrl(String host, String path, String apiKey, String apiSecret) throws Exception { // 1. 生成 RFC1123 格式的时间戳 SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); sdf.setTimeZone(TimeZone.getTimeZone("GMT")); String date = sdf.format(new Date()); // 2. 拼接签名原文,注意换行符是 \n,不是空格 String signatureOrigin = "host: " + host + "\n" + "date: " + date + "\n" + "GET " + path + " HTTP/1.1"; // 3. HMAC-SHA256 加密后用 Base64 编码 Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] signBytes = mac.doFinal(signatureOrigin.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signBytes); // 4. 拼出 authorization,再 URL 编码 String authorizationOrigin = "api_key=\"" + apiKey + "\", algorithm=\"hmac-sha256\", " + "headers=\"host date request-line\", signature=\"" + signature + "\""; String authorization = Base64.getEncoder().encodeToString( authorizationOrigin.getBytes(StandardCharsets.UTF_8)); // 5. 最终 URL return "wss://" + host + path + "?authorization=" + authorization + "&date=" + URLEncoder.encode(date, "UTF-8") + "&host=" + host; } }

逻辑说明:signatureOrigin的拼接顺序和换行符必须和官方文档完全一致,多一个空格都会导致 401。date用的是 GMT 时间,且必须做 URL 编码,否则+号会被解析成空格。参数上,apiKey和apiSecret从控制台获取,host短音频是iat-api.xfyun.cn,长音频是raasr.xfyun.cn,别搞混。

3. 短音频听写实战:WebSocket 流式转写的完整链路

3.1 建立连接与参数配置

语音听写走 WebSocket,连上之后先发一帧参数,再分片发音频数据。参数帧决定了识别引擎怎么工作:

public class IatClient extends WebSocketClient { private final String appId; private boolean firstFrame = true; public IatClient(URI serverUri, String appId) { super(serverUri); this.appId = appId; } @Override public void onOpen(ServerHandshake handshake) { // 连接建立后发送第一帧:业务参数 JSONObject business = new JSONObject(); business.put("language", "zh_cn"); // 中文 business.put("domain", "iat"); // 听写领域 business.put("accent", "mandarin"); // 普通话 business.put("vad_eos", 3000); // 静音 3 秒判定结束 JSONObject common = new JSONObject(); common.put("app_id", appId); JSONObject frame = new JSONObject(); frame.put("common", common); frame.put("business", business); send(frame.toJSONString()); } }

参数说明:vad_eos是端点检测的静音时长,单位毫秒,设太小会把正常停顿切断,设太大则响应慢,3000 是常用值。domain固定iat,如果做医疗或法律垂直领域,可以换成对应领域参数,但需要单独开通。

3.2 音频分片发送与结果拼接

音频不能一次性发完,要按 40ms 一帧切,每帧 1280 字节(16k 采样率下)。发送时用status标记帧类型:0 是第一帧,1 是中间帧,2 是最后一帧。

public void sendAudio(byte[] audioData) { int frameSize = 1280; for (int i = 0; i < audioData.length; i += frameSize) { int end = Math.min(i + frameSize, audioData.length); byte[] chunk = Arrays.copyOfRange(audioData, i, end); JSONObject data = new JSONObject(); data.put("status", firstFrame ? 0 : (end == audioData.length ? 2 : 1)); data.put("format", "audio/L16;rate=16000"); data.put("encoding", "raw"); data.put("audio", Base64.getEncoder().encodeToString(chunk)); JSONObject frame = new JSONObject(); frame.put("data", data); send(frame.toJSONString()); firstFrame = false; try { Thread.sleep(40); } catch (InterruptedException ignored) {} } }

逻辑说明:Thread.sleep(40)是为了模拟实时流,如果发太快,服务端可能丢帧。format里的rate=16000必须和实际音频采样率一致,否则识别出来是乱码。结果回调在onMessage里,把result.text逐段拼起来就是完整文本。

3.3 音频预处理:把任意格式转成 16k 单声道

前面说了,格式不对直接报错。用 jave 做转码:

public static File convertToPcm(File source) throws Exception { File target = new File(source.getParent(), "converted.wav"); AudioAttributes audio = new AudioAttributes(); audio.setCodec("pcm_s16le"); // 16bit 小端 PCM audio.setSamplingRate(16000); // 16k 采样率 audio.setChannels(1); // 单声道 EncodingAttributes attrs = new EncodingAttributes(); attrs.setFormat("wav"); attrs.setAudioAttributes(audio); Encoder encoder = new Encoder(); encoder.encode(new MultimediaObject(source), target, attrs); return target; }

参数说明:pcm_s16le是讯飞要求的编码格式,setChannels(1)强制单声道,双声道音频不处理会直接失败。转码后的文件大小会变大,因为 PCM 不压缩,但这是接口要求,没得商量。

4. 长音频转写:HTTP 轮询模式与结果落库

4.1 上传、查询、获取结果三步走

长音频转写不走 WebSocket,而是「上传 → 轮询 → 拿结果」的异步模式。上传接口返回一个taskId,之后拿这个 ID 去查状态,状态变成9表示完成。

public class LfasrClient { private static final String BASE_URL = "https://raasr.xfyun.cn/v2/api"; // 第一步:上传音频,拿到 taskId public String upload(File audio) throws Exception { OkHttpClient client = new OkHttpClient(); RequestBody body = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("file", audio.getName(), RequestBody.create(audio, MediaType.parse("audio/wav"))) .addFormDataPart("appId", XunfeiConfig.APP_ID) .build(); Request request = new Request.Builder() .url(BASE_URL + "/upload") .post(body) .build(); Response response = client.newCall(request).execute(); JSONObject json = JSON.parseObject(response.body().string()); return json.getJSONObject("data").getString("taskId"); } }

逻辑说明:上传接口的appId放在表单里,不是 Header。返回的taskId是后续所有操作的唯一凭证,建议存到数据库,避免重复上传。

4.2 轮询状态与结果解析

上传完不能干等,要定时查。查询间隔我一般设 30 秒,太频繁会被限流。

public String pollResult(String taskId) throws Exception { OkHttpClient client = new OkHttpClient(); while (true) { Request request = new Request.Builder() .url(BASE_URL + "/getResult?taskId=" + taskId) .get() .build(); Response response = client.newCall(request).execute(); JSONObject json = JSON.parseObject(response.body().string()); String status = json.getJSONObject("data").getString("status"); if ("9".equals(status)) { // 转写完成,解析结果 JSONArray result = json.getJSONObject("data").getJSONArray("result"); StringBuilder sb = new StringBuilder(); for (int i = 0; i < result.size(); i++) { sb.append(result.getJSONObject(i).getString("onebest")); } return sb.toString(); } Thread.sleep(30000); // 30 秒后再查 } }

参数说明:status为9是完成,-1是失败,0或1是处理中。onebest是识别出的最优文本,还有speaker字段可以区分说话人,做会议纪要时很有用。

4.3 结果落库与检索

转写出来的文本最终要能查。我一般建一张表:

CREATE TABLE asr_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL, file_name VARCHAR(255), duration INT COMMENT '音频时长,秒', content TEXT COMMENT '转写文本', speaker_info JSON COMMENT '说话人分段信息', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_task (task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

content用 TEXT 存全文,如果要做全文检索,MySQL 5.7 以上可以加FULLTEXT索引,或者把文本同步到 ES。speaker_info存 JSON,保留分段和时间戳,方便后续做字幕。

5. 避坑与常见问题排查

5.1 鉴权 401:签名对了但时间不对

现象:本地跑得好好的,部署到服务器就 401。原因:服务器时区和标准时间有偏差,date字段超时。解决:用 NTP 同步服务器时间,或者代码里强制用 GMT 时间,别用System.currentTimeMillis()直接转。

5.2 音频格式错误:采样率对但位深不对

现象:返回audio format error,但采样率明明是 16k。原因:位深是 24bit 或者浮点 PCM。解决:转码时显式指定pcm_s16le,别用默认参数。用ffprobe先看一眼源文件的真实参数。

5.3 长音频轮询超时:任务卡在 status=1

现象:轮询半小时还是处理中。原因:音频太长(超过 5 小时)或者服务端队列积压。解决:长音频先切片,每段不超过 1 小时,分别上传后合并结果。切片用 ffmpeg 的-f segment参数。

5.4 WebSocket 断连:发太快被服务端踢

现象:onClose收到 1006 异常关闭。原因:音频帧发送间隔小于 40ms,触发限流。解决:严格按 40ms 一帧发,或者用Thread.sleep控制节奏。别想着一次性把整个文件读进内存猛发。

5.5 中文标点丢失:结果里全是逗号

现象:转写文本没有句号、问号,全是逗号。原因:business参数里没开标点预测。解决:加"pd": "true"参数,开启标点预测。这个参数默认是关的,很多人不知道。

6. 二次开发改造:从单机工具到可扩展的转写服务

如果你要把这个项目改造成能对外服务的模块,有几个方向可以动手。第一,把IatClient和LfasrClient抽象成接口AsrService,短音频和长音频各自实现,上层根据音频时长自动路由。第二,加一层线程池,批量转写时并发跑,但注意讯飞对并发数有限制,免费版一般 2 路,超了会排队。第三,结果落库后接一个全文检索,我习惯用 ES 的ik_max_word分词器,中文检索效果比 MySQL 全文索引好一个量级。

改造时有个验证方法:拿一段 10 分钟的会议录音,先走短音频接口看会不会超时,再走长音频接口看轮询几次能完成。如果短音频在 60 秒内返回,说明分片逻辑没问题;长音频轮询超过 5 次,就要检查音频是不是太大。

代码层面,我一般会把配置项抽成AsrProperties类,用@ConfigurationProperties绑定,这样换环境只改配置文件。另外加一个重试机制,网络抖动导致的失败自动重试 3 次,但鉴权失败不重试,因为重试也没用。

从那以后我每次接入新的第三方 API,都强制先写一个最小鉴权 Demo 跑通,再动业务代码。这个习惯帮我省了至少两天排查签名问题的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询