2021-08-25

【Kotlin】微軟的 文字轉換語音(Text To Speach, TTS)

參考資料 ----

文字轉換語音

語音服務的語言和語音支援

線上試用語音資源庫

免費試用語音服務

開始使用文字轉換語音

適用於語音服務的 Azure 訂用帳戶金鑰

Github 範例

cognitive-services-speech-sdk

 

大家應該都聽過 Google 小姐說話,老人家好奇微軟是否也有類似的服務 -- 果然也有 MS 小姐,而且不只小姐,還有先生喔!而且,還不只一位喔。😄

 

用 手機 或 電腦 的瀏覽器,到上面 "參考資料" 的網址,往下捲動,會看到標題 "透過這個示範應用程式 (採用 JavaScript SDK 建置) 試用文字轉換語音功能",下方的 "文字"  欄可以清除裡面預設的文字,輸入您想要 MS 小姐朗讀的內容。

 

右方 "語言" 預設是 Chinese(Taiwanese Mandarin),還可以切換到 Chinese(Mandarin, Simplified) 簡体中文Chinese(Cantonese, Traditional) 廣東話

 

"語音" 則有 HsiaoChen(Neutral) - 曉臻HsiaoYu(Neutral) - 曉雨YunJhe(Neutral) - 雲哲HanHan - 涵涵Yating - 雅婷Zhiwei - 志威,有 (Neutral) 的表示是以 AI 合成的,標榜聲音更自然,也因此收費較高(2021.08.12 定價表);不過不用担心,不論選擇哪一種語音,每個月有免費額度,超過才需要付費。

 

"說話風格",Taiwanese 語言沒得選說話風格,只有 General,目前只支援简体中文有多種風格。

 

"速度" 應該不需解釋

"音調" 若調低就會像男生,但不像 Google 小姐會變鴨嗓


調整好您的喜好後,按 "播放" 鍵,會有一小段時間的寂靜,猜測應該是系統正在合成語音中,在播放中變更設定是沒有作用的,必須先按 "停止" 再變更。但之後寫成 APP,並不會有網頁的延遲現象。

中英文混著唸大致上也還可以,英文不會怪腔怪調的。

變更 "語言" 或 玩了一陣子後,如果覺得怪怪的,就按一下瀏覽器的 "重新整理"。

 

我去複製了維基的龜兔賽跑,發現 "睡覺" 简体中文會唸 ㄕㄨㄟˋ  ㄐㄩㄝˊ,問了大陸的朋友後,確認這是简体中文的 bug

後來又想,會不會是正體字的關係?於是,我將 "睡覺" 轉成 簡體字 "睡觉",果然發音就正確了 ;我再讓繁體中文的語音去唸簡體字的"睡觉" ,不意外地,也是唸 ㄕㄨㄟˋ  ㄐㄩㄝˊ -- 原來 AI 並沒有把繁簡差異納入考量,以此類推,雖都是英語系國家,同個字可能有不一樣的發音,所以在為該國家(地區) 挑選語音角色時,還是儘量以微軟為該國(地區) 設計的角色為優先選擇。

 



因為要收費,所以您必須具有微軟 Azure 雲端服務帳戶,沒有的話,就去申請一個 -- 參考 免費試用語音服務


登入 Azure 首頁,按一下左上角的 "功能表"

 

點擊 "建立資源"



在 搜尋欄 輸入 speech



輸入相應的資料,有點詭異的是...老人家是第一次使用,但輸入 "名稱" 時卻一直回應說已存在,換了好幾個名字才通過。

"資源群組" 因為是第一次建,所以要按一下 "新建"



出現 "您的部署已完成",按一下 "前往資源"



點擊 "按一下這裡以管理金鑰"



按一下 "顯示金鑰",金鑰會以明碼顯示


有兩組金鑰,只要選 1 組即可

兩組的用意是:

當您想變更金鑰 1 時,新的金鑰並不會立即生效(可能要過 24 小時後),而您的 app、網站、... 提供的服務又不能中斷,這時就可以先改用金鑰 2



系統需求:

Android Studio 3.3 以上

Android 6.0(Marshmallow, API23) 以上


建立新專案

 

修改專案層級的 build.gradle

 

...
...

allprojects {
    repositories {
        google()
        mavenCentral()
        
        // 加這個
        maven {
            url 'https://csspeechstorage.blob.core.windows.net/maven/'
        }
    }
}
 


 


修改 app 層級的 build.gradle

 
...
...

