將一篇文章轉化為語音朗讀的實踐
其實,我一開始也以為很簡單,畢竟百度和科大訊飛的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);
// }
}
};
如果你的文章比較短,每篇文字在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,這個只是一個記錄而已,僅此而已!