From 8b20d7ad0891e7f761baa8ae43da49a2789ce09c Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 10 Jul 2026 09:38:45 -0400 Subject: [PATCH] Preview the next exercise and count into each set during rests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run flow's between-exercise rest now previews the next exercise — its figure and a large "Coming up" name — and the hands-free narration gains a per-second count-in (", in 1, 2, 3, GO!") spoken before every set, plus a "Coming up" announcement as a between-exercise rest begins. A new Settings > Narration picker chooses the count-in cues, the setup/form read, or both. Also fixes spoken cues going silent after the first exercise in a flow split: the stop-on-teardown moved from the per-exercise view (rebuilt on every hand-off) to the run host, which stays mounted for the whole run. The audio session now holds its duck across the per-second count so background music doesn't pulse between words. Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh --- CHANGELOG.md | 8 + README.md | 17 +- Workouts/Speech/SpeechAnnouncer.swift | 19 ++- Workouts/Speech/SpeechSettings.swift | 29 ++++ Workouts/Views/Settings/SettingsView.swift | 11 +- .../WorkoutLogs/ExerciseProgressView.swift | 151 +++++++++++++++--- Workouts/Views/WorkoutLogs/RunFlowView.swift | 8 +- 7 files changed, 208 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7d30f..8a1f4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ **July 2026** +While you rest between exercises, the workout screen now previews the next exercise's figure and name so you can see what's coming up. + +Spoken cues can now announce the next exercise during a rest and count you into every set with "in 1, 2, 3, GO!". + +A new Narration setting picks whether spoken cues give the coming-up countdown, the full setup and form read, or both. + +Spoken cues no longer stop after the first exercise in an auto-advancing workout. + The watch now starts recording on its own whenever a workout is active, so a missed hand-off from the iPhone no longer leaves a session without heart rate or Health credit. A workout recording now survives the watch app crashing or the watch restarting, reconnecting to the in-progress session instead of losing it. diff --git a/README.md b/README.md index 4f64f2d..b7c613d 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,19 @@ your own iCloud Drive. Library's rig data with the working limbs highlighted — the camera slowly 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. + machines show true hip abduction and torso rotation. While you rest between + exercises in an auto-advancing split, the figure previews the next exercise under + a **Coming up** label, so you see what's next before it starts. - **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. + narrate a running workout hands-free. A **Narration** setting chooses the style: + *Coming Up & Countdown* announces the next exercise during a rest and counts you + into every set (the upcoming exercise, then "in 1, 2, 3, GO!", one word per beat), + *Setup & Form Read* speaks each exercise's cues as it starts, or *Both*. On-device + speech that ducks — never stops — your music, holding the duck across the count so + it doesn't pulse between words. 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/Speech/SpeechAnnouncer.swift b/Workouts/Speech/SpeechAnnouncer.swift index 9b9f09d..0f3460e 100644 --- a/Workouts/Speech/SpeechAnnouncer.swift +++ b/Workouts/Speech/SpeechAnnouncer.swift @@ -26,16 +26,25 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() + /// While true, `deactivateSession` won't hand audio focus back when an utterance ends — + /// so a per-second spoken countdown ("in 1… 2… 3… Go") stays ducked throughout instead + /// of pulsing other audio back to full in the gaps between counts. Cleared by the final + /// (non-holding) utterance of a run and by `stop`. + private var holdsSession = false + override init() { super.init() synthesizer.delegate = self } /// Speak `text`, replacing anything currently playing. Empty/blank text is a no-op. - func speak(_ text: String) { + /// Pass `holdSession: true` for all but the last of a rapid back-to-back sequence so + /// other audio stays ducked between the utterances rather than un-ducking in each gap. + func speak(_ text: String, holdSession: Bool = false) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + holdsSession = holdSession activateSession() // Replace, don't queue: a new exercise's cue supersedes the previous one. if synthesizer.isSpeaking { @@ -56,6 +65,7 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate { /// Stop immediately and hand audio focus back to other apps. func stop() { + holdsSession = false if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } @@ -73,10 +83,11 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate { 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. + /// Return other audio to full volume. Skipped while something is still speaking, or while + /// a countdown holds the session, so a cancel-then-speak (new exercise) or a per-second + /// count doesn't briefly un-duck between utterances. private func deactivateSession() { - guard !synthesizer.isSpeaking else { return } + guard !synthesizer.isSpeaking, !holdsSession else { return } try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } diff --git a/Workouts/Speech/SpeechSettings.swift b/Workouts/Speech/SpeechSettings.swift index d9d877f..b6d6e2b 100644 --- a/Workouts/Speech/SpeechSettings.swift +++ b/Workouts/Speech/SpeechSettings.swift @@ -112,6 +112,35 @@ enum SpeechSettings { } } +/// What the automatic workout narration says while a flow-mode split runs, chosen in +/// Settings (only meaningful when "Speak Exercise Cues" is on). Stored as its raw string +/// under `spokenCueStyle`, read by both the Settings picker and the run flow. +enum SpokenCueStyle: String, CaseIterable, Identifiable { + /// "Coming up, " during the rest, then "in 1, 2, 3, GO!" before each set. + case cues + /// Reads each exercise's setup & form script aloud when it starts (the original behavior). + case instructions + /// Both — the concise coming-up/countdown cues plus the setup & form read. + case both + + var id: String { rawValue } + + /// Picker label. + var displayName: String { + switch self { + case .cues: "Coming Up & Countdown" + case .instructions: "Setup & Form Read" + case .both: "Both" + } + } + + /// Whether this style speaks the concise "coming up" / "1, 2, 3, GO!" cues. + var speaksCues: Bool { self == .cues || self == .both } + + /// Whether this style reads each exercise's setup & form script on start. + var readsInstructions: Bool { self == .instructions || self == .both } +} + private extension AVSpeechSynthesisVoiceQuality { /// Orders the quality tiers for "best available" selection and picker sorting. var rank: Int { diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index a564489..94cd947 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -22,6 +22,7 @@ struct SettingsView: View { @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb @AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral @AppStorage("speakExerciseCues") private var speakExerciseCues = false + @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues @AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = "" @State private var isRestoringSeeds = false @@ -222,6 +223,14 @@ struct SettingsView: View { Toggle("Speak Exercise Cues", isOn: $speakExerciseCues) + if speakExerciseCues { + Picker("Narration", selection: $spokenCueStyle) { + ForEach(SpokenCueStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + } + Picker("Voice", selection: $speechVoiceIdentifier) { Text("Automatic").tag("") ForEach(installedVoices, id: \.identifier) { voice in @@ -257,7 +266,7 @@ struct SettingsView: View { } 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.") + Text("Speak Exercise Cues narrates a running workout hands-free. Coming Up & Countdown announces the next exercise during a rest and counts “in 1, 2, 3, GO!” before each set; Setup & Form Read reads each exercise's cues aloud when it starts. Tap a library exercise's speaker to hear its full instructions anytime.") } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 046b681..0a8c9f9 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -83,6 +83,10 @@ struct ExerciseProgressView: View { /// always available. @AppStorage("speakExerciseCues") private var speakExerciseCues = false + /// What the automatic narration says (only when `speakExerciseCues` is on): the concise + /// coming-up/countdown cues, the setup & form read, or both. + @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues + /// 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 @@ -211,6 +215,27 @@ struct ExerciseProgressView: View { }?.id } + /// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when + /// this is the last one. Drives the between-exercise rest's "Coming up …" preview. + private var nextExerciseName: String? { + guard let id = nextUnfinishedLogID else { return nil } + return doc.logs.first { $0.id == id }?.exerciseName + } + + /// The between-exercise rest — flow mode's terminal page when another exercise follows. + /// This is the only rest whose "next" is a *different* exercise, so it's the one that + /// previews the next figure and speaks "Coming up …". + private var isOnBetweenExerciseRest: Bool { + autoAdvance && nextUnfinishedLogID != nil && currentPage == base + cycleCount + } + + /// Which exercise the bottom-half figure should show: the *next* one while resting + /// between exercises (so you see what's coming up), otherwise this exercise. + private var figureExerciseName: String { + if isOnBetweenExerciseRest, let next = nextExerciseName { return next } + return log?.exerciseName ?? "" + } + /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } @@ -328,9 +353,11 @@ struct ExerciseProgressView: View { } } - // Bottom half: the looping form-guide figure when a bundled motion - // matches this exercise; empty space otherwise. - ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") + // Bottom half: the looping form-guide figure. Shows the *next* exercise while + // resting between exercises (a live preview of what's coming up), otherwise this + // one. The name swap re-loads the figure and carries through the hand-off, so it + // stays put as the next exercise takes over. + ExerciseFigureSlot(exerciseName: figureExerciseName) } .navigationTitle(log?.exerciseName ?? "") .navigationBarTitleDisplayMode(.inline) @@ -409,10 +436,12 @@ struct ExerciseProgressView: View { .onDisappear { // Leaving the flow (back / done) — stop the watch from following and dismiss the // Live Activity (it lingers briefly on a completed run before auto-dismissing). + // Speech is *not* stopped here: this view is torn down and rebuilt on every + // flow hand-off (`.id(currentLogID)`), so stopping would cut the incoming + // exercise's cue. The run host (`RunFlowView`) silences narration when the + // whole run leaves instead. onLiveEnded() onActivityEnded() - // Don't keep narrating a screen the user has left. - speechAnnouncer?.stop() } } @@ -586,7 +615,10 @@ struct ExerciseProgressView: View { seconds: effectiveRest, isActive: isActive, anchorStart: anchorStart, - anchorEnd: anchorEnd + anchorEnd: anchorEnd, + comingUpName: nextExerciseName, + onActivate: announceComingUp, + onCountdownBeat: announceCountdownBeat ) { _ in completeExercise() // Re-resolve at fire time — the next exercise may have been @@ -648,7 +680,8 @@ struct ExerciseProgressView: View { seconds: effectiveRest, isActive: isActive, anchorStart: anchorStart, - anchorEnd: anchorEnd + anchorEnd: anchorEnd, + onCountdownBeat: announceCountdownBeat ) { end in withAnimation { advance(from: index, phaseEndedAt: end) } } @@ -720,14 +753,51 @@ struct ExerciseProgressView: View { 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. + /// True when narration is on and the chosen style speaks the concise coming-up/countdown + /// cues. Also requires a wired announcer (nil in the screenshot host). + private var speaksCues: Bool { + speakExerciseCues && spokenCueStyle.speaksCues && speechAnnouncer != nil + } + + /// Speak the exercise's setup/form cues when it starts, if the user opted into a style + /// that reads them. 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 speakExerciseCues, spokenCueStyle.readsInstructions, + 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)) } + /// Announce the exercise coming up after a between-exercise rest ("Coming up, Pull Ups"). + /// Spoken as the rest begins, so it lands well before the next exercise starts. + private func announceComingUp() { + guard speaksCues, let announcer = speechAnnouncer, let next = nextExerciseName else { return } + announcer.speak("Coming up. \(next).") + } + + /// Count into the next work phase, one word per second synced to the countdown's final + /// beats: the upcoming exercise's name (4s left) → "in one" (3s) → "two" → "three" → + /// "Go!" as the timer reaches zero — e.g. "Torso Twist … in one, two, three, GO". The + /// name gets its own beat so even a long one is heard before the count. Every beat but + /// "Go" holds the audio session so background music doesn't pulse between them; "Go" + /// releases it, and (like any cue) plays to completion even if the rest ends mid-word. + /// `remaining` is the whole seconds left (0 at the boundary). + private func announceCountdownBeat(_ remaining: Int) { + guard speaksCues, let announcer = speechAnnouncer else { return } + switch remaining { + case 4: + // The exercise about to be worked: the next one across a between-exercise rest, + // otherwise this one. Skipped when unknown (never blocks the count that follows). + let name = figureExerciseName + if !name.isEmpty { announcer.speak(name + ".", holdSession: true) } + case 3: announcer.speak("In one.", holdSession: true) + case 2: announcer.speak("Two.", holdSession: true) + case 1: announcer.speak("Three.", holdSession: true) + default: announcer.speak("Go!") // the boundary — release the duck for the work phase + } + } + /// 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). @@ -1021,17 +1091,35 @@ private struct PhaseTimerLayout: View { let header: String let footer: String let tint: Color + /// When set, the header row becomes a two-line "Coming up" / exercise-name preview, + /// with the name in larger type — used by the between-exercise rest. + var comingUpName: String? = nil @ViewBuilder var timer: Content /// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor` /// so the largest sizes never overflow the top half). @ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108 + /// Scales the "coming up" exercise name — large, but well under the timer. + @ScaledMetric(relativeTo: .largeTitle) private var comingUpFontSize: CGFloat = 34 var body: some View { VStack(spacing: 10) { - Text(header) - .font(.title3) - .foregroundStyle(.secondary) + if let comingUpName { + VStack(spacing: 2) { + Text("Coming up") + .font(.title3) + .foregroundStyle(.secondary) + Text(comingUpName) + .font(.system(size: comingUpFontSize, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal) + } + } else { + Text(header) + .font(.title3) + .foregroundStyle(.secondary) + } timer .font(.system(size: timerFontSize, weight: .bold, design: .rounded)) @@ -1284,6 +1372,15 @@ private struct CountdownPhaseView: View { /// so the remaining time — and the auto-advance at zero — line up across both devices. var anchorStart: Date? = nil var anchorEnd: Date? = nil + /// When set, the header becomes a "Coming up / " preview (between-exercise rest). + var comingUpName: String? = nil + /// Fired once when the countdown genuinely begins (not a slept-through catch-up) — the + /// spoken "Coming up …" cue hangs off this. + var onActivate: (() -> Void)? = nil + /// Fired once per second through the count-in — the seconds remaining (4 for the exercise + /// name, then 3, 2, 1), then 0 at the boundary — so a spoken count ("Torso Twist… in one… + /// two… three… Go!") lands one word per beat, in step with the haptics. Nil skips it. + var onCountdownBeat: ((Int) -> Void)? = nil /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the /// phase's *computed* end so the next phase can anchor at the boundary itself — not at /// whenever this tick got runtime (a device asleep at the boundary ticks late). @@ -1302,7 +1399,7 @@ private struct CountdownPhaseView: View { private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() var body: some View { - PhaseTimerLayout(header: header, footer: footer, tint: tint) { + PhaseTimerLayout(header: header, footer: footer, tint: tint, comingUpName: comingUpName) { Text(timerInterval: startDate...endDate, countsDown: true) } .onAppear { if isActive { start(haptic: true) } } @@ -1317,9 +1414,12 @@ private struct CountdownPhaseView: View { endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - // No buzz for a chained catch-up page whose whole window already elapsed while - // the device slept — it advances again on its next tick. - if haptic, endDate > Date() { WorkoutHaptic.start.play() } + // No buzz — or spoken cue — for a chained catch-up page whose whole window already + // elapsed while the device slept; it advances again on its next tick. + if haptic, endDate > Date() { + WorkoutHaptic.start.play() + onActivate?() + } } private func tick() { @@ -1329,14 +1429,19 @@ private struct CountdownPhaseView: View { if remaining <= 0 { didFinish = true - // Buzz only for a boundary that just happened — fast-forwarding through - // boundaries that passed while the device slept stays silent. - if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() } + // Buzz and speak the final "Go" only for a boundary that just happened — + // fast-forwarding through boundaries that passed while the device slept stays silent. + if Date().timeIntervalSince(endDate) < 3 { + WorkoutHaptic.stop.play() + onCountdownBeat?(0) + } onFinished(endDate) - } else if remaining <= 3 && remaining < lastPingSecond { - // Once-per-second countdown ping for the final three seconds. + } else if remaining <= 4 && remaining < lastPingSecond { + // One spoken beat per second through the count-in: the exercise name at 4s, then + // the count. The haptic ping stays on the final three seconds only. lastPingSecond = remaining - WorkoutHaptic.tick.play() + if remaining <= 3 { WorkoutHaptic.tick.play() } + onCountdownBeat?(remaining) } } } diff --git a/Workouts/Views/WorkoutLogs/RunFlowView.swift b/Workouts/Views/WorkoutLogs/RunFlowView.swift index 85f5b58..201bb43 100644 --- a/Workouts/Views/WorkoutLogs/RunFlowView.swift +++ b/Workouts/Views/WorkoutLogs/RunFlowView.swift @@ -72,7 +72,13 @@ struct RunFlowView: View { .id(currentLogID) } .onAppear { liveRun.navigatedRunID = currentLogID } - .onDisappear { if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil } } + .onDisappear { + if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil } + // Silence narration only when the *whole run* leaves. This host stays mounted + // across per-exercise hand-offs (the child re-ids), so stopping here — rather + // than in the child's onDisappear — no longer cuts off the next exercise's cue. + speechAnnouncer?.stop() + } } /// Hand off to the next exercise. Suppresses the leaving exercise's live-ended/activity