diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a68ed..e5bd8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 4c21aa7..d23eabd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 45b1c89..16af465 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -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() diff --git a/Workouts/ExerciseFigure/ExerciseInfo.swift b/Workouts/ExerciseFigure/ExerciseInfo.swift index 2ff064c..2519b7f 100644 --- a/Workouts/ExerciseFigure/ExerciseInfo.swift +++ b/Workouts/ExerciseFigure/ExerciseInfo.swift @@ -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 = ["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. diff --git a/Workouts/Speech/SpeechAnnouncer.swift b/Workouts/Speech/SpeechAnnouncer.swift new file mode 100644 index 0000000..9b9f09d --- /dev/null +++ b/Workouts/Speech/SpeechAnnouncer.swift @@ -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() + } + } +} diff --git a/Workouts/Speech/SpeechSettings.swift b/Workouts/Speech/SpeechSettings.swift new file mode 100644 index 0000000..41d7282 --- /dev/null +++ b/Workouts/Speech/SpeechSettings.swift @@ -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 { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Workouts/Views/Exercises/ExerciseLibraryView.swift b/Workouts/Views/Exercises/ExerciseLibraryView.swift index ba54ae9..9243e00 100644 --- a/Workouts/Views/Exercises/ExerciseLibraryView.swift +++ b/Workouts/Views/Exercises/ExerciseLibraryView.swift @@ -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 diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index 80237d5..6616333 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -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, + range: ClosedRange, 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) + } } } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 772ddac..c5772b2 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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, 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, 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 diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index bee6fa9..495a1ab 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -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)") } diff --git a/WorkoutsTests/ExerciseInfoTests.swift b/WorkoutsTests/ExerciseInfoTests.swift index 83ef7aa..4ca8c16 100644 --- a/WorkoutsTests/ExerciseInfoTests.swift +++ b/WorkoutsTests/ExerciseInfoTests.swift @@ -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 { diff --git a/WorkoutsTests/SpeechSettingsTests.swift b/WorkoutsTests/SpeechSettingsTests.swift new file mode 100644 index 0000000..a27113d --- /dev/null +++ b/WorkoutsTests/SpeechSettingsTests.swift @@ -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)) + } +}