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