最近项目要实现语音识别合成与评测功能,看到了讯飞正好符合要求就打算介入讯飞sdk来实现,但是讯飞的sdk没有unity插件只有单个平台的sdk,看了开发文档之后发现可以用webapi实现跨平台这个可以再不同平台实现语音识别功能。讯飞语音合成与识别用的是WebSocket网络协议先要构建接口鉴权。在握手阶段,请求方需要对请求进行签名,服务端通过签名来校验请求的合法性。代码如下:

    string GetUrl(string uriStr)
    {
        Uri uri = new Uri(uriStr);
        string date = DateTime.Now.ToString("r");
        string signature_origin = string.Format("host: " + uri.Host + "\ndate: " + date + "\nGET " + uri.AbsolutePath + " HTTP/1.1");
        HMACSHA256 mac = new HMACSHA256(Encoding.UTF8.GetBytes(APISecret));
        string signature = Convert.ToBase64String(mac.ComputeHash(Encoding.UTF8.GetBytes(signature_origin)));
        string authorization_origin = string.Format("api_key=\"{0}\",algorithm=\"hmac-sha256\",headers=\"host date request-line\",signature=\"{1}\"", APIKey, signature);
        string authorization = Convert.ToBase64String(Encoding.UTF8.GetBytes(authorization_origin));
        string url = string.Format("{0}?authorization={1}&date={2}&host={3}", uri, authorization, date, uri.Host);
        return url;
    }

获取到鉴权整个url后就需要连接讯飞WebSocket服务器了。连接成功之后需要获取unity录音的数据流AudioClip转化为byte[],需要每隔一段时间发送数据直到录音结束。经过研究这里提供一种思路来截取语音流:

public static byte[] 获取音频流片段(int star, int length, AudioClip recordedClip)
    {
        float[] soundata = new float[length];
        recordedClip.GetData(soundata, star);
        int rescaleFactor = 32767;
        byte[] outData = new byte[soundata.Length * 2];
        for (int i = 0; i < soundata.Length; i++)
        {
            short temshort = (short)(soundata[i] * rescaleFactor);
            byte[] temdata = BitConverter.GetBytes(temshort);
            outData[i * 2] = temdata[0];
            outData[i * 2 + 1] = temdata[1];
        }
        return outData;
    }

