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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user