CrazyAirhead

疯狂的傻瓜,傻瓜也疯狂——傻方能执著,疯狂才专注!

0%

部署和使用 FunASR

说明

使用 whisper 的时候,发现有的时候会出现一些无法识别的部分,或者出现一些特殊的结尾,或者出现繁体,或者没有标点符号等问题。这些都需要额外的处理,导致识别的准确度不高,同时识别数据也不是很快,因此一直也在找有没有更好用的模型。

最近发现了 FunASR。FunASR 的官网是这么介绍的,FunASR 是离线文件转写软件包,提供了一款功能强大的语音离线文件转写服务。拥有完整的语音识别链路,结合了语音端点检测、语音识别、标点等模型,可以将几十个小时的长音频与视频识别成带标点的文字,而且支持上百路请求同时进行转写。输出为带标点的文字,含有字级别时间戳,支持ITN与用户自定义热词等。服务端集成有ffmpeg,支持各种音视频格式输入。软件包提供有html、python、c++、java与c#等多种编程语言客户端,用户可以直接使用与进一步开发。

我用来处理原来的音频文件,速度提升了不少,而且可以节省一些步骤,FunASR 提供的服务已经包含了对 mp3 文件的转换,文本也已经加了标点。

部署

FunASR 提供了 Docker 环境可以直接部署(https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_offline_zh.md),注意修改卷的地址为自己的实际地址。

1
2
3
4
5
6
7
8
docker pull \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.7

mkdir -p /Users/airhead/funasr-runtime-resources/models

docker run -p 10095:10095 -it --privileged=true \
-v /Users/airhead/funasr-runtime-resources/models:/workspace/models \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.7

执行上面的 docker 命令会直接进入容器内部,接着执行下面,可以启动 FunASR 服务,如果第一次启动,可以去掉 nohup> log.txt 2>&1 &的部分,方便查看日志。

1
2
3
4
5
6
7
8
9
10
cd FunASR/runtime
nohup bash run_server.sh \
--download-model-dir /workspace/models \
--model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx \
--vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx \
--punc-dir damo/punc_ct-transformer_cn-en-common-vocab471067-large-onnx \
--lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst \
--itn-dir thuduj12/fst_itn_zh \
--certfile 0 \
--hotword /workspace/models/hotwords.txt > log.txt 2>&1 &

执行上述指令后,启动离线文件转写服务,但关闭了 SSL。如果模型指定为ModelScope中model id,会自动从MoldeScope中下载如下模型: FSMN-VAD模型, Paraformer-lagre模型, CT-Transformer标点预测模型, 基于FST的中文ITN, Ngram中文语言模型

参数

–download-model-dir 模型下载地址,通过设置model ID从Modelscope下载模型
–model-dir 主ASR识别模型,必选modelscope model ID 或者 本地模型路径
–vad-dir 语音活动检测模型,可选modelscope model ID 或者 本地模型路径
–punc-dir 标点恢复模型,可选modelscope model ID 或者 本地模型路径
–lm-dir 语言模型,可选modelscope model ID 或者 本地模型路径
–itn-dir 逆文本归一化,可选modelscope model ID 或者 本地模型路径
–port 服务端监听的端口号,默认为 10095
–decoder-thread-num 服务端线程池个数(支持的最大并发路数),脚本会根据服务器线程数自动配置decoder-thread-num、io-thread-num
–io-thread-num 服务端启动的IO线程数
–model-thread-num 每路识别的内部线程数(控制ONNX模型的并行),默认为 1,其中建议 decoder-thread-num*model-thread-num 等于总线程数
–certfile sl的证书文件,默认为:../../../ssl_key/server.crt,如果需要关闭ssl,参数设置为0
–keyfile ssl的密钥文件,默认为:../../../ssl_key/server.key
–hotword 热词文件路径,每行一个热词,格式:热词 权重(例如:阿里巴巴 20),如果客户端提供热词,则与客户端提供的热词合并一起使用,服务端热词全局生效,客户端热词只针对对应客户端生效。

