转化为语音朗读的实践

Posted Vicent_9920

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了转化为语音朗读的实践相关的知识,希望对你有一定的参考价值。

其实,我一开始也以为很简单,毕竟百度和科大讯飞的SDK都有关于语音合成的内容,但是还是踩了不少坑,前前后后花了两天时间,虽然只是实现了一小块,但是感觉代码写得有些累了,于是在这里将自己的思路给整理一下:

合成语音——保存

一开始我想到如此简单,看看文档,然后直接将demo里面的代码做一个修改即可,不过真的是 too young too simple sometimes native 。我用的科大讯飞的SDK(开发文档),首先先把上面的代码贴出来:

// 第一步,实例化SDK   用自己的appid吧,这个我还有用
SpeechUtility.createUtility(this, SpeechConstant.APPID +"=59ad39c9");
//实例化语音合成对象
mTts = SpeechSynthesizer.createSynthesizer(this, mTtsInitListener);
 // 移动数据分析,收集开始合成事件
FlowerCollector.onEvent(MainActivity.this, "tts_play");
//设置一些参数
private void setParam()
        // 清空参数
        mTts.setParameter(SpeechConstant.PARAMS, null);
        // 根据合成引擎设置相应参数
        if(mEngineType.equals(SpeechConstant.TYPE_CLOUD)) 
            mTts.setParameter(SpeechConstant.ENGINE_TYPE, SpeechConstant.TYPE_CLOUD);
            // 设置在线合成发音人
            mTts.setParameter(SpeechConstant.VOICE_NAME, voicer);
            //设置合成语速
            mTts.setParameter(SpeechConstant.SPEED, "50");
            //设置合成音调
            mTts.setParameter(SpeechConstant.PITCH, "50");
            //设置合成音量
            mTts.setParameter(SpeechConstant.VOLUME, "50");
        else 
            mTts.setParameter(SpeechConstant.ENGINE_TYPE, SpeechConstant.TYPE_LOCAL);
            // 设置本地合成发音人 voicer为空,默认通过语记界面指定发音人。
            mTts.setParameter(SpeechConstant.VOICE_NAME, "");
            /**
             * TODO 本地合成不设置语速、音调、音量,默认使用语记设置
             * 开发者如需自定义参数,请参考在线合成参数设置
             */
        
        //设置播放器音频流类型
        mTts.setParameter(SpeechConstant.STREAM_TYPE, "3");
        // 设置播放合成音频打断音乐播放,默认为true
        mTts.setParameter(SpeechConstant.KEY_REQUEST_FOCUS, "true");

        // 设置音频保存路径,保存音频格式支持pcm、wav,设置路径为sd卡请注意WRITE_EXTERNAL_STORAGE权限
        // 注:AUDIO_FORMAT参数语记需要更新版本才能生效
        mTts.setParameter(SpeechConstant.AUDIO_FORMAT, "wav");
        mTts.setParameter(SpeechConstant.TTS_AUDIO_PATH, Environment.getExternalStorageDirectory()+"/msc/tts"+count+".wav");
        //开始合成
int code = mTts.startSpeaking(texts, mTtsListener);


