From c85e7142fcf8089808c96634fe21326d02b188f0 Mon Sep 17 00:00:00 2001 From: ShikiSuen Date: Fri, 5 Jan 2024 20:33:20 +0800 Subject: [PATCH] Repo // Implement "ReadingNarrationCoverage" feature. --- .../Sources/MainAssembly/AppDelegate.swift | 2 + .../InputHandler_HandleComposition.swift | 11 +++ .../Sources/MainAssembly/PrefMgr_Core.swift | 6 +- .../Sources/MainAssembly/SpeechSputnik.swift | 97 +++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 Packages/vChewing_MainAssembly/Sources/MainAssembly/SpeechSputnik.swift diff --git a/Packages/vChewing_MainAssembly/Sources/MainAssembly/AppDelegate.swift b/Packages/vChewing_MainAssembly/Sources/MainAssembly/AppDelegate.swift index 2733b490..6955b9b5 100644 --- a/Packages/vChewing_MainAssembly/Sources/MainAssembly/AppDelegate.swift +++ b/Packages/vChewing_MainAssembly/Sources/MainAssembly/AppDelegate.swift @@ -68,6 +68,8 @@ public extension AppDelegate { SecurityAgentHelper.shared.timer?.fire() + SpeechSputnik.shared.refreshStatus() // 根據現狀條件決定是否初期化語音引擎。 + // 一旦發現與使用者半衰模組的觀察行為有關的崩潰標記被開啟: // 如果有開啟 Debug 模式的話,就將既有的半衰記憶資料檔案更名+打上當時的時間戳。 // 如果沒有開啟 Debug 模式的話,則將半衰記憶資料直接清空。 diff --git a/Packages/vChewing_MainAssembly/Sources/MainAssembly/InputHandler_HandleComposition.swift b/Packages/vChewing_MainAssembly/Sources/MainAssembly/InputHandler_HandleComposition.swift index 1d94d71d..c08f86b0 100644 --- a/Packages/vChewing_MainAssembly/Sources/MainAssembly/InputHandler_HandleComposition.swift +++ b/Packages/vChewing_MainAssembly/Sources/MainAssembly/InputHandler_HandleComposition.swift @@ -67,6 +67,14 @@ extension InputHandler { || input.isControlHold || input.isOptionHold || input.isShiftHold || input.isCommandHold let confirmCombination = input.isSpace || input.isEnter + func narrateTheComposer(with maybeKey: String? = nil, when condition: Bool, allowDuplicates: Bool = true) { + guard condition else { return } + let maybeKey = maybeKey ?? composer.phonabetKeyForQuery(pronouncable: prefs.acceptLeadingIntonations) + guard var keyToNarrate = maybeKey else { return } + if composer.intonation == Tekkon.Phonabet(" ") { keyToNarrate.append("ˉ") } + SpeechSputnik.shared.narrate(keyToNarrate, allowDuplicates: allowDuplicates) + } + // 這裡 inputValidityCheck() 是讓注拼槽檢查 charCode 這個 UniChar 是否是合法的注音輸入。 // 如果是的話,就將這次傳入的這個按鍵訊號塞入注拼槽內且標記為「keyConsumedByReading」。 // 函式 composer.receiveKey() 可以既接收 String 又接收 UniChar。 @@ -100,6 +108,7 @@ extension InputHandler { // 鐵恨引擎並不具備對 Enter (CR / LF) 鍵的具體判斷能力,所以在這裡單獨處理。 composer.receiveKey(fromString: confirmCombination ? " " : inputText) keyConsumedByReading = true + narrateTheComposer(when: !overrideHappened && prefs.readingNarrationCoverage >= 2, allowDuplicates: false) // 沒有調號的話,只需要 setInlineDisplayWithCursor() 且終止處理(return true)即可。 // 有調號的話,則不需要這樣,而是轉而繼續在此之後的處理。 @@ -150,6 +159,8 @@ extension InputHandler { } else if !compositor.insertKey(readingKey) { delegate.callError("3CF278C9: 得檢查對應的語言模組的 hasUnigramsFor() 是否有誤判之情形。") return true + } else { + narrateTheComposer(with: readingKey, when: prefs.readingNarrationCoverage == 1) } // 讓組字器反爬軌格。 diff --git a/Packages/vChewing_MainAssembly/Sources/MainAssembly/PrefMgr_Core.swift b/Packages/vChewing_MainAssembly/Sources/MainAssembly/PrefMgr_Core.swift index 4c406d25..e76ca22a 100644 --- a/Packages/vChewing_MainAssembly/Sources/MainAssembly/PrefMgr_Core.swift +++ b/Packages/vChewing_MainAssembly/Sources/MainAssembly/PrefMgr_Core.swift @@ -128,7 +128,11 @@ import SwiftExtension public dynamic var autoCorrectReadingCombination: Bool @AppProperty(key: UserDef.kReadingNarrationCoverage.rawValue, defaultValue: 0) - public dynamic var readingNarrationCoverage: Int + public dynamic var readingNarrationCoverage: Int { + didSet { + SpeechSputnik.shared.refreshStatus() + } + } @AppProperty(key: UserDef.kAlsoConfirmAssociatedCandidatesByEnter.rawValue, defaultValue: false) public dynamic var alsoConfirmAssociatedCandidatesByEnter: Bool diff --git a/Packages/vChewing_MainAssembly/Sources/MainAssembly/SpeechSputnik.swift b/Packages/vChewing_MainAssembly/Sources/MainAssembly/SpeechSputnik.swift new file mode 100644 index 00000000..867854e7 --- /dev/null +++ b/Packages/vChewing_MainAssembly/Sources/MainAssembly/SpeechSputnik.swift @@ -0,0 +1,97 @@ +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import AppKit +import AVFoundation + +public class SpeechSputnik { + public static var shared: SpeechSputnik = .init() + private static var tags: [String] = ["ting-ting", "zh-CN", "mei-jia", "zh-TW"] + private var currentNarrator: NSObject? + private var currentVoice: NSObject? + + public func refreshStatus() { + switch PrefMgr.shared.readingNarrationCoverage { + case 1, 2: narrate(" ") // 讓語音引擎提前預熱。 + default: clear() + } + } + + private func clear() { + currentNarrator = nil + currentVoice = nil + previouslyNarrated = "" + } + + private var narrator: NSObject? { + get { + currentNarrator = currentNarrator ?? generateNarrator() + return currentNarrator + } + set { + currentNarrator = newValue + } + } + + private var voiceSpecified: NSObject? { + get { + currentVoice = currentVoice ?? generateVoice() + return currentVoice + } + set { + currentVoice = newValue + } + } + + private lazy var previouslyNarrated: String = "" +} + +// MARK: - Generators. + +extension SpeechSputnik { + private func generateNarrator() -> NSObject? { + guard #unavailable(macOS 14) else { return AVSpeechSynthesizer() } + let voice = NSSpeechSynthesizer.availableVoices.first { + // 這裡用 zh-CN 是因為 zh-TW 觸發的 voice 無法連讀某些注音。 + SpeechSputnik.tags.isOverlapped(with: $0.rawValue.components(separatedBy: ".")) + } + guard let voice = voice else { return nil } + let result = NSSpeechSynthesizer(voice: voice) + result?.rate = 90 + return result + } + + private func generateVoice() -> NSObject? { + guard #available(macOS 14, *) else { return nil } + // 這裡用 zh-CN 是因為 zh-TW 觸發的 voice 無法連讀某些注音。 + return AVSpeechSynthesisVoice(identifier: "com.apple.voice.compact.zh-CN.Binbin") + ?? AVSpeechSynthesisVoice(identifier: "com.apple.voice.compact.zh-CN.Tingting") + ?? .speechVoices().first { + $0.identifier.contains("Tingting") || $0.language.contains("zh-CN") || $0.language.contains("zh-TW") + } + } +} + +// MARK: - Public API. + +public extension SpeechSputnik { + func narrate(_ text: String, allowDuplicates: Bool = true) { + defer { previouslyNarrated = text } + guard !(!allowDuplicates && previouslyNarrated == text) else { return } + if #available(macOS 14, *) { + let utterance = AVSpeechUtterance(string: text) + utterance.voice = voiceSpecified as? AVSpeechSynthesisVoice ?? utterance.voice + utterance.rate = 0.55 + (narrator as? AVSpeechSynthesizer)?.stopSpeaking(at: .immediate) + (narrator as? AVSpeechSynthesizer)?.speak(utterance) + } else { + (narrator as? NSSpeechSynthesizer)?.stopSpeaking(at: .immediateBoundary) + (narrator as? NSSpeechSynthesizer)?.startSpeaking(text) + } + } +}