android {
    
    defaultConfig {
        ...
        ...
        
        minSdkVersion 23    // 注意要 23 以上
        
        ...
        ...
    }
    
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    
    ...
    ...
}

dependencies {

    ...
    ...
    
    // Speech SDK
    implementation 'com.microsoft.cognitiveservices.speech:client-sdk:1.18.0'
}
 


AndroidManifest.xml
 
...
...

<uses-permission android:name="android.permission.INTERNET" />

<application
    ...
    ...

 


strings.xml
 
...

<string name="tw">有一位國王,他擁有一座美麗的花園,花園裡有一棵結著金蘋果的樹。國王派人每天清點樹上的蘋果有幾顆。</string>

...
 


activity_main.xml(這是簡化的 layout,重點只在表達放了這些元件)
 
...

<Button
    android:id="@+id/btnSpeak"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:onClick="onbtnSpeakClicked"
    android:text="speak" />

<Button
    android:id="@+id/btnStop"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:onClick="onbtnStopClicked"
    android:text="stop" />

<EditText
    android:id="@+id/edtSpeech"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@string/tw" />

<TextView
    android:id="@+id/outputMessage"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="outputMessage" />

...
 


MainActivity.kt
 
...

class MainActivity : AppCompatActivity() 
{
    private val TAG = "MainActivity"
    private var speechConfig: SpeechConfig? = null
    private var synthesizer: SpeechSynthesizer? = null
    private lateinit var connection: Connection
    private var audioTrack: AudioTrack? = null
    lateinit var outputMessage: TextView
    private var speakingRunnable: SpeakingRunnable? = null
    private var singleThreadExecutor: ExecutorService? = null
    private val synchronizedObj = Any()
    private var stopped = false
    
    
    override fun onCreate(savedInstanceState: Bundle?)
    {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 請求取得網路授權
        val requestCode = 5 // Unique code for the permission request
        ActivityCompat.requestPermissions(this,
                                            arrayOf(Manifest.permission.INTERNET),
                                            requestCode
                                        )

        singleThreadExecutor = Executors.newSingleThreadExecutor()
        speakingRunnable = SpeakingRunnable()
        outputMessage = findViewById(R.id.outputMessage)
        outputMessage.setMovementMethod(ScrollingMovementMethod())
        audioTrack = AudioTrack(AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .build(),
            AudioFormat.Builder()
                .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                .setSampleRate(24000) // 音調
                .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
                .build(),
            AudioTrack.getMinBufferSize(24000,
                AudioFormat.CHANNEL_OUT_MONO,
                AudioFormat.ENCODING_PCM_16BIT
            ) * 2,
            AudioTrack.MODE_STREAM,
            AudioManager.AUDIO_SESSION_ID_GENERATE
        )
        // 設定說話速度, 正常速度是 1
        val params = PlaybackParams()
        params.speed = 0.7.toFloat()
        audioTrack!!.playbackParams = params

        // 設定音量, float: 0 ~ 1.0
        // audioTrack.setVolume((float) 0.01);
    }
    
    
    override fun onResume()
    {
        super.onResume()
        
        // 初始化
        CreateSynthesizer()
        
        // 連線 Azure 主機
        ConnectAzure()
    }
        
    override fun onDestroy()
    {
        super.onDestroy()

        if (synthesizer != null)
        {
            synthesizer.close()
            connection.close()
        }
        if (speechConfig != null)
        {
            speechConfig.close()
        }
        if (audioTrack != null)
        {
            singleThreadExecutor!!.shutdownNow()
            audioTrack!!.flush()
            audioTrack!!.stop()
            audioTrack!!.release()
        }
    }
    
    
    // 初始化
    fun CreateSynthesizer()
    {
        if (synthesizer != null)
        {
            speechConfig!!.close()
            synthesizer!!.close()
            connection.close()
        }

        Log.d(TAG, "初始化...")
        speechConfig = SpeechConfig.fromSubscription(speechSubscriptionKey, serviceRegion)
        // 使用較好的 24kHz 音質
        speechConfig!!.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm)

        // 設定語音角色
        // 語音列表: https://aka.ms/csspeech/voicenames
        // AI, 合成的聲音更接近真人, 收費較高
        // 2022.08.30, 偶然發現...標準語音都被移除了 😢 
        // speechConfig.setSpeechSynthesisVoiceName("en-US-GuyNeural");
        // speechConfig.setSpeechSynthesisVoiceName("en-US-JennyNeural");
        // speechConfig.setSpeechSynthesisVoiceName("en-US-AriaNeural");
        // speechConfig.setSpeechSynthesisVoiceName("zh-TW-HsiaoChenNeural");  // 曉臻

