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
+199
View File
@@ -7,6 +7,7 @@
import SwiftUI
import SwiftData
import AVFoundation
import IndieAbout
import IndieBackup
@@ -19,10 +20,19 @@ struct SettingsView: View {
@AppStorage("restSeconds") private var restSeconds: Int = 45
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
@State private var isRestoringSeeds = false
@State private var restoreSeedsMessage: String?
/// Installed English voices, best quality first. Loaded on appear (not per render) since
/// `speechVoices()` is a touch heavy and the list only changes when the user downloads a
/// voice in system settings re-read on each appear catches a return from doing exactly that.
@State private var installedVoices: [AVSpeechSynthesisVoice] = []
@State private var showingSpeechAdjustments = false
var body: some View {
NavigationStack {
Form {
@@ -49,12 +59,21 @@ struct SettingsView: View {
Text(unit.displayName).tag(unit)
}
}
Picker("Body Type", selection: $figureProfile) {
ForEach(FigureProfile.allCases, id: \.self) { profile in
Text(profile.displayName).tag(profile)
}
}
} header: {
Text("Workout")
} footer: {
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
}
// MARK: - Voice Section
voiceSection
// MARK: - Library Section
Section(header: Text("Library")) {
NavigationLink {
@@ -169,6 +188,186 @@ struct SettingsView: View {
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
.onAppear {
installedVoices = SpeechSettings.availableVoices()
// A previously-chosen voice that's since been uninstalled falls back to Automatic
// so the picker shows a valid selection (the announcer already falls back too).
if !speechVoiceIdentifier.isEmpty,
!installedVoices.contains(where: { $0.identifier == speechVoiceIdentifier }) {
speechVoiceIdentifier = ""
}
}
.sheet(isPresented: $showingSpeechAdjustments) {
SpeechAdjustmentsView()
}
}
}
// MARK: - Voice
/// True once at least one enhanced or premium voice is installed the nudge to go
/// download a nicer voice only shows when the user is still on a basic (default) voice.
private var hasUpgradedVoice: Bool {
installedVoices.contains { $0.quality != .default }
}
/// Most installed voices are default quality, so labeling every one "Default" is noise;
/// name only for those, and call out the enhanced/premium ones that are worth choosing.
private func voiceLabel(_ voice: AVSpeechSynthesisVoice) -> String {
voice.quality == .default
? voice.name
: "\(voice.name)\(SpeechSettings.qualityLabel(voice.quality))"
}
/// All spoken-instruction controls. The voice and the speed/pitch/volume adjustments also
/// govern the library exercise Speak button; only the toggle gates the automatic workout cue.
@ViewBuilder
private var voiceSection: some View {
Section {
if !hasUpgradedVoice {
premiumVoiceCallout
}
Toggle("Speak Exercise Cues", isOn: $speakExerciseCues)
Picker("Voice", selection: $speechVoiceIdentifier) {
Text("Automatic").tag("")
ForEach(installedVoices, id: \.identifier) { voice in
Text(voiceLabel(voice)).tag(voice.identifier)
}
}
Button {
showingSpeechAdjustments = true
} label: {
HStack {
Label("Speed, Pitch & Volume", systemImage: "slider.horizontal.3")
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
Button {
let announcer = services.speechAnnouncer
if announcer.isSpeaking {
announcer.stop()
} else {
announcer.speak("This is how your exercise cues will sound.")
}
} label: {
Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview Voice",
systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle")
}
} header: {
Text("Voice")
} footer: {
Text("Speak Exercise Cues reads each exercise's setup and form cues aloud when you start it; tap a library exercise's speaker to hear its full instructions anytime.")
}
}
/// Shown only when no enhanced/premium voice is installed: points the user to the system
/// voice download, the one lever that actually makes speech sound less robotic.
private var premiumVoiceCallout: some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Using a basic voice")
.font(.subheadline.weight(.semibold))
Text("For a much more natural sound, download a Premium voice in Settings Accessibility Spoken Content Voices, then choose it above.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
}
}
}
/// The speed / pitch / volume knobs, split into their own sheet with a reset. These are the
/// fine-tuning controls, kept out of the main Voice list; they write the same `@AppStorage`
/// keys the announcer reads, so changes preview live.
private struct SpeechAdjustmentsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AppServices.self) private var services
@AppStorage(SpeechSettings.rateKey) private var speechRate = SpeechSettings.rateDefault
@AppStorage(SpeechSettings.pitchKey) private var speechPitch = SpeechSettings.pitchDefault
@AppStorage(SpeechSettings.volumeKey) private var speechVolume = SpeechSettings.volumeDefault
/// True when every knob already sits at its default disables the Reset button.
private var isDefault: Bool {
speechRate == SpeechSettings.rateDefault
&& speechPitch == SpeechSettings.pitchDefault
&& speechVolume == SpeechSettings.volumeDefault
}
var body: some View {
NavigationStack {
Form {
Section {
voiceSlider("Speed", value: $speechRate, range: SpeechSettings.rateRange,
low: "tortoise", high: "hare")
voiceSlider("Pitch", value: $speechPitch, range: SpeechSettings.pitchRange,
low: "arrow.down", high: "arrow.up")
voiceSlider("Volume", value: $speechVolume, range: SpeechSettings.volumeRange,
low: "speaker.fill", high: "speaker.wave.3.fill")
}
Section {
Button {
let announcer = services.speechAnnouncer
if announcer.isSpeaking {
announcer.stop()
} else {
announcer.speak("This is how your exercise cues will sound.")
}
} label: {
Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview",
systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle")
}
Button(role: .destructive, action: resetToDefaults) {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
}
.disabled(isDefault)
}
}
.navigationTitle("Speed, Pitch & Volume")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.onDisappear { services.speechAnnouncer.stop() }
}
.presentationDetents([.medium, .large])
}
private func resetToDefaults() {
withAnimation {
speechRate = SpeechSettings.rateDefault
speechPitch = SpeechSettings.pitchDefault
speechVolume = SpeechSettings.volumeDefault
}
}
/// A labeled slider row with low/high symbol endcaps.
private func voiceSlider(_ title: String, value: Binding<Double>,
range: ClosedRange<Double>, low: String, high: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
Slider(value: value, in: range) {
Text(title)
} minimumValueLabel: {
Image(systemName: low).foregroundStyle(.secondary).imageScale(.small)
} maximumValueLabel: {
Image(systemName: high).foregroundStyle(.secondary).imageScale(.small)
}
}
}
}
@@ -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
@@ -248,7 +248,8 @@ struct WorkoutLogListView: View {
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
onActivity: { services.liveActivity.update($0) },
onActivityEnded: { services.liveActivity.end() },
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil },
speechAnnouncer: services.speechAnnouncer
)
.onAppear { liveRun.navigatedRunID = logID }
.onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } }
@@ -465,7 +466,10 @@ private struct WorkoutLogRow: View {
}
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
/// strength and the × dimmed, so the counts read at a glance.
/// strength and the × dimmed, so the counts read at a glance. A single set
/// drops the redundant "1 ×" and shows just the amount "45 sec" or
/// "10 reps" with reps carrying an explicit unit so the bare number reads
/// unambiguously (durations already include their "sec"/"min").
private var setsAndReps: Text {
let count: String
if loadType == .duration {
@@ -481,6 +485,10 @@ private struct WorkoutLogRow: View {
} else {
count = "\(log.reps)"
}
if log.sets == 1 {
if loadType == .duration { return Text(count) }
return Text("\(count) \(log.reps == 1 ? "rep" : "reps")")
}
return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
}