华为云语音交互SIS的使用案例(文字转语音-详细教程)

文章目录

题记

本文将根据一种具体业务场景:语音播报(将一篇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字符就需要切割再合并问题。

以上了解了需求场景能不能使用,接下来就看怎么用啦~

四、使用

主要有两种接入方式:APISDK

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、语音转换注意发送内容时进行过滤校验,留下有实际语义的内容。(比如内容只有空格换行符等等发送给华为云,并不会转行成语音,会导致报错等)

相关推荐
陈天伟教授32 分钟前
华为云 阿里云 腾讯 WorkBuddy的工具对比
阿里云·华为云·云计算
豆角焖肉1 小时前
Maven进阶与搭建私服
java·maven
大不点wow1 小时前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
噢,我明白了1 小时前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
dkbnull1 小时前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai1 小时前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器