Rework the spoken count-in and count timed sets out

The count-in now speaks the exercise name at 5s left, leaves the 4s
beat silent so a long name can finish, then counts down — "in three,
two, one, Go!" (it previously counted up from 4s). Timed work sets
get a mirrored count-out: "<Exercise> ends." at 5s, the same buffer
and countdown, and silence at zero where the rest buzz marks the
boundary; the final "one" releases the audio duck.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
2026-07-11 07:53:11 -04:00
parent 6e440317c4
commit 9a832ad960
@@ -657,7 +657,8 @@ struct ExerciseProgressView: View {
seconds: workDurationSeconds, seconds: workDurationSeconds,
isActive: isActive, isActive: isActive,
anchorStart: anchorStart, anchorStart: anchorStart,
anchorEnd: anchorEnd anchorEnd: anchorEnd,
onCountdownBeat: announceWorkEndBeat
) { end in ) { end in
withAnimation { advance(from: index, phaseEndedAt: end) } 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 /// 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" /// beats: the upcoming exercise's name at 5s left with the 4s beat left silent so even
/// "Go!" as the timer reaches zero e.g. "Torso Twist in one, two, three, GO". The /// a long name finishes then "in three" (3s) "two" "one" "Go!" as the timer
/// name gets its own beat so even a long one is heard before the count. Every beat but /// reaches zero. Every beat but "Go" holds the audio session so background music doesn't
/// "Go" holds the audio session so background music doesn't pulse between them; "Go" /// pulse between them; "Go" releases it, and (like any cue) plays to completion even if
/// releases it, and (like any cue) plays to completion even if the rest ends mid-word. /// the rest ends mid-word. `remaining` is the whole seconds left (0 at the boundary).
/// `remaining` is the whole seconds left (0 at the boundary).
private func announceCountdownBeat(_ remaining: Int) { private func announceCountdownBeat(_ remaining: Int) {
guard speaksCues, let announcer = speechAnnouncer else { return } guard speaksCues, let announcer = speechAnnouncer else { return }
switch remaining { switch remaining {
case 4: case 5:
// The exercise about to be worked: the next one across a between-exercise rest, // 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). // otherwise this one. Skipped when unknown (never blocks the count that follows).
let name = figureExerciseName let name = figureExerciseName
if !name.isEmpty { announcer.speak(name + ".", holdSession: true) } 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 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 default: announcer.speak("Go!") // the boundary release the duck for the work phase
} }
} }
/// Count a timed work set out, mirroring the count-in: "<Exercise> 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 /// 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 /// 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). /// 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 /// Fired once when the countdown genuinely begins (not a slept-through catch-up) the
/// spoken "Coming up " cue hangs off this. /// spoken "Coming up " cue hangs off this.
var onActivate: (() -> Void)? = nil var onActivate: (() -> Void)? = nil
/// Fired once per second through the count-in the seconds remaining (4 for the exercise /// Fired once per second through the final beats the seconds remaining (5 for the
/// name, then 3, 2, 1), then 0 at the boundary so a spoken count ("Torso Twist in one /// exercise name, 4 as its buffer, then 3, 2, 1), then 0 at the boundary so a spoken
/// two three Go!") lands one word per beat, in step with the haptics. Nil skips it. /// 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 var onCountdownBeat: ((Int) -> Void)? = nil
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the /// 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 /// 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) onCountdownBeat?(0)
} }
onFinished(endDate) onFinished(endDate)
} else if remaining <= 4 && remaining < lastPingSecond { } else if remaining <= 5 && remaining < lastPingSecond {
// One spoken beat per second through the count-in: the exercise name at 4s, then // One spoken beat per second through the final five: the exercise name at 5s,
// the count. The haptic ping stays on the final three seconds only. // a buffer, then the count. The haptic ping stays on the final three seconds only.
lastPingSecond = remaining lastPingSecond = remaining
if remaining <= 3 { WorkoutHaptic.tick.play() } if remaining <= 3 { WorkoutHaptic.tick.play() }
onCountdownBeat?(remaining) onCountdownBeat?(remaining)