        // 標準
        // speechConfig.setSpeechSynthesisVoiceName("en-US-ZiraRUS");   // 美, Zira
        // speechConfig.setSpeechSynthesisVoiceName("en-GB-HazelRUS");   // 英, Hazel
        // speechConfig.setSpeechSynthesisVoiceName("ja-JP-HarukaRUS");   // 曰, 春香, Haruka
        speechConfig!!.setSpeechSynthesisVoiceName("zh-TW-Yating") // 台灣, 雅婷, 也適用於大陸
        // speechConfig.setSpeechSynthesisVoiceName("zh-HK-Danny");   // 廣東話, Danny
        synthesizer = SpeechSynthesizer(speechConfig, null)
        connection = Connection.fromSpeechSynthesizer(synthesizer)
        val current = resources.configuration.locale
        connection.connected.addEventListener { o: Any?, e: ConnectionEventArgs? -> updateOutputMessage("Connection established.\n")    }
        connection.disconnected.addEventListener { o: Any?, e: ConnectionEventArgs? -> updateOutputMessage("Disconnected.\n")   }
        synthesizer!!.SynthesisStarted.addEventListener { o: Any?, e: SpeechSynthesisEventArgs -> updateOutputMessage(String.format(current,"Synthesis started. Result Id: %s.\n", e.result.resultId))
                                                          e.close()
                                                        }
        synthesizer!!.Synthesizing.addEventListener { o: Any?,
                                                    e: SpeechSynthesisEventArgs -> updateOutputMessage(String.format(current, "Synthesizing. received %d bytes.\n", e.result.audioLength))
                                                      e.close()
                                                    }
        synthesizer!!.SynthesisCompleted.addEventListener { o: Any?, e: SpeechSynthesisEventArgs ->
            updateOutputMessage("Synthesis finished.\n")
            updateOutputMessage("""	First byte latency: ${e.result.properties.getProperty(PropertyId.SpeechServiceResponse_SynthesisFirstByteLatencyMs)} ms.""" )
            updateOutputMessage("""	Finish latency: ${e.result.properties.getProperty(PropertyId.SpeechServiceResponse_SynthesisFinishLatencyMs)} ms.""" )
            e.close()
        }
        synthesizer!!.SynthesisCanceled.addEventListener { o: Any?, e: SpeechSynthesisEventArgs ->
            val cancellationDetails = SpeechSynthesisCancellationDetails.fromResult(e.result).toString()
            updateOutputMessage(""" Error synthesizing. Result ID: ${e.result.resultId}. Error detail: ${System.lineSeparator()}$cancellationDetails${System.lineSeparator()}Did you update the subscription info? """.trimIndent(), true, true)
            e.close()
        }
        synthesizer!!.WordBoundary.addEventListener { o: Any?, e: SpeechSynthesisWordBoundaryEventArgs ->
            updateOutputMessage(String.format(current, 
                                              "Word boundary. Text offset %d, length %d; audio offset %d ms.\n",
                                              e.textOffset,
                                              e.wordLength,
                                              e.audioOffset / 10000
                                            )
                               )
        }
    }
    
    
    // 連線 Azure 主機
    fun ConnectAzure()
    {
        if (connection == null)
        {
            updateOutputMessage("Please initialize the speech synthesizer first\n", true, true)
            return
        }
        connection.openConnection(true)
        updateOutputMessage("Opening connection.\n")
    }
    
    
    // 開始播放
    fun onbtnSpeakClicked(vv: View?)
    {
        clearOutputMessage()
        
        if (synthesizer == null)
        {
            Log.d(TAG, "speech synthesizer not initialized")
            return
        }
        val speakText = findViewById<EditText>(R.id.edtSpeech)
        speakingRunnable!!.setContent(speakText.text.toString())
        singleThreadExecutor!!.execute(speakingRunnable)
    }
    
    
    // 停止播放
    fun onbtnStopClicked(vv: View?)
    {
        if (synthesizer == null)
        {
            Log.d(TAG, "speech synthesizer not initialized")
            return
        }
        stopSynthesizing()
    }
    
    
    private fun stopSynthesizing()
    {
        if (synthesizer != null)
        {
            synthesizer!!.StopSpeakingAsync()
        }
        if (audioTrack != null)
        {
            synchronized(synchronizedObj) { stopped = true }
            audioTrack!!.pause()
            audioTrack!!.flush()
        }
    }
    
    
    internal inner class SpeakingRunnable : Runnable
    {
        private var content: String? = null
        fun setContent(content: String?)
        {
            this.content = content
        }

        override fun run()
        {
            try
            {
                audioTrack!!.play()
                synchronized(synchronizedObj)
                {
                    stopped = false
                }
                val result = synthesizer!!.StartSpeakingTextAsync(content).get()
                val audioDataStream = AudioDataStream.fromResult(result)

                // Set the chunk size to 50 ms. 24000 * 16 * 0.05 / 8 = 2400
                val buffer = ByteArray(2400)
                while (!stopped)
                {
                    val len = audioDataStream.readData(buffer)
                    if (len == 0L)
                    {
                        break
                    }
                    audioTrack!!.write(buffer, 0, len.toInt())
                }
                audioDataStream.close()
            }
            catch (ex: Exception)
            {
                Log.e("Speech Synthesis Demo", "unexpected " + ex.message)
                ex.printStackTrace()
                assert(false)
            }
        }
    }
    
    
    private fun updateOutputMessage(text: String)
    {
        updateOutputMessage(text, false, true)
    }

    
    @Synchronized
    private fun updateOutputMessage(text: String, error: Boolean, append: Boolean)
    {
        runOnUiThread 
        {
            if (append)
                {
                    outputMessage.append(text)
                }
            else
                {
                    outputMessage.text = text
                }
            if (error)
            {
                val spannableText = outputMessage.text as Spannable
                spannableText.setSpan(ForegroundColorSpan(Color.RED),
                                      spannableText.length - text.length,
                                      spannableText.length,
                                      0
                                     )
            }
        }
    }
    
    
    private fun clearOutputMessage()
    {
        updateOutputMessage("", false, false)
    }
    

    companion object
    {
        // 申請的金鑰
        private const val speechSubscriptionKey = "blablablablablablablablabla"
        // 申請的 Azure 主機所在區域
        private const val serviceRegion = "eastasia"
    }

}
 


