文章目录
- 题记
- [一 、语音交互服务(Speech Interaction Service,简称SIS)](#一 、语音交互服务(Speech Interaction Service,简称SIS))
- 二、功能介绍
- 三、约束与限制
- 四、使用
- 五、项目集成
-
- 1、引入pom依赖
- [2、初始化 Client](#2、初始化 Client)
-
- 1)准备参数
- 2)nacos配置
- 3)配置类-CommonClientsProperties.java
- 4)初始化客户端配置-CommonClientsCache.java
- [5)抽取公共文件客户端封装对象- CommonClientBean.java](#5)抽取公共文件客户端封装对象- CommonClientBean.java)
- 6)华为云语音生成客户端封装-HuaweiClientBean.java
- 7)工具类-FileUtils.java
- 8)封装公共请求参数-FileVoiceUploadReqDTO.java
- 9)业务类调用-ArticleManageController.java
题记
本文将根据一种具体业务场景:语音播报(将一篇ai撰写的文章异步转换成语音文件进行播报)为案例演示华为云语音交互SIS的集成使用。
一 、语音交互服务(Speech Interaction Service,简称SIS)
语音交互服务(Speech Interaction Service,简称SIS)是一种人机交互方式,用户通过实时访问和调用API(Application Programming Interface,应用程序编程接口)将语音识别成文字或者将文本转换成逼真的语音等。
常用的应用场景参看官网:应用场景
二、功能介绍
Tip:根据你的需求场景,是否实时、大小、时长、是语音转文字,还是文字转语音等等评估应该使用下边哪种功能。
1、实时语音识别
实时语音识别服务
,用户通过实时访问和调用API获取实时语音识别结果,支持的语言包含中文普通话、方言和英语,方言当前支持四川话、粤语和上海话。
-
文本时间戳
为音频转换结果生成特定的时间戳,从而通过搜索文本即可快速找到对应的原始音频。
-
智能断句
通过提取上下文相关语义特征,并结合语音特征,智能划分断句及添加标点符号,提升输出文本的可阅读性。
-
中英文混合识别
支持在中文句子识别中夹带英文字母、数字等,从而实现中、英文以及数字的混合识别。
-
即时输出识别结果
连续识别语音流内容,即时输出结果,并可根据上下文语言模型自动校正。
-
自动静音检测
对输入语音流进行静音检测,识别效率和准确率更高。
2、一句话识别
可以实现1分钟以内音频到文字的转换
。对于用户上传的二进制音频格式数据,系统经过处理,生成语音对应的文字,支持的语言包含中文普通话、方言以及英语。方言当前支持四川话、粤语和上海话。
3、录音文件识别
对于录制的长语音进行识别,转写成文字
,提供不同领域模型,具备良好的可扩展性,支持热词定制。
4、语音合成
文本转成语音
,语音合成支持多种音色,可调节语调,语速,音量。
这里我将使用【4、语音合成】功能实现开篇提到的文章转语音播报的目的。
三、约束与限制
明确了要使用的功能,接下来看有哪些约束限制,是否与需求契合。使用【语音合成】功能的注意点:
- 支持
"华北-北京四"、"华东-上海一"
区域。 - 支持中文、英文、中英文,文本
不长于500个字符
。 - 支持合成采样率8kHz、16kHz。
Tip:由上可知,如果文本大于500字符就需要切割再合并问题。
以上了解了需求场景能不能使用,接下来就看怎么用啦~
四、使用
主要有两种接入方式:API
或SDK
。
1、API
SIS服务提供了两种接口,包含REST(Representational State Transfer)API,支持您通过HTTPS请求
调用。也包含WebSocket接口,支持Websocket协议
。参看:API文档
本文使用SDK方式接入,API方式不过多赘述,可参考文档使用。
2、SDK
最新的sdk目前是3.1.128版本。
注意该SDK暂不支持websocket方法。
如果需要使用实时语音识别,可考虑使用替代SDK,当前支持Java SDK、Python SDK、CPP SDK、iOS SDK、Android SDK。
这里我不需要实时的,可以直接使用上边的最新sdk的方式。
五、项目集成
由于我的项目本身有华为云其他产品,为了兼容使用了3.1.116版本,以及排除了一些依赖。
1、引入pom依赖
xml
<dependency>
<groupId>com.huaweicloud.sdk</groupId>
<artifactId>huaweicloud-sdk-sis</artifactId>
<version>3.1.116</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</exclusion>
</exclusions>
</dependency>
2、初始化 Client
注意:官方文档上显示的客户端client可能是未更新的或者和你本地引入的依赖里的客户端不匹配,根据实际情况使用你依赖里的客户端去处理就好,以及封装的请求对象。
【我这里依赖里的客户端是:SisClient,请求类:RunTtsRequest】
1)准备参数
首先需要一些认证信息、配置信息,可参考官网获取方式:
请求参数:
目前SDK仅支持AK/SK认证方式。
2)nacos配置
我们将上边的信息以及可以调整的参数统一提取出来配置化,避免硬编码,这里我统一放到nacos中配置。
nacos配置文件内容:
yaml
#支持多租户分桶的文件服务配置,目前支持阿里云oss、亚马逊s3、华为云obs、NAS网络存储、微软云blob。
common:
clients:
#文件权限范围; default:平台, 租户code eg:100001
- bucketOwner: default
#桶类型; public:公有, private:私有; 其他自定义只作为备用桶, 需以_public或_private结尾
bucketType: public
#存储云类型;
cloudType: huaweiyun
#桶名称
bucketName: obs-group-test-xxxxx
#oss提供的内网访问域名
endpoint: https://obs.cn-north-4.myhuaweicloud.com
accessKeyId: YL6BxxxxxxxxxxxxxxxKL
accessKeySecret: w0pTVxxxxxxxxxxxxxx1hXnH
projectId: 0744xxxxxxxxxxxxxd9a
region: cn-north-4
default:
#默认的私有桶url有效时间,单位:秒。
expiration: 3600
#租户备用桶设置(只支持读取)
buckets:
#租户code
- tenantCode: test
#{bucketOwner}_{bucketType},根据bucketOwner和bucketType映射到上面配置的桶
spareBucket: test_public
#华为云语音合成音色设置
sis-client:
#语音格式头:wav、mp3、pcm 默认:wav
audioFormat: wav
#采样率:16000、8000赫兹 默认:8000
sampleRate: 8000
#语音合成特征字符串
property: chinese_huaxiaodong_common
#语速
speed: 0
#音高
pitch: 43
#音量默认50
volume: 44
3)配置类-CommonClientsProperties.java
CommonClientsProperties.java
java
@ConfigurationProperties(prefix = "common")
public class CommonClientsProperties {
private List<Properties> clients = new ArrayList<>();
public List<Properties> getClients() {
return clients;
}
public void setClients(List<Properties> clients) {
this.clients = clients;
}
@Data
public static class Properties {
private String bucketOwner;
private String bucketType;
private String cloudType;
private String bucketName;
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String region;
private Integer expiration;
private String baseDir;
private String connectStr;
private String projectId;
}
}
4)初始化客户端配置-CommonClientsCache.java
这里可以做的通用一些,将每个平台自家的产品的客户端都单独封装在一起,比如华为云的obs、语音、视频等封装成华为云的客户端;阿里的oss、语音等等封装成阿里的客户端;统一给外层调用。
另外accessKey可能涉及到加解密等注意处理即可。
这里我们将生成的语音文件上传到华为云obs,所以一并将obs客户端、http的也初始化了。
java
/**
* 文件客户端初始化
*/
@Slf4j
public class CommonClientsCache {
@Resource
CommonClientsProperties commonClientsProperties;
private final Map<String, CommonClientBean> cache = new HashMap<>();
@PostConstruct
public void init() {
List<CommonClientsProperties.Properties> clientParams = commonClientsProperties.getClients();
clientParams.forEach(properties -> {
String key = String.format("%s_%s", properties.getBucketOwner(), properties.getBucketType());
cache.put(key, buildCommonClientBean(properties));
});
}
private CommonClientBean buildCommonClientBean(CommonClientsProperties.Properties properties) {
String endpoint = properties.getEndpoint();
String accessKeySecret = decode(properties.getAccessKeySecret());
String bucketName = properties.getBucketName();
CloudTypeEnum cloudType = CloudTypeEnum.valueOfType(properties.getCloudType());
if (StringUtils.isBlank(bucketName) && StringUtils.isBlank(properties.getConnectStr())) {
log.info("file client configuration missing");
return null;
}
try {
log.info("file client init start, endpoint:{},bucketName:{}", endpoint, bucketName);
switch (Objects.requireNonNull(cloudType)) {
case HUAWEIYUN:
return getHuaWeiClientBean(properties, accessKeySecret);
default:
throw new FileBizException("cloud type is error");
}
} catch (Exception e) {
log.error("file client init failed", e);
return null;
}
}
private String decode(String accessKey) {
// 使用加密AK秘钥
try {
if (StringUtils.isNotEmpty(accessKey) && accessKey.contains(CoreConstants.ZAEC)) {
accessKey = Zaenc.decryptData(accessKey);
}
} catch (Exception e) {
log.error(" access key decrypt fail", e);
}
return accessKey;
}
private CommonClientBean getHuaWeiClientBean(CommonClientsProperties.Properties properties, String accessKeySecret) {
ObsClient obsClient = new ObsClient(properties.getAccessKeyId(), accessKeySecret, properties.getEndpoint());
HttpConfig httpConfig = HttpConfig.getDefaultHttpConfig().withIgnoreSSLVerification(true).withTimeout(10);
ICredential auth = new BasicCredentials()
.withAk(properties.getAccessKeyId())
.withSk(accessKeySecret)
.withProjectId(properties.getProjectId());
SisClient sisClient = SisClient.newBuilder().withCredential(auth)
.withHttpConfig(httpConfig)
.withRegion(SisRegion.valueOf(properties.getRegion()))
.build();
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.connectTimeout(180, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.writeTimeout(180, TimeUnit.SECONDS)
.build();
return new HuaweiSisClientBean(properties, obsClient, sisClient,okHttpClient);
}
}
public S3ClientBean getClientByOwnerAndType(BucketOwnerEnum bucketOwner, BucketTypeEnum bucketType, String tenantCode) {
String owner = bucketOwner.equals(BucketOwnerEnum.DEFAULT) ? bucketOwner.getType() : tenantCode;
String key = String.format("%s_%s", owner, bucketType.getType());
CommonClientBean s3Client = cache.get(key);
//如果找不到租户桶,取公共桶
if (s3Client == null && bucketOwner.equals(BucketOwnerEnum.TENANT)) {
String defaultKey = String.format("%s_%s", BucketOwnerEnum.DEFAULT.getType(), bucketType.getType());
s3Client = cache.get(defaultKey);
}
if (s3Client == null) {
log.error("file client not found, bucketOwner:{}, bucketType:{}", bucketOwner, bucketType);
throw new FileBizException("file client not found");
}
return s3Client;
}
5)抽取公共文件客户端封装对象- CommonClientBean.java
不同的客户端各自实现,比如阿里、华为、亚马逊。
CommonClientBean.java
java
/**
* 文件客户端封装对象
*
*/
public interface CommonClientBean {
/**
* 云存储类型
*
* @return CloudTypeEnum
*/
CloudTypeEnum getCloudType();
/**
* 基本目录
*
* @return 基本目录
*/
String getBaseDir();
/**
* 上传文件
*
* @param file 文件
* @param key 文件保存路径
*/
void uploadMultipartFile(MultipartFile file, String key);
/**
* 上传文件
* @param file 文件
* @param key 文件Key
* @return 文件Key
*/
default String uploadMultipartFileWithReturn(MultipartFile file, String key) {
uploadMultipartFile(file, key);
return key;
}
/**
* 上传字节数组
*
* @param bytes 字节数组
* @param key 文件保存路径
*/
void uploadByteArray(byte[] bytes, String key);
/**
* 上传网络流
*
* @param url 网络流地址
* @param key 文件保存路径
*/
void uploadNetworkFlow(String url, String key);
/**
* 上传输入流
*
* @param inputStream 輸入流
* @param key 文件保存路径
*/
void uploadInputStream(InputStream inputStream, String key);
/**
* 追加上传
*
* @param input 文件流
* @param key 文件保存路径
* @param position 追加位置
*/
void appendUpload(InputStream input, String key, Long position);
/**
* 根据Key获取文件下载流
*
* @param key 文件key
* @return 文件下载对象
*/
FileDownloadDTO downloadStream(String key);
/**
* 根据Key获取图片压缩url
*
* @param key 文件key
* @param size 文件大小
* @return 图片压缩url
*/
String getCompressUrl(String key, int size);
/**
* 根据Key获取文件Url
*
* @param key 文件key
* @return 文件Url
*/
String getUrl(String key);
/**
* 根据Key获取文件Url
* @param key 文件key
* @param assetId 资产id
* @return 文件Url
*/
default String getUrl(String key,String assetId){
return getUrl(key);
}
/**
* 根据Key获取文件大小
*
* @param key 文件key
* @return 文件大小
*/
Long getObjectLength(String key);
/**
* 发布视频
* @param assetId 资产id
*/
default void publishVideo(String assetId) {
//什么也不做
}
/**
* CDN预热
* @param assetId 资产id
*/
default void videoPreheat(String assetId) {
//什么也不做
}
default String obs2vod(String fileName,String obsUrl) {
//什么也不做
return null;
}
default CredentialDTO securityToken(){
//什么也不做
return null;
}
default TemporarySignatureDTO createTemporarySignature(String objectKey){
return null;
}
default byte[] convertTextToSpeech(FileVoiceUploadReqDTO dto) {
return null;
}
}
6)华为云语音生成客户端封装-HuaweiClientBean.java
新建个华为云的bean实现上边提到的common bean接口,进行扩展。
HuaweiClientBean.java
java
/**
* 华为云语音交互服务客户端封装对象
@Slf4j
@Getter
@SuppressWarnings("unchecked")
public class HuaweiClientBean implements CommonClientBean {
/**
* 桶名称
*/
private String bucketName;
/**
* 桶类型
*/
private BucketTypeEnum bucketType;
/**
* 云存储类型
*/
private CloudTypeEnum cloudType;
/**
* endpoint
*/
private String endpoint;
/**
* 自定义绑定域名
*/
private String bindingDomain;
/**
* 私有url有效期 单位:秒
*/
private Integer expiration;
/**
* 基本目录
*/
private String baseDir;
/**
* obs连接客户端
*/
private ObsClient s3Client;
/**
* 引入 sis 客户端
*/
private SisClient sisClient;
/**
* 引入 http client
*/
private OkHttpClient httpClient;
private CommonClientsProperties.Properties properties;
public HuaweiClientBean(CommonClientsProperties.Properties properties, ObsClient s3Client) {
this.bucketName = properties.getBucketName();
this.bucketType = BucketTypeEnum.valueOfTypeEndsWhit(properties.getBucketType());
this.cloudType = CloudTypeEnum.valueOfType(properties.getCloudType());
this.endpoint = UrlUtils.delProtocol(properties.getEndpoint());
this.bindingDomain = UrlUtils.delProtocol(properties.getBindingDomain());
this.expiration = properties.getExpiration();
this.baseDir = properties.getBaseDir();
this.s3Client = s3Client;
}
private final static String MATCHES = ".*[a-zA-Z\\d\\u4e00-\\u9fa5].*";
/**
* 最大字符长度
*/
public static Integer MAX_FILE_SIZE = 500;
/**
* 语音格式头:wav、mp3、pcm
*/
public static final List<String> VOICE_FORMATS = Arrays.asList("wav", "mp3", "pcm");
/**
* 采样率,支持"8000"、"16000"
*/
public static final List<String> SAMPLE_RATE_FORMATS = Arrays.asList("8000", "16000");
/**
* 文本转语音文件 - 上传到 SIS入口
*
* @param
*/
public byte[] convertTextToSpeech(FileVoiceUploadReqDTO dto) {
log.info(" start convertTextToSpeech :{}", JSONUtil.toJsonStr(dto));
if (!ObjectUtil.isEmpty(dto) && !StringUtils.isEmpty(dto.getText())) {
TtsConfig paramConfig = new TtsConfig();
paramConfig.setSpeed(dto.getSpeed());
paramConfig.setVolume(dto.getVolume());
paramConfig.setPitch(dto.getPitch());
paramConfig.setAudioFormat(TtsConfig.AudioFormatEnum.fromValue(dto.getAudioFormat()));
//采样率,支持"8000"、"16000",默认"8000"
paramConfig.setSampleRate(TtsConfig.SampleRateEnum.fromValue(dto.getSampleRate()));
paramConfig.setProperty(TtsConfig.PropertyEnum.fromValue(dto.getProperty()));
//文本小于500个字符直接转换,如果大于500分段
if (dto.getText().length() < MAX_FILE_SIZE) {
return uploadTextToSis(dto.getText(), paramConfig);
} else {
return uploadTextToSisPart(dto.getText(), paramConfig);
}
}
return null;
}
/**
* 分段处理text
*
* @param text
* @param paramConfig
* @return
*/
private byte[] uploadTextToSisPart(String text, TtsConfig paramConfig) {
int length = text.length();
int batchNum = (length % MAX_FILE_SIZE > 0) ? (length / MAX_FILE_SIZE + 1) : (length / MAX_FILE_SIZE);
log.info("待处理数据总数:{},总批次数:{}", length, batchNum);
int startIndex = 0;
int endIndex = 0;
Map map = new HashMap();
List list = new ArrayList();
if (batchNum > 0) {
//循环批次数,计算待处理数据下标
for (int currentNum = 1; currentNum <= batchNum; currentNum++) {
//每次计算要处理的数据起始位置 终止位置
String currentText = "";
startIndex = (currentNum - 1) * MAX_FILE_SIZE;
//最后一个批次特殊处理
if (currentNum == batchNum) {
endIndex = length ;
} else {
endIndex = startIndex + MAX_FILE_SIZE;
}
currentText = text.substring(startIndex, endIndex);
//发送请求
if(currentText.matches(MATCHES)){
byte[] result = uploadTextToSis(currentText, paramConfig);
list.add(result);
}
}
// 合并字节数组
return mergeByteArrays(list);
}
return null;
}
/**
* 合并字节数组
*
* @param byteArrayList
* @return
*/
public byte[] mergeByteArrays(List<byte[]> byteArrayList) {
// 计算所有字节数组的总长度
int totalLength = 0;
for (byte[] array : byteArrayList) {
totalLength += array.length;
}
// 创建一个新的字节数组以存放合并结果
byte[] mergedArray = new byte[totalLength];
int currentIndex = 0;
// 将每个字节数组复制到合并数组中
for (byte[] array : byteArrayList) {
System.arraycopy(array, 0, mergedArray, currentIndex, array.length);
currentIndex += array.length;
}
return mergedArray;
}
/**
* 发送请求并获取响应:合成后生成的语音数据,以Base64编码格式返回,并解码成byte数组
*
* @param text
* @param paramConfig
* @return
*/
private byte[] uploadTextToSis(String text, TtsConfig paramConfig) {
String data = uploadAssert(text, paramConfig);
if (!ObjectUtil.isEmpty(data)) {
return Base64.decodeBase64(data);
}
return null;
}
private String uploadAssert(String text, TtsConfig paramConfig) {
// 构建请求对象
RunTtsRequest request = new RunTtsRequest();
TtsConfig configBody = new TtsConfig();
//语音格式头:wav、mp3、pcm 默认:wav
configBody.setAudioFormat(paramConfig.getAudioFormat());
//采样率,支持"8000"、"16000",默认"8000"
configBody.setSampleRate(paramConfig.getSampleRate());
//语速取值范围:-500~500 默认值:0
configBody.setSpeed(paramConfig.getSpeed());
//音高 取值范围: -500~500 默认值:0
configBody.setPitch(paramConfig.getPitch());
//音量 取值范围:0~100 默认值:50
configBody.setVolume(paramConfig.getVolume());
//语音合成特征字符串,组成形式为{language}_{speaker}_{domain},即"语种_人员标识_领域"
configBody.setProperty(paramConfig.getProperty());
PostCustomTTSReq body = new PostCustomTTSReq();
body.withConfig(configBody);
body.withText(text);
request.withBody(body);
log.info("uploadAssert start:{}", JSONUtil.toJsonStr(request));
try {
//发送请求并处理响应
RunTtsResponse response = sisClient.runTts(request);
if (!ObjectUtil.isEmpty(response.getResult())) {
log.info("upload text to speech success!");
return response.getResult().getData();
} else {
log.error("upload text to speech error, response:{}", response);
return null;
}
} catch (Exception e) {
log.error("upload text to speech fail, text:{}", text, e);
throw new FileBizException("upload vod multipart file fail");
}
}
/**
* 上传MultipartFile
*/
@Override
public void uploadMultipartFile(MultipartFile file, String key) {
try {
uploadInputStream(file.getInputStream(), key);
} catch (IOException e) {
log.error("upload obs multipart file fail, bucketName:{}", bucketName, e);
throw new FileBizException("upload obs multipart file fail");
}
}
/**
* 上传输入流
*/
@Override
public void uploadInputStream(InputStream inputStream, String key) {
try {
PutObjectRequest request = new PutObjectRequest();
request.setBucketName(bucketName);
request.setObjectKey(key);
request.setInput(inputStream);
// 设置对象访问权限为公共读
if (BucketTypeEnum.PUBLIC.equals(bucketType)) {
request.setAcl(AccessControlList.REST_CANNED_PUBLIC_READ);
}
s3Client.putObject(request);
} catch (Exception e) {
log.error("upload obs fail, bucketName:{}", bucketName, e);
throw new FileBizException("upload obs fail");
} finally {
IoUtil.close(inputStream);
}
}
}
合成后生成的语音数据,以Base64编码格式
返回。
如需生成音频,需要将Base64编码解码成byte数组
,再保存为wav音频。
所以这里,当字符长度大于500,切割发送,再将返回的byte数组合并成生成完整的一个音频,再对视频进行业务处理,这里我选择将视频上传华为云obs存储,返回url供前端播放。
7)工具类-FileUtils.java
将封装好的客户端对外提供访问入口,可以封装成工具类等供server等调用
FileUtils.java
java
@Slf4j
public class FileUtils {
private static CommonClientBean commonClientBean;
private static CommonClientsCache commonClientsCache;
private static HuaweiClientBean huaweiClientBean;
/**
* 上传text转成语音并上传obs
*/
public static FileUploadResDTO convertToSpeechAndUploadObs(FileVoiceUploadReqDTO dto) {
//1.上传text转成byte[]
FileUtils.initClient(BucketOwnerEnum.DEFAULT, BucketTypeEnum.PUBLIC, null);
byte[] bytes = commonClientBean.convertTextToSpeech(dto);
if (bytes == null || bytes.length == 0) {
throw new FileBizException("file bytes cannot be empty");
}
try {
//2.byte[]转语音文件
AudioInputStream combinedAudioInputStream = new AudioInputStream(
new ByteArrayInputStream(bytes),
getAudioFormat(bytes),
bytes.length);
// 输出合并后的音频文件
File hbFile = new File(dto.getPath());
AudioSystem.write(combinedAudioInputStream, AudioFileFormat.Type.WAVE, hbFile);
//3.上传语音文件到obs
FileItem fileItem = createFileItem(dto.getPath(), dto.getFilename());
String key = initClientAndGetKey(dto, UUID.randomUUID().toString());
commonClientBean.uploadMultipartFile(new CommonsMultipartFile(fileItem), key);
String url = commonClientBean.getUrl(key);
// 最后删除临时文件释放资源
if (hbFile.exists()) {
hbFile.delete();
}
return new FileUploadResDTO(key, url, url, dto.getFilename());
} catch (Exception e) {
log.error("byte[]转语音文件异常", e);
throw new FileBizException("byte convert speech file fail");
}
}
private static void initClient(BucketOwnerEnum bucketOwner, BucketTypeEnum bucketType, String tenantCode) {
commonClientsCache= commonClientsCache.getClientByOwnerAndType(bucketOwner, bucketType, tenantCode);
}
public static AudioFormat getAudioFormat(byte[] audioBytes) throws IOException, UnsupportedAudioFileException {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(audioBytes);
AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(byteArrayInputStream);
return audioInputStream.getFormat();
}
public static FileItem createFileItem(String filePath, String fileName) {
String fieldName = "file";
FileItemFactory factory = new DiskFileItemFactory();
FileItem item = factory.createItem(fieldName, "text/plain", true, fileName);
File newfile = new File(filePath);
int bytesRead = 0;
byte[] buffer = new byte[8192];
try (FileInputStream fis = new FileInputStream(newfile);
OutputStream os = item.getOutputStream()) {
while ((bytesRead = fis.read(buffer, 0, 8192)) != -1) {
os.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
e.printStackTrace();
}
return item;
}
/**
* 初始化客户端并返回key
*/
private static String initClientAndGetKey(AbstractUploadReqDTO dto, String uuid) {
if (StringUtils.isAnyEmpty(dto.getFilename(), dto.getModel())) {
throw new FileBizException("filename or model can not be empty");
}
BucketOwnerEnum bucketOwner = BucketOwnerEnum.valueOfType(dto.getBucketOwner());
BucketTypeEnum bucketType = BucketTypeEnum.valueOfType(dto.getBucketType());
if (BucketOwnerEnum.TENANT.equals(bucketOwner) && StringUtils.isBlank(dto.getTenantCode())) {
throw new FileBizException("tenant code can not be empty");
}
initClient(bucketOwner, bucketType, dto.getTenantCode());
return generateKey(commonClientBean.getBaseDir(),
Objects.requireNonNull(bucketOwner), Objects.requireNonNull(bucketType),
dto.getTenantCode(), dto.getModel(), dto.getPath(),
uuid, dto.getFilename());
}
/**
* 根据Key获取上传文件的Url
*/
@Override
public String getUrl(String key) {
if (StringUtils.isNotEmpty(key)) {
if (bucketType.equals(BucketTypeEnum.PUBLIC)) {
//公有url(bindingDomain根据项目具体情况调整)
if (StringUtils.isNotBlank(bindingDomain)) {
return String.format("https://%s/%s", bindingDomain, key);
}
return String.format("https://%s/%s", bucketName + "." + endpoint, key);
}
//私有url
TemporarySignatureRequest request = new TemporarySignatureRequest(HttpMethodEnum.GET, expiration);
request.setBucketName(bucketName);
request.setObjectKey(key);
TemporarySignatureResponse response = s3Client.createTemporarySignature(request);
if (StringUtils.isNotBlank(bindingDomain)) {
return response.getSignedUrl().replace(String.format("%s.%s", bucketName, endpoint), bindingDomain);
}
return response.getSignedUrl();
}
return null;
}
}
8)封装公共请求参数-FileVoiceUploadReqDTO.java
java
@Data
@ApiModel("文件上传入参")
public class FileVoiceUploadReqDTO implements AbstractUploadReqDTO {
@ApiModelProperty("文件")
private MultipartFile file;
@ApiModelProperty("字节数组")
private byte[] bytes;
@ApiModelProperty("租户code")
private String tenantCode;
@ApiModelProperty("文件名称")
private String filename;
@ApiModelProperty("文件权限范围; default:平台,tenant:租户; 若为tenant,tenantCode不能为空")
private String bucketOwner = "tenant";
@ApiModelProperty("桶类型; public:公有,private:私有")
private String bucketType = "private";
@ApiModelProperty("模块名称")
@NotEmpty(message = "model cannot be empty")
private String model;
@ApiModelProperty("自定义路径")
private String path;
@ApiModelProperty("语音格式头:wav、mp3、pcm 默认:wav")
private String audioFormat = "wav";
@ApiModelProperty("采样率:16000、8000赫兹 默认:8000")
private String sampleRate = "8000";
@ApiModelProperty("语音合成特征字符串")
private String property = "chinese_huaxiaomei_common";
@ApiModelProperty("语速")
private Integer speed = 0;
@ApiModelProperty("音高")
private Integer pitch = 0;
@ApiModelProperty("音量")
private Integer volume = 50;
@ApiModelProperty("文本")
private String text;
}
@ApiModel("上传入参父类")
public interface AbstractUploadReqDTO {
String getTenantCode();
String getFilename();
String getBucketOwner();
String getBucketType();
String getModel();
String getPath();
}
9)业务类调用-ArticleManageController.java
这里注意异步容易丢失上下文,要在异步前将上下文获取RequestContextHolder.getRequestAttributes()
ArticleManageController.java
java
@Api(tags = {"文章管理"})
@Slf4j
@RestController
@RequestMapping("api/infoArticle")
public class ArticleManageController extends BaseController {
@ApiOperation(value = "新增保存", notes = "新增保存")
@PostMapping("/save")
public ResponseInteBean<Long> save(@RequestBody InfoArticleSaveOrUpdateReqVO articleSaveOrUpdateReqVO) {
//......保存文章逻辑
//接下来异步掉华为云语音,进行文章转语音播报
//插入语音转换记录表,成功后再更改表中状态和url
try {
//上传SIS
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
infoCommandService.uploadFile(articleAddOrUpdateReqDTO,sra);
} catch (Exception e) {
log.error("save saveArticleVoice error:", e);
}
}
}
}
return ResponseInteBean.ok(result.getData());
}
ArticleManageService.java (这里实际调用了刚封装好的文件处理工具类FileUtils)
java
@Component
@Slf4j
public class ArticleManageServiceImpl implements ArticleManageService {
@Async("threadPoolVoi")
@Override
public void uploadFile(InfoArticleAddOrUpdateReqDTO infoArticleAddOrUpdateReqDTO, ServletRequestAttributes sra) {
HttpServletRequest request = sra.getRequest();
RequestContextHolder.setRequestAttributes(sra,true);
//在异步方法调用之前手动传递请求上下文信息
prepareUploadRequest(infoArticleAddOrUpdateReqDTO);
}
public void prepareUploadRequest(InfoArticleAddOrUpdateReqDTO infoArticleAddOrUpdateReqDTO) {
log.info("异步处理语音播报 start");
FileUploadResDTO fileUploadResDTO = new FileUploadResDTO();
try {
FileVoiceUploadReqDTO reqBody = new FileVoiceUploadReqDTO();
//语音格式头:wav、mp3、pcm 默认:wav
reqBody.setAudioFormat(audioFormat);
//采样率,支持"8000"、"16000",默认"8000"
reqBody.setSampleRate(sampleRate);
//语速取值范围:-500~500 默认值:0
reqBody.setSpeed(speed);
//音高 取值范围: -500~500 默认值:0
reqBody.setPitch(pitch);
//音量 取值范围:0~100 默认值:50
reqBody.setVolume(volume);
//语音合成特征字符串,组成形式为{language}_{speaker}_{domain},即"语种_人员标识_领域"
reqBody.setProperty(property);
reqBody.setPath("contentVoice");
reqBody.setText(infoArticleAddOrUpdateReqDTO.getPureContent().replaceAll("[\\n\u00a0]+$", ""));
reqBody.setTenantCode(infoArticleAddOrUpdateReqDTO.getTenantCode());
reqBody.setBucketOwner(BucketOwnerEnum.DEFAULT.getType());
reqBody.setBucketType(BucketTypeEnum.PUBLIC.getType());
reqBody.setModel(ColumnConstants.CMS);
reqBody.setFilename(infoArticleAddOrUpdateReqDTO.getArticleId() + ".wav");
log.info("uploadFile to huawei sis start:{}", JSON.toJSONString(reqBody));
fileUploadResDTO = FileUtils.convertToSpeechAndUploadObs(reqBody);
log.info("uploadFile to huawei sis end:{}", fileUploadResDTO);
} catch (Exception e) {
log.error("upload text to speech fail, articleId:{},text:{}", infoArticleAddOrUpdateReqDTO.getArticleId(), infoArticleAddOrUpdateReqDTO.getPureContent(), e);
}
//更新发布记录
CmsContentVoiceRecordDO recordDO = new CmsContentVoiceRecordDO();
if (null != fileUploadResDTO && StringUtils.isNotBlank(fileUploadResDTO.getUrl())) {
recordDO.setVoiceStatus(ContentVoiceStatusEnum.STATUS_SUCCESS.getCode());
} else {
recordDO.setVoiceStatus(ContentVoiceStatusEnum.STATUS_FAILED.getCode());
}
recordDO.setArticleId(infoArticleAddOrUpdateReqDTO.getArticleId());
recordDO.setContent(infoArticleAddOrUpdateReqDTO.getPureContent());
recordDO.setFilePath(fileUploadResDTO.getUrl());
recordDO.setFileName(fileUploadResDTO.getOriginalFilename());
recordDO.setModifier(infoArticleAddOrUpdateReqDTO.getModifier());
recordDO.setGmtModified(Calendar.getInstance().getTime());
cmsContentVoiceRecordMapper.updateByArticleId(recordDO);
log.info("异步开始处理语音播报 end");
}
ThreadPoolVoice.java
java
@Configuration
public class ThreadPoolVoice {
//定义线程池
@Bean("threadPoolVoi") // bean的名称,线程池的bean的名字,不是创建线程的名字
public Executor threadPoolVoi(){
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); /** 核心线程数(默认线程数) */
executor.setMaxPoolSize(20);/** 最大线程数 */
executor.setQueueCapacity(100);/** 缓冲队列大小 */
executor.setKeepAliveSeconds(60);/** 允许线程空闲时间(单位:默认为秒) */
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setThreadNamePrefix("task-thread-voice-"); /** 线程池名前缀 */
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); //拒绝策略:缓存队列满了之后由调用线程处理,一般是主线程
executor.initialize();
//解决使用@Async注解,获取不到上下文信息的问题
executor.setTaskDecorator(runnable -> {
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
return ()->{
try {
// 我们set 进去 ,其实是一个ThreadLocal维护的.
RequestContextHolder.setRequestAttributes(requestAttributes);
runnable.run();
} finally {
// 最后记得释放内存
RequestContextHolder.resetRequestAttributes();
}
};
});
return executor;
}
}
至此,文章转华为云语音播报的功能就实现了~
小结:整个过程需要注意的点:
1、异步请求上下文丢失问题(一些在异步线程里请求feign接口的也会产生丢失问题)
2、对可设置的参数的抽取和配置化,避免硬编码(比如nacos配置、yaml配置等)
3、使用@Async时建议自定义线程池。
@Async默认异步配置,指在@Async注解在使用时,不指定线程池的名称。使用的是SimpleAsyncTaskExecutor,该线程池默认执行任务都会创建一个线程,若系统中不断的创建线程,最终会导致系统占用内存过高,引发OutOfMemoryError错误。所以建议自定义线程池(比如上文中的"threadPoolVoi")
4、语音转换注意发送内容时进行过滤校验,留下有实际语义的内容。(比如内容只有空格换行符等等发送给华为云,并不会转行成语音,会导致报错等)