刚接触 FunASR 可能对启动的几个模型感到困惑,因此这里做些补充。

img

语音活动检测(–vad-dir)

分离音频中的语音和非语音。

主ASR模型(–model-dir )

语音识别的主体模型,将音频转换为原始文本。

标点恢复(–punc-dir)

为ASR输出的无标点文本添加标点(逗号、句号等)

语言模型(–lm-dir)

使用语言模型进行二次解码,提升识别准确率。使用场景:对识别结果要求较高的场景。

逆文本归一化(–itn-dir)

将数值统一使用中文表达。比如,将”123”转为”一百二十三”,”10:30”转为”十点三十分”

模型

可以在魔搭上选合适的模型。

https://www.modelscope.cn/models?page=1&tasks=auto-speech-recognition

使用

使用样例在 https://github.com/modelscope/FunASR 仓库下的 runtime 目录。

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.goldsyear.solon.asr.config;

import lombok.Data;
import org.noear.solon.annotation.Configuration;

/**
* FunASR 配置类
*
* @author airhead
*/
@Configuration
@Data
public class FunAsrConfig {

/**
* WebSocket 服务地址,例如:ws://localhost:10095/funasr/ws/offline
*/
private String serverUrl;

/**
* 模式:offline(离线模式)
*/
private String mode = "offline";

/**
* 音频文件格式:pcm/mp3/mp4 等
*/
private String wavFormat = "pcm";

/**
* 是否启用文本规范化
*/
private Boolean itn = true;

/**
* 热词配置(JSON 字符串格式),例如:{"关键词":20}
*/
private String hotwords;

/**
* 超时时间(秒)
*/
private Integer timeout = 300;

/**
* PCM 采样率
*/
private Integer sampleRate = 16000;
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
package com.goldsyear.solon.asr.client;

import com.goldsyear.solon.asr.config.FunAsrConfig;
import com.goldsyear.solon.asr.exception.FunAsrException;
import com.goldsyear.solon.asr.model.FunAsrResult;
import com.goldsyear.solon.common.core.model.KeyValue;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okio.ByteString;
import org.jspecify.annotations.NonNull;
import org.noear.snack4.ONode;

/**
* FunASR WebSocket 客户端
*
* @author airhead
*/
@Slf4j
public class FunAsrWebSocketClient {

/**
* -- GETTER --
* 获取配置
*
* @return FunASR 配置
*/
@Getter
private final FunAsrConfig config;
private final OkHttpClient httpClient;

/**
* 创建 FunASR WebSocket 客户端
*
* @param config FunASR 配置
* @throws IllegalArgumentException 如果配置参数无效
*/
public FunAsrWebSocketClient(FunAsrConfig config) {
validateConfig(config);
this.config = config;
this.httpClient =
new OkHttpClient.Builder()
.readTimeout(config.getTimeout(), TimeUnit.SECONDS)
.writeTimeout(config.getTimeout(), TimeUnit.SECONDS)
.connectTimeout(config.getTimeout(), TimeUnit.SECONDS)
.build();
}

/**
* 识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @return 识别结果
*/
public FunAsrResult recognize(File audioFile, String wavName) {
return recognize(audioFile, wavName, null);
}

/**
* 识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @param hotWords 热词配置(可选,会覆盖配置中的热词)
* @return 识别结果
*/
public FunAsrResult recognize(File audioFile, String wavName, String hotWords) {
if (audioFile == null || !audioFile.exists()) {
throw new IllegalArgumentException("Audio file not found: " + audioFile);
}

// 读取音频文件
byte[] audioData;
try {
audioData = Files.readAllBytes(audioFile.toPath());
} catch (IOException e) {
throw new FunAsrException("Failed to read audio file: " + audioFile, e);
}

return recognize(audioData, wavName != null ? wavName : audioFile.getName(), hotWords);
}

/**
* 识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @return 识别结果
*/
public FunAsrResult recognize(byte[] audioData, String wavName) {
return recognize(audioData, wavName, null);
}

/**
* 识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @param hotwords 热词配置(可选,会覆盖配置中的热词)
* @return 识别结果
*/
public FunAsrResult recognize(byte[] audioData, String wavName, String hotwords) {
if (audioData == null || audioData.length == 0) {
throw new IllegalArgumentException("Audio data is empty");
}

// 构建 WebSocket 请求
Request request = new Request.Builder().url(config.getServerUrl()).build();

// 创建用于存储结果的容器
AtomicReference<FunAsrResult> resultRef = new AtomicReference<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);

// 创建 WebSocket 监听器
WebSocketListener listener =
new WebSocketListener() {
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
log.info("WebSocket closing: {} - {}", code, reason);
webSocket.close(code, reason);
// 如果已经有结果,立即释放 latch
// 对于离线模式,结果可能已经在 onMessage 中处理了
if (resultRef.get() != null) {
latch.countDown();
}
}

@Override
public void onFailure(
@NonNull WebSocket webSocket, @NonNull Throwable t, Response response) {
// 如果已经有结果且是因为关闭连接导致的错误,忽略这个异常
if (resultRef.get() != null && t instanceof java.net.SocketException) {
log.debug("WebSocket closed after result received: {}", t.getMessage());
return;
}
log.error("WebSocket error", t);
errorRef.set(t);
latch.countDown();
}

@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
log.info("Received message: {}", text);

try {
// 尝试解析消息
FunAsrResult result = parseResult(text);
// 立即缓存结果
resultRef.set(result);

// 离线模式:收到任何有效结果就立即返回
// 在线模式:等待 isFinal=true
if ("offline".equals(config.getMode())) {
log.info("Offline mode: received result, closing connection");
latch.countDown();
// 主动关闭连接
webSocket.close(1000, "Result received");
} else if (Boolean.TRUE.equals(result.getIsFinal())) {
log.info("Online mode: received final result");
latch.countDown();
}
} catch (Exception e) {
log.error("Failed to parse result: {}", text, e);
errorRef.set(e);
latch.countDown();
}
}

@Override
public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
log.debug("WebSocket connected");

// 发送初始化配置
try {
String initJson = buildInitConfig(wavName, hotwords);
log.debug("Sending init config: {}", initJson);
webSocket.send(initJson);

// 分块发送音频数据,每块 16KB
int chunkSize = 16 * 1024; // 16KB
int offset = 0;
while (offset < audioData.length) {
int length = Math.min(chunkSize, audioData.length - offset);
byte[] chunk = new byte[length];
System.arraycopy(audioData, offset, chunk, 0, length);
webSocket.send(ByteString.of(chunk));
log.debug(
"Sent audio chunk: {} bytes (offset: {}/{})", length, offset, audioData.length);
offset += length;
// 添加小延迟,避免发送过快
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.debug("Sent all audio data: {} bytes total", audioData.length);

// 发送结束标志
String endJson = buildEndFlag();
log.debug("Sending end flag: {}", endJson);
webSocket.send(endJson);

} catch (Exception e) {
log.error("Error during send", e);
errorRef.set(e);
latch.countDown();
webSocket.close(1000, "Error during send");
}
}
};

