Files
workouts/Workouts/Speech/SpeechSettings.swift
T
rzen 41226545e3 Don't double-label voices whose name already carries the quality tier
Premium/enhanced voices ship with the tier in their name (e.g. "Ava
(Premium)"), so appending "— Premium" produced "Ava (Premium) — Premium"
in the voice picker. Move the label logic to SpeechSettings.displayLabel
and skip the suffix when the name already contains the tier (still
name-only for default voices). Deterministic string helper, unit-tested.

Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
2026-07-09 15:19:52 -04:00

132 lines
5.2 KiB
Swift

//
// 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"
}
}
/// The picker label for a voice: its name, with the quality tier appended only when it
/// adds information. Default voices get no suffix (labeling every one "Default" is noise),
/// and enhanced/premium voices whose name already carries the tier — e.g. "Ava (Premium)" —
/// aren't labeled twice.
static func displayLabel(name: String, quality: AVSpeechSynthesisVoiceQuality) -> String {
guard quality != .default else { return name }
let label = qualityLabel(quality)
return name.localizedCaseInsensitiveContains(label) ? name : "\(name)\(label)"
}
}
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>) -> Double {
min(max(self, range.lowerBound), range.upperBound)
}
}