其中参数start是录音的位置与音频采样率有关,举个例子如果采样率是16000,那么start=16000的位置就是从获取一秒后语音流位置,以此类推。同样length也是采样长度。如果采样率是16000,start=16000*x,length=16000*y则是从x秒开始录音长度为y的一段语音流数据。明白这个道理后再研究文档就可以实现语音识别逻辑了:

    string APPID = "5c81de59";
    string APISecret = "ea4d5e9b06f8cfb0deae4d5360e7f8a7";
    string APIKey = "94348d7a6d5f3807176cb1f4923efa5c";
    public AudioClip RecordedClip;
    ClientWebSocket 语音识别WebSocket;
    public event Action<string> 语音识别完成事件;   //语音识别回调事件
    public void 开始语音识别()
    {
        if (语音识别WebSocket!=null && 语音识别WebSocket.State == WebSocketState.Open)
        {
            Debug.LogWarning("开始语音识别失败!,等待上次识别连接结束");
            return;
        }
        连接语音识别WebSocket();
        RecordedClip = Microphone.Start(null, false, 60, 16000);
    }

    public IEnumerator 停止语音识别()
    {   
        Microphone.End(null);
        yield return new WaitUntil(()=>语音识别WebSocket.State != WebSocketState.Open);
        Debug.Log("识别结束,停止录音");
    }


    async void 连接语音识别WebSocket()
    {
        using (语音识别WebSocket = new ClientWebSocket())
        {
            CancellationToken ct = new CancellationToken();
            Uri url = new Uri(GetUrl("wss://iat-api.xfyun.cn/v2/iat"));
            await 语音识别WebSocket.ConnectAsync(url, ct);
            Debug.Log("连接成功");
            StartCoroutine(发送录音数据流(语音识别WebSocket));
            StringBuilder stringBuilder = new StringBuilder();
            while (语音识别WebSocket.State == WebSocketState.Open)
            {
                var result = new byte[4096];
                await 语音识别WebSocket.ReceiveAsync(new ArraySegment<byte>(result), ct);//接受数据
                List<byte> list = new List<byte>(result);while (list[list.Count - 1] == 0x00) list.RemoveAt(list.Count - 1);//去除空字节
                string str = Encoding.UTF8.GetString(list.ToArray());
                Debug.Log("接收消息:" + str);
                if (string.IsNullOrEmpty(str))
                {
                    return;
                }
                识别data data = JsonUtility.FromJson<识别data>(str);
                stringBuilder.Append(获取识别单词(data));
                int status = data.data.status;
                if (status == 2)
                {
                    语音识别WebSocket.Abort();
                }
            }
            Debug.LogWarning("断开连接");
            string s = stringBuilder.ToString();
            if (!string.IsNullOrEmpty(s))
            {
                语音识别完成事件?.Invoke(s);
                Debug.LogWarning("识别到声音:" + s);
            }
        }
    }

    [Serializable]
    public class 识别data
    {
        [Serializable]
        public class Data
        {
            [Serializable]
            public class Result
            {
                [Serializable]
                public class Ws
                {
                    [Serializable]
                    public class Cw
                    {
                        public string w;
                    }
                    public Cw[] cw;
                }
                public Ws[] ws;
            }
            public int status;
            public Result result;
        }
        public Data data;
    }

    string 获取识别单词(识别data data)
    {
        StringBuilder stringBuilder = new StringBuilder();
        var ws = data.data.result.ws;
        foreach (var item in ws)
        {
            var cw = item.cw;
            foreach (var w in cw)
            {
                stringBuilder.Append(w.w);
            }
        }    
        return stringBuilder.ToString();
    }

    void 发送数据(byte[] audio, int status, ClientWebSocket socket)
    {
        if (socket.State != WebSocketState.Open)
        {
            return;
        }
        string audioStr = audio==null ?"": Convert.ToBase64String(audio);
        string message = "{\"common\":{\"app_id\":\"" + APPID + "\"},\"business\":{\"language\":\"zh_cn\",\"domain\":\"iat\",\"accent\":\"mandarin\",\"vad_eos\":2000}," +
            "\"data\":{\"status\":" + status + ",\"encoding\":\"raw\",\"format\":\"audio/L16;rate=16000\",\"audio\":\""+ audioStr + "\"}}";
        Debug.Log("发送消息:" + message);
        socket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(message)), WebSocketMessageType.Binary, true, new CancellationToken()); //发送数据
    }

    IEnumerator 发送录音数据流(ClientWebSocket socket)
    {
        yield return new WaitWhile(() => Microphone.GetPosition(null) <= 0);
        float t = 0;
        int position = Microphone.GetPosition(null);
        const float waitTime = 0.04f;//每隔40ms发送音频
        int status = 0;
        int lastPosition = 0;
        const int Maxlength = 640;//最大发送长度
        while (position < RecordedClip.samples && socket.State == WebSocketState.Open)
        {
            t += waitTime;
            yield return new WaitForSecondsRealtime(waitTime);
            if (Microphone.IsRecording(null)) position = Microphone.GetPosition(null);
            Debug.Log("录音时长:" + t + "position=" + position + ",lastPosition=" + lastPosition);
            if (position <= lastPosition)
            {
                Debug.LogWarning("字节流发送完毕!强制结束!");
                break;
            }
            int length = position - lastPosition > Maxlength ? Maxlength : position - lastPosition;
            byte[] date = 获取音频流片段(lastPosition, length, RecordedClip);
            发送数据(date, status, socket);
            lastPosition = lastPosition + length;
            status = 1;
        }
        发送数据(null, 2, socket);
        //WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "关闭WebSocket连接",new CancellationToken());
        Microphone.End(null);
    }

接下来再说说语音合成,主要的难点是语音数据流的播放。服务器发送给客户端的数据不是连续的,需要分段接受,并解析。并且需要把客户端接受的byte[]转化为AudioClip可读取的float[]类型。通过查阅资料可用以下方法实现:

   public static float[] bytesToFloat(byte[] byteArray)//byte[]数组转化为AudioClip可读取的float[]类型
    {
        float[] sounddata = new float[byteArray.Length / 2];
        for (int i = 0; i < sounddata.Length; i++)
        {
            sounddata[i] = bytesToFloat(byteArray[i * 2], byteArray[i * 2 + 1]);
        }
        return sounddata;
    }

    static float bytesToFloat(byte firstByte, byte secondByte)
    {
        // convert two bytes to one short (little endian)
        //小端和大端顺序要调整
        short s;
        if (BitConverter.IsLittleEndian)
            s = (short)((secondByte << 8) | firstByte);
        else
            s = (short)((firstByte << 8) | secondByte);
        // convert to range from -1 to (just below) 1
        return s / 32768.0F;
    }