// 建立 WebSocket 连接
WebSocket webSocket = httpClient.newWebSocket(request, listener);

try {
// 等待结果或超时
boolean completed = latch.await(config.getTimeout(), TimeUnit.SECONDS);

if (!completed) {
throw new FunAsrException(
"FunASR recognition timeout after " + config.getTimeout() + " seconds");
}

// 检查是否有错误
Throwable error = errorRef.get();
if (error != null) {
throw new FunAsrException("FunASR recognition failed", error);
}

// 获取结果
FunAsrResult result = resultRef.get();
if (result == null) {
throw new FunAsrException("No result received from FunASR");
}

return result;

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FunAsrException("FunASR recognition interrupted", e);
} finally {
webSocket.cancel();
}
}

/**
* 异步识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(File audioFile, String wavName) {
return recognizeAsync(audioFile, wavName, null);
}

/**
* 异步识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @param hotWords 热词配置(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(
File audioFile, String wavName, String hotWords) {
return CompletableFuture.supplyAsync(() -> recognize(audioFile, wavName, hotWords));
}

/**
* 异步识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(byte[] audioData, String wavName) {
return recognizeAsync(audioData, wavName, null);
}

/**
* 异步识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @param hotWords 热词配置(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(
byte[] audioData, String wavName, String hotWords) {
return CompletableFuture.supplyAsync(() -> recognize(audioData, wavName, hotWords));
}

/** 关闭客户端 */
public void shutdown() {
httpClient.dispatcher().executorService().shutdown();
httpClient.connectionPool().evictAll();
}

