Preview the next exercise and count into each set during rests
The run flow's between-exercise rest now previews the next exercise — its
figure and a large "Coming up" name — and the hands-free narration gains a
per-second count-in ("<Exercise>, in 1, 2, 3, GO!") spoken before every set,
plus a "Coming up" announcement as a between-exercise rest begins. A new
Settings > Narration picker chooses the count-in cues, the setup/form read,
or both.
Also fixes spoken cues going silent after the first exercise in a flow split:
the stop-on-teardown moved from the per-exercise view (rebuilt on every
hand-off) to the run host, which stays mounted for the whole run. The audio
session now holds its duck across the per-second count so background music
doesn't pulse between words.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
This commit is contained in:
@@ -26,16 +26,25 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
/// While true, `deactivateSession` won't hand audio focus back when an utterance ends —
|
||||
/// so a per-second spoken countdown ("in 1… 2… 3… Go") stays ducked throughout instead
|
||||
/// of pulsing other audio back to full in the gaps between counts. Cleared by the final
|
||||
/// (non-holding) utterance of a run and by `stop`.
|
||||
private var holdsSession = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// Speak `text`, replacing anything currently playing. Empty/blank text is a no-op.
|
||||
func speak(_ text: String) {
|
||||
/// Pass `holdSession: true` for all but the last of a rapid back-to-back sequence so
|
||||
/// other audio stays ducked between the utterances rather than un-ducking in each gap.
|
||||
func speak(_ text: String, holdSession: Bool = false) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
holdsSession = holdSession
|
||||
activateSession()
|
||||
// Replace, don't queue: a new exercise's cue supersedes the previous one.
|
||||
if synthesizer.isSpeaking {
|
||||
@@ -56,6 +65,7 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
/// Stop immediately and hand audio focus back to other apps.
|
||||
func stop() {
|
||||
holdsSession = false
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
@@ -73,10 +83,11 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
/// Return other audio to full volume. Skipped while something is still speaking, so a
|
||||
/// cancel-then-speak (new exercise) doesn't briefly un-duck between utterances.
|
||||
/// Return other audio to full volume. Skipped while something is still speaking, or while
|
||||
/// a countdown holds the session, so a cancel-then-speak (new exercise) or a per-second
|
||||
/// count doesn't briefly un-duck between utterances.
|
||||
private func deactivateSession() {
|
||||
guard !synthesizer.isSpeaking else { return }
|
||||
guard !synthesizer.isSpeaking, !holdsSession else { return }
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user