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
110 lines
4.4 KiB
Swift
110 lines
4.4 KiB
Swift
//
|
||
// SpeechAnnouncer.swift
|
||
// Workouts
|
||
//
|
||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import AVFoundation
|
||
import Observation
|
||
|
||
/// Speaks exercise instructions aloud with `AVSpeechSynthesizer` — on-device, offline,
|
||
/// no network. Two callers share one instance (via `AppServices`): the library detail
|
||
/// screen's Speak button (full reference read-through) and the active-workout flow's
|
||
/// hands-free cue when an exercise starts. A fresh `speak` cancels whatever's playing,
|
||
/// so a new exercise's cue never stacks on the last one.
|
||
///
|
||
/// The audio session ducks other audio (music, podcasts) rather than stopping it, and is
|
||
/// deactivated the moment nothing is speaking so that audio returns to full volume. iPhone
|
||
/// only: the watch's `HKWorkoutSession` owns its own audio session, so this stays out of
|
||
/// the watch target.
|
||
@MainActor
|
||
@Observable
|
||
final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||
/// True while an utterance is being spoken — drives the Speak/Stop toggle in the UI.
|
||
private(set) var isSpeaking = false
|
||
|
||
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.
|
||
/// 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 {
|
||
synthesizer.stopSpeaking(at: .immediate)
|
||
}
|
||
|
||
// Voice and prosody come from SpeechSettings (the Settings › Voice controls), read
|
||
// fresh each time so a just-changed slider or picker takes effect immediately.
|
||
let utterance = AVSpeechUtterance(string: trimmed)
|
||
utterance.voice = SpeechSettings.resolvedVoice()
|
||
utterance.rate = SpeechSettings.rate
|
||
utterance.pitchMultiplier = SpeechSettings.pitch
|
||
utterance.volume = SpeechSettings.volume
|
||
|
||
isSpeaking = true
|
||
synthesizer.speak(utterance)
|
||
}
|
||
|
||
/// Stop immediately and hand audio focus back to other apps.
|
||
func stop() {
|
||
holdsSession = false
|
||
if synthesizer.isSpeaking {
|
||
synthesizer.stopSpeaking(at: .immediate)
|
||
}
|
||
isSpeaking = false
|
||
deactivateSession()
|
||
}
|
||
|
||
// MARK: - Audio session
|
||
|
||
/// Duck other audio while a prompt plays; `.voicePrompt` routes and mixes the way
|
||
/// navigation/assistant prompts do. Activation is idempotent.
|
||
private func activateSession() {
|
||
let session = AVAudioSession.sharedInstance()
|
||
try? session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers])
|
||
try? session.setActive(true)
|
||
}
|
||
|
||
/// 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, !holdsSession else { return }
|
||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||
}
|
||
|
||
// MARK: - AVSpeechSynthesizerDelegate
|
||
|
||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||
Task { @MainActor in
|
||
self.isSpeaking = self.synthesizer.isSpeaking
|
||
self.deactivateSession()
|
||
}
|
||
}
|
||
|
||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||
Task { @MainActor in
|
||
self.isSpeaking = self.synthesizer.isSpeaking
|
||
self.deactivateSession()
|
||
}
|
||
}
|
||
}
|