/**
* 健康检查 尝试连接到 FunASR 服务器,检查服务是否可用
*
* @return true 如果服务可用,false 否则
*/
public boolean healthCheck() {
try {
Request request = new Request.Builder().url(config.getServerUrl()).build();

// 使用短超时进行连接测试
OkHttpClient testClient =
httpClient
.newBuilder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build();

AtomicReference<Boolean> connected = new AtomicReference<>(false);
CountDownLatch latch = new CountDownLatch(1);

WebSocketListener listener =
new WebSocketListener() {
@Override
public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t, Response response) {
connected.set(false);
latch.countDown();
}

@Override
public void onOpen(WebSocket webSocket, @NonNull Response response) {
connected.set(true);
latch.countDown();
webSocket.close(1000, "Health check");
}
};

WebSocket webSocket = testClient.newWebSocket(request, listener);
boolean completed = latch.await(10, TimeUnit.SECONDS);
webSocket.cancel();

return completed && connected.get();

} catch (Exception e) {
log.error("FunASR health check failed: {}", e.getMessage());
return false;
}
}

/**
* 验证配置参数
*
* @param config FunASR 配置
* @throws IllegalArgumentException 如果配置参数无效
*/
private void validateConfig(FunAsrConfig config) {
if (config == null) {
throw new IllegalArgumentException("FunAsrConfig cannot be null");
}
if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) {
throw new IllegalArgumentException("Server URL cannot be empty");
}
if (!config.getServerUrl().startsWith("ws://") && !config.getServerUrl().startsWith("wss://")) {
throw new IllegalArgumentException("Server URL must start with ws:// or wss://");
}
if (config.getTimeout() != null && config.getTimeout() <= 0) {
throw new IllegalArgumentException("Timeout must be positive");
}
}

/** 构建初始化配置 JSON */
private String buildInitConfig(String wavName, String hotWords) {
KeyValue data = KeyValue.of();
data.set("mode", config.getMode());
data.set("wav_name", wavName);
data.set("wav_format", config.getWavFormat());
data.set("is_speaking", true);
data.set("itn", config.getItn());

String hw = hotWords != null ? hotWords : config.getHotwords();
if (hw != null && !hw.isEmpty()) {
data.set("hotwords", hw);
}

return ONode.serialize(data);
}

/** 构建结束标志 JSON */
private String buildEndFlag() {
return ONode.serialize(KeyValue.of("is_speaking", false));
}

/** 解析识别结果 */
private FunAsrResult parseResult(String jsonText) {
KeyValue root = ONode.deserialize(jsonText, KeyValue.class);

FunAsrResult result = new FunAsrResult();

// 检查错误响应
if (root.notNull("error_code")) {
result.setErrorCode(root.getStr("error_code"));
result.setErrorMsg(root.getStr("error_msg"));
return result;
}

// 解析成功响应
result.setMode(root.getStr("mode"));
result.setWavName(root.getStr("wav_name"));
result.setText(root.getStr("text"));
result.setIsFinal(root.getBoolean("is_final"));
result.setTimestamp(root.getStr("timestamp"));

// 解析时间戳句子列表
if (root.notNull("stamp_sents")) {
result.setStampSents(parseStampSents((List<?>) root.get("stamp_sents")));
}

return result;
}

