简介:本资源是一份面向Java开发者的技术实践包,聚焦讯飞语音识别(ASR)在Java环境中的集成与落地,适用于智能交互、语音转写类项目开发及AI API调用学习场景。压缩包共8个文件,含6个核心Java源码(如XunFeiService、SR2Words、XunfeiController等,覆盖OAuth2鉴权、音频上传、JSON解析、异步回调等关键逻辑)及2个必要依赖jar(Msc.jar、Jim2mov.jar),整体仅143KB,轻量易导入,便于快速理解API调用全流程。已有3968人学习下载,说明其在初/中级Java工程师中具备较强实操参考价值。读者可直接复用服务层封装、HTTP请求模板与错误重试机制,结合注释清晰的代码快速掌握讯飞ASR接入要点,避免从零踩坑于Token获取、Multipart上传或音频格式适配等典型问题。
1. 一个 Java 工程师在会议纪要场景里摔了三次后,把讯飞语音转文字封装成了可直接mvn clean install的模块
你刚接手一个内部会议系统升级任务,老板说:“下次上线前,把录音自动转成文字纪要,支持关键词高亮和时间戳定位。”你搜“java 讯飞语音转文字”,满屏是零散的 CSDN 博客、过期的 Maven 依赖写法、粘贴即报错的 token 过期提示——没人告诉你,讯飞 WebAPI 的X-Appid和X-Checksum必须每 5 分钟重算一次,也没人提醒你audio/pcm流必须严格按 16bit little-endian、16kHz 单声道送,少一个字节,返回就是{"code":10104,"desc":"invalid parameter"}。这个项目不是 Demo,它是一套跑在 Spring Boot 3.2 + JDK 17 环境下的生产级语音识别客户端:自带重试熔断、音频格式自动校验、结果流式拼接、错误码分级日志,所有 HTTP 调用被抽象成SpeechClient接口,你可以把它当RestTemplate一样注入、测试、Mock。适合正在做智能办公、在线教育、客服质检类系统的 Java 后端,尤其当你已经踩过“录音文件转文字失败但控制台没报错”“并发调用触发频率限制却无降级策略”“返回 JSON 字段名和文档对不上”这三类典型翻车现场。
2. 项目结构与核心依赖:从pom.xml到SpeechClient的五层封装
2.1 目录结构:为什么src/main/java/com/example/speech下只有 4 个包?
整个工程采用极简分层,不套 Spring Cloud 微服务那套大而全的架子,而是围绕“一次语音识别请求”的生命周期组织代码:
src/main/java/com/example/speech/ ├── config/ // XunfeiProperties 配置绑定、OkHttpClient Bean 定制(连接池、超时) ├── exception/ // 自定义异常 SpeechException(含 code/desc/traceId)、全局统一处理 ├── model/ // 请求体 XunfeiRequest(含 audio、common、business 字段)、响应体 XunfeiResponse(含 data.result、data.status) ├── service/ // 核心 SpeechClient 接口 + XunfeiSpeechClientImpl 实现(含签名生成、重试逻辑、流式解析) └── util/ // AudioFormatChecker(校验 wav/pcm 头)、SignatureUtils(HMAC-SHA256 + Base64)、JsonPathUtils(安全取 result 字段)提示:没有
controller包——这不是 Web 应用,而是 SDK 模块。你把它install到本地 Maven 仓库后,在你的业务项目里@Autowired SpeechClient就能用,完全解耦。
2.2pom.xml关键依赖:为什么只选 OkHttp 而不用 RestTemplate?
<dependencies> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> <!-- 支持 HTTP/2、自动重试、拦截器链 --> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> </dependencies>不选RestTemplate的原因很实际:讯飞 WebAPI 要求手动设置X-Appid、X-CurTime、X-CheckSum三个 Header,且X-CheckSum是SHA256(appid + curtime + salt + md5(body))的 Base64 编码——这种动态签名逻辑塞进RestTemplate的ClientHttpRequestInterceptor里太重,而 OkHttp 的Interceptor天然支持request.newBuilder().addHeader(...)链式构建。更重要的是,OkHttp 的Call.clone()可以在重试时安全复用原始请求体(避免InputStream已读完的问题),这是RestTemplate做不到的底层能力。
2.3SpeechClient接口设计:为什么方法签名是CompletableFuture<SpeechResult>?
public interface SpeechClient { /** * 异步识别语音流(支持 wav/pcm/mp3,自动转换为讯飞要求的 16kHz/16bit/little-endian PCM) * @param audioBytes 音频原始字节(wav 文件需先剥离头,pcm 文件需确保格式正确) * @param language 语言类型,如 "zh_cn"、"en_us" * @return CompletableFuture 包裹的 SpeechResult,含 text、word_list、timestamp_list */ CompletableFuture<SpeechResult> recognizeAsync(byte[] audioBytes, String language); }返回CompletableFuture而非SpeechResult,是因为讯飞 WebAPI 是典型的“长轮询”模式:你发一个/v1/private/iat请求拿到sid,再用sid轮询/v1/status直到status == 2(识别完成)。同步阻塞会卡死线程,而CompletableFuture可以用thenApplyAsync把轮询逻辑扔进自定义线程池(speech-polling-pool),主线程继续干别的事。SpeechResult是聚合对象,包含text(最终文本)、wordList(带时间戳的词粒度结果)、durationMs(音频时长)——这些字段在XunfeiResponse里分散在data.result、data.result.word_list、header.audio_duration里,SpeechClientImpl在thenApply里做了归一化映射。
2.4XunfeiProperties配置项:哪些必须配,哪些可以不配?
@ConfigurationProperties(prefix = "xunfei.speech") @Data public class XunfeiProperties { private String appid; // 必填:讯飞开放平台申请的应用 ID private String apiSecret; // 必填:应用密钥(非 API Key!) private String apiKey; // 必填:API Key(用于获取临时 token,但本项目已弃用该方式) private String host = "https://api.xfyun.cn"; // 可选:默认值已够用 private int connectTimeout = 5000; // 可选:毫秒,默认 5s private int readTimeout = 15000; // 可选:毫秒,默认 15s private int maxRetry = 3; // 可选:网络失败重试次数 private long pollIntervalMs = 1000; // 可选:轮询间隔,默认 1s }注意apiKey和apiSecret的区别:讯飞现在主推appid + apiSecret签名方式(更安全),apiKey是旧版 token 方式遗留字段,本项目保留只为兼容老账号。maxRetry不是无限重试——它只作用于HTTP 500/502/503/504或连接超时等网络层错误;若返回{"code":10104}这类业务错误,会直接抛SpeechException,不重试(重试也没用,参数错了)。
3. 核心模块拆解:XunfeiSpeechClientImpl的签名生成与流式解析
3.1 签名生成:X-CheckSum不是简单拼字符串,而是三重哈希
讯飞文档写“X-CheckSum = Base64(SHA256(appid + curtime + salt + md5(body)))”,但实际body是 JSON 字符串,而md5(body)要求body是字节数组。很多开发者卡在这里:用String.getBytes()默认编码,导致 MD5 结果和讯飞服务器不一致。本项目SignatureUtils的实现是:
public class SignatureUtils { public static String generateCheckSum(String appid, long curTime, String salt, byte[] body) { try { // 1. 计算 body 的 MD5(必须用 UTF-8 字节,不能用 String) String bodyMd5 = DigestUtils.md5Hex(new ByteArrayInputStream(body), "UTF-8"); // 2. 拼接字符串:appid + curtime + salt + bodyMd5 String plainText = appid + curTime + salt + bodyMd5; // 3. SHA256 哈希 + Base64 编码 byte[] sha256Bytes = MessageDigest.getInstance("SHA-256").digest(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sha256Bytes); } catch (Exception e) { throw new RuntimeException("Failed to generate X-CheckSum", e); } } }关键点:DigestUtils.md5Hex(InputStream, charset)显式指定 UTF-8,避免String.getBytes()因 JVM 默认编码不同导致哈希不一致。curTime是当前秒级时间戳(System.currentTimeMillis() / 1000),salt是随机 16 位字符串(UUID.randomUUID().toString().substring(0, 16)),每次请求都不同——这是防重放攻击的设计。
3.2 音频格式自动校验:AudioFormatChecker如何判断一个 wav 是否合规?
讯飞要求 PCM 数据必须是 16kHz、16bit、单声道、little-endian。但用户上传的可能是 44.1kHz 的音乐 wav,或是 8bit 的电话录音。AudioFormatChecker不做硬转码(那是 FFmpeg 的事),而是做前置校验 + 智能提示:
public class AudioFormatChecker { public static void validateWav(byte[] wavBytes) throws IllegalArgumentException { if (wavBytes.length < 44) { throw new IllegalArgumentException("WAV file too short, missing RIFF header"); } // 检查 RIFF 头 if (!"RIFF".equals(new String(wavBytes, 0, 4, StandardCharsets.US_ASCII)) || !"WAVE".equals(new String(wavBytes, 8, 4, StandardCharsets.US_ASCII))) { throw new IllegalArgumentException("Invalid WAV format: missing RIFF/WAVE signature"); } // 采样率:bytes[24-27] 是 little-endian 整数 int sampleRate = ByteBuffer.wrap(wavBytes, 24, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); if (sampleRate != 16000) { throw new IllegalArgumentException(String.format( "WAV sample rate %dHz not supported. Expected 16000Hz.", sampleRate)); } // 位深度:bytes[34-35] 是 bit depth short bitsPerSample = ByteBuffer.wrap(wavBytes, 34, 2).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (bitsPerSample != 16) { throw new IllegalArgumentException(String.format( "WAV bit depth %d not supported. Expected 16bit.", bitsPerSample)); } // 声道数:bytes[22-23] short channels = ByteBuffer.wrap(wavBytes, 22, 2).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (channels != 1) { throw new IllegalArgumentException(String.format( "WAV channels %d not supported. Expected mono (1 channel).", channels)); } } }这段代码直接读取 wav 文件二进制头(RFC 2361 标准),不依赖任何第三方库。如果校验失败,抛出带具体参数的IllegalArgumentException,比如WAV sample rate 44100Hz not supported. Expected 16000Hz.——比invalid parameter有用十倍。你在业务层 catch 这个异常,就能给前端返回精准错误码,而不是让用户反复试。
3.3 流式响应解析:如何把{"data":{"result":"你好"}}和{"data":{"result":"世界"}}拼成“你好世界”?
讯飞 WebAPI 的识别结果是分片返回的,/v1/status接口可能多次返回status=1(识别中),每次带部分文本。XunfeiSpeechClientImpl用ConcurrentHashMap<String, StringBuilder>缓存每个sid的中间结果:
private final Map<String, StringBuilder> partialResults = new ConcurrentHashMap<>(); private SpeechResult buildFinalResult(String sid, XunfeiResponse response) { StringBuilder sb = partialResults.computeIfAbsent(sid, k -> new StringBuilder()); // response.getData().getResult() 是本次返回的文本片段 String fragment = response.getData().getResult(); if (fragment != null && !fragment.trim().isEmpty()) { sb.append(fragment); } // status == 2 表示结束,清空缓存并返回 if (response.getHeader().getStatus() == 2) { String fullText = sb.toString().trim(); partialResults.remove(sid); return new SpeechResult(fullText, extractWordList(response), response.getHeader().getAudioDuration()); } return null; // 还没结束,继续轮询 }这里extractWordList(response)会从response.getData().getWordList()解析出带时间戳的词列表(如[{"word":"你好","start":0,"end":500},{"word":"世界","start":500,"end":1000}]),SpeechResult把它转成List<WordItem>,方便上层做高亮或跳转。注意ConcurrentHashMap是必须的——多个并发请求的sid不同,但同一个sid的轮询请求是串行的,所以StringBuilder不需要锁。
3.4 重试与熔断:RetryableCallInterceptor如何避免雪崩?
OkHttp 的Interceptor链里插入RetryableCallInterceptor,它只对特定 HTTP 状态码重试:
public class RetryableCallInterceptor implements Interceptor { private final int maxRetry; @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); Response response = null; IOException exception = null; for (int i = 0; i <= maxRetry; i++) { try { response = chain.proceed(request); // 只对网络层错误重试:5xx、408、429 if (response.code() >= 500 || response.code() == 408 || response.code() == 429) { if (i < maxRetry) { Thread.sleep((long) Math.pow(2, i) * 100); // 指数退避 continue; } } return response; } catch (IOException e) { exception = e; if (i < maxRetry) { Thread.sleep((long) Math.pow(2, i) * 100); continue; } } } if (exception != null) throw exception; if (response != null) return response; throw new IOException("Unknown error after " + maxRetry + " retries"); } }重点:不重试 400/401/403/404 这类客户端错误。比如401 Unauthorized是appid/apiSecret错了,重试 3 次还是错;400 Bad Request是bodyJSON 格式不对,重试也白搭。真正的重试目标是502 Bad Gateway(讯飞网关抖动)、429 Too Many Requests(你没配好限流,但重试+退避能扛过去)。指数退避Math.pow(2, i) * 100是血泪经验:第一次等 100ms,第二次 200ms,第三次 400ms,避免瞬间打爆自己或讯飞的限流阈值。
4. 运行与调试:从application.yml配置到SpeechClientTest单测
4.1application.yml最小配置:三行搞定启动
xunfei: speech: appid: your_appid_here api-secret: your_api_secret_here api-key: your_api_key_here # 兼容字段,可留空把这三行加到你的 Spring Boot 项目application.yml里,再加一个@EnableConfigurationProperties(XunfeiProperties.class),SpeechClient就能自动注入。不需要写@Bean SpeechClient——XunfeiAutoConfiguration类里已经用@ConditionalOnMissingBean做了自动装配。
4.2 本地测试:用test-audio.pcm验证端到端流程
项目根目录下附带test-audio.pcm(1 秒钟“你好”的 16kHz/16bit/little-endian PCM),运行SpeechClientTest:
@SpringBootTest class SpeechClientTest { @Autowired private SpeechClient speechClient; @Test void shouldRecognizeHelloWorld() throws Exception { // 1. 读取测试 PCM 文件 byte[] pcmBytes = Files.readAllBytes(Paths.get("test-audio.pcm")); // 2. 异步调用 CompletableFuture<SpeechResult> future = speechClient.recognizeAsync(pcmBytes, "zh_cn"); // 3. 等待结果(生产环境用 thenAccept 处理) SpeechResult result = future.get(30, TimeUnit.SECONDS); // 4. 断言 assertThat(result.getText()).isEqualTo("你好"); assertThat(result.getWordList()).hasSize(1); assertThat(result.getWordList().get(0).getWord()).isEqualTo("你好"); } }这个测试覆盖了完整链路:音频读取 → 签名生成 → HTTP 发送 → 轮询等待 → 结果解析。future.get(30, TimeUnit.SECONDS)是为了单测可控,生产环境绝对不要这么写——要用thenAccept(result -> doSomething(result))做异步回调。
4.3 日志排查:SpeechException的code和desc怎么对应讯飞文档?
当SpeechClient抛出SpeechException,日志里会打印code和desc,比如:
com.example.speech.exception.SpeechException: code=10104, desc=invalid parameter, traceId=abc123讯飞错误码文档里10104是“参数错误”,但没说具体哪个参数。本项目在XunfeiSpeechClientImpl的buildRequest()方法里加了防御性检查:
private Request buildRequest(byte[] audioBytes, String language) { // ... 构建 XunfeiRequest 对象 XunfeiRequest request = new XunfeiRequest(); request.setCommon(new Common(appid)); request.setBusiness(new Business(language)); request.setData(new Data(audioBytes)); // 防御性检查:language 必须是讯飞支持的值 if (!SUPPORTED_LANGUAGES.contains(language)) { throw new SpeechException(10104, "unsupported language: " + language, "abc123"); } // ... 签名、构建 OkHttp Request }SUPPORTED_LANGUAGES是一个Set<String>,包含"zh_cn"、"en_us"、"ja_jp"等。这样当传入"zh"时,异常信息就是unsupported language: zh,比invalid parameter直观得多。你可以在SpeechException构造时传入traceId,方便在 ELK 里关联上下游日志。
4.4 常见问题排查:5 条真实踩坑记录
现象:调用recognizeAsync后一直卡住,30 秒超时,日志无任何输出
原因:XunfeiProperties.host配错了,比如写成http://api.xfyun.cn(少 s)或https://www.xfyun.cn(多 www),OkHttp 连接被 DNS 拦截或 SSL 证书不匹配,但Interceptor没捕获到异常。
解决:在RetryableCallInterceptor的catch (IOException e)里加一行log.warn("Network error for request {}", request.url(), e),看是否是UnknownHostException或SSLHandshakeException。
现象:返回{"code":10111,"desc":"invalid appid or api key"},但appid和apiSecret确认无误
原因:讯飞控制台里该appid绑定的服务未开通“语音听写(流式版)”,只开了“语音合成”。WebAPI 的权限是按服务单独开通的。
解决:登录讯飞开放平台 → 进入“我的应用” → 找到对应appid→ “服务管理” → 开通“语音听写(流式版)”。
现象:PCM 音频识别结果全是乱码,如“ ”
原因:PCM 数据是 big-endian 格式,但讯飞只要 little-endian。JavaByteBuffer.order(ByteOrder.LITTLE_ENDIAN)默认是 big-endian。
解决:用AudioFormatChecker.validatePcm()校验字节序,或在转 PCM 时用AudioSystem.write(new AudioInputStream(...), AudioFileFormat.Type.WAVE, ...)写成 wav 再提取数据,wav 头里明确声明了字节序。
现象:并发 10 个请求,其中 3 个返回{"code":10404,"desc":"frequency limit exceeded"}
原因:讯飞免费版 QPS 限制是 5,你没配maxRetry或重试策略,导致瞬间打满限流。
解决:在XunfeiProperties里设max-retry=1,并在业务层用Semaphore控制并发数,比如semaphore.tryAcquire(1, 1, TimeUnit.SECONDS)。
现象:SpeechResult.getWordList()返回null,但getText()有内容
原因:XunfeiResponse.getData().getWordList()是null,因为讯飞返回的 JSON 里data.word_list字段不存在(旧版接口或配置未开启词粒度)。
解决:在XunfeiSpeechClientImpl.extractWordList()方法里加空值保护:if (response.getData().getWordList() == null) { return Collections.emptyList(); },避免 NPE。
5. 二次开发指南:替换为 WebSocket 版本、接入企业微信通知、添加方言支持
5.1 替换为 WebSocket 版本:为什么SpeechClient接口不变,但实现类要重写?
讯飞提供 WebSocket 版本的语音识别(wss://ws-api.xfyun.cn/v2/iat),它比 HTTP 长轮询更实时、更省资源。但 WebSocket 是长连接,SpeechClient当前设计是“一次请求一次连接”,无法复用。改造思路是:保持SpeechClient接口不变,新增WebSocketSpeechClientImpl实现类,并用@Primary注解让其优先注入。
关键改动点:
WebSocketSpeechClientImpl构造时建立WebSocket连接(用 OkHttp 的WebSocketListener),并维护一个ConcurrentMap<String, CompletableFuture<SpeechResult>>缓存sid到Future的映射。recognizeAsync()方法不再发 HTTP,而是webSocket.send(jsonRequest),并在onMessage()回调里根据sid找到对应的CompletableFuture并complete(result)。- 连接断开时自动重连(带指数退避),重连后重新发送未确认的请求。
注意:WebSocket 版本需要额外依赖
okhttp-ws,且XunfeiProperties要增加ws-url配置。但上层业务代码完全不用改——这就是接口抽象的价值。
5.2 接入企业微信通知:在SpeechResult生成后自动推送
假设你用企业微信机器人推送识别结果,只需在SpeechClientImpl的thenAccept链里加一环:
speechClient.recognizeAsync(pcmBytes, "zh_cn") .thenAccept(result -> { // 1. 保存到数据库 speechResultRepository.save(result); // 2. 推送到企微 weComNotifier.sendText( String.format("【会议纪要】%s\n%s", LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm")), result.getText()) ); // 3. 触发关键词高亮(调用另一个服务) highlightService.highlightKeywords(result.getText(), Arrays.asList("预算", "截止", "负责人")); }) .exceptionally(throwable -> { log.error("Speech recognition failed", throwable); // 推送失败告警 weComNotifier.sendText("⚠️ 语音识别失败:" + throwable.getMessage()); return null; });weComNotifier是你项目里已有的企业微信工具类,highlightService是另一个微服务。这里thenAccept是非阻塞的,不会拖慢主线程。
5.3 添加粤语支持:只需改两处,无需动核心逻辑
讯飞支持粤语识别,language参数传"yue_hk"即可。但你需要:
- 在
SUPPORTED_LANGUAGES里加"yue_hk"; - 在
application.yml示例里加注释说明"yue_hk: 粤语(香港)"。
然后业务方调用时:
speechClient.recognizeAsync(pcmBytes, "yue_hk") .thenAccept(result -> { log.info("粤语识别结果:{}", result.getText()); // 如“你好呀” });核心逻辑完全不用改——XunfeiRequest.Business的language字段就是透传给讯飞的,SpeechClient只负责组装和解析。
5.4 性能压测技巧:用 JMeter 模拟 100 QPS,看OkHttpClient连接池是否撑得住
OkHttpClient的连接池默认是5个空闲连接,keepAlive5 分钟。100 QPS 下,如果每个请求耗时 200ms,理论上需要100 * 0.2 = 20个并发连接。压测前务必调大连接池:
@Bean public OkHttpClient okHttpClient(XunfeiProperties props) { return new OkHttpClient.Builder() .connectTimeout(props.getConnectTimeout(), TimeUnit.MILLISECONDS) .readTimeout(props.getReadTimeout(), TimeUnit.MILLISECONDS) .connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES)) // 改为 50 个空闲连接 .addInterceptor(new RetryableCallInterceptor(props.getMaxRetry())) .build(); }压测时监控OkHttpClient.connectionPool().connectionCount(),如果长期 > 45,说明连接池不够,要继续调大;如果大量ConnectionPool的evict()日志,说明连接空闲太久被回收,keepAlive时间可适当缩短。
从那以后我每次接入第三方 API,都强制走一遍「签名生成 → 格式校验 → 错误码映射 → 重试策略」四步 checklist,哪怕文档写得再简单。讯飞这个 case 让我明白:所谓“封装 SDK”,不是把 HTTP 调用包一层@Service就完事,而是要把对方的黑匣子,变成你手里可测、可调、可降级的确定性模块。希望帮到你。
本文还有配套的精品资源,点击获取