參考資料 ----
大家應該都聽過 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 小姐,倒是唸正確的。
沒有留言:
張貼留言