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
+23
View File
@@ -32,6 +32,29 @@ struct ExerciseInfoTests {
}
}
/// The spoken script (fed to `AVSpeechSynthesizer`) leads with the exercise name and
/// its summary, and `brief` drops the reference-only sections so a hands-free workout
/// cue reads Setup/Execution/Cues but not Common Mistakes/Progression.
@Test func spokenScriptBriefDropsReferenceSections() throws {
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
let full = info.spokenScript(name: "Chest Press", brief: false)
#expect(full.hasPrefix("Chest Press."))
#expect(full.contains("Setup."))
#expect(full.contains("Execution."))
#expect(full.contains("Cues."))
#expect(full.contains("Common Mistakes."))
#expect(full.contains("Progression."))
let brief = info.spokenScript(name: "Chest Press", brief: true)
#expect(brief.hasPrefix("Chest Press."))
#expect(brief.contains("Setup."))
#expect(brief.contains("Execution."))
#expect(brief.contains("Cues."))
#expect(!brief.contains("Common Mistakes."))
#expect(!brief.contains("Progression."))
}
/// Every bundled motion rig ships a parseable info page the exporter copies
/// `info.md` per entry, and the detail screen counts on the shape.
@Test func everyBundledExerciseHasParseableInfo() throws {