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,
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: "<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
/// 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)