当每次获取并解析成功一段语音流是可以把获取的float[]放入到队列中准备播放。这时还需要语音流播放完毕判断并停止播放。我这里的逻辑是用一个变量记录语音流总长度,当接受到新的语音流数据或播放空帧音频是增加这个变量长度,再实时与播放长度做对比就可以了。不废话了合成的所有逻辑代码如下:

 public AudioSource 语音合成流播放器;
    ClientWebSocket 语音合成WebSocket;
    int 语音流总长度 = 0;
    Queue<float> 播放队列 = new Queue<float>();

    /// <summary>
    /// 开始语音合成
    /// </summary>
    /// <param name="s">需要合成的字符串</param>
    /// <param name="voice">发音人,默认是xiaoyan</param>
    /// <param name="speed">语速,范围是0~100,默认是50</param>
    /// <param name="volume">音量,范围是0~100,默认50</param>
    public IEnumerator 开始语音合成(String text, string voice = "xiaoyan", int speed = 50, int volume = 50)
    {
        if (语音合成WebSocket != null)
        {
            语音合成WebSocket.Abort();
        }
        if (语音合成流播放器==null)
        {       
            语音合成流播放器 = gameObject.AddComponent<AudioSource>();
        }
        语音合成流播放器.Stop();
        连接语音合成WebSocket(GetUrl("wss://tts-api.xfyun.cn/v2/tts"),text, voice,speed,volume);
        语音合成流播放器.loop = true;
        语音合成流播放器.clip = AudioClip.Create("语音合成流", 16000*60 , 1, 16000, true, OnAudioRead);//播放器循环播放采样率16000,最长播放时长60秒,如果合成语音更长可以设置一个必60足够大的数
        语音合成流播放器.Play();//播放语音流
        while (true)
        {
            yield return null;
            if (!语音合成流播放器.isPlaying || 语音合成WebSocket.State == WebSocketState.Aborted && 语音合成流播放器.timeSamples >= 语音流总长度)
            {
                Debug.Log(text+"语音流播放完毕!");
                语音合成流播放器.Stop();
                break;
            }
        }
    }
    void OnAudioRead(float[] data)
    {
        for (int i = 0; i < data.Length; i++)
        {
            if (播放队列.Count > 0)
            {
                data[i] = 播放队列.Dequeue();
            }
            else
            {
                if (语音合成WebSocket == null || 语音合成WebSocket.State != WebSocketState.Aborted) 语音流总长度++;
                data[i] = 0;
            }
        }
    }


    [Serializable]
    public class 合成data
    {
        [Serializable]
        public class Data
        {
            public int status;
            public string audio;
        }
        public Data data;
    }

 
     public async void 连接语音合成WebSocket(string urlStr, String text, string voice , int speed, int volume)
    {
        //ServicePointManager.SecurityProtocol = SecurityProtocolType.Ssl3;
        using (语音合成WebSocket = new ClientWebSocket())
        {
            CancellationToken ct = new CancellationToken();
            Uri url = new Uri(urlStr);
            await 语音合成WebSocket.ConnectAsync(url, ct);
            text = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
            string message = "{\"common\":{\"app_id\":\""+ APPID + "\"},\"business\":{\"vcn\":\""+ voice + "\",\"aue\":\"raw\",\"speed\":"+ speed + ",\"volume\":"+ volume + ",\"tte\":\"UTF8\"}," +
                "\"data\":{\"status\":2,\"text\":\""+ text + "\"}}";
            Debug.Log("发送消息:" + message);
            Debug.Log("连接成功");
            await 语音合成WebSocket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(message)), WebSocketMessageType.Binary, true, ct); //发送数据
            StringBuilder sb = new StringBuilder();
            播放队列.Clear();
            while (语音合成WebSocket.State == WebSocketState.Open)
            {
                var result = new byte[4096];
                await 语音合成WebSocket.ReceiveAsync(new ArraySegment<byte>(result), ct);//接受数据
                List<byte> list = new List<byte>(result); while (list[list.Count - 1] == 0x00) list.RemoveAt(list.Count - 1);//去除空字节  
                var str = Encoding.UTF8.GetString(list.ToArray());
                Debug.Log(str);
                sb.Append(str);
                if (str.EndsWith("}}"))
                {
                    合成data.Data data =JsonUtility.FromJson<合成data>(sb.ToString()).data;
                    Debug.Log("收到完整json数据:" + sb);
                    int status = data.status;
                    float[] fs = bytesToFloat(Convert.FromBase64String(data.audio));
                    语音流总长度 += fs.Length;
                    foreach (float f in fs) 播放队列.Enqueue(f);
                    sb.Clear();
                    if (status == 2)
                    {
                        语音合成WebSocket.Abort();
                        break;
                    }
                }
            }
        }
    }

以上代码只是一部分,demo项目多了语音评测功能,不过缺点就是不支持webgl平台,更详细内容可以下载原始项目:https://download.csdn.net/download/chunyu90225/12433898

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