// // SpeechSettings.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import AVFoundation import Foundation /// Shared configuration for spoken instructions. The Settings UI writes these via /// `@AppStorage` on the same keys, and `SpeechAnnouncer` reads them from `UserDefaults` /// at speak time — one source of truth, so the two never drift. Voice selection targets /// English, since the exercise library is authored in English regardless of the device /// language (a French voice reading English cues mispronounces everything). enum SpeechSettings { // UserDefaults keys — used verbatim as the `@AppStorage` keys in Settings. static let voiceIdentifierKey = "speechVoiceIdentifier" static let rateKey = "speechRate" static let pitchKey = "speechPitch" static let volumeKey = "speechVolume" // Defaults, shared so a missing key resolves identically in the UI and the announcer. static let rateDefault = Double(AVSpeechUtteranceDefaultSpeechRate) static let pitchDefault = 1.0 static let volumeDefault = 1.0 // Slider ranges (also used to clamp what the announcer reads). static let rateRange = Double(AVSpeechUtteranceMinimumSpeechRate)...Double(AVSpeechUtteranceMaximumSpeechRate) static let pitchRange = 0.5...2.0 static let volumeRange = 0.0...1.0 /// The exercise library is English; match voices to the content, not the device locale. private static let languagePrefix = "en" // MARK: - Current values (read by the announcer) static var rate: Float { let v = UserDefaults.standard.object(forKey: rateKey) as? Double ?? rateDefault return Float(v.clamped(to: rateRange)) } static var pitch: Float { let v = UserDefaults.standard.object(forKey: pitchKey) as? Double ?? pitchDefault return Float(v.clamped(to: pitchRange)) } static var volume: Float { let v = UserDefaults.standard.object(forKey: volumeKey) as? Double ?? volumeDefault return Float(v.clamped(to: volumeRange)) } /// The stored voice identifier, or `nil` for automatic best-available selection. static var voiceIdentifier: String? { let id = UserDefaults.standard.string(forKey: voiceIdentifierKey) ?? "" return id.isEmpty ? nil : id } // MARK: - Voice resolution /// The voice to speak with: the user's stored choice if it's still installed, otherwise /// the best available (an enhanced/premium English voice if one is downloaded, else the /// system default English voice). static func resolvedVoice() -> AVSpeechSynthesisVoice? { if let id = voiceIdentifier, let voice = AVSpeechSynthesisVoice(identifier: id) { return voice } return bestAvailableVoice() } /// English voices offered in the Settings picker, best quality first. static func availableVoices() -> [AVSpeechSynthesisVoice] { AVSpeechSynthesisVoice.speechVoices() .filter { $0.language.hasPrefix(languagePrefix) } .sorted { a, b in if a.quality.rank != b.quality.rank { return a.quality.rank > b.quality.rank } if a.name != b.name { return a.name < b.name } return a.language < b.language } } /// Prefer a downloaded enhanced/premium English voice; only such an upgrade is worth /// overriding the system default, which otherwise reads cues perfectly acceptably. static func bestAvailableVoice() -> AVSpeechSynthesisVoice? { let voices = availableVoices() if let upgraded = voices.first(where: { $0.quality.rank >= AVSpeechSynthesisVoiceQuality.enhanced.rank }) { return upgraded } let code = AVSpeechSynthesisVoice.currentLanguageCode() let language = code.hasPrefix(languagePrefix) ? code : "en-US" return AVSpeechSynthesisVoice(language: language) ?? voices.first } /// Human label for a voice's quality tier, shown next to its name in the picker. static func qualityLabel(_ quality: AVSpeechSynthesisVoiceQuality) -> String { switch quality { case .premium: "Premium" case .enhanced: "Enhanced" case .default: "Default" @unknown default: "Default" } } } private extension AVSpeechSynthesisVoiceQuality { /// Orders the quality tiers for "best available" selection and picker sorting. var rank: Int { switch self { case .premium: 3 case .enhanced: 2 case .default: 1 @unknown default: 0 } } } private extension Double { func clamped(to range: ClosedRange) -> Double { min(max(self, range.lowerBound), range.upperBound) } }