微信小程序語音識別服務搭建全過程解析

silk v3錄音轉olami語音識別和語義處理的api服務(ubuntu16.04服務器上實現)


## 重要的寫在前面

重要事項一:
目前本文中提到的API已支持微信小程序錄音文件格式:silk v三、webm/base64。
注:微信小程序開發工具上的錄音雖而後輟名也是silk,但不是真正的silk v3格式的(打開xx.silk看頭部是「data:audio/webm;base64,」開頭的),爲了便於調試,這類格式我今天加急給支持上了,因此:微信小程序開發工具也能夠調用個人API調試了。php

重要事項二:
想要用我這個API,務必先去cn.olami.ai申請appKey和appSecret,而後將appKey告知我,我加進支持列表方可調用,兩者缺一不可。文末有將有調用此文提到的API服務的案例以及源碼分享文章連接。html

調用案例:「遙知之」智能小祕,歡迎掃碼體驗:
小程序碼小java

重要事項三:
歡迎轉載本文,沒有什麼別的要求,請保留:
原文連接:http://blog.csdn.net/happycxz/article/details/78016299
本文全部源碼對應碼雲連接:https://gitee.com/happycxz/silk2asr
本文全部源碼對應github連接:https://github.com/happycxz/silk2asrlinux


## 爲何作?

前不久剛發佈了一個智能生活信息查詢的小助手「遙知之」,惋惜只能手動輸入來玩,這一點體驗很很差,由於微信小程序錄音是silk格式的,如今主要的語音識別接口都不支持。ios

在網上搜了下相應的功能,也只有php作的開源代碼實現的silk轉wav的服務器代碼,首先我不熟悉PHP,其次也不知道後期有沒有維護,乾脆本身作一個tomcat + java版的,權當學習娛樂一下。nginx


## 怎麼作?

準備環境

先須要有一個支持https的服務器,我目前用的服務器是阿里雲秒殺的免費最低配置的服務器,預裝的ubuntu16.04 LTS版,而後本身搗鼓一下,配置上了https,具體是用 nginx + let's encrypt + tomcat來提供的https的API。這裏不詳細介紹,感興趣的本身研究下。git

須要一個silk解碼器,網上有一牛在2015年年初曾經發貼討論過這個話題:silk v3 編碼的音頻怎麼轉換成 wav 或 mp3 之類的?github

並且此牛後面有持續研究,提供了開源的silk_v3_decoder項目,具體見:kn007大牛的silk_v3_decoderweb

對了,開源項目是github上的,服務器上裝個git,這不用額外再說明了吧。spring

搭建服務步驟

下載silk-v3-decoder

基本就是在服務器上找個目錄,把大牛kn007的項目下載下來。

root@alijod:/home/jod/wechat_app# mkdir download
root@alijod:/home/jod/wechat_app# cd download/
root@alijod:/home/jod/wechat_app/download# git clone https://github.com/kn007/silk-v3-decoder.git
Cloning into 'silk-v3-decoder'...
remote: Counting objects: 634, done.
remote: Total 634 (delta 0), reused 0 (delta 0), pack-reused 634
Receiving objects: 100% (634/634), 72.79 MiB | 9.50 MiB/s, done.
Resolving deltas: 100% (352/352), done.
Checking connectivity... done.
root@alijod:/home/jod/wechat_app/download# ll
total 12
drwxr-xr-x 3 root root 4096 Sep 18 10:11 ./
drwxr-xr-x 7 root root 4096 Sep 18 10:11 ../
drwxr-xr-x 5 root root 4096 Sep 18 10:11 silk-v3-decoder/
root@alijod:/home/jod/wechat_app/download# ls silk-v3-decoder/
converter_beta.sh  converter.sh  LICENSE  README.md  silk  windows

看上述目錄,其實只用到了silk這個目錄,和converter.sh這個腳本。silk目錄中的C代碼須要gcc編譯,converter.sh腳本須要修改一下,後續都會提。

編譯silk_v3_decoder

根據https://github.com/kn007/silk-v3-decoder上的README,用上這個工具,須要gcc和ffmpeg,gcc是在編譯silk時執行make時用到的(普及一下小白),ffmpeg實際上是腳本里要用的,與編譯無關。事實是,ffmpeg在整個服務搭建過程確實不是必備的,後文將有針對這個額外說明,只是本人偷懶,暫時不想再深刻研究了。

gcc的環境,若是沒有安裝,本身網搜吧,這裏不扯了,直接進入正題:

root@alijod:/home/jod/wechat_app/download# cd silk-v3-decoder/silk/
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder/silk# ll
total 32
drwxr-xr-x 5 root root  4096 Sep 18 10:11 ./
drwxr-xr-x 5 root root  4096 Sep 18 10:11 ../
drwxr-xr-x 2 root root  4096 Sep 18 10:11 interface/
-rw-r--r-- 1 root root  3278 Sep 18 10:11 Makefile
drwxr-xr-x 2 root root 12288 Sep 18 10:11 src/
drwxr-xr-x 2 root root  4096 Sep 18 10:11 test/
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder/silk# make
…………
…………(這裏是一大段編譯過程日誌)
…………
a - src/SKP_Silk_scale_vector.o
gcc -c -Wall -enable-threads -O3   -Iinterface -Isrc -Itest  -o test/Decoder.o test/Decoder.c
test/Decoder.c: In function ‘main’:
test/Decoder.c:187:9: warning: ignoring return value of ‘fread’, declared with attribute warn_unused_result [-Wunused-result]
         fread(header_buf, sizeof(char), 1, bitInFile);
         ^
g++  -L./ test/Decoder.o -lSKP_SILK_SDK -o decoder
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder/silk# ls
decoder  interface  libSKP_SILK_SDK.a  Makefile  src  test
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder/silk#

能夠看到,上面編譯過程當中,最後出現了一個warning,不過不要緊,ls查一下,第一個「decoder」就是咱們要用的binary啦,有它就證實編譯成功了。

測試silk_v3_decoder功能

接下來就要驗證一下編出來的這個能不能用了。
根據https://github.com/kn007/silk-v3-decoder上的README,摘下來一段:

sh converter.sh silk_v3_file/input_folder output_format/output_folder flag(format)

好比轉換一個文件,使用:

sh converter.sh 33921FF3774A773BB193B6FD4AD7C33E.slk mp3

注意:其中33921FF3774A773BB193B6FD4AD7C33E.slk是要轉換的文件,而mp3是最終轉換後輸出的格式。

參考上面那個例子就行了,腳本參數只有兩個,一個是源文件相對或絕對路徑,另外一個是目標格式。
也就是說上述命令會將33921FF3774A773BB193B6FD4AD7C33E.slk(注意,例子裏是slk後輟,你本身在獲取微信小程序錄音重命名時若是是.silk,別疑惑了,linux環境文件後輟名是沒有實際意義的,感興趣本身網搜,to小白)轉碼成33921FF3774A773BB193B6FD4AD7C33E.mp3。

沒有silk源文件?別急,我準備了個silk_v3錄音文件,附帶着轉出來的mp3一塊兒放在我服務器上了,須要的能夠去下載(右擊後另存便可,mp3能夠在線播放,silk播放不了,直接單擊會「403」):
微信小程序原始錄音文件:sample.silk
converter.sh腳本轉碼後的文件:sample.mp3

附上我轉碼的操做過程:

root@alijod:/home/jod/wechat_app/download/silk-v3-decoder# ll
total 48
drwxr-xr-x 5 root root 4096 Sep 18 10:43 ./
drwxr-xr-x 3 root root 4096 Sep 18 10:11 ../
-rw-r--r-- 1 root root 4131 Sep 18 10:11 converter_beta.sh
-rw-r--r-- 1 root root 3639 Sep 18 10:11 converter.sh
drwxr-xr-x 8 root root 4096 Sep 18 10:11 .git/
-rw-r--r-- 1 root root 1076 Sep 18 10:11 LICENSE
-rw-r--r-- 1 root root 3582 Sep 18 10:11 README.md
-rw-r----- 1 root root 6188 Sep 18 10:43 sample.silk
drwxr-xr-x 5 root root 4096 Sep 18 10:26 silk/
drwxr-xr-x 3 root root 4096 Sep 18 10:11 windows/
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder# 
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder# 
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder# sh converter.sh sample.silk mp3
-e [OK] Convert sample.silk To sample.mp3 Finish.
root@alijod:/home/jod/wechat_app/download/silk-v3-decoder# ll
total 68
drwxr-xr-x 5 root root  4096 Sep 18 10:43 ./
drwxr-xr-x 3 root root  4096 Sep 18 10:11 ../
-rw-r--r-- 1 root root  4131 Sep 18 10:11 converter_beta.sh
-rw-r--r-- 1 root root  3639 Sep 18 10:11 converter.sh
drwxr-xr-x 8 root root  4096 Sep 18 10:11 .git/
-rw-r--r-- 1 root root  1076 Sep 18 10:11 LICENSE
-rw-r--r-- 1 root root  3582 Sep 18 10:11 README.md
-rw-r--r-- 1 root root 17709 Sep 18 10:43 sample.mp3
-rw-r----- 1 root root  6188 Sep 18 10:43 sample.silk
drwxr-xr-x 5 root root  4096 Sep 18 10:26 silk/
drwxr-xr-x 3 root root  4096 Sep 18 10:11 windows/

關於converter.sh腳本

