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:
2026-07-09 13:27:40 -04:00
parent 8c8f22850e
commit 0f5f11e5e2
12 changed files with 615 additions and 8 deletions
@@ -28,6 +28,7 @@ struct ExerciseLibraryView: View {
/// progression chart; the looping form-guide figure holds the bottom.
struct ExerciseLibraryDetailView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
let exerciseName: String
@@ -143,6 +144,25 @@ struct ExerciseLibraryDetailView: View {
}
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let info {
ToolbarItem(placement: .primaryAction) {
let announcer = services.speechAnnouncer
Button {
if announcer.isSpeaking {
announcer.stop()
} else {
announcer.speak(info.spokenScript(name: exerciseName, brief: false))
}
} label: {
Image(systemName: announcer.isSpeaking ? "stop.fill" : "speaker.wave.2.fill")
}
.accessibilityLabel(announcer.isSpeaking ? "Stop reading" : "Read instructions aloud")
}
}
}
// Leaving the screen shouldn't keep narrating from a page you can no longer see.
.onDisappear { services.speechAnnouncer.stop() }
.sheet(item: $settingsEdit) { route in
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
let targets = route.targetSplitIDs