參考資料 ----
大家應該都聽過 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" } }
* 微軟支援多種語音輸出格式,如 raw、ogg、mp3...,您可參考 SpeechSynthesisOutputFormat,以 音訊、網路連線 品質做為評估要點,選擇您喜好的格式;不過本例因採用即時輸出語音,要即時語音,就要採用 AudioTrack,所以只能播 Raw...Pcm,要播其他格式,就需要調整播放方式,例如:先轉出其他語音格式並存檔,然後再讀取音源檔來播放。
在程式碼播放格式為 Raw24Khz16BitMonoPcm,若改為 Raw16Khz16BitMonoPcm 則音調變高且速度變快
* 如果您有玩過黑膠唱片,就知道將唱機轉速變慢,聲音就會變低沈;而 AudioTrack 在 API22(含) 之前就是類似黑膠的效果, 但 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 小姐,倒是唸正確的。
沒有留言:
張貼留言