將一篇文章轉化爲語音朗讀的實踐

其實,我一開始也以爲很簡單,畢竟百度和科大訊飛的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,這個只是一個記錄而已,僅此而已!

發佈了61 篇原創文章 · 獲贊 28 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章