The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
161 lines
6.4 KiB
Swift
161 lines
6.4 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)"
|
|
}
|
|
}
|
|
|
|
/// What the automatic workout narration says while a flow-mode routine runs, chosen in
|
|
/// Settings (only meaningful when "Speak Exercise Cues" is on). Stored as its raw string
|
|
/// under `spokenCueStyle`, read by both the Settings picker and the run flow.
|
|
enum SpokenCueStyle: String, CaseIterable, Identifiable {
|
|
/// "Coming up, <exercise>" during the rest, then "in 1, 2, 3, GO!" before each set.
|
|
case cues
|
|
/// Reads each exercise's setup & form script aloud when it starts (the original behavior).
|
|
case instructions
|
|
/// Both — the concise coming-up/countdown cues plus the setup & form read.
|
|
case both
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// Picker label.
|
|
var displayName: String {
|
|
switch self {
|
|
case .cues: "Coming Up & Countdown"
|
|
case .instructions: "Setup & Form Read"
|
|
case .both: "Both"
|
|
}
|
|
}
|
|
|
|
/// Whether this style speaks the concise "coming up" / "1, 2, 3, GO!" cues.
|
|
var speaksCues: Bool { self == .cues || self == .both }
|
|
|
|
/// Whether this style reads each exercise's setup & form script on start.
|
|
var readsInstructions: Bool { self == .instructions || self == .both }
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|