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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user