99 lines
3.1 KiB
Swift
99 lines
3.1 KiB
Swift
// (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
|
||
import Shared
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|