vim打開converter.sh腳本,顯示一下行號(vim中輸入":set nu"後回車,我爲小白操心很多),想要簡單使用,其實只須要關注最後面這一段,若是想要深刻研究,最好是把腳本完整過程搞懂。

61 
 62 $cur_dir/silk/decoder "$1" "$1.pcm" > /dev/null 2>&1
 63 if [ ! -f "$1.pcm" ]; then
 64         ffmpeg -y -i "$1" "${1%.*}.$2" > /dev/null 2>&1 &
 65         ffmpeg_pid=$!
 66         while kill -0 "$ffmpeg_pid"; do sleep 1; done > /dev/null 2>&1
 67         [ -f "${1%.*}.$2" ]&&echo -e "${GREEN}[OK]${RESET} Convert $1 to ${1%.*}.$2 success, ${YELLOW}but not a silk v3 encoded file.${RESET}"&&exit
 68         echo -e "${YELLOW}[Warning]${RESET} Convert $1 false, maybe not a silk v3 encoded file."&&exit
 69 fi
 70 ffmpeg -y -f s16le -ar 24000 -ac 1 -i "$1.pcm" "${1%.*}.$2" > /dev/null 2>&1
 71 ffmpeg_pid=$!
 72 while kill -0 "$ffmpeg_pid"; do sleep 1; done > /dev/null 2>&1
 73 rm "$1.pcm"
 74 [ ! -f "${1%.*}.$2" ]&&echo -e "${YELLOW}[Warning]${RESET} Convert $1 false, maybe ffmpeg no format handler for $2."&&exit
 75 echo -e "${GREEN}[OK]${RESET} Convert $1 To ${1%.*}.$2 Finish."
 76 exit

其實關鍵的兩行也就是Line 62和Line 70。第62行就是調用咱們上文編出來的decoder解碼silk_v3文件,第70行是將silk_v3文件解碼出來的raw data數據轉成相應格式。

這裏額外說明一下我跟這兩行的幾個插曲:

插曲一:speex壓縮

我作這個SILK語音識別服務的起初目的是讓個人「遙知之」支持語音輸入功能,「遙知之」上用的OLAMI接口也有語音識別,並且研究了一下他們的JAVA SDK和在線文檔,從在線文檔(OLAMI 文檔中心->語音識別接口文檔->「支持的音頻格式」)上看是支持wav格式,另外支持speex壓縮。

wav格式文件是很佔空間的(至關於PCM原始採樣數據未經壓縮的,加了一個文件頭),以下圖所示(可能實際speex壓縮的效果會更好一點):
pcm, silk, speex格式文件佔空間比較圖

若是將數據經過speex壓縮,就只須要腳本中的第62行,就不用依賴ffmpeg去轉碼也能夠直接省流量上傳到OLAMI語音識別服務器了。這裏就是爲何我前面說到,ffmpeg並非此服務搭建中必備之緣由。

若是經過speex會大大下降傳輸效率,因而期間我有花蠻長時間在研究如何將pcm數據轉成speex的,好比怎麼調用c代碼實現的speex的編碼(java下經過JNI調用speex的encoder,研究未果,放棄了這個方案),後來又找了jspeex(java版的speex codec)等等,後面因有另外一個省事方案,這裏用jspeex的方案就中斷未深刻研究了,其實應該是行的通的。

在QQ羣(羣號:656580961)裏提了一下,熱心的羣主「黃眉毛」說olami java sdk裏默認是將wav或pcm經過speex壓縮傳輸的,這樣一來,我只須要將wav或pcm對接olami java sdk就能夠實現「省流量」傳輸到olami語音識別服務器了。這就是我最終採用的省事方案。

插曲二:採樣率不適配

發現經過微信小程序端錄音出來的silk v3文件,通過kn007的converter.sh轉出來的wav文件,再送到olami語音識別接口,發現識別效果很糟,把wav文件拿出來聽聽,彷佛也正常。

這時候想起來腳本中PCM轉wav是按24K轉的,轉出來的WAV應該是24K的,而olami語音識別端支持的是16K(訊飛還支持8K的),多是這個採樣率不一致致使的識別率差,網搜了一下,還真有前人碰到過相同問題,參見此文文中提到的「誤打誤撞」那一段:從微信中提取語音文件,並轉換成文字的全自動化解決方案 ,他的誤打誤撞的原理應該是小程序錄音就是雙通道12K的,而後ffmpeg額外指定一下參數將雙通道12K的數據流轉成16K的wav。

這下好了,離不開ffmpeg了,須要它幫着轉採樣率呀,speex壓縮又不負責解決採樣率轉換的問題。

重要的事說三遍:在原始腳本的基礎上,修改一下第70行:
重要的事說三遍:在原始腳本的基礎上,修改一下第70行:
重要的事說三遍:在原始腳本的基礎上,修改一下第70行:

ffmpeg -y -f s16le -ar 12000 -ac 2 -i "$1.pcm" -f wav -ar 16000 -ac 1 "${1%.*}.$2" > /dev/null 2>&1

插曲三:假silk真webm/base64格式

在使用微信小程序開發工具模擬手機作調試時,錄音文件不能被silk和ffmpeg轉,vim打開一看,頭部是「data:audio/webm;base64,」。

由此引申出一個現象:微信小程序的錄音不全是silk v3格式,其中還有剛剛提到的webm/base64的,好像還有AMR格式的,聽kn007大神說還有混淆格式,也就是那種一個文件含多種格式混合的,也不知道爲何會有這種狀況。

關於webm/base64格式,kn007的回覆是,base64 decoder而後直接ffmpeg轉,因而我分兩步實現:
第一步:用java代碼作base64 decoder,再將文件寫到 xxx.webm文件中,這部分簡單,可參考微信小程序 錄音文件格式silk 坑那樣作便可。
第二步:再調用ffmpeg命令直接轉碼成wav,主要是調用一下下述轉碼命令轉成16K的WAV:

ffmpeg -i "$1" -f wav -ar 16000 -ac 1 "${1%.*}.$2" > /dev/null 2>&1

其中調用ffmpeg命令容易出現失敗,緣由之一可能會是文件讀寫權限不足,緣由之二可能會是調用ffmpeg後,須要等ffmpeg進程消失,即轉碼任務完成,才退出。 以爲我我的碰到的問題應該是緣由之二致使的,由於我確實是將/usr/bin/ffmpeg設置成了777權限,仍是會轉失敗,將調ffmpeg命令的部分在腳本中實現,而且加上kn007大神converter.sh中那樣的等待ffmpeg完成的部分,就搞定了。

爲了讓腳本更通用,我將上述解決採樣率不匹配的問題,修改後的腳本基礎上,又添加了對webm格式的單獨ffmpeg轉碼支持(經過判斷傳入第1個參數的後輟是不是webm來判斷是否是直接ffmpeg轉碼而後exit,簡單粗暴而且高效!)大概在腳本的上方添加下面這一段:

