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,98 @@
|
||||
//
|
||||
// SpeechAnnouncer.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Observation
|
||||
|
||||
/// Speaks exercise instructions aloud with `AVSpeechSynthesizer` — on-device, offline,
|
||||
/// no network. Two callers share one instance (via `AppServices`): the library detail
|
||||
/// screen's Speak button (full reference read-through) and the active-workout flow's
|
||||
/// hands-free cue when an exercise starts. A fresh `speak` cancels whatever's playing,
|
||||
/// so a new exercise's cue never stacks on the last one.
|
||||
///
|
||||
/// The audio session ducks other audio (music, podcasts) rather than stopping it, and is
|
||||
/// deactivated the moment nothing is speaking so that audio returns to full volume. iPhone
|
||||
/// only: the watch's `HKWorkoutSession` owns its own audio session, so this stays out of
|
||||
/// the watch target.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
/// True while an utterance is being spoken — drives the Speak/Stop toggle in the UI.
|
||||
private(set) var isSpeaking = false
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// Speak `text`, replacing anything currently playing. Empty/blank text is a no-op.
|
||||
func speak(_ text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
activateSession()
|
||||
// Replace, don't queue: a new exercise's cue supersedes the previous one.
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
|
||||
// Voice and prosody come from SpeechSettings (the Settings › Voice controls), read
|
||||
// fresh each time so a just-changed slider or picker takes effect immediately.
|
||||
let utterance = AVSpeechUtterance(string: trimmed)
|
||||
utterance.voice = SpeechSettings.resolvedVoice()
|
||||
utterance.rate = SpeechSettings.rate
|
||||
utterance.pitchMultiplier = SpeechSettings.pitch
|
||||
utterance.volume = SpeechSettings.volume
|
||||
|
||||
isSpeaking = true
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
/// Stop immediately and hand audio focus back to other apps.
|
||||
func stop() {
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
isSpeaking = false
|
||||
deactivateSession()
|
||||
}
|
||||
|
||||
// MARK: - Audio session
|
||||
|
||||
/// Duck other audio while a prompt plays; `.voicePrompt` routes and mixes the way
|
||||
/// navigation/assistant prompts do. Activation is idempotent.
|
||||
private func activateSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers])
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
/// Return other audio to full volume. Skipped while something is still speaking, so a
|
||||
/// cancel-then-speak (new exercise) doesn't briefly un-duck between utterances.
|
||||
private func deactivateSession() {
|
||||
guard !synthesizer.isSpeaking else { return }
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
}
|
||||
|
||||
// MARK: - AVSpeechSynthesizerDelegate
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.isSpeaking = self.synthesizer.isSpeaking
|
||||
self.deactivateSession()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.isSpeaking = self.synthesizer.isSpeaking
|
||||
self.deactivateSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// 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>) -> Double {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user