diff --git a/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift b/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift index 0a031bf..f722c31 100644 --- a/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift +++ b/Enchanted/Extensions/AVSpeechSynthesisVoice+Extension.swift @@ -10,20 +10,23 @@ import AVFoundation extension AVSpeechSynthesisVoice { var prettyName: String { - let name = self.name - if name.lowercased().contains("default") || name.lowercased().contains("premium") || name.lowercased().contains("enhanced") { - return name - } - - let qualityString = { - switch self.quality.rawValue { - case 1: return "Default" - case 2: return "Enhanced" - case 3: return "Premium" - default: return "Unknown" - } - }() + guard quality == .enhanced || quality == .premium else { return name } + + let qualityString = quality.displayString + guard !name.lowercased().contains(qualityString.lowercased()) else { return name } return "\(name) (\(qualityString))" } } + +extension AVSpeechSynthesisVoiceQuality { + var displayString: String { + switch self { + case .default: return "Default" + case .enhanced: return "Enhanced" + case .premium: return "Premium" + @unknown default: + return "Unknown" + } + } +} diff --git a/Enchanted/Services/SpeechService.swift b/Enchanted/Services/SpeechService.swift index 9d5aff7..ee487e3 100644 --- a/Enchanted/Services/SpeechService.swift +++ b/Enchanted/Services/SpeechService.swift @@ -13,15 +13,15 @@ import SwiftUI class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { var onSpeechFinished: (() -> Void)? var onSpeechStart: (() -> Void)? - + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { onSpeechFinished?() } - + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { onSpeechStart?() } - + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didReceiveError error: Error, for utterance: AVSpeechUtterance, at characterIndex: UInt) { print("Speech synthesis error: \(error)") } @@ -31,35 +31,41 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { static let shared = SpeechSynthesizer() private let synthesizer = AVSpeechSynthesizer() private let delegate = SpeechSynthesizerDelegate() - + @Published var isSpeaking = false @Published var voices: [AVSpeechSynthesisVoice] = [] - + override init() { super.init() synthesizer.delegate = delegate fetchVoices() } - + + static func systemDefaultVoiceIdentifier() -> String { + // Type system says this might be nil, but documentation says we'll receive + // the default voice for the system's language & region + return AVSpeechSynthesisVoice(language: nil)?.identifier ?? "" + } + func getVoiceIdentifier() -> String? { let voiceIdentifier = UserDefaults.standard.string(forKey: "voiceIdentifier") if let voice = voices.first(where: {$0.identifier == voiceIdentifier}) { return voice.identifier } - - return voices.first?.identifier + + return SpeechSynthesizer.systemDefaultVoiceIdentifier() } - + var lastCancelation: (()->Void)? = {} - + func speak(text: String, onFinished: @escaping () -> Void = {}) async { guard let voiceIdentifier = getVoiceIdentifier() else { print("could not find identifier") return } - + print("selected", voiceIdentifier) - + #if os(iOS) let audioSession = AVAudioSession() do { @@ -69,7 +75,7 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { print("❓", error.localizedDescription) } #endif - + lastCancelation = onFinished delegate.onSpeechFinished = { withAnimation { @@ -82,18 +88,18 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { self.isSpeaking = true } } - + let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier) utterance.rate = 0.5 synthesizer.speak(utterance) - + let voices = AVSpeechSynthesisVoice.speechVoices() voices.forEach { voice in print("\(voice.identifier) - \(voice.name)") } } - + func stopSpeaking() async { withAnimation { isSpeaking = false @@ -101,19 +107,33 @@ class SpeechSynthesizerDelegate: NSObject, AVSpeechSynthesizerDelegate { lastCancelation?() synthesizer.stopSpeaking(at: .immediate) } - - + + func fetchVoices() { - let voices = AVSpeechSynthesisVoice.speechVoices().sorted { (firstVoice: AVSpeechSynthesisVoice, secondVoice: AVSpeechSynthesisVoice) -> Bool in - return firstVoice.quality.rawValue > secondVoice.quality.rawValue - } - + let currentLanguage: String = AVSpeechSynthesisVoice.currentLanguageCode() + let voicesByLanguage = Dictionary(grouping: AVSpeechSynthesisVoice.speechVoices(), by: \.language) + // Filter the list to only include voices for the current language + // example language codes: en-US, en-GB, fr-FR + .filter { $0.key.prefix(2) == currentLanguage.prefix(2) } + + let voices = voicesByLanguage.values.reduce( + // Start with all voices that exactly match current language & locale + into: voicesByLanguage[currentLanguage, default: []]) { result, voices in + // add one instance of other voices that match the language, uniquing by name & quality + for voice in voices { + if !result.contains(where: { otherVoice in otherVoice.name == voice.name && otherVoice.quality == voice.quality }) { + result.append(voice) + } + } + } + .sorted { $0.prettyName.localizedStandardCompare($1.prettyName) == .orderedAscending } + /// prevent state refresh if there are no new elements let diff = self.voices.elementsEqual(voices, by: { $0.identifier == $1.identifier }) if diff { return } - + DispatchQueue.main.async { self.voices = voices } diff --git a/Enchanted/UI/Shared/Settings/Settings.swift b/Enchanted/UI/Shared/Settings/Settings.swift index 310ec4e..ea78aca 100644 --- a/Enchanted/UI/Shared/Settings/Settings.swift +++ b/Enchanted/UI/Shared/Settings/Settings.swift @@ -21,8 +21,8 @@ struct Settings: View { @AppStorage("ollamaBearerToken") private var ollamaBearerToken: String = "" @AppStorage("appUserInitials") private var appUserInitials: String = "" @AppStorage("pingInterval") private var pingInterval: String = "5" - @AppStorage("voiceIdentifier") private var voiceIdentifier: String = "" - + @AppStorage("voiceIdentifier") private var voiceIdentifier: String = SpeechSynthesizer.systemDefaultVoiceIdentifier() + @StateObject private var speechSynthesiser = SpeechSynthesizer.shared @Environment(\.presentationMode) var presentationMode