android專案接入科大訊飛語音評測功能過程
前言
最近專案需要接入語音評測功能,公司有做過這方面的同事推薦了科大訊飛語音評測,於是根據官網的開發指南接入了sdk,可以成功評測使用者的口語能力,並給出合適的分數,但是期間遇到了很多小問題,於是寫在這篇文章記錄一下開發及填坑的過程。
正文
1.接入sdk:
如何接入sdk請去看科大訊飛官網提供的接入指南,這裡就不做介紹了
2.編寫語音評測工具類:
因為有兩個地方用到了這個評測功能,所以為了使用方便,寫了一個工具類,直接上程式碼:
/** * @ClassName: SpeechEvaluatorUtil * @Desciption: //語音評測工具類 * @author: jesse * @date: 2018-06-29 */ public class SpeechEvaluatorUtil { private static final String TAG = SpeechEvaluatorUtil.class.getSimpleName(); public static final String EVA_RECORD_PATH = Environment.getExternalStorageDirectory().getAbsolutePath() + "/msc/ise.wav"; private static SpeechEvaluator mIse; public static void init(Context context) { if (mIse == null) { mIse = SpeechEvaluator.createEvaluator(context, null); } } /** * @param evaText 評測用句 * @param mEvaluatorListener 語音評測回撥介面 * @return 評測錄音儲存路徑 */ public static void startSpeechEva(String evaText, EvaluatorListener mEvaluatorListener) { setParams(); // 設定音訊儲存路徑,儲存音訊格式支援pcm、wav,設定路徑為sd卡請注意WRITE_EXTERNAL_STORAGE許可權 // 注:AUDIO_FORMAT引數語記需要更新版本才能生效 mIse.startEvaluating(evaText, null, mEvaluatorListener); } //通過寫入音訊檔案進行評測 public static void startEva(byte[] audioData,String evaText,EvaluatorListener mEvaluatorListener){ setParams(); //通過writeaudio方式直接寫入音訊時才需要此設定 mIse.setParameter(SpeechConstant.AUDIO_SOURCE,"-1"); int ret = mIse.startEvaluating(evaText, null, mEvaluatorListener); //在startEvaluating介面呼叫之後,加入以下方法,即可通過直接 //寫入音訊的方式進行評測業務 if (ret != ErrorCode.SUCCESS) { Log.i(TAG,"識別失敗,錯誤碼:" + ret); } else { if(audioData != null) { //防止寫入音訊過早導致失敗 try{ new Thread().sleep(100); }catch (InterruptedException e) { Log.d(TAG,"InterruptedException :"+e); } mIse.writeAudio(audioData,0,audioData.length); mIse.stopEvaluating(); }else{ Log.i(TAG,"audioData == null"); } } } private static void setParams() { Log.i(TAG, "setParams()"); // 設定評測語種:英語 mIse.setParameter(SpeechConstant.LANGUAGE, "en_us"); // 設定評測題型:句子 mIse.setParameter(SpeechConstant.ISE_CATEGORY, "read_sentence"); mIse.setParameter(SpeechConstant.RESULT_LEVEL,"plain"); mIse.setParameter(SpeechConstant.ISE_AUDIO_PATH, EVA_RECORD_PATH); mIse.setParameter(SpeechConstant.AUDIO_FORMAT, "wav"); } //停止評測 public static void stopSpeechEva() { if (mIse.isEvaluating()) { mIse.stopEvaluating(); } } //取消評測 public static void cancelSpeechEva() { mIse.cancel(); } }
這裡寫了兩種評測方式:
第一種是“直接根據mic錄到的音訊進行評測”(startSpeechEva()),這種方式會在EVA_RECORD_PATH路徑下生成一個約44kb的wav格式音訊檔案,但是這裡有一個巨大的坑--音訊檔案不會立即重新整理覆蓋上一次錄音,大概會延遲0.7-1.2s的時間,這樣就造成了一個問題:如果想要錄音完後立即播放這次的錄音的話,會發現播放的錄音是上一次的錄音!而很不巧,我就需要做這樣的一個功能,所以我棄用了第一種方式,改用了第二種方式。
第二種是“先自己把音訊錄下來,生成wav格式檔案,然後再轉換成byte陣列進行評測”(startEva()),這種方式因為是自己錄音,所以沒有重新整理錄音檔案的延遲,可以實現錄音完後立即播放錄音音訊的效果,這就解決了第一種方式裡的大坑。但是還有個坑就是,較之第一種方式,這種方式的評分偏低很多(第一種方式能得90分的發音,第二種方式大概得70分)。如果有人能夠解決這個坑的話,希望你能給我留言告知一下方法。(此坑已填,文中程式碼已修改)
3.編寫錄音工具類
這裡我寫了兩個工具類,一個用的是MediaRecorder進行錄音,一個是用AudioRecord,第2步裡的第二種方式用到的是AudioRecorder這個工具類。這裡兩種都奉上。
MediaRecorder工具類:
/** * @ClassName: MediaRecordUtil * @Desciption: //錄音工具類 * @author: jesse * @date: 2018-06-15 */ public class MediaRecordUtil { //檔案路徑 private String filePath; //資料夾路徑 private String FolderPath; private MediaRecorder mMediaRecorder; private final String TAG = MediaRecordUtil.class.getSimpleName(); public static final int MAX_LENGTH = 1000 * 60 * 10;// 最大錄音時長1000*60*10; private OnAudioStatusUpdateListener audioStatusUpdateListener; /** * 檔案儲存預設sdcard/record */ public MediaRecordUtil(){ //預設儲存路徑為/sdcard/record/下 this(Environment.getExternalStorageDirectory().getAbsolutePath()+"/ShushanRecord/"); } public MediaRecordUtil(String filePath) { File path = new File(filePath); if(!path.exists()) path.mkdirs(); this.FolderPath = filePath; } private long startTime; private long endTime; /** * 開始錄音 使用amr格式 * 錄音檔案 * @return */ public void startRecord() { // 開始錄音 /* ①Initial:例項化MediaRecorder物件 */ if (mMediaRecorder == null) mMediaRecorder = new MediaRecorder(); try { /* ②setAudioSource/setVedioSource */ mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);// 設定麥克風 /* ②設定音訊檔案的編碼:AAC/AMR_NB/AMR_MB/Default 聲音的(波形)的取樣 */ mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.DEFAULT); /* * ②設定輸出檔案的格式:THREE_GPP/MPEG-4/RAW_AMR/Default THREE_GPP(3gp格式 * ,H263視訊/ARM音訊編碼)、MPEG-4、RAW_AMR(只支援音訊且音訊編碼要求為AMR_NB) */ mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.DEFAULT); filePath = FolderPath + DateUtils.createFileName() + ".amr" ; Log.i(TAG,"utils : filePath == "+filePath); /* ③準備 */ mMediaRecorder.setOutputFile(filePath); // mMediaRecorder.setMaxDuration(MAX_LENGTH); mMediaRecorder.prepare(); /* ④開始 */ mMediaRecorder.start(); // AudioRecord audioRecord. /* 獲取開始時間* */ startTime = System.currentTimeMillis(); // updateMicStatus(); } catch (IllegalStateException e) { e.printStackTrace(); Log.i(TAG, "call startAmr(File mRecAudioFile) failed!" + e.toString()); } catch (IOException e) { e.printStackTrace(); Log.i(TAG, "call startAmr(File mRecAudioFile) failed!" + e.toString()); } } /** * 停止錄音 */ public long stopRecord() { if (mMediaRecorder == null) return 0L; endTime = System.currentTimeMillis(); //有一些網友反應在5.0以上在呼叫stop的時候會報錯,翻閱了一下谷歌文件發現上面確實寫的有可能會報錯的情況,捕獲異常清理一下就行了,感謝大家反饋! try { mMediaRecorder.setOnErrorListener(null); mMediaRecorder.setOnInfoListener(null); mMediaRecorder.setPreviewDisplay(null); mMediaRecorder.stop(); mMediaRecorder.release(); audioStatusUpdateListener.onStop(filePath); filePath = ""; }catch (RuntimeException e){ mMediaRecorder.release(); File file = new File(filePath); if (file.exists()) file.delete(); filePath = ""; Log.i(TAG,"stopRecord : "+e.toString()); e.printStackTrace(); }finally { mMediaRecorder = null; } return endTime - startTime; } /** * 取消錄音 */ public void cancelRecord(){ try { mMediaRecorder.stop(); mMediaRecorder.reset(); mMediaRecorder.release(); mMediaRecorder = null; }catch (RuntimeException e){ mMediaRecorder.reset(); mMediaRecorder.release(); mMediaRecorder = null; } File file = new File(filePath); if (file.exists()) file.delete(); filePath = ""; } private final Handler mHandler = new Handler(); private Runnable mUpdateMicStatusTimer = new Runnable() { public void run() { // updateMicStatus(); } }; private int BASE = 1; private int SPACE = 100;// 間隔取樣時間 public void setOnAudioStatusUpdateListener(OnAudioStatusUpdateListener audioStatusUpdateListener) { this.audioStatusUpdateListener = audioStatusUpdateListener; } /** * 更新麥克狀態 */ private void updateMicStatus() { if (mMediaRecorder != null) { double ratio = (double)mMediaRecorder.getMaxAmplitude() / BASE; double db = 0;// 分貝 if (ratio > 1) { db = 20 * Math.log10(ratio); if(null != audioStatusUpdateListener) { audioStatusUpdateListener.onUpdate(db, System.currentTimeMillis()-startTime); } } mHandler.postDelayed(mUpdateMicStatusTimer, SPACE); } } public interface OnAudioStatusUpdateListener { /** * 錄音中... * @param db 當前聲音分貝 * @param time 錄音時長 */ public void onUpdate(double db, long time); /** * 停止錄音 * @param filePath 儲存路徑 */ public void onStop(String filePath); } }
AudioRecord工具類:
/**
* @ClassName: AudioRecordUtil
* @Desciption: //錄製wav格式音訊
* @author: jesse
* @date: 2018-07-21
*/
public class AudioRecordUtil {
private static AudioRecordUtil mInstance;
private AudioRecord recorder;
//錄音源
private static int audioSource = MediaRecorder.AudioSource.MIC;
//錄音的取樣頻率
private static int audioRate = 16000;//這個取樣率是官方提供的標準取樣率,評測精度很高
//錄音的聲道,單聲道
private static int audioChannel = AudioFormat.CHANNEL_IN_MONO;
//量化的深度
private static int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
//快取的大小
private static int bufferSize = AudioRecord.getMinBufferSize(audioRate,audioChannel,audioFormat);
//記錄播放狀態
private boolean isRecording = false;
//數字訊號陣列
private byte [] noteArray;
//PCM檔案
private File pcmFile;
//WAV檔案
private File wavFile;
//檔案輸出流
private OutputStream os;
//檔案根目錄
private String basePath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/eva/";
//wav檔案目錄
private String outFileName = basePath+"/eva.wav";
//pcm檔案目錄
private String inFileName = basePath+"/eva.pcm";
private AudioRecordUtil(){
createFile();//建立檔案
recorder = new AudioRecord(audioSource,audioRate,audioChannel,audioFormat,bufferSize);
}
public synchronized static AudioRecordUtil getInstance(){
if(mInstance == null){
mInstance = new AudioRecordUtil();
}
return mInstance;
}
//讀取錄音數字資料執行緒
class WriteThread implements Runnable{
public void run(){
writeData();
}
}
//開始錄音
public void startRecord(){
isRecording = true;
recorder.startRecording();
}
//停止錄音
public void stopRecord(){
isRecording = false;
recorder.stop();
}
//將資料寫入資料夾,檔案的寫入沒有做優化
public void writeData(){
noteArray = new byte[bufferSize];
//建立檔案輸出流
try {
os = new BufferedOutputStream(new FileOutputStream(pcmFile));
}catch (IOException e){
}
while(isRecording == true){
int recordSize = recorder.read(noteArray,0,bufferSize);
if(recordSize>0){
try{
os.write(noteArray);
}catch(IOException e){
}
}
}
if (os != null) {
try {
os.close();
}catch (IOException e){
}
}
}
// 這裡得到可播放的音訊檔案
public void convertWaveFile() {
FileInputStream in = null;
FileOutputStream out = null;
long totalAudioLen = 0;
long totalDataLen;
long longSampleRate = AudioRecordUtil.audioRate;
int channels = 1;
long byteRate = 16 *AudioRecordUtil.audioRate * channels / 8;
byte[] data = new byte[bufferSize];
try {
in = new FileInputStream(inFileName);
out = new FileOutputStream(outFileName);
totalAudioLen = in.getChannel().size();
//由於不包括RIFF和WAV
totalDataLen = totalAudioLen + 36;
WriteWaveFileHeader(out, totalAudioLen, totalDataLen, longSampleRate, channels, byteRate);
while (in.read(data) != -1) {
out.write(data);
}
in.close();
out.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
/* 任何一種檔案在頭部新增相應的標頭檔案才能夠確定的表示這種檔案的格式,wave是RIFF檔案結構,每一部分為一個chunk,其中有RIFF WAVE chunk, FMT Chunk,Fact chunk,Data chunk,其中Fact chunk是可以選擇的, */
private void WriteWaveFileHeader(FileOutputStream out, long totalAudioLen, long totalDataLen, long longSampleRate,
int channels, long byteRate) throws IOException {
byte[] header = new byte[44];
header[0] = 'R'; // RIFF
header[1] = 'I';
header[2] = 'F';
header[3] = 'F';
header[4] = (byte) (totalDataLen & 0xff);//資料大小
header[5] = (byte) ((totalDataLen >> 8) & 0xff);
header[6] = (byte) ((totalDataLen >> 16) & 0xff);
header[7] = (byte) ((totalDataLen >> 24) & 0xff);
header[8] = 'W';//WAVE
header[9] = 'A';
header[10] = 'V';
header[11] = 'E';
//FMT Chunk
header[12] = 'f'; // 'fmt '
header[13] = 'm';
header[14] = 't';
header[15] = ' ';//過渡位元組
//資料大小
header[16] = 16; // 4 bytes: size of 'fmt ' chunk
header[17] = 0;
header[18] = 0;
header[19] = 0;
//編碼方式 10H為PCM編碼格式
header[20] = 1; // format = 1
header[21] = 0;
//通道數
header[22] = (byte) channels;
header[23] = 0;
//取樣率,每個通道的播放速度
header[24] = (byte) (longSampleRate & 0xff);
header[25] = (byte) ((longSampleRate >> 8) & 0xff);
header[26] = (byte) ((longSampleRate >> 16) & 0xff);
header[27] = (byte) ((longSampleRate >> 24) & 0xff);
//音訊資料傳送速率,取樣率*通道數*取樣深度/8
header[28] = (byte) (byteRate & 0xff);
header[29] = (byte) ((byteRate >> 8) & 0xff);
header[30] = (byte) ((byteRate >> 16) & 0xff);
header[31] = (byte) ((byteRate >> 24) & 0xff);
// 確定系統一次要處理多少個這樣位元組的資料,確定緩衝區,通道數*取樣位數
header[32] = (byte) (1 * 16 / 8);
header[33] = 0;
//每個樣本的資料位數
header[34] = 16;
header[35] = 0;
//Data chunk
header[36] = 'd';//data
header[37] = 'a';
header[38] = 't';
header[39] = 'a';
header[40] = (byte) (totalAudioLen & 0xff);
header[41] = (byte) ((totalAudioLen >> 8) & 0xff);
header[42] = (byte) ((totalAudioLen >> 16) & 0xff);
header[43] = (byte) ((totalAudioLen >> 24) & 0xff);
out.write(header, 0, 44);
}
//建立資料夾,首先建立目錄,然後建立對應的檔案
public void createFile(){
File baseFile = new File(basePath);
if(!baseFile.exists())
baseFile.mkdirs();
pcmFile = new File(basePath+"/eva.pcm");
wavFile = new File(basePath+"/eva.wav");
if(pcmFile.exists()){
pcmFile.delete();
}
if(wavFile.exists()){
wavFile.delete();
}
try{
pcmFile.createNewFile();
wavFile.createNewFile();
}catch(IOException e){
}
}
//音訊檔案轉byte陣列
public static byte[] getAudioData(String audioPath){
byte[] buffer = null;
try {
File file = new File(audioPath);
FileInputStream fis = new FileInputStream(file);
ByteArrayOutputStream bos = new ByteArrayOutputStream(1000);
byte[] b = new byte[1000];
int n;
while ((n = fis.read(b)) != -1) {
bos.write(b, 0, n);
}
fis.close();
bos.close();
buffer = bos.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return buffer;
}
//記錄資料
public void recordData(){
new Thread(new WriteThread()).start();
}
public String getOutFileName() {
return outFileName;
}
4.開始評測
1.錄音開始
AudioRecordUtil.getInstance().startRecord();
AudioRecordUtil.getInstance().recordData();
2.錄音結束
AudioRecordUtil.getInstance().stopRecord();
AudioRecordUtil.getInstance().convertWaveFile();
3.開始評測
SpeechEvaluatorUtil.startEva(AudioRecordUtil.getAudioData(recordPath),text,ReadReciteExamFragment.this);
注意: 使用評測前先要執行SpeechEvaluatorUtil.init(getContext());我是在fragment的oncreate方法中執行的。
4.顯示得分
這一步需要實現EvaluatorListener介面,這裡只分享一下onResult這個回撥的實現:
@Override
public void onResult(EvaluatorResult result, boolean isLast) {
Log.d(TAG,"onresult : isLast == "+isLast);
if (isLast) {
StringBuilder builder = new StringBuilder();
builder.append(result.getResultString());
if(oldScorePad != null && oldScorePad.getVisibility() == View.VISIBLE){
oldScorePad.setVisibility(View.GONE);
}
showScorePad(parseXml(builder.toString()));
}
}
parseXml方法,直接返回得分,滿分5分(換算成100分制乘以20即可):
private float parseXml(String xmlStr){
float totalScore = 0f;
try {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
XmlPullParser xmlPullParser = factory.newPullParser();
xmlPullParser.setInput(new StringReader(xmlStr));
int eventType = xmlPullParser.getEventType();
String value;
while(eventType != xmlPullParser.END_DOCUMENT) {
String nodeName = xmlPullParser.getName();
switch (eventType){
case XmlPullParser.START_TAG:
if("total_score".equals(nodeName)){
value = xmlPullParser.getAttributeValue(0);
totalScore = Float.parseFloat(value);
}
break;
case XmlPullParser.END_TAG:
break;
}
eventType = xmlPullParser.next();
}
}catch (XmlPullParserException xppe){
Log.i(TAG,xppe.toString());
}catch (IOException ioe){
Log.i(TAG,ioe.toString());
}
return totalScore;
}
再發一個完整錄音程式碼,可以作為參考:
ExamAudioPlayUtil.stopPlay();
coverPopup = PopupWindowUtil.showCoverPopupWindow(getActivity(),rootView);
final Button btn = btnList.get(index).get(index);
AudioRecordUtil.getInstance().startRecord();
AudioRecordUtil.getInstance().recordData();
pb.countBack(pb,(int) ((end-begin)*1000));
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
AudioRecordUtil.getInstance().stopRecord();
AudioRecordUtil.getInstance().convertWaveFile();
mHandler.sendMessage(mHandler.obtainMessage(0,btn));
SpeechEvaluatorUtil.startEva(AudioRecordUtil.getAudioData(recordPath),text,ReadReciteExamFragment.this);
ExamAudioPlayUtil.playAudio(pbRecordPlay, recordPath, new MediaPlayer.OnCompletionListener() {
@Override
public void onCompletion(MediaPlayer mp) {
coverPopup.dismiss();
}
});
}
},(long)((end-begin)*1000));
以上。
總結
科大訊飛的語音評測,評分還是蠻準的,但是其中也有一些坑,文中已經介紹。這裡再次希望如果有人能填上坑的話,給我留言說一下方法,也希望這篇文章能夠幫到想要接入科大訊飛語音評測功能的安卓工程師。
說句題外話,csdn寫部落格的體驗真是越來越好了!
博主上傳資源下載連結:
自制免費無廣告小說閱讀APP下載:
全屏播放視訊不拉伸原始碼:
科大訊飛語音評測服務接入原始碼:
android餃子播放器使用原始碼:
視訊播放前顯示視訊第一幀原始碼: