diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 0094622..bef113f 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -657,7 +657,8 @@ struct ExerciseProgressView: View { seconds: workDurationSeconds, isActive: isActive, anchorStart: anchorStart, - anchorEnd: anchorEnd + anchorEnd: anchorEnd, + onCountdownBeat: announceWorkEndBeat ) { end in withAnimation { advance(from: index, phaseEndedAt: end) } } @@ -777,27 +778,45 @@ struct ExerciseProgressView: View { } /// 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). + /// beats: the upcoming exercise's name at 5s left — with the 4s beat left silent so even + /// a long name finishes — then "in three" (3s) → "two" → "one" → "Go!" as the timer + /// reaches zero. 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: + case 5: // 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 4: break // buffer beat — lets the exercise name play out before the count + case 3: announcer.speak("In three.", holdSession: true) case 2: announcer.speak("Two.", holdSession: true) - case 1: announcer.speak("Three.", holdSession: true) + case 1: announcer.speak("One.", holdSession: true) default: announcer.speak("Go!") // the boundary — release the duck for the work phase } } + /// Count a timed work set out, mirroring the count-in: " ends." at 5s left + /// (4s is its silent buffer), then "in three" → "two" → "one" — and silence at zero, + /// where the rest phase's own buzz marks the boundary. "One" is the last word, so it + /// releases the audio duck instead of holding it. + private func announceWorkEndBeat(_ remaining: Int) { + guard speaksCues, let announcer = speechAnnouncer else { return } + switch remaining { + case 5: + if let name = log?.exerciseName, !name.isEmpty { + announcer.speak("\(name) ends.", holdSession: true) + } + case 3: announcer.speak("In three.", holdSession: true) + case 2: announcer.speak("Two.", holdSession: true) + case 1: announcer.speak("One.") + default: break // 4 is the name's buffer beat; 0 stays silent + } + } + /// 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). @@ -1377,9 +1396,10 @@ private struct CountdownPhaseView: View { /// 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. + /// Fired once per second through the final beats — the seconds remaining (5 for the + /// exercise name, 4 as its buffer, then 3, 2, 1), then 0 at the boundary — so a spoken + /// count ("Torso Twist… in three… two… one… 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 @@ -1436,9 +1456,9 @@ private struct CountdownPhaseView: View { onCountdownBeat?(0) } onFinished(endDate) - } 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. + } else if remaining <= 5 && remaining < lastPingSecond { + // One spoken beat per second through the final five: the exercise name at 5s, + // a buffer, then the count. The haptic ping stays on the final three seconds only. lastPingSecond = remaining if remaining <= 3 { WorkoutHaptic.tick.play() } onCountdownBeat?(remaining)