Files
workouts/Workouts/Speech/SpeechAnnouncer.swift
T
rzen 0f5f11e5e2 Add spoken exercise instructions (on-device TTS)
Speak exercise setup and form cues aloud with AVSpeechSynthesizer:

- Library detail: a speaker toolbar button reads the full reference aloud.
- Active workout: an opt-in "Speak Exercise Cues" setting speaks a brief
  cue (Setup/Execution/Cues) when an exercise starts, hands-free.
- Settings › Voice: pick the voice (auto-prefers an installed enhanced/
  premium English voice), a premium-download nudge shown only while on a
  basic voice, and a Speed/Pitch/Volume sheet with Reset to Defaults.

On-device and offline; ducks other audio rather than stopping it. iPhone
only for now. Shared SpeechSettings is the single source of truth for the
voice/prosody, read fresh per utterance so changes preview live.

Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
2026-07-09 13:27:40 -04:00

99 lines
3.7 KiB
Swift
Raw 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()
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()
}
}
}