最近无意中发现有需要做语音播报插件的需求,因为考虑到语音助手集成会使得原应用程序包体积变大,所以采取插件的形式去从外部下载,然后安装这样一个插件即可完成语音合成(文字转成语音)。主要用到的技术就是集成 百度语音合成SDK,AIDL跨进程通信。 github下载地址
前戏
无图无真相,关键这并没有什么绚丽的特效,哈哈 ~ 主要功能就是使用插件apk去朗读主apk输入的文字,两个apk用AIDL跨进程通信。
集成百度语音合成SDK
这个集成也没啥难度,大家可以取参考上面链接对应的开发文档去看一下。简单说下几个重要步骤:
1.下载语音合成SDK,包含Sample里面,但是是Eclipse的工程,所以这里还需要自己新建一个AS工程。
2.拷贝资源文件到自己项目里面,再把SO文件拷贝到libs目录下,并把jar包add到项目里面,还需要在gradle文件里面去把libs目录加到项目里面,如下图所示:
3.在AndroidManifest.xml文件配置需要的权限:
官网上是吧Appid,Key都放到AndroidManifest.xml里面,我这里想在代码里面设置,等下看下代码就知道。
编写AIDL通信
首先在main目录下新建aidl目录,并且新建一个aidl类,如图所示:aidl类很简单,就是一个接口,因为我们是要接收文字然后读取的所以定义了一个方法:void speak(String text);
这个时候可以rebuild一把这个插件module,这样会生成该接口对应的java文件,这个文件里面虽然不用我们自己去写,自动生成,但是我们要知道里面有哪些主要的方法。
好了 ,现在我们需要一个Service在后台默默的等待着主app去调用它,所以这个Service应该具有可读取传过来文字的功能,我们来看一下这个Service代码:
import android.app.Service;
import android.content.Intent;
import android.os.Environment;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import com.baidu.tts.auth.AuthInfo;
import com.baidu.tts.client.SpeechError;
import com.baidu.tts.client.SpeechSynthesizer;
import com.baidu.tts.client.SpeechSynthesizerListener;
import com.baidu.tts.client.TtsMode;
import com.wzh.aidl.IVoiceInterface;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* author:Administrator on 2017/3/10 16:49
* description:文件说明
* version:版本
*/
public class VoiceService extends Service implements SpeechSynthesizerListener {
private SpeechSynthesizer mSpeechSynthesizer;
private static final String TAG = "AIDLService";
private static final String SAMPLE_DIR_NAME = "baiduTTS";
private static final String SPEECH_FEMALE_MODEL_NAME = "bd_etts_speech_female.dat";
private static final String SPEECH_MALE_MODEL_NAME = "bd_etts_speech_male.dat";
private static final String TEXT_MODEL_NAME = "bd_etts_text.dat";
private static final String LICENSE_FILE_NAME = "temp_license";
private static final String ENGLISH_SPEECH_FEMALE_MODEL_NAME = "bd_etts_speech_female_en.dat";
private static final String ENGLISH_SPEECH_MALE_MODEL_NAME = "bd_etts_speech_male_en.dat";
private static final String ENGLISH_TEXT_MODEL_NAME = "bd_etts_text_en.dat";
private String mSampleDirPath;
private static final int PRINT = 0;
private static final int UI_CHANGE_INPUT_TEXT_SELECTION = 1;
private static final int UI_CHANGE_SYNTHES_TEXT_SELECTION = 2;
@Override
public void onCreate() {
super.onCreate();
initialEnv();
initialTts();
}
private void initialEnv() {
if (mSampleDirPath == null) {
String sdcardPath = Environment.getExternalStorageDirectory().toString();
mSampleDirPath = sdcardPath + "/" + SAMPLE_DIR_NAME;
}
makeDir(mSampleDirPath);
copyFromAssetsToSdcard(false, SPEECH_FEMALE_MODEL_NAME, mSampleDirPath + "/" + SPEECH_FEMALE_MODEL_NAME);
copyFromAssetsToSdcard(false, SPEECH_MALE_MODEL_NAME, mSampleDirPath + "/" + SPEECH_MALE_MODEL_NAME);
copyFromAssetsToSdcard(false, TEXT_MODEL_NAME, mSampleDirPath + "/" + TEXT_MODEL_NAME);
copyFromAssetsToSdcard(false, LICENSE_FILE_NAME, mSampleDirPath + "/" + LICENSE_FILE_NAME);
copyFromAssetsToSdcard(false, "english/" + ENGLISH_SPEECH_FEMALE_MODEL_NAME, mSampleDirPath + "/"
+ ENGLISH_SPEECH_FEMALE_MODEL_NAME);
copyFromAssetsToSdcard(false, "english/" + ENGLISH_SPEECH_MALE_MODEL_NAME, mSampleDirPath + "/"
+ ENGLISH_SPEECH_MALE_MODEL_NAME);
copyFromAssetsToSdcard(false, "english/" + ENGLISH_TEXT_MODEL_NAME, mSampleDirPath + "/"
+ ENGLISH_TEXT_MODEL_NAME);
}
private void makeDir(String dirPath) {
File file = new File(dirPath);
if (!file.exists()) {
file.mkdirs();
}
}
/**
* 将sample工程需要的资源文件拷贝到SD卡中使用(授权文件为临时授权文件,请注册正式授权)
*
* @param isCover 是否覆盖已存在的目标文件
* @param source
* @param dest
*/
private void copyFromAssetsToSdcard(boolean isCover, String source, String dest) {
File file = new File(dest);
if (isCover || (!isCover && !file.exists())) {
InputStream is = null;
FileOutputStream fos = null;
try {
is = getResources().getAssets().open(source);
String path = dest;
fos = new FileOutputStream(path);
byte[] buffer = new byte[1024];
int size = 0;
while ((size = is.read(buffer, 0, 1024)) >= 0) {
fos.write(buffer, 0, size);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
try {
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private void initialTts() {
this.mSpeechSynthesizer = SpeechSynthesizer.getInstance();
this.mSpeechSynthesizer.setContext(this);
this.mSpeechSynthesizer.setSpeechSynthesizerListener(this);
// 文本模型文件路径 (离线引擎使用)
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_TTS_TEXT_MODEL_FILE, mSampleDirPath + "/"
+ TEXT_MODEL_NAME);
// 声学模型文件路径 (离线引擎使用)
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_TTS_SPEECH_MODEL_FILE, mSampleDirPath + "/"
+ SPEECH_FEMALE_MODEL_NAME);
// 本地授权文件路径,如未设置将使用默认路径.设置临时授权文件路径,LICENCE_FILE_NAME请替换成临时授权文件的实际路径,仅在使用临时license文件时需要进行设置,如果在[应用管理]中开通了正式离线授权,不需要设置该参数,建议将该行代码删除(离线引擎)
// 如果合成结果出现临时授权文件将要到期的提示,说明使用了临时授权文件,请删除临时授权即可。
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_TTS_LICENCE_FILE, mSampleDirPath + "/"
+ LICENSE_FILE_NAME);
// 请替换为语音开发者平台上注册应用得到的App ID (离线授权)
this.mSpeechSynthesizer.setAppId("9378271"/*这里只是为了让Demo运行使用的APPID,请替换成自己的id。*/);
// 请替换为语音开发者平台注册应用得到的apikey和secretkey (在线授权)
this.mSpeechSynthesizer.setApiKey("RC33pEHG3Kd3DOOrI2GMhHMC",
"e8d7636d58dc96322ab704aec5999744"/*这里只是为了让Demo正常运行使用APIKey,请替换成自己的APIKey*/);
// 发音人(在线引擎),可用参数为0,1,2,3。。。(服务器端会动态增加,各值含义参考文档,以文档说明为准。0--普通女声,1--普通男声,2--特别男声,3--情感男声。。。)
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_SPEAKER, "0");
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_SPEED, "6");
// 设置Mix模式的合成策略
this.mSpeechSynthesizer.setParam(SpeechSynthesizer.PARAM_MIX_MODE, SpeechSynthesizer.MIX_MODE_DEFAULT);
// 授权检测接口(只是通过AuthInfo进行检验授权是否成功。)
// AuthInfo接口用于测试开发者是否成功申请了在线或者离线授权,如果测试授权成功了,可以删除AuthInfo部分的代码(该接口首次验证时比较耗时),不会影响正常使用(合成使用时SDK内部会自动验证授权)
AuthInfo authInfo = this.mSpeechSynthesizer.auth(TtsMode.MIX);
if (authInfo.isSuccess()) {
} else {
String errorMsg = authInfo.getTtsError().getDetailMessage();
}
// 初始化tts
mSpeechSynthesizer.initTts(TtsMode.MIX);
// 加载离线英文资源(提供离线英文合成功能)
int result =
mSpeechSynthesizer.loadEnglishModel(mSampleDirPath + "/" + ENGLISH_TEXT_MODEL_NAME, mSampleDirPath
+ "/" + ENGLISH_SPEECH_FEMALE_MODEL_NAME);
}
IVoiceInterface.Stub stub = new IVoiceInterface.Stub() {
@Override
public void speak(String text) throws RemoteException {
int result = mSpeechSynthesizer.speak(text);
if (result < 0) {
Log.i(TAG, "failed");
}
}
};
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "onBind() called");
return stub;
}
@Override
public boolean onUnbind(Intent intent) {
Log.i(TAG, "onUnbind() called");
this.mSpeechSynthesizer.release();
return true;
}
@Override
public void onDestroy() {
super.onDestroy();
Log.i(TAG, "onDestroy() called");
}
/*
* @param arg0
*/
@Override
public void onSynthesizeStart(String utteranceId) {
}
/**
* 合成数据和进度的回调接口,分多次回调
*
* @param utteranceId
* @param data 合成的音频数据。该音频数据是采样率为16K,2字节精度,单声道的pcm数据。
* @param progress 文本按字符划分的进度,比如:你好啊 进度是0-3
*/
@Override
public void onSynthesizeDataArrived(String utteranceId, byte[] data, int progress) {
}
/**
* 合成正常结束,每句合成正常结束都会回调,如果过程中出错,则回调onError,不再回调此接口
*
* @param utteranceId
*/
@Override
public void onSynthesizeFinish(String utteranceId) {
}
/**
* 播放开始,每句播放开始都会回调
*
* @param utteranceId
*/
@Override
public void onSpeechStart(String utteranceId) {
}
/**
* 播放进度回调接口,分多次回调
*
* @param utteranceId
* @param progress 文本按字符划分的进度,比如:你好啊 进度是0-3
*/
@Override
public void onSpeechProgressChanged(String utteranceId, int progress) {
}
/**
* 播放正常结束,每句播放正常结束都会回调,如果过程中出错,则回调onError,不再回调此接口
*
* @param utteranceId
*/
@Override
public void onSpeechFinish(String utteranceId) {
}
/**
* 当合成或者播放过程中出错时回调此接口
*
* @param utteranceId
* @param error 包含错误码和错误信息
*/
@Override
public void onError(String utteranceId, SpeechError error) {
}
}
这里部分代码可以参考百度语音助手文档,我们来看主要的一个地方:
IVoiceInterface.Stub stub = new IVoiceInterface.Stub() {
@Override
public void speak(String text) throws RemoteException {
int result = mSpeechSynthesizer.speak(text);
if (result < 0) {
Log.i(TAG, "failed");
}
}
};
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "onBind() called");
return stub;
}
这里声明的stub对象我们可以看到,在这个方法里面是实现了读取文字的功能,因为他是集成IBinder 的,所以可以直接返回。
好了,到现在基本这个插件已经具备语音合成的功能了,但是我不想要图标显示在桌面,所以这里去掉图标,并且注册Service,这里的action我们要记住,因为客户端也需要用到:
到这里插件基本可以使用了,现在来新建一个客户端程序。
拷贝aidl包及aidl文件,要保持和插件的aidl包名及文件一致。
新建一个布局一个按钮一个EditText,我们要实现的就是输入文字,插件去郎读。
import android.app.Service;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.RemoteException;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import com.wzh.aidl.IVoiceInterface;
public class MainActivity extends AppCompatActivity {
private EditText input ;
private Button button ;
/**
* 创建远程服务
*/
private IVoiceInterface iVoiceInterface;
private ServiceConnection conn = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
iVoiceInterface = IVoiceInterface.Stub.asInterface(service);
}
@Override
public void onServiceDisconnected(ComponentName name) {
iVoiceInterface = null ;
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
/**
* 绑定、启动所谓的服务端 服务
*/
final Intent intent = new Intent();
intent.setAction("android.intent.action.VoiceService");
intent.setPackage("com.wzh.baiduvoice");
bindService(intent,conn, Service.BIND_AUTO_CREATE);
input = (EditText) findViewById(R.id.input);
button = (Button) findViewById(R.id.speak);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String text = input.getText().toString();
if (iVoiceInterface!=null){
try {
iVoiceInterface.speak(text);
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
});
}
@Override
protected void onDestroy() {
super.onDestroy();
unbindService(conn);
}
}
我们在创建Activity的时候去绑定插件的那个Service,新建一个ServiceConnection并获取iVoiceInterface对象,当点击朗读按钮的时候调用插件Service的speak( )方法。
这里大家可以发现是显示启动Service的,因为Android 5.0之后google出于安全的角度禁止了隐式声明Intent来启动Service.也禁止使用Intent filter,隐式启动会报错,所以采用这种启动方式。
如此一来我们输入文字,就可以听到插件给我们朗读的声音!
其实这里我遇到一个问题,就是这个插件apk可能会被有的系统自带的手机管家禁止关联启动,我用的vivo x6splus测试就是被禁止了,目前还没找到比较好的解决方案,希望有知道的同学告之~
这里其实只是AIDL最简单的使用方式了,如果有问题请指出,谢谢~