Preview the next exercise and count into each set during rests

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 ("<Exercise>, 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
This commit is contained in:
2026-07-10 09:38:45 -04:00
parent bce2bf7539
commit 8b20d7ad08
7 changed files with 208 additions and 35 deletions
+8
View File
@@ -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.
+11 -6
View File
@@ -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.
+15 -4
View File
@@ -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])
}
+29
View File
@@ -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, <exercise>" 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 {
+10 -1
View File
@@ -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.")
}
}
@@ -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<Content: View>: 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 / <name>" 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)
}
}
}
+7 -1
View File
@@ -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