//上面用到的接口基本上可以不用处理,直接复制demo里面的接口代码就好
/**
     * 初始化监听。
     */
    private InitListener mTtsInitListener = new InitListener() 
        @Override
        public void onInit(int code) 
            Log.d(TAG, "InitListener init() code = " + code);
            if (code != ErrorCode.SUCCESS) 
                showTip("初始化失败,错误码:"+code);
             else 
                // 初始化成功,之后可以调用startSpeaking方法
                // 注:有的开发者在onCreate方法中创建完合成对象之后马上就调用startSpeaking进行合成,
                // 正确的做法是将onCreate中的startSpeaking调用移至这里
                   
        
    ;
    

   /**
     * 合成回调监听。
     */
    private SynthesizerListener mTtsListener = new SynthesizerListener() 

        @Override
        public void onSpeakBegin() 
            showTip("开始播放");
        

        @Override
        public void onSpeakPaused() 
            showTip("暂停播放");
        

        @Override
        public void onSpeakResumed() 
            showTip("继续播放");
        

        @Override
        public void onBufferProgress(int percent, int beginPos, int endPos,
                String info) 
            // 合成进度
            mPercentForBuffering = percent;
            showTip(String.format(getString(R.string.tts_toast_format),
                    mPercentForBuffering, mPercentForPlaying));
        

        @Override
        public void onSpeakProgress(int percent, int beginPos, int endPos) 
            // 播放进度
            mPercentForPlaying = percent;
            showTip(String.format(getString(R.string.tts_toast_format),
                    mPercentForBuffering, mPercentForPlaying));
        

        @Override
        public void onCompleted(SpeechError error) 
            if (error == null) 
                showTip("播放完成");
             else if (error != null) 
                showTip(error.getPlainDescription(true));
            
        

        @Override
        public void onEvent(int eventType, int arg1, int arg2, Bundle obj) 
            // 以下代码用于获取与云端的会话id,当业务出错时将会话id提供给技术支持人员,可用于查询会话日志,定位出错原因
            // 若使用本地能力,会话id为null
            //  if (SpeechEvent.EVENT_SESSION_ID == eventType) 
            //      String sid = obj.getString(SpeechEvent.KEY_EVENT_SESSION_ID);
            //      Log.d(TAG, "session id =" + sid);
            //  
        
    ; 

上述代码可以直接在github上面看官方demo
https://github.com/KouChengjian/SpeechDemo/blob/master/sample/SpeechDemo/src/com/iflytek/voicedemo/TtsDemo.java

如果你的文章比较短,每篇文字在4000 字以下的话完全没有问题,可以直接使用了!如果每篇文章的字数在4000字以上的话,直接使用上面的方法就不行,有一个异常会直接崩掉app

10117 内存不足

遇到这种问题我去看了一下SDK的源码,原来这里面使用到了IPC通信,也就是说合成语音的运算运行在另一个进程,而文本传输到另一个进程使用的是Intent,虽然Intent的大小限制在1020KB(据说,因为好像每个手机都不一样),但是5000个字也不到1020KB,问了客服(科大讯飞不像高德地图这样可以有工单或者在线客服,只有一个论坛,看来公司的客服服务还有很长的路要走)说一次合成的字数是没有限制的(那么这个内存不足是什么鬼?反正没有得到解答),然后专业的读书软件都是一句一句的合成的。
然后没有demo,只有指示,于是咱们继续修改。
我们可以手动将整篇文章的字符串截取为3500个字符串为一组,然后得到一个数组,在SynthesizerListener 合成的回调监听的onCompleted方法再次合成下一组,直至合成完数组里面的字符串。这个方法是测试可以有效的。
直接上源码:

 //字符串数组
 private List<String> texts;
 //字符串数组长度
 private int textsize;
 //已合成字符串数组的下标志
 private int count = 0;
  /**
     * 处理字符串,返回字符串数组
     * 此处测试,因此将字符串内容截取较短,太长了合成时间过长测试时间也就长了
     * @return
     */
 private List<String> getvalue() 
        List<String> data = new ArrayList<>();
        String text = "#android 内存泄漏总结\\n" +
                "内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏大家都不陌生了,简单粗俗的讲,就是该被释放的对象没有释放,一直被某个或某些实例所持有却不再被使用导致 GC 不能回收。最近自己阅读了大量相关的文档资料,打算做个 总结 沉淀下来跟大家一起分享和学习,也给自己一个警示,以后 coding 时怎么避免这些情况,提高应用的体验和质量。\\n" +
                "我会从 java 内存泄漏的基础知识开始,并通过具体例子来说明 Android 引起内存泄漏的各种原因,以及如何利用工具来分析应用内存泄漏,最后再做总结。\\n" +
                "##Java 内存分配策略\\n" +
                "Java 程序运行时的内存分配策略有三种,分别是静态分配,栈式分配,和堆式分配,对应的,三种存储策略使用的内存空间主要分别是静态存储区(也称方法区)、栈区和堆区。";
        if(text.length()<=300)
            data.add(text);
        else
            int toal = text.length();
            int count = toal/200;
            for (int i = 0; i < count; i++) 
                String info;
                if(i<count)
                    Log.e(TAG, "getvalue:1 " );
                    info = text.substring(i*200,(i+1)*200);
                    data.add(info);
                
                if(i == count-1)
                    String info2 = text.substring((i+1)*200,text.length());
                    data.add(info2);
                    Log.e(TAG, "getvalue: 2" );
                


            
        
        return data;
    

