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

  1.  
  2.  
  3. ...
  4. ...
  5.  
  6. allprojects {
  7. repositories {
  8. google()
  9. mavenCentral()
  10. // 加這個
  11. maven {
  12. url 'https://csspeechstorage.blob.core.windows.net/maven/'
  13. }
  14. }
  15. }
  16.  


 


修改 app 層級的 build.gradle

  1.  
  2. ...
  3. ...
  4.  
  5. android {
  6. defaultConfig {
  7. ...
  8. ...
  9. minSdkVersion 23 // 注意要 23 以上
  10. ...
  11. ...
  12. }
  13. compileOptions {
  14. sourceCompatibility JavaVersion.VERSION_1_8
  15. targetCompatibility JavaVersion.VERSION_1_8
  16. }
  17. ...
  18. ...
  19. }
  20.  
  21. dependencies {
  22.  
  23. ...
  24. ...
  25. // Speech SDK
  26. implementation 'com.microsoft.cognitiveservices.speech:client-sdk:1.18.0'
  27. }
  28.  


AndroidManifest.xml
  1.  
  2. ...
  3. ...
  4.  
  5. <uses-permission android:name="android.permission.INTERNET" />
  6.  
  7. <application
  8. ...
  9. ...
  10.  
  11.  


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


activity_main.xml(這是簡化的 layout,重點只在表達放了這些元件)
  1.  
  2. ...
  3.  
  4. <Button
  5. android:id="@+id/btnSpeak"
  6. android:layout_width="wrap_content"
  7. android:layout_height="wrap_content"
  8. android:onClick="onbtnSpeakClicked"
  9. android:text="speak" />
  10.  
  11. <Button
  12. android:id="@+id/btnStop"
  13. android:layout_width="wrap_content"
  14. android:layout_height="wrap_content"
  15. android:onClick="onbtnStopClicked"
  16. android:text="stop" />
  17.  
  18. <EditText
  19. android:id="@+id/edtSpeech"
  20. android:layout_width="match_parent"
  21. android:layout_height="wrap_content"
  22. android:text="@string/tw" />
  23.  
  24. <TextView
  25. android:id="@+id/outputMessage"
  26. android:layout_width="wrap_content"
  27. android:layout_height="wrap_content"
  28. android:text="outputMessage" />
  29.  
  30. ...
  31.  


