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
@@ -62,6 +62,38 @@ struct ExerciseInfo {
}
extension ExerciseInfo {
/// Sections read aloud in the brief hands-free cue (the workout-flow announcement).
/// The library's Speak button reads everything; a mid-workout cue skips the reference
/// sections (Common Mistakes, Progression) you don't need with the bar in your hands.
private static let briefSectionTitles: Set<String> = ["Setup", "Execution", "Cues"]
/// Render this exercise as a plain-text script for `AVSpeechSynthesizer`: the name,
/// the summary, then each section's title and items as sentences. `brief` keeps only
/// the hands-free sections (`briefSectionTitles`), falling back to every section when
/// none of them are present, so an exercise with only unusual headings still speaks.
func spokenScript(name: String, brief: Bool) -> String {
var parts: [String] = [name]
let summaryLine = summary.replacingOccurrences(of: "\n", with: " ")
if !summaryLine.trimmingCharacters(in: .whitespaces).isEmpty {
parts.append(summaryLine)
}
var chosen = brief ? sections.filter { Self.briefSectionTitles.contains($0.title) } : sections
if chosen.isEmpty { chosen = sections }
for section in chosen {
parts.append(section.title)
parts.append(contentsOf: section.items.map(\.text))
}
return parts
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { $0.hasSuffix(".") || $0.hasSuffix("!") || $0.hasSuffix("?") ? $0 : $0 + "." }
.joined(separator: " ")
}
/// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line
/// that doesn't start a new block (heading, list marker, blank) continues the
/// previous one, matching how Markdown renders soft breaks.