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