Files
workouts/Workouts/Speech/SpeechAnnouncer.swift
rzen 8b20d7ad08 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
2026-07-10 09:38:45 -04:00

110 lines
4.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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()
}
}
}