MainActivity.kt
  1.  
  2. ...
  3.  
  4. class MainActivity : AppCompatActivity()
  5. {
  6. private val TAG = "MainActivity"
  7. private var speechConfig: SpeechConfig? = null
  8. private var synthesizer: SpeechSynthesizer? = null
  9. private lateinit var connection: Connection
  10. private var audioTrack: AudioTrack? = null
  11. lateinit var outputMessage: TextView
  12. private var speakingRunnable: SpeakingRunnable? = null
  13. private var singleThreadExecutor: ExecutorService? = null
  14. private val synchronizedObj = Any()
  15. private var stopped = false
  16. override fun onCreate(savedInstanceState: Bundle?)
  17. {
  18. super.onCreate(savedInstanceState)
  19. setContentView(R.layout.activity_main)
  20.  
  21. // 請求取得網路授權
  22. val requestCode = 5 // Unique code for the permission request
  23. ActivityCompat.requestPermissions(this,
  24. arrayOf(Manifest.permission.INTERNET),
  25. requestCode
  26. )
  27.  
  28. singleThreadExecutor = Executors.newSingleThreadExecutor()
  29. speakingRunnable = SpeakingRunnable()
  30. outputMessage = findViewById(R.id.outputMessage)
  31. outputMessage.setMovementMethod(ScrollingMovementMethod())
  32. audioTrack = AudioTrack(AudioAttributes.Builder()
  33. .setUsage(AudioAttributes.USAGE_MEDIA)
  34. .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
  35. .build(),
  36. AudioFormat.Builder()
  37. .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
  38. .setSampleRate(24000) // 音調
  39. .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
  40. .build(),
  41. AudioTrack.getMinBufferSize(24000,
  42. AudioFormat.CHANNEL_OUT_MONO,
  43. AudioFormat.ENCODING_PCM_16BIT
  44. ) * 2,
  45. AudioTrack.MODE_STREAM,
  46. AudioManager.AUDIO_SESSION_ID_GENERATE
  47. )
  48. // 設定說話速度, 正常速度是 1
  49. val params = PlaybackParams()
  50. params.speed = 0.7.toFloat()
  51. audioTrack!!.playbackParams = params
  52.  
  53. // 設定音量, float: 0 ~ 1.0
  54. // audioTrack.setVolume((float) 0.01);
  55. }
  56. override fun onResume()
  57. {
  58. super.onResume()
  59. // 初始化
  60. CreateSynthesizer()
  61. // 連線 Azure 主機
  62. ConnectAzure()
  63. }
  64. override fun onDestroy()
  65. {
  66. super.onDestroy()
  67.  
  68. if (synthesizer != null)
  69. {
  70. synthesizer.close()
  71. connection.close()
  72. }
  73. if (speechConfig != null)
  74. {
  75. speechConfig.close()
  76. }
  77. if (audioTrack != null)
  78. {
  79. singleThreadExecutor!!.shutdownNow()
  80. audioTrack!!.flush()
  81. audioTrack!!.stop()
  82. audioTrack!!.release()
  83. }
  84. }
  85. // 初始化
  86. fun CreateSynthesizer()
  87. {
  88. if (synthesizer != null)
  89. {
  90. speechConfig!!.close()
  91. synthesizer!!.close()
  92. connection.close()
  93. }
  94.  
  95. Log.d(TAG, "初始化...")
  96. speechConfig = SpeechConfig.fromSubscription(speechSubscriptionKey, serviceRegion)
  97. // 使用較好的 24kHz 音質
  98. speechConfig!!.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm)
  99.  
  100. // 設定語音角色
  101. // 語音列表: https://aka.ms/csspeech/voicenames
  102. // AI, 合成的聲音更接近真人, 收費較高
  103. // 2022.08.30, 偶然發現...標準語音都被移除了 😢
  104. // speechConfig.setSpeechSynthesisVoiceName("en-US-GuyNeural");
  105. // speechConfig.setSpeechSynthesisVoiceName("en-US-JennyNeural");
  106. // speechConfig.setSpeechSynthesisVoiceName("en-US-AriaNeural");
  107. // speechConfig.setSpeechSynthesisVoiceName("zh-TW-HsiaoChenNeural"); // 曉臻
  108.  
  109. // 標準
  110. // speechConfig.setSpeechSynthesisVoiceName("en-US-ZiraRUS"); // 美, Zira
  111. // speechConfig.setSpeechSynthesisVoiceName("en-GB-HazelRUS"); // 英, Hazel
  112. // speechConfig.setSpeechSynthesisVoiceName("ja-JP-HarukaRUS"); // 曰, 春香, Haruka
  113. speechConfig!!.setSpeechSynthesisVoiceName("zh-TW-Yating") // 台灣, 雅婷, 也適用於大陸
  114. // speechConfig.setSpeechSynthesisVoiceName("zh-HK-Danny"); // 廣東話, Danny
  115. synthesizer = SpeechSynthesizer(speechConfig, null)
  116. connection = Connection.fromSpeechSynthesizer(synthesizer)
  117. val current = resources.configuration.locale
  118. connection.connected.addEventListener { o: Any?, e: ConnectionEventArgs? -> updateOutputMessage("Connection established.\n") }
  119. connection.disconnected.addEventListener { o: Any?, e: ConnectionEventArgs? -> updateOutputMessage("Disconnected.\n") }
  120. synthesizer!!.SynthesisStarted.addEventListener { o: Any?, e: SpeechSynthesisEventArgs -> updateOutputMessage(String.format(current,"Synthesis started. Result Id: %s.\n", e.result.resultId))
  121. e.close()
  122. }
  123. synthesizer!!.Synthesizing.addEventListener { o: Any?,
  124. e: SpeechSynthesisEventArgs -> updateOutputMessage(String.format(current, "Synthesizing. received %d bytes.\n", e.result.audioLength))
  125. e.close()
  126. }
  127. synthesizer!!.SynthesisCompleted.addEventListener { o: Any?, e: SpeechSynthesisEventArgs ->
  128. updateOutputMessage("Synthesis finished.\n")
  129. updateOutputMessage(""" First byte latency: ${e.result.properties.getProperty(PropertyId.SpeechServiceResponse_SynthesisFirstByteLatencyMs)} ms.""" )
  130. updateOutputMessage(""" Finish latency: ${e.result.properties.getProperty(PropertyId.SpeechServiceResponse_SynthesisFinishLatencyMs)} ms.""" )
  131. e.close()
  132. }
  133. synthesizer!!.SynthesisCanceled.addEventListener { o: Any?, e: SpeechSynthesisEventArgs ->
  134. val cancellationDetails = SpeechSynthesisCancellationDetails.fromResult(e.result).toString()
  135. updateOutputMessage(""" Error synthesizing. Result ID: ${e.result.resultId}. Error detail: ${System.lineSeparator()}$cancellationDetails${System.lineSeparator()}Did you update the subscription info? """.trimIndent(), true, true)
  136. e.close()
  137. }
  138. synthesizer!!.WordBoundary.addEventListener { o: Any?, e: SpeechSynthesisWordBoundaryEventArgs ->
  139. updateOutputMessage(String.format(current,
  140. "Word boundary. Text offset %d, length %d; audio offset %d ms.\n",
  141. e.textOffset,
  142. e.wordLength,
  143. e.audioOffset / 10000
  144. )
  145. )
  146. }
  147. }
  148. // 連線 Azure 主機
  149. fun ConnectAzure()
  150. {
  151. if (connection == null)
  152. {
  153. updateOutputMessage("Please initialize the speech synthesizer first\n", true, true)
  154. return
  155. }
  156. connection.openConnection(true)
  157. updateOutputMessage("Opening connection.\n")
  158. }
  159. // 開始播放
  160. fun onbtnSpeakClicked(vv: View?)
  161. {
  162. clearOutputMessage()
  163. if (synthesizer == null)
  164. {
  165. Log.d(TAG, "speech synthesizer not initialized")
  166. return
  167. }
  168. val speakText = findViewById<EditText>(R.id.edtSpeech)
  169. speakingRunnable!!.setContent(speakText.text.toString())
  170. singleThreadExecutor!!.execute(speakingRunnable)
  171. }
  172. // 停止播放
  173. fun onbtnStopClicked(vv: View?)
  174. {
  175. if (synthesizer == null)
  176. {
  177. Log.d(TAG, "speech synthesizer not initialized")
  178. return
  179. }
  180. stopSynthesizing()
  181. }
  182. private fun stopSynthesizing()
  183. {
  184. if (synthesizer != null)
  185. {
  186. synthesizer!!.StopSpeakingAsync()
  187. }
  188. if (audioTrack != null)
  189. {
  190. synchronized(synchronizedObj) { stopped = true }
  191. audioTrack!!.pause()
  192. audioTrack!!.flush()
  193. }
  194. }
  195. internal inner class SpeakingRunnable : Runnable
  196. {
  197. private var content: String? = null
  198. fun setContent(content: String?)
  199. {
  200. this.content = content
  201. }
  202.  
  203. override fun run()
  204. {
  205. try
  206. {
  207. audioTrack!!.play()
  208. synchronized(synchronizedObj)
  209. {
  210. stopped = false
  211. }
  212. val result = synthesizer!!.StartSpeakingTextAsync(content).get()
  213. val audioDataStream = AudioDataStream.fromResult(result)
  214.  
  215. // Set the chunk size to 50 ms. 24000 * 16 * 0.05 / 8 = 2400
  216. val buffer = ByteArray(2400)
  217. while (!stopped)
  218. {
  219. val len = audioDataStream.readData(buffer)
  220. if (len == 0L)
  221. {
  222. break
  223. }
  224. audioTrack!!.write(buffer, 0, len.toInt())
  225. }
  226. audioDataStream.close()
  227. }
  228. catch (ex: Exception)
  229. {
  230. Log.e("Speech Synthesis Demo", "unexpected " + ex.message)
  231. ex.printStackTrace()
  232. assert(false)
  233. }
  234. }
  235. }
  236. private fun updateOutputMessage(text: String)
  237. {
  238. updateOutputMessage(text, false, true)
  239. }
  240.  
  241. @Synchronized
  242. private fun updateOutputMessage(text: String, error: Boolean, append: Boolean)
  243. {
  244. runOnUiThread
  245. {
  246. if (append)
  247. {
  248. outputMessage.append(text)
  249. }
  250. else
  251. {
  252. outputMessage.text = text
  253. }
  254. if (error)
  255. {
  256. val spannableText = outputMessage.text as Spannable
  257. spannableText.setSpan(ForegroundColorSpan(Color.RED),
  258. spannableText.length - text.length,
  259. spannableText.length,
  260. 0
  261. )
  262. }
  263. }
  264. }
  265. private fun clearOutputMessage()
  266. {
  267. updateOutputMessage("", false, false)
  268. }
  269.  
  270. companion object
  271. {
  272. // 申請的金鑰
  273. private const val speechSubscriptionKey = "blablablablablablablablabla"
  274. // 申請的 Azure 主機所在區域
  275. private const val serviceRegion = "eastasia"
  276. }
  277.  
  278. }
  279.  


* 微軟支援多種語音輸出格式,如 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 小姐,倒是唸正確的。
不過我找不到可以向微軟反應的管道,如果有朋友知道的也請告知,謝謝。

沒有留言:

張貼留言