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:
2026-07-09 13:27:40 -04:00
parent 8c8f22850e
commit 0f5f11e5e2
12 changed files with 615 additions and 8 deletions
+6
View File
@@ -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.
+7
View File
@@ -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.
+4
View File
@@ -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.
+98
View File
@@ -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()
}
}
}
+121
View File
@@ -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
+199
View File
@@ -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)")
}
+23
View File
@@ -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 {
+55
View File
@@ -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))
}
}