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
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user