//合成

texts = getvalue();
                textsize = texts.size();
                mTts.setParameter(SpeechConstant.TTS_AUDIO_PATH, Environment.getExternalStorageDirectory()+"/msc/tts"+count+".wav");
                int code = mTts.startSpeaking(texts.get(count), mTtsListener);
                count++;

//合成一段以后回调处理
        @Override
        public void onCompleted(SpeechError error) 
            Log.e(TAG, "onCompleted: " );
            if (error == null) 
                if(textsize>count)
                    mTts.setParameter(SpeechConstant.TTS_AUDIO_PATH, Environment.getExternalStorageDirectory()+"/msc/tts"+count+".wav");
                    int code = mTts.startSpeaking(texts.get(count), mTtsListener);
                    count++;
                    Log.e(TAG, "合成结果: "+code );
                else

                

             else if (error != null) 
//                showTip(error.getPlainDescription(true));
            
        

上面就算完成了雏形,但是这样拿到的是很多段音频,如果需要拼接成一段音频应该怎么处理呢?虽然合成的音频为无损的wav格式,这个格式不清楚的话可以看看这张图:

我看了很久也没有整明白,后来经过谷歌、百度,终于在简书上面找到了一个工具类Android中实现多段wav音频文件拼接,经过测试完全有效:

import android.content.Context;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;

/**
 * Created by asus on 2017/9/5.
 */

