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:
@@ -1,5 +1,11 @@
|
||||
**July 2026**
|
||||
|
||||
Tap the speaker on any library exercise to hear its full instructions read aloud.
|
||||
|
||||
Turn on Speak Exercise Cues in Settings to hear each exercise's setup and form cues spoken automatically when you start it, hands-free.
|
||||
|
||||
Settings now has a Voice section to choose the speaking voice and adjust its speed, pitch, and volume, with a preview.
|
||||
|
||||
Editing the same workout on your iPhone and Apple Watch at the same time no longer loses one device's changes.
|
||||
|
||||
The watch workout view now closes reliably when you end a workout on your iPhone, instead of the app reappearing on every wrist raise.
|
||||
|
||||
@@ -38,6 +38,13 @@ your own iCloud Drive.
|
||||
orbits every exercise while it moves, machine equipment (seats, bars, cables,
|
||||
roller pads) has world-space 3D form that turns with the figure, and face-on
|
||||
machines show true hip abduction and torso rotation.
|
||||
- **Spoken instructions** — tap the speaker in any library exercise to hear its
|
||||
full reference read aloud, and turn on **Speak Exercise Cues** in Settings to
|
||||
have the setup and form cues spoken automatically when you start an exercise, so
|
||||
you can follow along hands-free. On-device speech that ducks — never stops — your
|
||||
music. A **Voice** section in Settings picks the voice (preferring any enhanced or
|
||||
premium voices you've installed) and tunes its speed, pitch, and volume, with a
|
||||
live preview.
|
||||
- **Machine comfort settings** — machine-based exercises remember your setup
|
||||
(seat height, back-rest position, pin position, …), shown on the workout
|
||||
screen and editable mid-workout; changes save back to the split for next time.
|
||||
|
||||
@@ -19,6 +19,10 @@ final class AppServices {
|
||||
let workoutLauncher = WorkoutLauncher()
|
||||
let workoutHealthDeleter = WorkoutHealthDeleter()
|
||||
|
||||
/// Speaks exercise instructions aloud (library Speak button + hands-free workout cues).
|
||||
/// Shared so a new exercise's cue cancels whatever the last screen was still saying.
|
||||
let speechAnnouncer = SpeechAnnouncer()
|
||||
|
||||
/// Owns the active-workout Live Activity (lock screen + Dynamic Island). Driven by the run
|
||||
/// flow's `LiveProgress` frames, so locking the phone mid-set doesn't lose the timer.
|
||||
let liveActivity = WorkoutLiveActivityController()
|
||||
|
||||
@@ -62,6 +62,38 @@ struct ExerciseInfo {
|
||||
}
|
||||
|
||||
extension ExerciseInfo {
|
||||
/// Sections read aloud in the brief hands-free cue (the workout-flow announcement).
|
||||
/// The library's Speak button reads everything; a mid-workout cue skips the reference
|
||||
/// sections (Common Mistakes, Progression) you don't need with the bar in your hands.
|
||||
private static let briefSectionTitles: Set<String> = ["Setup", "Execution", "Cues"]
|
||||
|
||||
/// Render this exercise as a plain-text script for `AVSpeechSynthesizer`: the name,
|
||||
/// the summary, then each section's title and items as sentences. `brief` keeps only
|
||||
/// the hands-free sections (`briefSectionTitles`), falling back to every section when
|
||||
/// none of them are present, so an exercise with only unusual headings still speaks.
|
||||
func spokenScript(name: String, brief: Bool) -> String {
|
||||
var parts: [String] = [name]
|
||||
|
||||
let summaryLine = summary.replacingOccurrences(of: "\n", with: " ")
|
||||
if !summaryLine.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
parts.append(summaryLine)
|
||||
}
|
||||
|
||||
var chosen = brief ? sections.filter { Self.briefSectionTitles.contains($0.title) } : sections
|
||||
if chosen.isEmpty { chosen = sections }
|
||||
|
||||
for section in chosen {
|
||||
parts.append(section.title)
|
||||
parts.append(contentsOf: section.items.map(\.text))
|
||||
}
|
||||
|
||||
return parts
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { $0.hasSuffix(".") || $0.hasSuffix("!") || $0.hasSuffix("?") ? $0 : $0 + "." }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line
|
||||
/// that doesn't start a new block (heading, list marker, blank) continues the
|
||||
/// previous one, matching how Markdown renders soft breaks.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ struct ExerciseLibraryView: View {
|
||||
/// progression chart; the looping form-guide figure holds the bottom.
|
||||
struct ExerciseLibraryDetailView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
let exerciseName: String
|
||||
|
||||
@@ -143,6 +144,25 @@ struct ExerciseLibraryDetailView: View {
|
||||
}
|
||||
.navigationTitle(exerciseName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let info {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
let announcer = services.speechAnnouncer
|
||||
Button {
|
||||
if announcer.isSpeaking {
|
||||
announcer.stop()
|
||||
} else {
|
||||
announcer.speak(info.spokenScript(name: exerciseName, brief: false))
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: announcer.isSpeaking ? "stop.fill" : "speaker.wave.2.fill")
|
||||
}
|
||||
.accessibilityLabel(announcer.isSpeaking ? "Stop reading" : "Read instructions aloud")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Leaving the screen shouldn't keep narrating from a page you can no longer see.
|
||||
.onDisappear { services.speechAnnouncer.stop() }
|
||||
.sheet(item: $settingsEdit) { route in
|
||||
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
|
||||
let targets = route.targetSplitIDs
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AVFoundation
|
||||
import IndieAbout
|
||||
import IndieBackup
|
||||
|
||||
@@ -19,10 +20,19 @@ struct SettingsView: View {
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
|
||||
|
||||
@State private var isRestoringSeeds = false
|
||||
@State private var restoreSeedsMessage: String?
|
||||
|
||||
/// Installed English voices, best quality first. Loaded on appear (not per render) since
|
||||
/// `speechVoices()` is a touch heavy and the list only changes when the user downloads a
|
||||
/// voice in system settings — re-read on each appear catches a return from doing exactly that.
|
||||
@State private var installedVoices: [AVSpeechSynthesisVoice] = []
|
||||
@State private var showingSpeechAdjustments = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -49,12 +59,21 @@ struct SettingsView: View {
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Body Type", selection: $figureProfile) {
|
||||
ForEach(FigureProfile.allCases, id: \.self) { profile in
|
||||
Text(profile.displayName).tag(profile)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Workout")
|
||||
} footer: {
|
||||
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
|
||||
}
|
||||
|
||||
// MARK: - Voice Section
|
||||
voiceSection
|
||||
|
||||
// MARK: - Library Section
|
||||
Section(header: Text("Library")) {
|
||||
NavigationLink {
|
||||
@@ -169,6 +188,186 @@ struct SettingsView: View {
|
||||
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
|
||||
.onAppear {
|
||||
installedVoices = SpeechSettings.availableVoices()
|
||||
// A previously-chosen voice that's since been uninstalled falls back to Automatic
|
||||
// so the picker shows a valid selection (the announcer already falls back too).
|
||||
if !speechVoiceIdentifier.isEmpty,
|
||||
!installedVoices.contains(where: { $0.identifier == speechVoiceIdentifier }) {
|
||||
speechVoiceIdentifier = ""
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSpeechAdjustments) {
|
||||
SpeechAdjustmentsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
/// True once at least one enhanced or premium voice is installed — the nudge to go
|
||||
/// download a nicer voice only shows when the user is still on a basic (default) voice.
|
||||
private var hasUpgradedVoice: Bool {
|
||||
installedVoices.contains { $0.quality != .default }
|
||||
}
|
||||
|
||||
/// Most installed voices are default quality, so labeling every one "Default" is noise;
|
||||
/// name only for those, and call out the enhanced/premium ones that are worth choosing.
|
||||
private func voiceLabel(_ voice: AVSpeechSynthesisVoice) -> String {
|
||||
voice.quality == .default
|
||||
? voice.name
|
||||
: "\(voice.name) — \(SpeechSettings.qualityLabel(voice.quality))"
|
||||
}
|
||||
|
||||
/// All spoken-instruction controls. The voice and the speed/pitch/volume adjustments also
|
||||
/// govern the library exercise Speak button; only the toggle gates the automatic workout cue.
|
||||
@ViewBuilder
|
||||
private var voiceSection: some View {
|
||||
Section {
|
||||
if !hasUpgradedVoice {
|
||||
premiumVoiceCallout
|
||||
}
|
||||
|
||||
Toggle("Speak Exercise Cues", isOn: $speakExerciseCues)
|
||||
|
||||
Picker("Voice", selection: $speechVoiceIdentifier) {
|
||||
Text("Automatic").tag("")
|
||||
ForEach(installedVoices, id: \.identifier) { voice in
|
||||
Text(voiceLabel(voice)).tag(voice.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingSpeechAdjustments = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Speed, Pitch & Volume", systemImage: "slider.horizontal.3")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
|
||||
Button {
|
||||
let announcer = services.speechAnnouncer
|
||||
if announcer.isSpeaking {
|
||||
announcer.stop()
|
||||
} else {
|
||||
announcer.speak("This is how your exercise cues will sound.")
|
||||
}
|
||||
} label: {
|
||||
Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview Voice",
|
||||
systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle")
|
||||
}
|
||||
} header: {
|
||||
Text("Voice")
|
||||
} footer: {
|
||||
Text("Speak Exercise Cues reads each exercise's setup and form cues aloud when you start it; tap a library exercise's speaker to hear its full instructions anytime.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown only when no enhanced/premium voice is installed: points the user to the system
|
||||
/// voice download, the one lever that actually makes speech sound less robotic.
|
||||
private var premiumVoiceCallout: some View {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Using a basic voice")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("For a much more natural sound, download a Premium voice in Settings › Accessibility › Spoken Content › Voices, then choose it above.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The speed / pitch / volume knobs, split into their own sheet with a reset. These are the
|
||||
/// fine-tuning controls, kept out of the main Voice list; they write the same `@AppStorage`
|
||||
/// keys the announcer reads, so changes preview live.
|
||||
private struct SpeechAdjustmentsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@AppStorage(SpeechSettings.rateKey) private var speechRate = SpeechSettings.rateDefault
|
||||
@AppStorage(SpeechSettings.pitchKey) private var speechPitch = SpeechSettings.pitchDefault
|
||||
@AppStorage(SpeechSettings.volumeKey) private var speechVolume = SpeechSettings.volumeDefault
|
||||
|
||||
/// True when every knob already sits at its default — disables the Reset button.
|
||||
private var isDefault: Bool {
|
||||
speechRate == SpeechSettings.rateDefault
|
||||
&& speechPitch == SpeechSettings.pitchDefault
|
||||
&& speechVolume == SpeechSettings.volumeDefault
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
voiceSlider("Speed", value: $speechRate, range: SpeechSettings.rateRange,
|
||||
low: "tortoise", high: "hare")
|
||||
voiceSlider("Pitch", value: $speechPitch, range: SpeechSettings.pitchRange,
|
||||
low: "arrow.down", high: "arrow.up")
|
||||
voiceSlider("Volume", value: $speechVolume, range: SpeechSettings.volumeRange,
|
||||
low: "speaker.fill", high: "speaker.wave.3.fill")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
let announcer = services.speechAnnouncer
|
||||
if announcer.isSpeaking {
|
||||
announcer.stop()
|
||||
} else {
|
||||
announcer.speak("This is how your exercise cues will sound.")
|
||||
}
|
||||
} label: {
|
||||
Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview",
|
||||
systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle")
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: resetToDefaults) {
|
||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.disabled(isDefault)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Speed, Pitch & Volume")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.onDisappear { services.speechAnnouncer.stop() }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private func resetToDefaults() {
|
||||
withAnimation {
|
||||
speechRate = SpeechSettings.rateDefault
|
||||
speechPitch = SpeechSettings.pitchDefault
|
||||
speechVolume = SpeechSettings.volumeDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// A labeled slider row with low/high symbol endcaps.
|
||||
private func voiceSlider(_ title: String, value: Binding<Double>,
|
||||
range: ClosedRange<Double>, low: String, high: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
Slider(value: value, in: range) {
|
||||
Text(title)
|
||||
} minimumValueLabel: {
|
||||
Image(systemName: low).foregroundStyle(.secondary).imageScale(.small)
|
||||
} maximumValueLabel: {
|
||||
Image(systemName: high).foregroundStyle(.secondary).imageScale(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,19 @@ struct ExerciseProgressView: View {
|
||||
/// without re-broadcasting or re-recording — the originating device owns the durable write.
|
||||
var incomingFrame: LiveProgress?
|
||||
|
||||
/// Reads the exercise's setup/form cues aloud when the user taps Start, if
|
||||
/// `speakExerciseCues` is on. Optional so decoupled hosts (the screenshot rig) that
|
||||
/// don't inject `AppServices` simply stay silent.
|
||||
var speechAnnouncer: SpeechAnnouncer? = nil
|
||||
|
||||
/// Rest length between sets, shared with the watch via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
/// Read aloud the setup/form cues when an exercise starts (hands-free). Off by default;
|
||||
/// opt in from Settings. Governs only the automatic cue — the library Speak button is
|
||||
/// always available.
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@@ -125,7 +135,7 @@ struct ExerciseProgressView: View {
|
||||
/// `startsCompleted`.
|
||||
@State private var startsSkipped: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
@@ -134,6 +144,7 @@ struct ExerciseProgressView: View {
|
||||
self.onActivity = onActivity
|
||||
self.onActivityEnded = onActivityEnded
|
||||
self.incomingFrame = incomingFrame
|
||||
self.speechAnnouncer = speechAnnouncer
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
@@ -346,6 +357,8 @@ struct ExerciseProgressView: View {
|
||||
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
|
||||
onLiveEnded()
|
||||
onActivityEnded()
|
||||
// Don't keep narrating a screen the user has left.
|
||||
speechAnnouncer?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,9 +628,18 @@ struct ExerciseProgressView: View {
|
||||
/// Leave the Ready page for the first work phase, marking the exercise started.
|
||||
private func start() {
|
||||
beginExercise()
|
||||
announceCue()
|
||||
withAnimation { currentPage = base }
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted in. Silent
|
||||
/// when the announcer isn't wired (screenshot host) or the exercise has no bundled info.
|
||||
private func announceCue() {
|
||||
guard speakExerciseCues, let announcer = speechAnnouncer, let log else { return }
|
||||
guard let info = ExerciseInfoLibrary.info(for: log.exerciseName) else { return }
|
||||
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
|
||||
}
|
||||
|
||||
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
@@ -751,15 +773,16 @@ enum SetEntryFormat {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the
|
||||
/// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies,
|
||||
/// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight.
|
||||
/// All of a log's entries on one line: "4 × 10 reps @ 45 lb" when every set
|
||||
/// matched, "10 · 8 · 6 reps @ 45 lb" when reps vary but the weight is uniform,
|
||||
/// a per-set "10 × 45 lb · 8 × 42.5 lb" list when the weight varies too,
|
||||
/// "3 × 45 sec" / "45 · 45 · 30 sec" for durations, and the same for bodyweight.
|
||||
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
|
||||
guard !entries.isEmpty else { return "" }
|
||||
if entries.contains(where: { $0.seconds != nil }) {
|
||||
return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec"
|
||||
return collapsedList(entries.map { $0.seconds ?? 0 }) + " sec"
|
||||
}
|
||||
let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ")
|
||||
let repsList = collapsedList(entries.map { $0.reps ?? 0 })
|
||||
let weights = entries.compactMap(\.weight)
|
||||
guard !weights.isEmpty else { return repsList + " reps" }
|
||||
if Set(weights).count == 1 {
|
||||
@@ -770,6 +793,17 @@ enum SetEntryFormat {
|
||||
return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
|
||||
}.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// A per-set value list, collapsed to "N × v" when every set matches (so four
|
||||
/// tens read as "4 × 10", not "10 · 10 · 10 · 10") and left as a "10 · 8 · 6"
|
||||
/// list otherwise. A single set is just its value (never "1 × v").
|
||||
private static func collapsedList(_ values: [Int]) -> String {
|
||||
guard let first = values.first else { return "" }
|
||||
if values.count > 1 && values.allSatisfy({ $0 == first }) {
|
||||
return "\(values.count) × \(first)"
|
||||
}
|
||||
return values.map(String.init).joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set entry adjust sheet
|
||||
|
||||
@@ -248,7 +248,8 @@ struct WorkoutLogListView: View {
|
||||
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
|
||||
onActivity: { services.liveActivity.update($0) },
|
||||
onActivityEnded: { services.liveActivity.end() },
|
||||
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
|
||||
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil },
|
||||
speechAnnouncer: services.speechAnnouncer
|
||||
)
|
||||
.onAppear { liveRun.navigatedRunID = logID }
|
||||
.onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } }
|
||||
@@ -465,7 +466,10 @@ private struct WorkoutLogRow: View {
|
||||
}
|
||||
|
||||
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
||||
/// strength and the × dimmed, so the counts read at a glance.
|
||||
/// strength and the × dimmed, so the counts read at a glance. A single set
|
||||
/// drops the redundant "1 ×" and shows just the amount — "45 sec" or
|
||||
/// "10 reps" — with reps carrying an explicit unit so the bare number reads
|
||||
/// unambiguously (durations already include their "sec"/"min").
|
||||
private var setsAndReps: Text {
|
||||
let count: String
|
||||
if loadType == .duration {
|
||||
@@ -481,6 +485,10 @@ private struct WorkoutLogRow: View {
|
||||
} else {
|
||||
count = "\(log.reps)"
|
||||
}
|
||||
if log.sets == 1 {
|
||||
if loadType == .duration { return Text(count) }
|
||||
return Text("\(count) \(log.reps == 1 ? "rep" : "reps")")
|
||||
}
|
||||
return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,29 @@ struct ExerciseInfoTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The spoken script (fed to `AVSpeechSynthesizer`) leads with the exercise name and
|
||||
/// its summary, and `brief` drops the reference-only sections so a hands-free workout
|
||||
/// cue reads Setup/Execution/Cues but not Common Mistakes/Progression.
|
||||
@Test func spokenScriptBriefDropsReferenceSections() throws {
|
||||
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
|
||||
|
||||
let full = info.spokenScript(name: "Chest Press", brief: false)
|
||||
#expect(full.hasPrefix("Chest Press."))
|
||||
#expect(full.contains("Setup."))
|
||||
#expect(full.contains("Execution."))
|
||||
#expect(full.contains("Cues."))
|
||||
#expect(full.contains("Common Mistakes."))
|
||||
#expect(full.contains("Progression."))
|
||||
|
||||
let brief = info.spokenScript(name: "Chest Press", brief: true)
|
||||
#expect(brief.hasPrefix("Chest Press."))
|
||||
#expect(brief.contains("Setup."))
|
||||
#expect(brief.contains("Execution."))
|
||||
#expect(brief.contains("Cues."))
|
||||
#expect(!brief.contains("Common Mistakes."))
|
||||
#expect(!brief.contains("Progression."))
|
||||
}
|
||||
|
||||
/// Every bundled motion rig ships a parseable info page — the exporter copies
|
||||
/// `info.md` per entry, and the detail screen counts on the shape.
|
||||
@Test func everyBundledExerciseHasParseableInfo() throws {
|
||||
|
||||
@@ -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