* 微軟支援多種語音輸出格式,如 rawoggmp3...,您可參考 SpeechSynthesisOutputFormat,以 音訊、網路連線 品質做為評估要點,選擇您喜好的格式;不過本例因採用即時輸出語音,要即時語音,就要採用 AudioTrack,所以只能播 Raw...Pcm,要播其他格式,就需要調整播放方式,例如:先轉出其他語音格式並存檔,然後再讀取音源檔來播放。

在程式碼播放格式為 Raw24Khz16BitMonoPcm,若改為 Raw16Khz16BitMonoPcm 則音調變高且速度變快


* 如果您有玩過黑膠唱片,就知道將唱機轉速變慢,聲音就會變低沈;而 AudioTrackAPI22(含) 之前就是類似黑膠的效果, 但 API23 將速度放慢,聲調並不會變低沈,可能這也是微軟要求 minSdkVersion 23 的理由吧。

 

 * 似乎按 STOP 鈕後再按 SPEAK 鈕,就是從頭開始,沒辦法從停止處再接下去播放。 

目前想到的解法是:

因為是唸一段文章,中間必定會有標點符號,那就將一整段文章做分割(split) ,存入 List<String> 內,再逐個段落元素取出朗讀;但也不能切太小段,因為似乎也有提出請求的數量的限制,而且同一段文章,分割 段落 的 長短,微軟小姐的語調是有些差異的,目前踹出來的可行方式是以句點做為切割點,分段落切。

 

* 語音長度有限制,請參考 Text-to-Speech Quotas and limits per Speech resource ,若是長篇文章,要想辦法切成 10 分鐘內的片段。


然後,老人家又好奇了,微軟有 AI 語音,那 Google 有嗎?還真的有!而且 Amazon 也有,不過後兩者都沒有微軟的豐富,另外老人家覺得微軟的語音夠用了,就懶的再試了😁,有興趣的看倌就自行摸索吧。



2022.09.27
無意間在一段文章內聽到微軟小姐把 “成長率” 唸成 “ㄔㄥˊ  ㄔㄤˊ  ㄌㄩˋ”!! 並且我選的還是台灣的語音角色 -- 曉臻,這麼基本的詞唸錯還號稱 AI 技術,實在很不應該,反而要將字改成簡體中文 “成长率” 發音才會正確;去對照 Google 小姐,倒是唸正確的。
不過我找不到可以向微軟反應的管道,如果有朋友知道的也請告知,謝謝。

沒有留言:

張貼留言