// // 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() override init() { super.init() synthesizer.delegate = self } /// Speak `text`, replacing anything currently playing. Empty/blank text is a no-op. func speak(_ text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } 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() { 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, so a /// cancel-then-speak (new exercise) doesn't briefly un-duck between utterances. private func deactivateSession() { guard !synthesizer.isSpeaking 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() } } }