/** 解析时间戳句子列表 */
private List<FunAsrResult.StampSent> parseStampSents(List<?> stampSentsList) {
if (stampSentsList == null) {
return null;
}
List<FunAsrResult.StampSent> stampSents = new ArrayList<>();
for (Object item : stampSentsList) {
if (item instanceof KeyValue) {
KeyValue sentNode = (KeyValue) item;
FunAsrResult.StampSent stampSent = new FunAsrResult.StampSent();
stampSent.setTextSeg(sentNode.getStr("text_seg"));
stampSent.setPunc(sentNode.getStr("punc"));
stampSent.setStart(sentNode.getLong("start"));
stampSent.setEnd(sentNode.getLong("end"));
stampSent.setTsList(parseTsList(sentNode.getAs("ts_list")));
stampSents.add(stampSent);
} else if (item instanceof java.util.Map) {
// 处理普通 Map
@SuppressWarnings("unchecked")
java.util.Map<String, Object> sentNode = (java.util.Map<String, Object>) item;
FunAsrResult.StampSent stampSent = new FunAsrResult.StampSent();
stampSent.setTextSeg((String) sentNode.get("text_seg"));
stampSent.setPunc((String) sentNode.get("punc"));
stampSent.setStart(toLong(sentNode.get("start")));
stampSent.setEnd(toLong(sentNode.get("end")));
stampSent.setTsList(parseTsList((List<?>) sentNode.get("ts_list")));
stampSents.add(stampSent);
}
}
return stampSents;
}

/** 解析时间戳列表 */
private List<List<Integer>> parseTsList(List<?> tsListRaw) {
if (tsListRaw == null) {
return null;
}
List<List<Integer>> tsList = new ArrayList<>();
for (Object item : tsListRaw) {
if (item instanceof List<?> tsRaw) {
List<Integer> ts = new ArrayList<>();
for (Object num : tsRaw) {
ts.add(toInteger(num));
}
tsList.add(ts);
}
}
return tsList;
}

/** 安全转换为 Long */
private Long toLong(Object value) {
switch (value) {
case null -> {
return null;
}
case Long l -> {
return l;
}
case Integer i -> {
return i.longValue();
}
case Number number -> {
return number.longValue();
}
case String command -> {
try {
return Long.parseLong(command);
} catch (NumberFormatException e) {
return null;
}
}
default -> {}
}
return null;
}

/** 安全转换为 Integer */
private Integer toInteger(Object value) {
switch (value) {
case null -> {
return null;
}
case Integer i -> {
return i;
}
case Long l -> {
return l.intValue();
}
case Number number -> {
return number.intValue();
}
case String command -> {
try {
return Integer.parseInt(command);
} catch (NumberFormatException e) {
return null;
}
}
default -> {}
}

return null;
}

}

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Test
public void testRecognizeFile() {
// 创建配置
FunAsrConfig config = new FunAsrConfig();
config.setServerUrl("ws://localhost:10095/funasr/ws/offline");
config.setWavFormat("mp3");
config.setMode("offline");
config.setItn(true);
config.setHotwords("{}");
config.setTimeout(300);

// 创建客户端
FunAsrWebSocketClient client = FunAsrClientFactory.create(config);

try {
// 识别音频文件(请替换为实际的音频文件路径)
File audioFile = new File("1d829d6c-558b-4865-98cc-a3bcecffc485.mp3");
if (!audioFile.exists()) {
log.warn("Audio file not found: {}", audioFile.getAbsolutePath());
return;
}

FunAsrResult result = client.recognize(audioFile, "test_audio");

if (result.isSuccess()) {
log.info("识别成功:");
log.info("文本: {}", result.getText());
log.info("模式: {}", result.getMode());
log.info("时间戳: {}", result.getTimestamp());
} else {
log.error("离线识别失败: {} - {}", result.getErrorCode(), result.getErrorMsg());
}

} catch (Exception e) {
log.error("识别过程出错", e);
} finally {
client.shutdown();
}
}

欢迎关注我的其它发布渠道