前言

    讯飞语音是国内的智能语音前沿,有语音合成及语音识别还有一些其他高级的语音服务。前面已经写过一篇unity中使用在线语音的方式,不过由于是国外的网站不稳定,速度相对也慢,目前貌似已经不能用了。所以看好讯飞在线免费使用的优点,同时也看靠c++的而不是直接通过http请求的方式,在这里简要分享下在unity3d开发环境下,基于windows平台的在线语音生成。值得说明的讯飞的语音选择面广,还支持部分方言!但是对于创业型公司来说,离线版本的收费还是比较昂贵的,也是为什么本文只会涉及在线语音生成。

准备SDK

    在讯飞语音官方网站(http://www.xfyun.cn/),可以下载到最新的SDK,不过要先注册,而注册后创建应用后生成的apiKey就是使用这些SDK的钥匙。解压后,应该能看到doc文件夹,如果你精通c语言,同时也熟练使用C#调用C的库,那么这篇文字对你意义不大,你需要的只是马上查看doc中的api去写在c#中实现c的接口了。当然因为本人对调用c的dll不是很熟练,所以才想把一些遇到的小问题记录下来,防止和我一样不熟悉的人会卡壳。在simple文件夹中有一些官方的demo,都是c写的,直接调用在bin中的msc.dll中的方法。在往下阅读之前,你或许可以先去学习下官方的例子。

提取接口

    查看doc中的iFlytek MSC Reference Manual.html网页,能直接看到msc.dll中所有的api。如果你只关心语音生成,那么我们直接跳到qtts.h部分,这里面一共就只有5个接口,也就是说我们的工作量并不大,只需要搞清楚这几个接口就好了。

Android 讯飞语音合成语音播报 讯飞生成语音_错误代码

将这些接口转换到C#中,应该看起来是这样的:

[DllImport(mscdll, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        public static extern IntPtr QTTSSessionBegin(string _params, ref int errorCode);

        [DllImport(mscdll, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)]
        public static extern int QTTSTextPut(string sessionID, string textString, uint textLen, string _params);

        [DllImport(mscdll, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)]
        public static extern IntPtr QTTSAudioGet(string sessionID, ref int audioLen, ref SynthStatus synthStatus, ref int errorCode);

        [DllImport(mscdll, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)]
        public static extern int QTTSSessionEnd(string sessionID, string hints);

        [DllImport(mscdll, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)]
        public static extern int QTTSGetParam(string sessionID, string paramName, string paramValue, ref uint valueLen);



值得注意的是,因为涉及到中文章

QTTSTextPut

接口中CharSet必须要设置为

CharSet.Unicode

这是因为,如果在C#中调用dll,如果不指定的话,默认应该就传入Ascii码了,这样就会出现接收端为乱码的问题。


调用接口

    在使用这几个接口的时候,可以参考官方demo中的tts.c文件,而这里实现的逻辑也和其中相差无几,唯一的区别就是,用的是C#无言也调用那几个接口。要说明的是本人很久没有用c++和c了,所以下面这段代码也不算原创,只是其中部分功能稍微调整了一下。

public void Speak(string speekText, string szParams, string outWaveFlie)
        {
            byte[] bytes = null;
            int ret = 0;
            try
            {
                sessionID = Marshal.PtrToStringAuto(MSPAPI.QTTSSessionBegin(szParams, ref ret));
                if (ret != 0)
                {
                    if (ttsSpeakErrorEvent != null) ttsSpeakErrorEvent.Invoke("初始化TTS引会话错误,错误代码:" + ret);
                    return;
                }
                ret = MSPAPI.QTTSTextPut(sessionID, speekText, (uint)Encoding.Unicode.GetByteCount(speekText), string.Empty);
                if (ret != 0)
                {
                    if (ttsSpeakErrorEvent != null) ttsSpeakErrorEvent.Invoke("向服务器发送数据,错误代码:" + ret);
                    return;
                }
                IntPtr audio_data;
                int audio_len = 0;
                SynthStatus synth_status = SynthStatus.MSP_TTS_FLAG_STILL_HAVE_DATA;
                using (MemoryStream ms = new MemoryStream())
                {
                    ms.Write(new byte[44], 0, 44);
                    //写44字节的空文件头
                    while (synth_status == SynthStatus.MSP_TTS_FLAG_STILL_HAVE_DATA)
                    {
                        audio_data = MSPAPI.QTTSAudioGet(sessionID, ref audio_len, ref synth_status, ref ret);
                        if (audio_data != IntPtr.Zero)
                        {
                            byte[] data = new byte[audio_len];
                            Marshal.Copy(audio_data, data, 0, audio_len);
                            ms.Write(data, 0, data.Length);
                            if (synth_status == SynthStatus.MSP_TTS_FLAG_DATA_END || ret != 0)
                            {
                                if (ret != 0)
                                {
                                    if (ttsSpeakErrorEvent != null) ttsSpeakErrorEvent.Invoke("下载TTS文件错误,错误代码:" + ret);
                                    return;
                                }
                                break;
                            }
                        }
                        Thread.Sleep(150);
                    }
                    System.Diagnostics.Debug.WriteLine("wav header");
                    WAVE_Header header = getWave_Header((int)ms.Length - 44);     //创建wav文件头
                    byte[] headerByte = StructToBytes(header);                         //把文件头结构转化为字节数组                      //写入文件头
                    ms.Position = 0;                                                        //定位到文件头
                    ms.Write(headerByte, 0, headerByte.Length);                             //写入文件头
                    bytes = ms.ToArray();
                    ms.Close();
                }

                if (outWaveFlie != null)
                {
                    if (File.Exists(outWaveFlie))
                    {
                        File.Delete(outWaveFlie);
                    }
                    File.WriteAllBytes(outWaveFlie, bytes);
                }
            }
            catch (Exception ex)
            {
                if (ttsSpeakErrorEvent != null) ttsSpeakErrorEvent.Invoke("Error:" + ex.Message);
                return;
            }
            finally
            {
                ret = MSPAPI.QTTSSessionEnd(sessionID, "");
                if (ret != 0)
                {
                    if (ttsSpeakErrorEvent != null) ttsSpeakErrorEvent.Invoke("结束TTS会话错误,错误代码:" + ret);
                }
                else
                {
                    if (tts_SpeakFinishedEvent != null) tts_SpeakFinishedEvent.Invoke(speekText, bytes);
                }
            }
        }

其中要注意的一点是,因为c的dll中返回char*,在c#中,接收到的只能是指针,所以用了一个指针转换为字符串的方法:

Marshal.PtrToStringAuto

在对于字符串的长度问题,也没有直接使用string.Length,而是使用

(uint)Encoding.Unicode.GetByteCount(speekText)

封装为Unity模块

    在上面已经实现了文字转语音功能的基础上,可以将这些功能封装为unity主线程中可以直接调用的一个模块,便于程序的使用。和前面一篇文章实现的接口是一样的,只是添加了一个批量下载的功能。当然你也可以自己去封装,毕竟这里的功能未必适合你的项目。下面是封装后预留的接口:

public interface ITextToAudio
    {
        event UnityAction<string> onError;
        IEnumerator GetAudioClip(string text, UnityAction<AudioClip> OnGet, Params param = null);
        IEnumerator Downland(string[] text,UnityAction<float> onProgressChanged ,Params param = null);
        void CleanUpCatchs();
    }

其中,下载的时候都是使用协程,可以利用WWW直接将得到的AudioClip返回回来(值得注意的是,如果你的音频足够了解应该可以直接从byte中创建audioClip,就没有必要使用www了,但目前也就暂时这样使用着)。而Params就是对官方参数的解析类,可以自行定义。基于官方的字符串结构,这样生成比较理想(直接重写ToString):

public override string ToString()
    {
        var fields = typeof(Params).GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.GetField | System.Reflection.BindingFlags.Instance);
        var param = new string[fields.Length];
        for (int i = 0; i < fields.Length; i++)
        {
            param[i] = fields[i].Name + "=" + fields[i].GetValue(this);
        }
        return string.Join(",",param);
    }