// // 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() } } }