Add spoken exercise instructions (on-device TTS)
Speak exercise setup and form cues aloud with AVSpeechSynthesizer: - Library detail: a speaker toolbar button reads the full reference aloud. - Active workout: an opt-in "Speak Exercise Cues" setting speaks a brief cue (Setup/Execution/Cues) when an exercise starts, hands-free. - Settings › Voice: pick the voice (auto-prefers an installed enhanced/ premium English voice), a premium-download nudge shown only while on a basic voice, and a Speed/Pitch/Volume sheet with Reset to Defaults. On-device and offline; ducks other audio rather than stopping it. iPhone only for now. Shared SpeechSettings is the single source of truth for the voice/prosody, read fresh per utterance so changes preview live. Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import AVFoundation
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the voice-resolution contract: the picker offers English voices, and the
|
||||
/// announcer speaks with the user's stored choice when set, falling back to a valid
|
||||
/// English voice otherwise.
|
||||
struct SpeechSettingsTests {
|
||||
|
||||
@Test func availableVoicesAreEnglish() {
|
||||
let voices = SpeechSettings.availableVoices()
|
||||
#expect(!voices.isEmpty)
|
||||
#expect(voices.allSatisfy { $0.language.hasPrefix("en") })
|
||||
}
|
||||
|
||||
@Test func resolvedVoiceHonorsStoredIdentifierThenFallsBack() {
|
||||
let key = SpeechSettings.voiceIdentifierKey
|
||||
let original = UserDefaults.standard.string(forKey: key)
|
||||
defer {
|
||||
if let original { UserDefaults.standard.set(original, forKey: key) }
|
||||
else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
}
|
||||
|
||||
// Automatic (empty) resolves to some installed English voice.
|
||||
UserDefaults.standard.set("", forKey: key)
|
||||
let auto = SpeechSettings.resolvedVoice()
|
||||
#expect(auto != nil)
|
||||
#expect(auto?.language.hasPrefix("en") == true)
|
||||
|
||||
// A specific installed voice resolves to exactly that voice.
|
||||
let target = try? #require(SpeechSettings.availableVoices().first)
|
||||
if let target {
|
||||
UserDefaults.standard.set(target.identifier, forKey: key)
|
||||
#expect(SpeechSettings.resolvedVoice()?.identifier == target.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func prosodyValuesClampToValidRanges() {
|
||||
let rateKey = SpeechSettings.rateKey
|
||||
let originalRate = UserDefaults.standard.object(forKey: rateKey) as? Double
|
||||
defer {
|
||||
if let originalRate { UserDefaults.standard.set(originalRate, forKey: rateKey) }
|
||||
else { UserDefaults.standard.removeObject(forKey: rateKey) }
|
||||
}
|
||||
|
||||
// An out-of-range stored value is clamped, never passed raw to the utterance.
|
||||
UserDefaults.standard.set(99.0, forKey: rateKey)
|
||||
#expect(Double(SpeechSettings.rate) <= SpeechSettings.rateRange.upperBound)
|
||||
|
||||
// A missing value resolves to the shared default.
|
||||
UserDefaults.standard.removeObject(forKey: rateKey)
|
||||
#expect(SpeechSettings.rate == Float(SpeechSettings.rateDefault))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user