public class WavMergeUtil 

    public static void mergeWav(List<File> inputs, File output) throws IOException 
        if (inputs.size() < 1) 
            return;
        
        FileInputStream fis = new FileInputStream(inputs.get(0));
        FileOutputStream fos = new FileOutputStream(output);
        byte[] buffer = new byte[2048];
        int total = 0;
        int count;
        while ((count = fis.read(buffer)) > -1) 
            fos.write(buffer, 0, count);
            total += count;
        
        fis.close();
        for (int i = 1; i < inputs.size(); i++) 
            File file = inputs.get(i);
            Header header = resolveHeader(file);
            FileInputStream dataInputStream = header.dataInputStream;
            while ((count = dataInputStream.read(buffer)) > -1) 
                fos.write(buffer, 0, count);
                total += count;
            
            dataInputStream.close();
        
        fos.flush();
        fos.close();
        Header outputHeader = resolveHeader(output);
        outputHeader.dataInputStream.close();
        RandomAccessFile res = new RandomAccessFile(output, "rw");
        res.seek(4);
        byte[] fileLen = intToByteArray(total + outputHeader.dataOffset - 8);
        res.write(fileLen, 0, 4);
        res.seek(outputHeader.dataSizeOffset);
        byte[] dataLen = intToByteArray(total);
        res.write(dataLen, 0, 4);
        res.close();
    

    /**
     * 解析头部,并获得文件指针指向数据开始位置的InputStreram,记得使用后需要关闭
     */
    private static Header resolveHeader(File wavFile) throws IOException 
        FileInputStream fis = new FileInputStream(wavFile);
        byte[] byte4 = new byte[4];
        byte[] buffer = new byte[2048];
        int readCount = 0;
        Header header = new Header();
        fis.read(byte4);//RIFF
        fis.read(byte4);
        readCount += 8;
        header.fileSizeOffset = 4;
        header.fileSize = byteArrayToInt(byte4);
        fis.read(byte4);//WAVE
        fis.read(byte4);//fmt
        fis.read(byte4);
        readCount += 12;
        int fmtLen = byteArrayToInt(byte4);
        fis.read(buffer, 0, fmtLen);
        readCount += fmtLen;
        fis.read(byte4);//data or fact
        readCount += 4;
        if (isFmt(byte4, 0)) //包含fmt段
            fis.read(byte4);
            int factLen = byteArrayToInt(byte4);
            fis.read(buffer, 0, factLen);
            fis.read(byte4);//data
            readCount += 8 + factLen;
        
        fis.read(byte4);// data size
        int dataLen = byteArrayToInt(byte4);
        header.dataSize = dataLen;
        header.dataSizeOffset = readCount;
        readCount += 4;
        header.dataOffset = readCount;
        header.dataInputStream = fis;
        return header;
    

    private static boolean isRiff(byte[] bytes, int start) 
        if (bytes[start + 0] == 'R' && bytes[start + 1] == 'I' && bytes[start + 2] == 'F' && bytes[start + 3] == 'F') 
            return true;
         else 
            return false;
        
    

    private static boolean isFmt(byte[] bytes, int start) 
        if (bytes[start + 0] == 'f' && bytes[start + 1] == 'm' && bytes[start + 2] == 't' && bytes[start + 3] == ' ') 
            return true;
         else 
            return false;
        
    

    private static boolean isData(byte[] bytes, int start) 
        if (bytes[start + 0] == 'd' && bytes[start + 1] == 'a' && bytes[start + 2] == 't' && bytes[start + 3] == 'a') 
            return true;
         else 
            return false;
        
    

    /**
     * 将int转化为byte[]
     */
    private static byte[] intToByteArray(int data) 
        return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(data).array();
    

    /**
     * 将short转化为byte[]
     */
    private static byte[] shortToByteArray(short data) 
        return ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(data).array();
    

    /**
     * 将byte[]转化为short
     */
    private static short byteArrayToShort(byte[] b) 
        return ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).getShort();
    

    /**
     * 将byte[]转化为int
     */
    private static int byteArrayToInt(byte[] b) 
        return ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).getInt();
    

    /**
     * 头部部分信息
     */
    static class Header 
        public int fileSize;
        public int fileSizeOffset;
        public int dataSize;
        public int dataSizeOffset;
        public int dataOffset;
        public FileInputStream dataInputStream;
    


    public static File getAllAudio(Context context,List<File> inputs,String name) throws IOException 
        File file = new File(name);
//        String fileName  = "j2222j.mp3";//输出文件名j2222j.mp3

        FileOutputStream fos = context.openFileOutput(name,Context.MODE_APPEND);
        BufferedOutputStream bos = new BufferedOutputStream(fos,10000);//缓冲刘
        byte input[] = new byte[10000];
        for (int i = 0; i < inputs.size(); i++) 
            InputStream is = new FileInputStream(inputs.get(i));
            BufferedInputStream bis =new BufferedInputStream(is,10000);//转换缓冲流
            while (  bis.read(input) != -1)
            
                bos.write(input);

            
            bis.close();
            is.close();

        
        bos.close();
        fos.close();
        context = null;
        return file;

    


现在终于可以实现将一大篇文章通过语音合成一段视频了,不过还有一个坑:就是暂停与继续播放,
合成对象有一个方法:isSpeaking(),这个方法经过测试却不好用,查看文档才知道这里的播放与暂停需要自己来控制,坑如下:

boolean isSpeaking()
是否在合成 是否在合成状态,包括是否在播放状态,音频从服务端获取完成后,若未播放 完成,依然处于当前会话的合成中。

过一段时间写一个比较完整的demo,这个只是一个记录而已,仅此而已!

以上是关于转化为语音朗读的实践的主要内容,如果未能解决你的问题,请参考以下文章

TXT文件文字转语音有啥方法?

文章怎么测试语音时长

文本转音频(百度语音合成api)(python)

如何把文字转换成语音?

华为语音合成服务,为用户提供实时可替换多音调的语音播放体验

Android 轻松实现语音朗读