SOURCE_FILE_SUFFIX=${1##*.}
echo -e "XXXX SOURCE_FILE_SUFFIX:${SOURCE_FILE_SUFFIX}"
if [ "${SOURCE_FILE_SUFFIX}" = "webm" ]; then
        ## if webm, ffmpeg it directly. webm/base64 had been base64 decoder on my java server already.
        echo -e "begin to ffmpeg $2 from webm now..."
        ffmpeg -i "$1" -f wav -ar 16000 -ac 1 "${1%.*}.$2" > /dev/null 2>&1
        ##ffmpeg -i "$1" -f wav "${1%.*}.$2" > /dev/null 2>&1
        ffmpeg_pid=$!
        while kill -0 "$ffmpeg_pid"; do sleep 1; done > /dev/null 2>&1
        [ ! -f "${1%.*}.$2" ]&&echo -e "${YELLOW}[Warning]${RESET} Convert $1 false, maybe ffmpeg no format handler for $2."&&exit
        echo -e "${GREEN}[OK]${RESET} Convert $1 To ${1%.*}.$2 Finish."
        exit
else
        echo -e "begin to silk decoder flow..."
        ## if not webm, follows default silk decoder road.
fi

至此,converter_cxz.sh修改結束。

搭建web服務及主要代碼說明

前面至關於評估可行性,基本驗證了從小程序錄音文件 xx.silk 到語音識別API能認的數據或文件格式,這條路走通了,接下來就是堆JAVA代碼實現細節部分了。

建立sprinMVC工程

大概的工程目錄結構以下:
這裏寫圖片描述

com.happycxz.controller中有兩個controller:
第1個,AdditionalController.java是用來查服務器狀態和在線更新數據用的,可忽略。
第2個,OlamiController.java是對接微信小程序silk文件上傳API接口的,代碼以下:

package com.happycxz.controller;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.NoSuchAlgorithmException;
import java.util.Map;  
  
import javax.servlet.ServletException;  
import javax.servlet.http.HttpServletRequest;  
import javax.servlet.http.HttpServletResponse;  
import javax.servlet.http.Part;

import org.springframework.stereotype.Controller;  
import org.springframework.util.StringUtils;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RequestParam;  
import org.springframework.web.bind.annotation.ResponseBody;

import com.happycxz.olami.AsrAdditionInfo;
import com.happycxz.olami.OlamiEntityFactory;
import com.happycxz.olami.SdkEntity;
import com.happycxz.utils.Configuration;
import com.happycxz.utils.Util;
import com.sun.org.apache.xml.internal.security.utils.Base64;  

/** 
 * olami與微信小程序 接口相關對接
 * @author Jod
 */
@Controller  
@RequestMapping("/olami")  
public class OlamiController {
    
    //保存linux shell命令字符串
    private static final String SHELL_CMD = Configuration.getInstance().getValue("local.shell.cmd", "sh /YOUR_PATH/silk-v3-decoder/converter_cxz.sh %s wav");

    //保存silk和wav文件的目錄,放在web目錄、或一個指定的絕對目錄下 
    private static final String localFilePath = Configuration.getInstance().getValue("local.file.path", "/YOUR/LOCAL/VOICE/PATH/");;  
    
    static {
        Util.p("OlamiController base SHELL_CMD:" + SHELL_CMD);
        Util.p("OlamiController base localFilePath:" + localFilePath);
    }

    @RequestMapping(value="/asr", produces="plain/text; charset=UTF-8")  
    public @ResponseBody String asrUploadFile(HttpServletRequest request, HttpServletResponse response, @RequestParam Map<String, Object> p)  
            throws ServletException, IOException {  

        AsrAdditionInfo additionInfo = new AsrAdditionInfo(p);
        if (additionInfo.getErrCode() != 0) {
            //參數不合法,或者appKey沒有在支持列表中備錄
            return Util.JsonResult(String.valueOf(additionInfo.getErrCode()), additionInfo.getErrMsg());  
        }
        
        String localPathToday = localFilePath + Util.getDateStr() + File.separator;
        // 若是文件存放路徑不存在,則mkdir一個  
        File fileSaveDir = new File(localPathToday);  
        if (!fileSaveDir.exists()) {  
            fileSaveDir.mkdirs();  
        }
  
        int count = 1;
        String asrResult = "";
        for (Part part : request.getParts()) {  
            String fileName_origin = extractFileName(part);
            //這裏必需要用原始文件名是否爲空來判斷,由於part列表是全部數據,前三個被formdata佔了,對應文件名實際上是空
            if(!StringUtils.isEmpty(fileName_origin)) {
                String fileName = additionInfo.getVoiceFileName();
                String silkFile = localPathToday + fileName;
                Util.p("silkFile[" + count + "]:" + silkFile);

                part.write(silkFile);
                
                if (webmBase64Decoder2Wav(silkFile)) {
                    // support webm/base64 in webmBase64Decoder2Wav();
                    // is webm base64 format, and xxxx.webm file is temporary created, xxxx.wav was last be converted.
                } else {
                    // run script to convert silk(v3) to wav
                    Util.RunShell2Wav(SHELL_CMD, silkFile);
                }
                
                // get wave file path and name, prepare for olami asr
                String waveFile = DotSilk2DotOther(silkFile, "wav");
                Util.p("OlamiController.asrUploadFile() waveFile:" + waveFile);
                
                if (new File(waveFile).exists() == false) {
                    Util.w("OlamiController.asrUploadFile() wav file[" + waveFile + "] not exist!", null);
                    return Util.JsonResult("80", "convert silk to wav failed, NOW NOT SUPPORT WXAPP DEVELOP RECORD because it is not silk_v3 format. anyother reason please tell QQ:404499164."); 
                }
                
                try {
                    SdkEntity entity = OlamiEntityFactory.createEntity(additionInfo.getAppKey(), additionInfo.getAppSecret(), additionInfo.getUserId());
                    asrResult = entity.getSpeechResult(waveFile);
                    Util.p("OlamiController.asrUploadFile() asrResult:" + asrResult);
                } catch (NoSuchAlgorithmException | InterruptedException e) {
                    Util.w("OlamiController.asrUploadFile() asr NoSuchAlgorithmException or InterruptedException", e);
                } catch (FileNotFoundException e) {
                    Util.w("OlamiController.asrUploadFile() asr FileNotFoundException", e);
                    return Util.JsonResult("80", "convert silk to wav failed, NOW NOT SUPPORT WXAPP DEVELOP RECORD because it is not silk_v3 format. anyother reason please tell QQ:404499164."); 
                } catch (Exception e) {
                    Util.w("OlamiController.asrUploadFile() asr Exception", e);
                }
            }
            count++;
        }
        
        //防止數據傳遞亂碼
        //response.setContentType("application/json;charset=UTF-8");

        return Util.JsonResult("0", "olami asr success!", asrResult);  
    }  
   
    /**
     * 將  xxxxx.silk 文件名轉 xxxx.wav
     * @param silkName
     * @param otherSubFix
     * @return
     */
    private static String DotSilk2DotOther(String silkName, String otherSubFix) {
        int removeByte = 4;
        if (silkName.endsWith("silk")) {
            removeByte = 4;
        } else if (silkName.endsWith("slk")) {
            removeByte = 3;
        }
        return silkName.substring(0, silkName.length()-removeByte) + otherSubFix;
    }
    
    /** 
     * 從content-disposition頭中獲取源文件名 
     *  
     * content-disposition頭的格式以下: 
     * form-data; name="dataFile"; filename="PHOTO.JPG" 
     *  
     * @param part 
     * @return 
     */  
    @SuppressWarnings("unused")
    private String extractFileName(Part part) {  
        String contentDisp = part.getHeader("content-disposition");  
        String[] items = contentDisp.split(";");  
        for (String s : items) {  
            if (s.trim().startsWith("filename")) {  
                return s.substring(s.indexOf("=") + 2, s.length()-1);  
            }  
        }  
        return "";  
    }


    /**
     * 經過filePath內容判斷是不是webm/base64格式,若是是,先decode base64後,再直接ffmpeg轉wav,
     * 若是不是,返回false丟給外層繼續看成silk v3去解
     * @param filePath
     * @return
     */
    public static boolean webmBase64Decoder2Wav(String filePath) {
        boolean isWebm = false;
        try {
            String encoding = "utf-8";
            File file = new File(filePath);
            // 判斷文件是否存在
            if ((file.isFile() == false) || (file.exists() == false)) {
                Util.w("webmBase64Decoder2Wav() no file[" + filePath + "] exist.", null);
            }
            
            StringBuilder lineTxt = new StringBuilder();
            String line = null;
            try (
            InputStreamReader read = new InputStreamReader(new FileInputStream(file), encoding);
            BufferedReader bufferedReader = new BufferedReader(read);) {
                while ((line = bufferedReader.readLine()) != null) {
                    lineTxt.append(line);
                }
                read.close();
            } catch (Exception e) {
                Util.w("webmBase64Decoder2Wav() exception0:", e);
                return isWebm;
            }
            
            String oldData = lineTxt.toString();
            if (oldData.startsWith("data:audio/webm;base64,") == false) {
                Util.d("webmBase64Decoder2Wav() file[" + filePath + "] is not webm, or already decoded." );
                return isWebm;
            }
            
            isWebm = true;
            oldData = oldData.replace("data:audio/webm;base64,", "");
            String webmFileName = DotSilk2DotOther(filePath, "webm");
            try {

                File webmFile = new File(webmFileName);
                byte[] bt = Base64.decode(oldData);
                FileOutputStream in = new FileOutputStream(webmFile);
                try {
                    in.write(bt, 0, bt.length);
                    in.close();
                } catch (IOException e) {
                    Util.w("webmBase64Decoder2Wav() exception1:", e);
                    return isWebm;
                }
            } catch (FileNotFoundException e) {
                Util.w("webmBase64Decoder2Wav() exception2:", e);
                return isWebm;
            }
            
            // run cmd to convert webm to wav
            Util.RunShell2Wav(SHELL_CMD, webmFileName);
        } catch (Exception e) {
            Util.w("webmBase64Decoder2Wav() exception3:", e);
            return isWebm;
        }
        
        return isWebm;
    }
    
    public static void main(String[] args) {
        webmBase64Decoder2Wav("D:\\secureCRT_RZSZ\\1505716415538_f7d98081-4d21-3b40-a7df-e56c046a784d_b4118cd178064b45b7c8f1242bcde31f.silk");
    }
}

利用springMVC的註解,很方便的實現API功能,主要看這個asrUploadFile方法,參數包括request和response以外,還有一個Map結構的p,這個p是用來接收formdata的,即上傳錄音文件時附帶的信息。
我這裏強制了必須上傳appKey、appSecret以及userId,由於我是直接對接的olami開放平臺的接口。

大概的流程是(懶的畫流程圖了,直接看上面代碼,很容易看明白的):

  1. 接收p中上傳的appKey、appSecret以及userId這三個必選參數
  2. 接收request中的Parts,獲取原始silk格式文件及對應的上傳文件名
    這裏面實際上是包括file和formdata的,這裏還掉進一個坑過,想着不須要調用「extractFileName」來拿原始文件名,直接收以請求,隨機生成一個文件名保存了得了,事實是,經過「extractFileName」拿文件名,當文件名爲""或null時,這時候是formdata,不是文件,強制保存成文件確定就出問題了(調試時發現有些錄音文件裏只有一個很短的數字字母組成的字符串,就是這個緣由)。
  3. 將文件另取個名字保存到服務器指定目錄
    爲何要另存文件名:微信小程序上傳的錄音文件統一是wx-file.silk,不像小程序開發工具上錄音那樣文件名隨機生成。
  4. 這裏有個額外判斷第3步中保存的xxx.silk是否是webm/base64格式的,若是是,就直接base64 decoder後保存文件 xxx.webm,而後調用converter_cxz.sh將webm格式的文件轉碼成xxx.wav的,走完流程或異常都跳過下一步,直接到第6步。若是不是webm/base64格式的,返回false,繼續走下一步。
  5. 調用silk_v3_decoder中的腳本(這裏是上文提到的修改以後的腳本,我給重命名converter_cxz.sh了)轉xxx.wav
  6. 經過原來的silk文件全路徑,計算出wav文件全路徑
  7. 經過上一步獲得的wav文件全路徑,以及appKey、appSecret以及userId這三個參數,生成一個SdkEntity實體,調用getSpeechResult接口獲取語音識別和語義處理的結果
  8. 組織輸出結果返回。

com.happycxz.olami中有四個文件:
第1個,AsrAdditionInfo.java是用來檢查https請求中formdata必選的三個參數是否都上傳了,是否合法。
這裏我額外作了個限制,除了在olami平臺上申請的appKey和appSecret以外,appKey還要額外告知我,我在支持列表中加上才能夠用,避免被攻擊了你們都無法用,沒辦法,小窩帶寬有限。

第2個,OlamiEntityFactory.java是作一個SdkEntity的緩存,若是formdata中上傳的userId不同,這個緩存就沒用了:(

第3個,OlamiKeyManager.java是配合第一個文件作appKey限制管理的。

第4個,SdkEntity.java是對接olami接口的部分,主要是從olami java sdk sample代碼中拷出來改改的。代碼以下:

package com.happycxz.olami;


import java.io.IOException;
import java.security.NoSuchAlgorithmException;

import com.google.gson.Gson;
import com.happycxz.utils.Util;

import ai.olami.cloudService.APIConfiguration;
import ai.olami.cloudService.APIResponse;
import ai.olami.cloudService.CookieSet;
import ai.olami.cloudService.SpeechRecognizer;
import ai.olami.cloudService.SpeechResult;
import ai.olami.nli.NLIResult;
import ai.olami.util.GsonFactory;

public class SdkEntity {
    
    //indicate simplified input
    private static int localizeOption = APIConfiguration.LOCALIZE_OPTION_SIMPLIFIED_CHINESE;
    // * Replace the audio type you want to analyze with this variable.
    
    private static int audioType = SpeechRecognizer.AUDIO_TYPE_PCM_WAVE;
    //private static int audioType = SpeechRecognizer.AUDIO_TYPE_PCM_RAW;

    // * Replace FALSE with this variable if your test file is not final audio. 
    private static boolean isTheLastAudio = true;
    
    private APIConfiguration config = null;
    
    //configure text recognizer
    SpeechRecognizer recoginzer = null; 
    // * Prepare to send audio by a new task identifier.
    //CookieSet cookie = new CookieSet();
    
    // json string for print pretty
    private static Gson jsonDump = GsonFactory.getDebugGson(false);
    // normal json string
    private static Gson mGson = GsonFactory.getNormalGson();

    public SdkEntity(String appKey, String appSecret, String userId) {
        Util.d("new SdkEntity() start.  appKey:" + appKey + ", appSecret: " + appSecret + ", userId: " + userId);
        try {
            config = new APIConfiguration(appKey, appSecret, localizeOption);
            recoginzer = new SpeechRecognizer(config);
            recoginzer.setEndUserIdentifier(userId);
            recoginzer.setTimeout(10000);
            recoginzer.setAudioType(audioType);
        } catch (Exception e) {
            Util.w("new SdkEntity() exception", e);
        }
        Util.d("new SdkEntity() done");
    }
    
    public String getSpeechResult(String inputFilePath) throws NoSuchAlgorithmException, IOException, InterruptedException {
        String lastResult = "";
        
        Util.d("SdkEntity.getSpeechResult() inputFilePath:" + inputFilePath);
        
        CookieSet cookie = new CookieSet();
        
        // * Start sending audio.
        APIResponse response = recoginzer.uploadAudio(cookie, inputFilePath, audioType, isTheLastAudio);
        //
        // You can also send audio data from a buffer (in bytes).
        //
        // For Example :
        // ===================================================================
        // byte[] audioBuffer = Files.readAllBytes(Paths.get(inputFilePath));
        // APIResponse response = recoginzer.uploadAudio(cookie, audioBuffer, audioType, isTheLastAudio);
        // ===================================================================
        //
        Util.d("\nOriginal Response : " + response.toString());
        Util.d("\n---------- dump ----------\n");
        Util.d(jsonDump.toJson(response));
        Util.d("\n--------------------------\n");

        //四種結果,full最完整,seg, nli, asr只包括那一部分
        String full = "", seg = "", nli = "", asr = "";
        // Check request status.
        if (response.ok()) {
            // Now we can try to get recognition result.
            Util.d("\n[Get Speech Result] =====================");
            while (true) {
                Thread.sleep(500);
                // * Get result by the task identifier you used for audio upload.
                Util.d("\nRequest CookieSet[" + cookie.getUniqueID() + "] speech result...");
                response = recoginzer.requestRecognitionWithAll(cookie);
                Util.d("\nOriginal Response : " + response.toString());
                Util.d("\n---------- dump ----------\n");
                Util.d(jsonDump.toJson(response));
                Util.d("\n--------------------------\n");
                // Check request status.
                if (response.ok() && response.hasData()) {
                    full = mGson.toJson(response.getData());
                    // * Check to see if the recognition has been completed.
                    SpeechResult sttResult = response.getData().getSpeechResult();
                    if (sttResult.complete()) {
                        // * Get speech-to-text result
                        Util.p("* STT Result : " + sttResult.getResult());
                        asr = mGson.toJson(sttResult);
                        // * Check to see if the recognition has be
                        // Because we used requestRecognitionWithAll()
                        // So we should be able to get more results.
                        // --- Like the Word Segmentation.
                        if (response.getData().hasWordSegmentation()) {
                            String[] ws = response.getData().getWordSegmentation();
                            for (int i = 0; i < ws.length; i++) {
                                Util.d("* Word[" + i + "] " + ws[i]);
                            }
                            seg = response.getData().getWordSegmentationSingleString();
                        }
                        // --- Or the NLI results.
                        if (response.getData().hasNLIResults()) {
                            NLIResult[] nliResults = response.getData().getNLIResults();
                            nli = mGson.toJson(nliResults);
                        }
                        // * Done.
                        break;
                    } else {
                        // The recognition is still in progress.
                        // But we can still get immediate recognition results.
                        Util.d("* STT Result [Not yet completed] ");
                        Util.d(" --> " + sttResult.getResult());
                    }
                }
            }
        } else {
            // Error
            Util.w("* Error! Code : " + response.getErrorCode(), null);
            Util.w(response.getErrorMessage(), null);
        }
        
        lastResult = full;
        
        Util.d("\n===========================================\n");
        return lastResult;
    }
    
    public static void main(String[] args) throws NoSuchAlgorithmException, IOException, InterruptedException {
        Util.p("SdkEntity.main() start...");
        int argLen = args.length;
        
        Util.d("SdkEntity.main() args.length[" + argLen + "]:");
        for (String arg : args) {
            Util.d("SpeexPcm.main() arg[" + arg + "]");
        }

        new SdkEntity("b4118cd178064b45b7c8f1242bcde31f", "7908028332a64e47b8336d71ad3ce9ab", "abdd").getSpeechResult(args[0]);
        Util.p("SdkEntity.main() end...");
    }
}

com.happycxz.olami中有兩個文件,是使用到的一些util、讀配置文件、系統日誌等部分。

另外WEB-INFO/lib中加載olami的java sdk,如圖:
這裏寫圖片描述

另外,額外附上一張olami-java-client-1.0.1-source.jar中關於默認採用speex壓縮的源碼部分:
這裏寫圖片描述


## 怎麼用

接口:
https://api.happycxz.com/test/silk2asr/olami/asr

formdata必選參數:
|參數 | 是否必選| 說明 |
|---------|---------|---------------|
|appKey |是 |從olami.cn上申請的key|
|appSecret|是 |從olami.cn上申請的secret|
|userId |是 |用戶的惟一標識,好比手機號,或惟一性的ID,或IMEI號之類的|

返回數據res.data就是olami開放平臺返回結果徹底一致,未經修改,具體參考他們在線文檔:
olami開放平臺的API接口返回數據格式

大概的是 seg字段是語音識別分段結果,asr是語音識別結果,nli是語義或語義處理的結果。小程序的開發工具上無法DEBUG,就沒辦法截一段例子說明了。

調用案例:「遙知之」智能小祕

小程序碼小
歡迎掃碼試用。這一版支持語音識別,博客還沒來得及更新,稍後我會把相關代碼在這個文章「個人微信小程序支持語音識別啦!「遙知之」再也不裝聾」中分享出來,主要是分享一下微信小程序裏如何上傳SILK錄音部分以及如何解析olami返回的語音識別和語義處理結果的代碼。


## 最後閒話

本文歡迎轉載,原文連接:http://blog.csdn.net/happycxz/article/details/78016299

服務端工程的代碼分享:
本文全部源碼對應碼雲連接:https://gitee.com/happycxz/silk2asr
本文全部源碼對應github連接:https://github.com/happycxz/silk2asr

若是有不明白的均可以在本博客文章後面留言,也歡迎你們指正文中的理解或文字描述錯誤或不清楚的部分,我將及時更正,避免帶人跳坑。

須要用這個接口的,appKey能夠在這裏留言或私信告訴我,我幫你加進個人白名單你才能夠用。

相關文章
相關標籤/搜索