Repo // Implement "ReadingNarrationCoverage" feature.
This commit is contained in:
parent
2a694081bd
commit
c85e7142fc
|
@ -68,6 +68,8 @@ public extension AppDelegate {
|
||||||
|
|
||||||
SecurityAgentHelper.shared.timer?.fire()
|
SecurityAgentHelper.shared.timer?.fire()
|
||||||
|
|
||||||
|
SpeechSputnik.shared.refreshStatus() // 根據現狀條件決定是否初期化語音引擎。
|
||||||
|
|
||||||
// 一旦發現與使用者半衰模組的觀察行為有關的崩潰標記被開啟:
|
// 一旦發現與使用者半衰模組的觀察行為有關的崩潰標記被開啟:
|
||||||
// 如果有開啟 Debug 模式的話,就將既有的半衰記憶資料檔案更名+打上當時的時間戳。
|
// 如果有開啟 Debug 模式的話,就將既有的半衰記憶資料檔案更名+打上當時的時間戳。
|
||||||
// 如果沒有開啟 Debug 模式的話,則將半衰記憶資料直接清空。
|
// 如果沒有開啟 Debug 模式的話,則將半衰記憶資料直接清空。
|
||||||
|
|
|
@ -67,6 +67,14 @@ extension InputHandler {
|
||||||
|| input.isControlHold || input.isOptionHold || input.isShiftHold || input.isCommandHold
|
|| input.isControlHold || input.isOptionHold || input.isShiftHold || input.isCommandHold
|
||||||
let confirmCombination = input.isSpace || input.isEnter
|
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 是否是合法的注音輸入。
|
// 這裡 inputValidityCheck() 是讓注拼槽檢查 charCode 這個 UniChar 是否是合法的注音輸入。
|
||||||
// 如果是的話,就將這次傳入的這個按鍵訊號塞入注拼槽內且標記為「keyConsumedByReading」。
|
// 如果是的話,就將這次傳入的這個按鍵訊號塞入注拼槽內且標記為「keyConsumedByReading」。
|
||||||
// 函式 composer.receiveKey() 可以既接收 String 又接收 UniChar。
|
// 函式 composer.receiveKey() 可以既接收 String 又接收 UniChar。
|
||||||
|
@ -100,6 +108,7 @@ extension InputHandler {
|
||||||
// 鐵恨引擎並不具備對 Enter (CR / LF) 鍵的具體判斷能力,所以在這裡單獨處理。
|
// 鐵恨引擎並不具備對 Enter (CR / LF) 鍵的具體判斷能力,所以在這裡單獨處理。
|
||||||
composer.receiveKey(fromString: confirmCombination ? " " : inputText)
|
composer.receiveKey(fromString: confirmCombination ? " " : inputText)
|
||||||
keyConsumedByReading = true
|
keyConsumedByReading = true
|
||||||
|
narrateTheComposer(when: !overrideHappened && prefs.readingNarrationCoverage >= 2, allowDuplicates: false)
|
||||||
|
|
||||||
// 沒有調號的話,只需要 setInlineDisplayWithCursor() 且終止處理(return true)即可。
|
// 沒有調號的話,只需要 setInlineDisplayWithCursor() 且終止處理(return true)即可。
|
||||||
// 有調號的話,則不需要這樣,而是轉而繼續在此之後的處理。
|
// 有調號的話,則不需要這樣,而是轉而繼續在此之後的處理。
|
||||||
|
@ -150,6 +159,8 @@ extension InputHandler {
|
||||||
} else if !compositor.insertKey(readingKey) {
|
} else if !compositor.insertKey(readingKey) {
|
||||||
delegate.callError("3CF278C9: 得檢查對應的語言模組的 hasUnigramsFor() 是否有誤判之情形。")
|
delegate.callError("3CF278C9: 得檢查對應的語言模組的 hasUnigramsFor() 是否有誤判之情形。")
|
||||||
return true
|
return true
|
||||||
|
} else {
|
||||||
|
narrateTheComposer(with: readingKey, when: prefs.readingNarrationCoverage == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 讓組字器反爬軌格。
|
// 讓組字器反爬軌格。
|
||||||
|
|
|
@ -128,7 +128,11 @@ import SwiftExtension
|
||||||
public dynamic var autoCorrectReadingCombination: Bool
|
public dynamic var autoCorrectReadingCombination: Bool
|
||||||
|
|
||||||
@AppProperty(key: UserDef.kReadingNarrationCoverage.rawValue, defaultValue: 0)
|
@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)
|
@AppProperty(key: UserDef.kAlsoConfirmAssociatedCandidatesByEnter.rawValue, defaultValue: false)
|
||||||
public dynamic var alsoConfirmAssociatedCandidatesByEnter: Bool
|
public dynamic var alsoConfirmAssociatedCandidatesByEnter: Bool
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue