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:
@@ -60,9 +60,19 @@ struct ExerciseProgressView: View {
|
||||
/// without re-broadcasting or re-recording — the originating device owns the durable write.
|
||||
var incomingFrame: LiveProgress?
|
||||
|
||||
/// Reads the exercise's setup/form cues aloud when the user taps Start, if
|
||||
/// `speakExerciseCues` is on. Optional so decoupled hosts (the screenshot rig) that
|
||||
/// don't inject `AppServices` simply stay silent.
|
||||
var speechAnnouncer: SpeechAnnouncer? = nil
|
||||
|
||||
/// Rest length between sets, shared with the watch via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
/// Read aloud the setup/form cues when an exercise starts (hands-free). Off by default;
|
||||
/// opt in from Settings. Governs only the automatic cue — the library Speak button is
|
||||
/// always available.
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@@ -125,7 +135,7 @@ struct ExerciseProgressView: View {
|
||||
/// `startsCompleted`.
|
||||
@State private var startsSkipped: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
@@ -134,6 +144,7 @@ struct ExerciseProgressView: View {
|
||||
self.onActivity = onActivity
|
||||
self.onActivityEnded = onActivityEnded
|
||||
self.incomingFrame = incomingFrame
|
||||
self.speechAnnouncer = speechAnnouncer
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
@@ -346,6 +357,8 @@ struct ExerciseProgressView: View {
|
||||
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
|
||||
onLiveEnded()
|
||||
onActivityEnded()
|
||||
// Don't keep narrating a screen the user has left.
|
||||
speechAnnouncer?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,9 +628,18 @@ struct ExerciseProgressView: View {
|
||||
/// Leave the Ready page for the first work phase, marking the exercise started.
|
||||
private func start() {
|
||||
beginExercise()
|
||||
announceCue()
|
||||
withAnimation { currentPage = base }
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted in. Silent
|
||||
/// when the announcer isn't wired (screenshot host) or the exercise has no bundled info.
|
||||
private func announceCue() {
|
||||
guard speakExerciseCues, let announcer = speechAnnouncer, let log else { return }
|
||||
guard let info = ExerciseInfoLibrary.info(for: log.exerciseName) else { return }
|
||||
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
|
||||
}
|
||||
|
||||
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
@@ -751,15 +773,16 @@ enum SetEntryFormat {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the
|
||||
/// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies,
|
||||
/// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight.
|
||||
/// All of a log's entries on one line: "4 × 10 reps @ 45 lb" when every set
|
||||
/// matched, "10 · 8 · 6 reps @ 45 lb" when reps vary but the weight is uniform,
|
||||
/// a per-set "10 × 45 lb · 8 × 42.5 lb" list when the weight varies too,
|
||||
/// "3 × 45 sec" / "45 · 45 · 30 sec" for durations, and the same for bodyweight.
|
||||
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
|
||||
guard !entries.isEmpty else { return "" }
|
||||
if entries.contains(where: { $0.seconds != nil }) {
|
||||
return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec"
|
||||
return collapsedList(entries.map { $0.seconds ?? 0 }) + " sec"
|
||||
}
|
||||
let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ")
|
||||
let repsList = collapsedList(entries.map { $0.reps ?? 0 })
|
||||
let weights = entries.compactMap(\.weight)
|
||||
guard !weights.isEmpty else { return repsList + " reps" }
|
||||
if Set(weights).count == 1 {
|
||||
@@ -770,6 +793,17 @@ enum SetEntryFormat {
|
||||
return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
|
||||
}.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// A per-set value list, collapsed to "N × v" when every set matches (so four
|
||||
/// tens read as "4 × 10", not "10 · 10 · 10 · 10") and left as a "10 · 8 · 6"
|
||||
/// list otherwise. A single set is just its value (never "1 × v").
|
||||
private static func collapsedList(_ values: [Int]) -> String {
|
||||
guard let first = values.first else { return "" }
|
||||
if values.count > 1 && values.allSatisfy({ $0 == first }) {
|
||||
return "\(values.count) × \(first)"
|
||||
}
|
||||
return values.map(String.init).joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set entry adjust sheet
|
||||
|
||||
Reference in New Issue
Block a user