From d896e5866682749d57e785e25eb649728cfd5e37 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Tue, 21 Jul 2026 23:32:28 +0100 Subject: [PATCH 1/7] Replace Voice Settings storyboard with SwiftUI Rebuild Voice Settings as a native SwiftUI list hosted from the UIKit Settings screen. Preserve voice selection and preview behavior, enhanced voice confirmation, speaking rate announcements, foreground refresh, telemetry, accessibility focus, and temporary callout and beacon muting. Remove the legacy VoiceSettings storyboard, table controller, speaking rate cell, storyboard segue, and obsolete project references. Automated testing: - Soundscape Debug arm64 simulator build - 66 UnitTests tests - InterfaceBuilderAudit with no Voice Settings candidates or stale references Manual testing required: - Verify Settings > Voice navigation, localized sections, subtitles, checkmarks, preview spinner, enhanced voice alert, selection persistence, foreground voice refresh, and back navigation. - Verify speaking-rate commit and delayed announcement, voice preview completion, and cancellation when leaving the screen. - Verify callouts and beacon audio are muted only while Voice Settings is visible and restored afterward. - Verify VoiceOver focus, traits, labels, and hints; Dynamic Type; and light/dark appearance colors. --- apps/ios/GuideDogs.xcodeproj/project.pbxproj | 8 - .../SpeakingRateTableViewCell.swift | 57 --- .../Settings/SettingsViewController.swift | 4 + .../VoiceSettingsTableViewController.swift | 339 ------------- .../Code/Visual UI/Views/Settings.storyboard | 11 - .../Settings/Voice/VoiceSettingsView.swift | 476 ++++++++++++++++++ .../Visual UI/Views/VoiceSettings.storyboard | 142 ------ 7 files changed, 480 insertions(+), 557 deletions(-) delete mode 100644 apps/ios/GuideDogs/Code/Visual UI/Controls/Voice Settings/SpeakingRateTableViewCell.swift delete mode 100644 apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/Voice/VoiceSettingsTableViewController.swift create mode 100644 apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift delete mode 100644 apps/ios/GuideDogs/Code/Visual UI/Views/VoiceSettings.storyboard diff --git a/apps/ios/GuideDogs.xcodeproj/project.pbxproj b/apps/ios/GuideDogs.xcodeproj/project.pbxproj index faa41ec38..eeed29a94 100644 --- a/apps/ios/GuideDogs.xcodeproj/project.pbxproj +++ b/apps/ios/GuideDogs.xcodeproj/project.pbxproj @@ -605,8 +605,6 @@ C30DDADF2255796B00104085 /* NotificationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30DDADE2255796B00104085 /* NotificationProtocol.swift */; }; C30DDAE9225684BC00104085 /* GDASpatialDataResultEntity+Typeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30DDAE8225684BC00104085 /* GDASpatialDataResultEntity+Typeable.swift */; }; C30DDAEC2256935F00104085 /* Typeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30DDAEB2256935F00104085 /* Typeable.swift */; }; - C30EF61F2089475D00BEA785 /* VoiceSettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30EF61E2089475D00BEA785 /* VoiceSettingsTableViewController.swift */; }; - C30EF6212089481100BEA785 /* SpeakingRateTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30EF6202089481100BEA785 /* SpeakingRateTableViewCell.swift */; }; C30EF62320894AD400BEA785 /* VoiceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30EF62220894AD400BEA785 /* VoiceTableViewCell.swift */; }; C317F24E2370E9AF000579BA /* NotificationContainerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C317F24D2370E9AF000579BA /* NotificationContainerManager.swift */; }; C317F26523722ECF000579BA /* NotificationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C317F26423722ECF000579BA /* NotificationObserver.swift */; }; @@ -1406,8 +1404,6 @@ C30DDADE2255796B00104085 /* NotificationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NotificationProtocol.swift; path = Code/Notifications/Notifications/NotificationProtocol.swift; sourceTree = ""; }; C30DDAE8225684BC00104085 /* GDASpatialDataResultEntity+Typeable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "GDASpatialDataResultEntity+Typeable.swift"; path = "Code/Data/Models/Extensions/OSM Entity/GDASpatialDataResultEntity+Typeable.swift"; sourceTree = ""; }; C30DDAEB2256935F00104085 /* Typeable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Typeable.swift; path = Code/Data/Models/Helpers/Types/Typeable.swift; sourceTree = ""; }; - C30EF61E2089475D00BEA785 /* VoiceSettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VoiceSettingsTableViewController.swift; path = "Code/Visual UI/View Controllers/Settings/Voice/VoiceSettingsTableViewController.swift"; sourceTree = ""; }; - C30EF6202089481100BEA785 /* SpeakingRateTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SpeakingRateTableViewCell.swift; path = "Code/Visual UI/Controls/Voice Settings/SpeakingRateTableViewCell.swift"; sourceTree = ""; }; C30EF62220894AD400BEA785 /* VoiceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VoiceTableViewCell.swift; path = "Code/Visual UI/Controls/Voice Settings/VoiceTableViewCell.swift"; sourceTree = ""; }; C317F24D2370E9AF000579BA /* NotificationContainerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NotificationContainerManager.swift; path = Code/Notifications/NotificationContainerManager.swift; sourceTree = ""; }; C317F26423722ECF000579BA /* NotificationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NotificationObserver.swift; path = Code/Notifications/Observers/NotificationObserver.swift; sourceTree = ""; }; @@ -1614,7 +1610,6 @@ 31D62B811DFD9B8800C5A2D8 /* ThirdPartyNoticesViewController.swift */, 31ACEE4D20B776D700916B9B /* VersionHistoryTableViewController.swift */, 287D3E8C22DE50340084B92B /* StatusTableViewController.swift */, - C30EF61E2089475D00BEA785 /* VoiceSettingsTableViewController.swift */, B92EA05D27AC80E900127A9A /* SiriShortcutsTableViewController.swift */, ); name = Settings; @@ -3719,7 +3714,6 @@ isa = PBXGroup; children = ( C33084DD209D1094007FF6DC /* Voice Cells */, - C30EF6202089481100BEA785 /* SpeakingRateTableViewCell.swift */, C30EF62220894AD400BEA785 /* VoiceTableViewCell.swift */, C338FF0B20AA2312003BEBF2 /* VoiceTableViewCellDelegate.swift */, ); @@ -4976,7 +4970,6 @@ B9243A7024E1DAC40044ECC6 /* DirectionRange.swift in Sources */, 62D73709249A935D00BA0A6D /* LocationDetail.swift in Sources */, 6249500326FBE247008D842B /* RouteEditViewRepresentable.swift in Sources */, - C30EF61F2089475D00BEA785 /* VoiceSettingsTableViewController.swift in Sources */, 62A61232243F9542008A8174 /* MPEditAnnotationTableViewCell.swift in Sources */, 31B94363212B6E4400AB12B2 /* GDATelemetry.swift in Sources */, 31FE6C78233A7B760098D8C5 /* TelemetryHelper.swift in Sources */, @@ -5224,7 +5217,6 @@ 623D3C35264C592F0005F405 /* RouteWaypoint.swift in Sources */, 31ACEE4E20B776D700916B9B /* VersionHistoryTableViewController.swift in Sources */, 622CAFA026AB38A900F804D6 /* BeaconTitleViewController.swift in Sources */, - C30EF6212089481100BEA785 /* SpeakingRateTableViewCell.swift in Sources */, C37E33D32367C11C0033D640 /* HeadsetCalibrationModalViewObserver.swift in Sources */, 28EB9C79237CC7D90064E7EF /* ExperimentServiceModel.swift in Sources */, B9D3FF951E11AF7300501DA3 /* LocalizationContext.swift in Sources */, diff --git a/apps/ios/GuideDogs/Code/Visual UI/Controls/Voice Settings/SpeakingRateTableViewCell.swift b/apps/ios/GuideDogs/Code/Visual UI/Controls/Voice Settings/SpeakingRateTableViewCell.swift deleted file mode 100644 index 3a73fc186..000000000 --- a/apps/ios/GuideDogs/Code/Visual UI/Controls/Voice Settings/SpeakingRateTableViewCell.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// SpeakingRateTableViewCell.swift -// Soundscape -// -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// - -import Foundation - -class SpeakingRateTableViewCell: UITableViewCell { - - // MARK: Properties - - @IBOutlet weak var speakingRateSlider: UISlider! - fileprivate var previousWorkItem: DispatchWorkItem? - - func initialize() { - // We only want to receive the valueChanged event when the user stops moving the slider. - speakingRateSlider.isContinuous = false - - // Load the speaking rate setting. - speakingRateSlider.value = SettingsContext.shared.speakingRate - } - - // MARK: Speaking Rate - - @IBAction func onSpeakingRateSliderValueChanged() { - SettingsContext.shared.speakingRate = speakingRateSlider.value - - announcementTest() - - GDATelemetry.track("settings.voice.rate", with: ["value": String(speakingRateSlider.value), "voice": SettingsContext.shared.voiceId ?? "not_set"]) - } - - fileprivate func announcementTest() { - let test = { - AppContext.shared.eventProcessor.hush(playSound: false) - AppContext.shared.audioEngine.stopDiscrete() - AppContext.process(GenericAnnouncementEvent(GDLocalizedString("voice.voice_rate_test"))) - } - - // When VoiceOver is running, we will wait until the current announcement has finished - if !UIAccessibility.isVoiceOverRunning { - test() - } else { - previousWorkItem?.cancel() - - previousWorkItem = DispatchWorkItem { - test() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500), execute: previousWorkItem!) - } - } - -} diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift index 73d49cb48..ba25df54a 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift @@ -13,6 +13,7 @@ import SwiftUI class SettingsViewController: BaseTableViewController { private static let languageSettingsIndexPath = IndexPath(row: 0, section: Section.general.rawValue) + private static let voiceSettingsIndexPath = IndexPath(row: 1, section: Section.general.rawValue) private static let beaconSettingsIndexPath = IndexPath(row: 2, section: Section.general.rawValue) private static let volumeSettingsIndexPath = IndexPath(row: 3, section: Section.general.rawValue) private static let siriShortcutsIndexPath = IndexPath(row: 5, section: Section.general.rawValue) @@ -151,6 +152,9 @@ class SettingsViewController: BaseTableViewController { case SettingsViewController.languageSettingsIndexPath: let controller = UIHostingController(rootView: LanguageSettingsView()) navigationController?.pushViewController(controller, animated: true) + case SettingsViewController.voiceSettingsIndexPath: + let controller = UIHostingController(rootView: VoiceSettingsView()) + navigationController?.pushViewController(controller, animated: true) case SettingsViewController.beaconSettingsIndexPath: navigationController?.pushViewController(BeaconSelectionHostViewController(), animated: true) case SettingsViewController.volumeSettingsIndexPath: diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/Voice/VoiceSettingsTableViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/Voice/VoiceSettingsTableViewController.swift deleted file mode 100644 index b67b6652a..000000000 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/Voice/VoiceSettingsTableViewController.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// VoiceSettingsTableViewController.swift -// Soundscape -// -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// - -import Foundation -import AVFoundation -import Combine - -protocol VoicesTableViewControllerDelegate: AnyObject { - func onVoiceSelected(_ voice: AVSpeechSynthesisVoice) -} - -class VoiceSettingsTableViewController: BaseTableViewController { - - struct Section { - static let instructions = 0 - static let speakingRate = 1 - static let currentLanguage = 2 - static let otherLanguages = 3 - } - - struct Cell { - static let downloadFromSettings = 0 - } - - // MARK: Properties - - @IBOutlet weak var largeBannerContainerView: UIView! - - weak var delegate: VoicesTableViewControllerDelegate? - - private var didMuteCallouts: Bool = false - private var didMuteBeacon: Bool = false - - private var initialDefaultIdentifier: String? - private var currentVoiceIdentifier: String? - private var currentVoiceIndex: IndexPath? - private var previewingVoiceIdentifier: String? - private var cancellable: AnyCancellable? - - private var voicesInCurrentLang: [AVSpeechSynthesisVoice] = [] - private var voicesInOtherLang: [AVSpeechSynthesisVoice] = [] - - private let currentLocale = LocalizationContext.currentAppLocale - - // MARK: View Life Cycle - - override func viewDidLoad() { - super.viewDidLoad() - - if let id = SettingsContext.shared.voiceId { - if AVSpeechSynthesisVoice(identifier: id) == nil { - // The user must have deleted a voice they previously downloaded... Reset the setting... - SettingsContext.shared.voiceId = nil - currentVoiceIdentifier = TTSConfigHelper.defaultVoice(forLocale: LocalizationContext.currentAppLocale)?.identifier - initialDefaultIdentifier = currentVoiceIdentifier - } else { - currentVoiceIdentifier = id - initialDefaultIdentifier = nil - } - } else { - currentVoiceIdentifier = TTSConfigHelper.defaultVoice(forLocale: LocalizationContext.currentAppLocale)?.identifier - initialDefaultIdentifier = currentVoiceIdentifier - } - - updateVoiceState() - - // Enable automatic sizing - tableView.estimatedRowHeight = 52.5 - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedSectionHeaderHeight = 20 - tableView.sectionHeaderHeight = UITableView.automaticDimension - - cancellable = NotificationCenter.default.publisher(for: .appWillEnterForeground).sink { [weak self] (_) in - guard let `self` = self else { - return - } - - if let id = SettingsContext.shared.voiceId, AVSpeechSynthesisVoice(identifier: id) == nil { - // The user must have deleted a voice they previously downloaded... Reset the setting... - SettingsContext.shared.voiceId = nil - self.currentVoiceIdentifier = TTSConfigHelper.defaultVoice(forLocale: LocalizationContext.currentAppLocale)?.identifier - self.initialDefaultIdentifier = self.currentVoiceIdentifier - } - - self.updateVoiceState() - self.tableView.reloadData() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - GDATelemetry.trackScreenView("settings.voice") - - if AppContext.shared.spatialDataContext.destinationManager.isAudioEnabled { - didMuteBeacon = true - AppContext.shared.spatialDataContext.destinationManager.toggleDestinationAudio(false) - } - - if SettingsContext.shared.automaticCalloutsEnabled { - didMuteCallouts = true - SettingsContext.shared.automaticCalloutsEnabled = false - AppContext.shared.eventProcessor.hush(playSound: false) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - if didMuteBeacon { - AppContext.shared.spatialDataContext.destinationManager.toggleDestinationAudio(false) - } - - if didMuteCallouts { - SettingsContext.shared.automaticCalloutsEnabled = true - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - tableView.reloadData() - } - - private func updateVoiceState() { - voicesInCurrentLang = TTSConfigHelper.loadVoices(forCurrentLanguage: true, currentLocale: LocalizationContext.currentAppLocale) - voicesInOtherLang = TTSConfigHelper.loadVoices(forCurrentLanguage: false, currentLocale: LocalizationContext.currentAppLocale) - } - - // MARK: UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - return 4 - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let locale = LocalizationContext.currentAppLocale - - switch section { - case Section.speakingRate: - return GDLocalizedString("voice.settings.speaking_rate") - - case Section.instructions: - return nil - - case Section.currentLanguage: - guard let lang = locale.languageCode else { - return locale.localizedDescription - } - - return locale.localizedString(forLanguageCode: lang) - - default: - return GDLocalizedString("voice.apple.other_languages") - } - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - switch section { - case Section.instructions: - return "\(GDLocalizedString("voice.apple.additional")) \(GDLocalizedString("voice.apple.no_siri"))" - - default: - return nil - } - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case Section.speakingRate: - return 1 - - case Section.instructions: - return 0 - - case Section.currentLanguage: - return voicesInCurrentLang.count - - default: - return voicesInOtherLang.count - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch (indexPath.section, indexPath.row) { - case (Section.speakingRate, _): - // Handle the rate cell - let cell = tableView.dequeueReusableCell(withIdentifier: "SpeakingRateCell", for: indexPath) - - if let rateCell = cell as? SpeakingRateTableViewCell { - rateCell.initialize() - } - - return cell - - case (Section.instructions, _): - // Handle the instructions cell - return tableView.dequeueReusableCell(withIdentifier: "VoiceInstructionsCell", for: indexPath) - - default: - // Handle the voice cells - let cell = tableView.dequeueReusableCell(withIdentifier: "VoiceCell", for: indexPath) - let voice = indexPath.section == Section.currentLanguage ? voicesInCurrentLang[indexPath.row] : voicesInOtherLang[indexPath.row] - - cell.textLabel?.text = voice.name - cell.detailTextLabel?.text = detail(for: voice, default: voice.identifier == initialDefaultIdentifier) - cell.accessibilityHint = GDLocalizedString("voice.apple.preview_hint") - - if previewingVoiceIdentifier == voice.identifier { - let spinner = UIActivityIndicatorView(style: .medium) - spinner.color = Colors.Foreground.primary - spinner.startAnimating() - cell.accessoryType = .none - cell.accessoryView = spinner - currentVoiceIndex = indexPath - } else if currentVoiceIdentifier == voice.identifier { - cell.accessoryView = nil - cell.accessoryType = .checkmark - currentVoiceIndex = indexPath - } else { - cell.accessoryView = nil - cell.accessoryType = .none - } - - return cell - } - } - - private func detail(for voice: AVSpeechSynthesisVoice, default isDefault: Bool) -> String { - var detail = "" - - let voiceLocale = Locale(identifier: voice.language) - if voiceLocale.languageCode != currentLocale.languageCode { - detail = voiceLocale.localizedDescription - } else if let region = voiceLocale.regionCode { - detail = voiceLocale.localizedString(forRegionCode: region) ?? "" - } - - return isDefault ? GDLocalizedString("voice.apple.default", detail).trimmingCharacters(in: .whitespaces) : detail - } - - // MARK: UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let voice = indexPath.section == Section.currentLanguage ? voicesInCurrentLang[indexPath.row] : voicesInOtherLang[indexPath.row] - - tableView.deselectRow(at: indexPath, animated: true) - - // If this voice is currently selected, or is already an enhanced voice, just select it and play a sample - guard voice.hasEnhancedVersion(), currentVoiceIdentifier != voice.identifier else { - select(voice, at: indexPath) - return - } - - // If the user has selected the compact version of a downloaded premium voice, don't show the alert - guard !voice.hasEnhancedVersionDownloaded() else { - select(voice, at: indexPath) - return - } - - // Show an alert indicating that the user can download an enhanced version of the voice in Settings - let alert = UIAlertController(title: GDLocalizedString("voice.settings.enhanced_available.title"), - message: GDLocalizedString("voice.settings.enhanced_available"), - preferredStyle: .alert) - - let enhancedAction = UIAlertAction(title: GDLocalizedString("voice.settings.enhanced_available.button"), style: .default) { [weak self] (_) in - self?.select(voice, at: indexPath) - } - alert.addAction(enhancedAction) - alert.preferredAction = enhancedAction - - alert.addAction(UIAlertAction(title: GDLocalizedString("general.alert.cancel"), style: .cancel, handler: nil)) - - present(alert, animated: true) - } - - private func select(_ voice: AVSpeechSynthesisVoice, at indexPath: IndexPath) { - initialDefaultIdentifier = nil - previewingVoiceIdentifier = voice.identifier - currentVoiceIdentifier = voice.identifier - SettingsContext.shared.voiceId = voice.identifier - - AppContext.shared.eventProcessor.hush() - - refreshCells(previous: currentVoiceIndex, new: indexPath) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard self?.previewingVoiceIdentifier == voice.identifier else { - return - } - - GDATelemetry.track("settings.voice.preview", with: ["voice": voice.name]) - AppContext.process(TTSVoicePreviewEvent(name: voice.name) { [weak self] _ in - guard self?.previewingVoiceIdentifier == voice.identifier else { - return - } - - self?.previewingVoiceIdentifier = nil - self?.refreshCells(previous: nil, new: indexPath) - self?.updateVoiceOverFocus(on: indexPath) - }) - } - - GDATelemetry.track("settings.voice.select", with: ["voice": voice.name]) - GDLogAppInfo("Selected voice: \(voice.name) (ID: \(voice.identifier))") - } - - private func refreshCells(previous: IndexPath?, new: IndexPath) { - DispatchQueue.main.async { [weak self] in - if let previous = previous { - self?.tableView.reloadRows(at: [previous, new], with: .none) - } else { - self?.tableView.reloadRows(at: [new], with: .none) - } - } - } - - private func updateVoiceOverFocus(on indexPath: IndexPath) { - DispatchQueue.main.async { [weak self] in - guard let updated = self?.tableView.cellForRow(at: indexPath) else { - return - } - - UIAccessibility.post(notification: .layoutChanged, argument: updated) - GDLogAppVerbose("Updated VO focus on selected voice") - } - } -} - -extension VoiceSettingsTableViewController: LargeBannerContainerView { - - func setLargeBannerHeight(_ height: CGFloat) { - largeBannerContainerView.setHeight(height) - tableView.reloadData() - } - -} diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings.storyboard b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings.storyboard index 0807f1425..86d657cb8 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings.storyboard +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings.storyboard @@ -127,9 +127,6 @@ - - - @@ -713,14 +710,6 @@ - - - - - - - - diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift new file mode 100644 index 000000000..7cde61c28 --- /dev/null +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -0,0 +1,476 @@ +// +// VoiceSettingsView.swift +// Soundscape +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import AVFoundation +import SwiftUI + +struct VoiceSettingsView: View { + @StateObject private var model = VoiceSettingsModel() + @AccessibilityFocusState private var focusedVoiceIdentifier: String? + + var body: some View { + List { + Section { + EmptyView() + } footer: { + Text(model.additionalVoicesGuidance) + .foregroundColor(.secondaryForeground) + } + + Section(header: VoiceSettingsSectionHeader(text: GDLocalizedString("voice.settings.speaking_rate"))) { + VoiceSettingsSpeakingRateSlider( + speakingRate: $model.speakingRate, + onEditingChanged: model.speakingRateEditingChanged + ) + .listRowBackground(Color.primaryBackground) + .listRowSeparatorTint(Color.secondaryBackground) + } + + voiceSection( + title: model.currentLanguageSectionTitle, + voices: model.voicesInCurrentLanguage + ) + + voiceSection( + title: GDLocalizedString("voice.apple.other_languages"), + voices: model.voicesInOtherLanguages + ) + } + .voiceSettingsListBackground() + .background(Color.quaternaryBackground.ignoresSafeArea()) + .listStyle(PlainListStyle()) + .tint(.primaryForeground) + .navigationTitle(GDLocalizedString("settings.general.voice")) + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: model.screenDidAppear) + .onDisappear(perform: model.screenDidDisappear) + .onReceive(NotificationCenter.default.publisher(for: .appWillEnterForeground)) { _ in + model.appWillEnterForeground() + } + .onChange(of: model.voiceToFocus) { voiceIdentifier in + focusedVoiceIdentifier = voiceIdentifier + } + .alert( + GDLocalizedString("voice.settings.enhanced_available.title"), + isPresented: isShowingEnhancedVoiceAlert + ) { + Button(GDLocalizedString("voice.settings.enhanced_available.button")) { + model.confirmEnhancedVoiceSelection() + } + + Button(GDLocalizedString("general.alert.cancel"), role: .cancel) { + model.cancelEnhancedVoiceSelection() + } + } message: { + Text(GDLocalizedString("voice.settings.enhanced_available")) + } + } + + private var isShowingEnhancedVoiceAlert: Binding { + Binding( + get: { model.pendingEnhancedVoiceIdentifier != nil }, + set: { isPresented in + if !isPresented { + model.cancelEnhancedVoiceSelection() + } + } + ) + } + + private func voiceSection(title: String, voices: [AVSpeechSynthesisVoice]) -> some View { + Section(header: VoiceSettingsSectionHeader(text: title)) { + ForEach(voices, id: \.identifier) { voice in + Button { + model.selectVoice(voice) + } label: { + VoiceSettingsVoiceRow( + voice: voice, + subtitle: model.detail(for: voice), + isSelected: model.selectedVoiceIdentifier == voice.identifier, + isPreviewing: model.previewingVoiceIdentifier == voice.identifier + ) + } + .buttonStyle(.plain) + .accessibilityFocused($focusedVoiceIdentifier, equals: voice.identifier) + .listRowBackground(Color.primaryBackground) + .listRowSeparatorTint(Color.secondaryBackground) + } + } + } +} + +private struct VoiceSettingsSpeakingRateSlider: View { + @Binding var speakingRate: Float + let onEditingChanged: (Bool) -> Void + + var body: some View { + Slider( + value: $speakingRate, + in: 0.0 ... 1.0, + onEditingChanged: onEditingChanged, + minimumValueLabel: Image(systemName: "tortoise") + .accessibilityHidden(true), + maximumValueLabel: Image(systemName: "hare") + .accessibilityHidden(true), + label: { + GDLocalizedTextView("voice.settings.speaking_rate") + } + ) + .tint(.secondaryForeground) + .accessibilityLabel(GDLocalizedTextView("voice.settings.speaking_rate")) + } +} + +@MainActor +private final class VoiceSettingsModel: ObservableObject { + @Published private(set) var voicesInCurrentLanguage: [AVSpeechSynthesisVoice] = [] + @Published private(set) var voicesInOtherLanguages: [AVSpeechSynthesisVoice] = [] + @Published private(set) var selectedVoiceIdentifier: String? + @Published private(set) var defaultVoiceIdentifier: String? + @Published private(set) var previewingVoiceIdentifier: String? + @Published private(set) var pendingEnhancedVoiceIdentifier: String? + @Published private(set) var voiceToFocus: String? + @Published var speakingRate: Float + + private let currentLocale = LocalizationContext.currentAppLocale + private var didMuteCallouts = false + private var didMuteBeacon = false + private var isScreenVisible = false + private var previewTask: Task? + private var rateAnnouncementTask: Task? + + var additionalVoicesGuidance: String { + "\(GDLocalizedString("voice.apple.additional")) \(GDLocalizedString("voice.apple.no_siri"))" + } + + var currentLanguageSectionTitle: String { + guard let languageCode = currentLocale.languageCode else { + return currentLocale.localizedDescription + } + + return currentLocale.localizedString(forLanguageCode: languageCode) ?? currentLocale.localizedDescription + } + + init() { + speakingRate = SettingsContext.shared.speakingRate + resetDeletedSelectedVoiceIfNeeded() + reloadVoices() + } + + func screenDidAppear() { + guard !isScreenVisible else { + return + } + + isScreenVisible = true + GDATelemetry.trackScreenView("settings.voice") + + let destinationManager = AppContext.shared.spatialDataContext.destinationManager + if destinationManager.isAudioEnabled { + didMuteBeacon = true + destinationManager.toggleDestinationAudio(false) + } + + if SettingsContext.shared.automaticCalloutsEnabled { + didMuteCallouts = true + SettingsContext.shared.automaticCalloutsEnabled = false + AppContext.shared.eventProcessor.hush(playSound: false) + } + } + + func screenDidDisappear() { + guard isScreenVisible else { + return + } + + isScreenVisible = false + cancelPendingAudio() + + let destinationManager = AppContext.shared.spatialDataContext.destinationManager + if didMuteBeacon, !destinationManager.isAudioEnabled { + destinationManager.toggleDestinationAudio(false) + } + + if didMuteCallouts, !SettingsContext.shared.automaticCalloutsEnabled { + SettingsContext.shared.automaticCalloutsEnabled = true + } + + didMuteBeacon = false + didMuteCallouts = false + } + + func appWillEnterForeground() { + resetDeletedSelectedVoiceIfNeeded() + reloadVoices() + } + + func selectVoice(_ voice: AVSpeechSynthesisVoice) { + guard voice.hasEnhancedVersion(), selectedVoiceIdentifier != voice.identifier else { + commitVoiceSelection(voice) + return + } + + guard !voice.hasEnhancedVersionDownloaded() else { + commitVoiceSelection(voice) + return + } + + pendingEnhancedVoiceIdentifier = voice.identifier + } + + func confirmEnhancedVoiceSelection() { + guard let identifier = pendingEnhancedVoiceIdentifier, + let voice = voice(withIdentifier: identifier) else { + pendingEnhancedVoiceIdentifier = nil + return + } + + pendingEnhancedVoiceIdentifier = nil + commitVoiceSelection(voice) + } + + func cancelEnhancedVoiceSelection() { + pendingEnhancedVoiceIdentifier = nil + } + + func speakingRateEditingChanged(_ isEditing: Bool) { + guard !isEditing else { + return + } + + SettingsContext.shared.speakingRate = speakingRate + announceSpeakingRateTest() + + GDATelemetry.track( + "settings.voice.rate", + with: [ + "value": String(speakingRate), + "voice": SettingsContext.shared.voiceId ?? "not_set" + ] + ) + } + + func detail(for voice: AVSpeechSynthesisVoice) -> String { + var detail = "" + let voiceLocale = Locale(identifier: voice.language) + + if voiceLocale.languageCode != currentLocale.languageCode { + detail = voiceLocale.localizedDescription + } else if let regionCode = voiceLocale.regionCode { + detail = voiceLocale.localizedString(forRegionCode: regionCode) ?? "" + } + + guard voice.identifier == defaultVoiceIdentifier else { + return detail + } + + return GDLocalizedString("voice.apple.default", detail) + .trimmingCharacters(in: .whitespaces) + } + + private func resetDeletedSelectedVoiceIfNeeded() { + if let identifier = SettingsContext.shared.voiceId { + if AVSpeechSynthesisVoice(identifier: identifier) == nil { + SettingsContext.shared.voiceId = nil + selectedVoiceIdentifier = TTSConfigHelper.defaultVoice(forLocale: currentLocale)?.identifier + defaultVoiceIdentifier = selectedVoiceIdentifier + } else { + selectedVoiceIdentifier = identifier + defaultVoiceIdentifier = nil + } + } else { + selectedVoiceIdentifier = TTSConfigHelper.defaultVoice(forLocale: currentLocale)?.identifier + defaultVoiceIdentifier = selectedVoiceIdentifier + } + } + + private func reloadVoices() { + voicesInCurrentLanguage = TTSConfigHelper.loadVoices( + forCurrentLanguage: true, + currentLocale: currentLocale + ) + voicesInOtherLanguages = TTSConfigHelper.loadVoices( + forCurrentLanguage: false, + currentLocale: currentLocale + ) + } + + private func voice(withIdentifier identifier: String) -> AVSpeechSynthesisVoice? { + (voicesInCurrentLanguage + voicesInOtherLanguages).first { $0.identifier == identifier } + } + + private func commitVoiceSelection(_ voice: AVSpeechSynthesisVoice) { + previewTask?.cancel() + rateAnnouncementTask?.cancel() + voiceToFocus = nil + defaultVoiceIdentifier = nil + previewingVoiceIdentifier = voice.identifier + selectedVoiceIdentifier = voice.identifier + SettingsContext.shared.voiceId = voice.identifier + + AppContext.shared.eventProcessor.hush() + + previewTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + return + } + + guard let self, + !Task.isCancelled, + self.isScreenVisible, + self.previewingVoiceIdentifier == voice.identifier else { + return + } + + GDATelemetry.track("settings.voice.preview", with: ["voice": voice.name]) + AppContext.process(TTSVoicePreviewEvent(name: voice.name) { [weak self] _ in + Task { @MainActor in + self?.voicePreviewDidFinish(identifier: voice.identifier) + } + }) + } + + GDATelemetry.track("settings.voice.select", with: ["voice": voice.name]) + GDLogAppInfo("Selected voice: \(voice.name) (ID: \(voice.identifier))") + } + + private func voicePreviewDidFinish(identifier: String) { + guard isScreenVisible, previewingVoiceIdentifier == identifier else { + return + } + + previewingVoiceIdentifier = nil + voiceToFocus = identifier + GDLogAppVerbose("Updated VO focus on selected voice") + } + + private func announceSpeakingRateTest() { + rateAnnouncementTask?.cancel() + + guard UIAccessibility.isVoiceOverRunning else { + playSpeakingRateTest() + return + } + + rateAnnouncementTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 1_500_000_000) + } catch { + return + } + + guard !Task.isCancelled, self?.isScreenVisible == true else { + return + } + + self?.playSpeakingRateTest() + } + } + + private func playSpeakingRateTest() { + AppContext.shared.eventProcessor.hush(playSound: false) + AppContext.shared.audioEngine.stopDiscrete() + AppContext.process(GenericAnnouncementEvent(GDLocalizedString("voice.voice_rate_test"))) + } + + private func cancelPendingAudio() { + previewTask?.cancel() + previewTask = nil + rateAnnouncementTask?.cancel() + rateAnnouncementTask = nil + pendingEnhancedVoiceIdentifier = nil + voiceToFocus = nil + + if previewingVoiceIdentifier != nil { + previewingVoiceIdentifier = nil + AppContext.shared.eventProcessor.hush(playSound: false) + AppContext.shared.audioEngine.stopDiscrete() + } + } +} + +private struct VoiceSettingsSectionHeader: View { + let text: String + + var body: some View { + Text(text.localizedUppercase) + .font(.caption) + .foregroundColor(.primaryForeground) + .accessibilityAddTraits(.isHeader) + } +} + +private struct VoiceSettingsVoiceRow: View { + let voice: AVSpeechSynthesisVoice + let subtitle: String + let isSelected: Bool + let isPreviewing: Bool + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(localizedVoiceName) + .foregroundColor(.primaryForeground) + .accessibleTextFormat() + .frame(maxWidth: .infinity, alignment: .leading) + + if !subtitle.isEmpty { + Text(localizedSubtitle) + .font(.caption) + .foregroundColor(.secondaryForeground) + .accessibleTextFormat() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Spacer(minLength: 12) + + if isPreviewing { + ProgressView() + .progressViewStyle(.circular) + .tint(.primaryForeground) + .accessibilityHidden(true) + } else if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.primaryForeground) + .accessibilityHidden(true) + } + } + .padding(.vertical, 6.0) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint(GDLocalizedTextView("voice.apple.preview_hint")) + } + + private var localizedVoiceName: AttributedString { + var name = AttributedString(voice.name) + name.languageIdentifier = voice.language + return name + } + + private var localizedSubtitle: AttributedString { + var localizedSubtitle = AttributedString(subtitle) + localizedSubtitle.languageIdentifier = LocalizationContext.currentAppLocale.identifierHyphened + return localizedSubtitle + } +} + +private extension View { + @ViewBuilder + func voiceSettingsListBackground() -> some View { + if #available(iOS 16.0, *) { + scrollContentBackground(.hidden) + } else { + self + } + } +} diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/VoiceSettings.storyboard b/apps/ios/GuideDogs/Code/Visual UI/Views/VoiceSettings.storyboard deleted file mode 100644 index 208aa7a72..000000000 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/VoiceSettings.storyboard +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From cd7e9459cfead3c379b8060a58109a3a94802eaf Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Sun, 26 Jul 2026 22:26:30 +0100 Subject: [PATCH 2/7] Refine Voice Settings catalogue --- apps/ios/GuideDogs.xcodeproj/project.pbxproj | 6 + .../en-GB.lproj/Localizable.strings | 4 + .../en-US.lproj/Localizable.strings | 3 + .../Text To Speech/TTSConfigHelper.swift | 38 +-- .../Views/Settings/Voice/VoiceCatalogue.swift | 193 ++++++++++++++++ .../Settings/Voice/VoiceSettingsView.swift | 218 +++++++++++++----- .../UnitTests/Audio/VoiceCatalogueTests.swift | 176 ++++++++++++++ 7 files changed, 548 insertions(+), 90 deletions(-) create mode 100644 apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift create mode 100644 apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift diff --git a/apps/ios/GuideDogs.xcodeproj/project.pbxproj b/apps/ios/GuideDogs.xcodeproj/project.pbxproj index eeed29a94..e344b2531 100644 --- a/apps/ios/GuideDogs.xcodeproj/project.pbxproj +++ b/apps/ios/GuideDogs.xcodeproj/project.pbxproj @@ -530,6 +530,7 @@ 91172A732AD8D56D00E6E8E9 /* CoreGPX in Frameworks */ = {isa = PBXBuildFile; productRef = 91172A722AD8D56D00E6E8E9 /* CoreGPX */; }; 914BAAF32AD745E400CB2171 /* DestinationManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAAF22AD745E400CB2171 /* DestinationManagerTest.swift */; }; 914BAAFD2AD7483300CB2171 /* AudioEngineTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */; }; + F100000131A2000100C0FFEE /* VoiceCatalogueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F100000031A2000100C0FFEE /* VoiceCatalogueTests.swift */; }; 914BAB012AD7490100CB2171 /* NavigationControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAB002AD7490100CB2171 /* NavigationControllerTest.swift */; }; D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */; }; D4A72B1131A2000100C0FFEE /* LocalizationContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B1031A2000100C0FFEE /* LocalizationContextTest.swift */; }; @@ -746,6 +747,7 @@ D29832961E4E249700352A5A /* GeoJsonGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29832951E4E249700352A5A /* GeoJsonGeometry.swift */; }; D30355D31B61401D00E1DDED /* GDAJSONObject.m in Sources */ = {isa = PBXBuildFile; fileRef = D30355D21B61401D00E1DDED /* GDAJSONObject.m */; }; D395B0F71B41DA15005A6407 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D395B0F61B41DA15005A6407 /* Images.xcassets */; }; + D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */; }; DCAAD87F2BC1E95F0018D135 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = DCAAD87E2BC1E95F0018D135 /* PrivacyInfo.xcprivacy */; }; DCE4D2AD2D7CE40F00B5DA4B /* NewFeatures.json in Resources */ = {isa = PBXBuildFile; fileRef = DCE4D2AC2D7CE40F00B5DA4B /* NewFeatures.json */; }; /* End PBXBuildFile section */ @@ -1321,6 +1323,7 @@ 6A4891BA2A5E66DE0002D146 /* ExternalNavigationApps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExternalNavigationApps.swift; path = Code/App/ExternalNavigationApps.swift; sourceTree = ""; }; 914BAAF22AD745E400CB2171 /* DestinationManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DestinationManagerTest.swift; sourceTree = ""; }; 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioEngineTest.swift; sourceTree = ""; }; + F100000031A2000100C0FFEE /* VoiceCatalogueTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoiceCatalogueTests.swift; sourceTree = ""; }; 914BAB002AD7490100CB2171 /* NavigationControllerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerTest.swift; sourceTree = ""; }; D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLResourceIdentifierTest.swift; sourceTree = ""; }; D4A72B1031A2000100C0FFEE /* LocalizationContextTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalizationContextTest.swift; sourceTree = ""; }; @@ -1558,6 +1561,7 @@ D3CE458E1C1737AE00AC1A25 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; D3D7CFC81B3D96470020B5E9 /* Soundscape.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Soundscape.app; sourceTree = BUILT_PRODUCTS_DIR; }; D3E2A6361CAD5CA100A5192A /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; + D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLResourceIdentifierTest.swift; sourceTree = ""; }; DCAAD87E2BC1E95F0018D135 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Assets/PropertyLists/PrivacyInfo.xcprivacy; sourceTree = ""; }; DCE09DD62E54AC9500B66BDF /* fa-IR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fa-IR"; path = "fa-IR.lproj/Localizable.strings"; sourceTree = ""; }; DCE09DD72E54AE7400B66BDF /* fa-IR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fa-IR"; path = "fa-IR.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -3497,6 +3501,7 @@ isa = PBXGroup; children = ( 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */, + F100000031A2000100C0FFEE /* VoiceCatalogueTests.swift */, ); path = Audio; sourceTree = ""; @@ -4729,6 +4734,7 @@ files = ( 915FF9F62ADE4BAF002B3690 /* AuthoredActivityContentTest.swift in Sources */, 914BAAFD2AD7483300CB2171 /* AudioEngineTest.swift in Sources */, + F100000131A2000100C0FFEE /* VoiceCatalogueTests.swift in Sources */, 914BAAF32AD745E400CB2171 /* DestinationManagerTest.swift in Sources */, 91C82ABE2A6B08500086D126 /* RouteGuidanceTest.swift in Sources */, AC3A3EE22C40C9080061EEB8 /* NearbyTableFilterTest.swift in Sources */, diff --git a/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings b/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings index b620cc2d6..b5e7674a7 100644 --- a/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings +++ b/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings @@ -508,6 +508,10 @@ "voice.settings.speaking_rate" = "Speaking Rate"; +/* */ +"voice.settings.other_voices" = "Other Voices"; + + /* */ "voice.voices" = "Voices"; diff --git a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings index 36fc0bc58..b191dce1e 100644 --- a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings +++ b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings @@ -452,6 +452,9 @@ /* Setting for the rate of speech. The speed of the text-to-speech voice. */ "voice.settings.speaking_rate" = "Speaking Rate"; +/* Label for a collapsed group containing voices from providers other than Eloquence and eSpeak */ +"voice.settings.other_voices" = "Other Voices"; + /* Screen title to select a speech voice to for use. */ "voice.voices" = "Voices"; diff --git a/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift b/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift index 467d4b54e..f66f76e12 100644 --- a/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift +++ b/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift @@ -3,6 +3,7 @@ // Soundscape // // Copyright (c) Microsoft Corporation. +// Copyright (c) Soundscape Community Contributors. // Licensed under the MIT License. // @@ -36,13 +37,13 @@ struct TTSConfigHelper { return voice } - static func loadVoices(forCurrentLanguage: Bool, currentLocale: Locale) -> [AVSpeechSynthesisVoice] { + static func loadVoices() -> [AVSpeechSynthesisVoice] { let availableVoices = AVSpeechSynthesisVoice.speechVoices().filter({ !self.disallowedVoices.contains($0.identifier) }) let enhancedVoiceIdStubs: [String] = availableVoices.compactMap { $0.quality == .enhanced ? $0.identifier.replacingOccurrences(of: "premium", with: "") : nil } let currentIdentifier = SettingsContext.shared.voiceId // Condense the list of voices such that we keep only the enhanced version if both an enhanced and default version exist - let condensedVoices = availableVoices.filter { (voice) -> Bool in + return availableVoices.filter { (voice) -> Bool in // Keep all enhanced quality voices guard voice.quality == .default, voice.identifier != currentIdentifier else { return true @@ -51,38 +52,5 @@ struct TTSConfigHelper { // Remove any default quality voices which matching enhanced voices return !enhancedVoiceIdStubs.contains(voice.identifier.replacingOccurrences(of: "compact", with: "")) } - - if forCurrentLanguage { - // Filter our voices in other languages and then sort by locale and then name, but - // ensure voices for the current locale show up first in the list - return condensedVoices.filter({ Locale(identifier: $0.language).languageCode == currentLocale.languageCode }).sorted { - let identifierA = Locale(identifier: $0.language).identifierHyphened - let identifierB = Locale(identifier: $1.language).identifierHyphened - let current = currentLocale.identifierHyphened - - if identifierA == current && identifierB != current { - return true - } else if identifierA != current && identifierB == current { - return false - } else if identifierA == identifierB { - return $0.name < $1.name - } else { - return identifierA < identifierB - } - } - } else { - // Filter our voices in the current language and then sort by locale and then name, but - // ensure voices for the current locale show up first in the list - return condensedVoices.filter({ Locale(identifier: $0.language).languageCode != currentLocale.languageCode }).sorted { - let identifierA = Locale(identifier: $0.language).identifierHyphened - let identifierB = Locale(identifier: $1.language).identifierHyphened - - if identifierA == identifierB { - return $0.name < $1.name - } else { - return identifierA < identifierB - } - } - } } } diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift new file mode 100644 index 000000000..5f290c952 --- /dev/null +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift @@ -0,0 +1,193 @@ +// +// VoiceCatalogue.swift +// Soundscape +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import Foundation + +struct VoiceCatalogueDescriptor: Equatable, Identifiable { + let identifier: String + let name: String + let localeIdentifier: String + + var id: String { + identifier + } + + var providerGroup: VoiceCatalogueProviderGroup? { + VoiceCatalogueProviderGroup.classify(identifier: identifier) + } +} + +enum VoiceCatalogueProviderGroup: Int, CaseIterable, Comparable, Identifiable { + case eloquence + case eSpeak + case other + + var id: Self { + self + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + static func classify(identifier: String) -> Self? { + let normalizedIdentifier = identifier.lowercased() + + if normalizedIdentifier.hasPrefix("com.apple.eloquence.") { + return .eloquence + } + + if normalizedIdentifier.contains("espeak") { + return .eSpeak + } + + if !normalizedIdentifier.hasPrefix("com.apple.") { + return .other + } + + return nil + } +} + +struct VoiceCatalogueProviderSection: Equatable, Identifiable { + let provider: VoiceCatalogueProviderGroup + let voices: [VoiceCatalogueDescriptor] + + var id: VoiceCatalogueProviderGroup { + provider + } + + func selectedVoice(identifier: String?) -> VoiceCatalogueDescriptor? { + voices.first { $0.identifier == identifier } + } +} + +struct VoiceCatalogueLanguageSection: Equatable, Identifiable { + let localeIdentifier: String + let title: String + let standardVoices: [VoiceCatalogueDescriptor] + let providerSections: [VoiceCatalogueProviderSection] + + var id: String { + localeIdentifier + } + + var allVoices: [VoiceCatalogueDescriptor] { + standardVoices + providerSections.flatMap(\.voices) + } +} + +struct VoiceCatalogueProviderExpansion: Equatable { + let localeIdentifier: String + let provider: VoiceCatalogueProviderGroup +} + +struct VoiceCatalogue { + let sections: [VoiceCatalogueLanguageSection] + + init( + voices: [VoiceCatalogueDescriptor], + selectedVoiceIdentifier: String?, + defaultVoiceIdentifier: String?, + appLocale: Locale, + displayLocale: Locale + ) { + let groupedVoices = Dictionary(grouping: voices) { + Self.canonicalLocaleIdentifier($0.localeIdentifier) + } + let selectedLocaleIdentifier = Self.preferredLocaleIdentifier( + voices: voices, + selectedVoiceIdentifier: selectedVoiceIdentifier, + defaultVoiceIdentifier: defaultVoiceIdentifier, + appLocale: appLocale + ) + + sections = groupedVoices.map { localeIdentifier, voices in + let sortedVoices = voices.sorted { + Self.voiceIsOrderedBefore($0, $1, locale: displayLocale) + } + let providerSections = VoiceCatalogueProviderGroup.allCases.compactMap { provider in + let providerVoices = sortedVoices.filter { $0.providerGroup == provider } + return providerVoices.isEmpty + ? nil + : VoiceCatalogueProviderSection(provider: provider, voices: providerVoices) + } + + return VoiceCatalogueLanguageSection( + localeIdentifier: localeIdentifier, + title: displayLocale.localizedString(forIdentifier: localeIdentifier) + ?? Locale(identifier: localeIdentifier).localizedDescription(with: displayLocale), + standardVoices: sortedVoices.filter { $0.providerGroup == nil }, + providerSections: providerSections + ) + } + .sorted { + if $0.localeIdentifier == selectedLocaleIdentifier { + return true + } + + if $1.localeIdentifier == selectedLocaleIdentifier { + return false + } + + let titleComparison = $0.title.compare( + $1.title, + options: [.caseInsensitive, .diacriticInsensitive], + locale: displayLocale + ) + if titleComparison != .orderedSame { + return titleComparison == .orderedAscending + } + + return $0.localeIdentifier < $1.localeIdentifier + } + } + + private static func preferredLocaleIdentifier( + voices: [VoiceCatalogueDescriptor], + selectedVoiceIdentifier: String?, + defaultVoiceIdentifier: String?, + appLocale: Locale + ) -> String? { + if let selectedVoiceIdentifier, + let selectedVoice = voices.first(where: { $0.identifier == selectedVoiceIdentifier }) { + return canonicalLocaleIdentifier(selectedVoice.localeIdentifier) + } + + if let defaultVoiceIdentifier, + let defaultVoice = voices.first(where: { $0.identifier == defaultVoiceIdentifier }) { + return canonicalLocaleIdentifier(defaultVoice.localeIdentifier) + } + + let appLocaleIdentifier = canonicalLocaleIdentifier(appLocale.identifier) + return voices.contains { + canonicalLocaleIdentifier($0.localeIdentifier) == appLocaleIdentifier + } ? appLocaleIdentifier : nil + } + + private static func voiceIsOrderedBefore( + _ lhs: VoiceCatalogueDescriptor, + _ rhs: VoiceCatalogueDescriptor, + locale: Locale + ) -> Bool { + let nameComparison = lhs.name.compare( + rhs.name, + options: [.caseInsensitive, .diacriticInsensitive], + locale: locale + ) + if nameComparison != .orderedSame { + return nameComparison == .orderedAscending + } + + return lhs.identifier < rhs.identifier + } + + private static func canonicalLocaleIdentifier(_ identifier: String) -> String { + Locale(identifier: identifier).identifier.replacingOccurrences(of: "_", with: "-") + } +} diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift index 7cde61c28..d68b65d76 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -31,15 +31,9 @@ struct VoiceSettingsView: View { .listRowSeparatorTint(Color.secondaryBackground) } - voiceSection( - title: model.currentLanguageSectionTitle, - voices: model.voicesInCurrentLanguage - ) - - voiceSection( - title: GDLocalizedString("voice.apple.other_languages"), - voices: model.voicesInOtherLanguages - ) + ForEach(model.languageSections) { section in + voiceSection(section) + } } .voiceSettingsListBackground() .background(Color.quaternaryBackground.ignoresSafeArea()) @@ -82,26 +76,56 @@ struct VoiceSettingsView: View { ) } - private func voiceSection(title: String, voices: [AVSpeechSynthesisVoice]) -> some View { - Section(header: VoiceSettingsSectionHeader(text: title)) { - ForEach(voices, id: \.identifier) { voice in - Button { - model.selectVoice(voice) + private func voiceSection(_ section: VoiceCatalogueLanguageSection) -> some View { + Section(header: VoiceSettingsSectionHeader(text: section.title)) { + ForEach(section.standardVoices) { voice in + voiceButton(voice) + } + + ForEach(section.providerSections) { providerSection in + DisclosureGroup( + isExpanded: model.isExpandedBinding( + localeIdentifier: section.localeIdentifier, + provider: providerSection.provider + ) + ) { + ForEach(providerSection.voices) { voice in + voiceButton(voice) + } } label: { - VoiceSettingsVoiceRow( - voice: voice, - subtitle: model.detail(for: voice), - isSelected: model.selectedVoiceIdentifier == voice.identifier, - isPreviewing: model.previewingVoiceIdentifier == voice.identifier + VoiceSettingsProviderRow( + title: model.title(for: providerSection.provider), + selectedVoice: providerSection.selectedVoice( + identifier: model.selectedVoiceIdentifier + ), + isPreviewing: providerSection.selectedVoice( + identifier: model.previewingVoiceIdentifier + ) != nil ) } - .buttonStyle(.plain) - .accessibilityFocused($focusedVoiceIdentifier, equals: voice.identifier) + .tint(.primaryForeground) .listRowBackground(Color.primaryBackground) .listRowSeparatorTint(Color.secondaryBackground) } } } + + private func voiceButton(_ voice: VoiceCatalogueDescriptor) -> some View { + Button { + model.selectVoice(identifier: voice.identifier) + } label: { + VoiceSettingsVoiceRow( + voice: voice, + subtitle: model.detail(for: voice), + isSelected: model.selectedVoiceIdentifier == voice.identifier, + isPreviewing: model.previewingVoiceIdentifier == voice.identifier + ) + } + .buttonStyle(.plain) + .accessibilityFocused($focusedVoiceIdentifier, equals: voice.identifier) + .listRowBackground(Color.primaryBackground) + .listRowSeparatorTint(Color.secondaryBackground) + } } private struct VoiceSettingsSpeakingRateSlider: View { @@ -128,16 +152,17 @@ private struct VoiceSettingsSpeakingRateSlider: View { @MainActor private final class VoiceSettingsModel: ObservableObject { - @Published private(set) var voicesInCurrentLanguage: [AVSpeechSynthesisVoice] = [] - @Published private(set) var voicesInOtherLanguages: [AVSpeechSynthesisVoice] = [] + @Published private(set) var languageSections: [VoiceCatalogueLanguageSection] = [] @Published private(set) var selectedVoiceIdentifier: String? @Published private(set) var defaultVoiceIdentifier: String? @Published private(set) var previewingVoiceIdentifier: String? @Published private(set) var pendingEnhancedVoiceIdentifier: String? @Published private(set) var voiceToFocus: String? + @Published private(set) var expandedProvider: VoiceCatalogueProviderExpansion? @Published var speakingRate: Float private let currentLocale = LocalizationContext.currentAppLocale + private var voicesByIdentifier: [String: AVSpeechSynthesisVoice] = [:] private var didMuteCallouts = false private var didMuteBeacon = false private var isScreenVisible = false @@ -148,14 +173,6 @@ private final class VoiceSettingsModel: ObservableObject { "\(GDLocalizedString("voice.apple.additional")) \(GDLocalizedString("voice.apple.no_siri"))" } - var currentLanguageSectionTitle: String { - guard let languageCode = currentLocale.languageCode else { - return currentLocale.localizedDescription - } - - return currentLocale.localizedString(forLanguageCode: languageCode) ?? currentLocale.localizedDescription - } - init() { speakingRate = SettingsContext.shared.speakingRate resetDeletedSelectedVoiceIfNeeded() @@ -209,7 +226,11 @@ private final class VoiceSettingsModel: ObservableObject { reloadVoices() } - func selectVoice(_ voice: AVSpeechSynthesisVoice) { + func selectVoice(identifier: String) { + guard let voice = voice(withIdentifier: identifier) else { + return + } + guard voice.hasEnhancedVersion(), selectedVoiceIdentifier != voice.identifier else { commitVoiceSelection(voice) return @@ -255,24 +276,43 @@ private final class VoiceSettingsModel: ObservableObject { ) } - func detail(for voice: AVSpeechSynthesisVoice) -> String { - var detail = "" - let voiceLocale = Locale(identifier: voice.language) - - if voiceLocale.languageCode != currentLocale.languageCode { - detail = voiceLocale.localizedDescription - } else if let regionCode = voiceLocale.regionCode { - detail = voiceLocale.localizedString(forRegionCode: regionCode) ?? "" - } - + func detail(for voice: VoiceCatalogueDescriptor) -> String { guard voice.identifier == defaultVoiceIdentifier else { - return detail + return "" } - return GDLocalizedString("voice.apple.default", detail) + return GDLocalizedString("voice.apple.default", "") .trimmingCharacters(in: .whitespaces) } + func title(for provider: VoiceCatalogueProviderGroup) -> String { + switch provider { + case .eloquence: + return "Eloquence" + case .eSpeak: + return "eSpeak" + case .other: + return GDLocalizedString("voice.settings.other_voices") + } + } + + func isExpandedBinding( + localeIdentifier: String, + provider: VoiceCatalogueProviderGroup + ) -> Binding { + let expansion = VoiceCatalogueProviderExpansion( + localeIdentifier: localeIdentifier, + provider: provider + ) + + return Binding( + get: { self.expandedProvider == expansion }, + set: { isExpanded in + self.expandedProvider = isExpanded ? expansion : nil + } + ) + } + private func resetDeletedSelectedVoiceIfNeeded() { if let identifier = SettingsContext.shared.voiceId { if AVSpeechSynthesisVoice(identifier: identifier) == nil { @@ -290,18 +330,39 @@ private final class VoiceSettingsModel: ObservableObject { } private func reloadVoices() { - voicesInCurrentLanguage = TTSConfigHelper.loadVoices( - forCurrentLanguage: true, - currentLocale: currentLocale - ) - voicesInOtherLanguages = TTSConfigHelper.loadVoices( - forCurrentLanguage: false, - currentLocale: currentLocale - ) + let voices = TTSConfigHelper.loadVoices() + voicesByIdentifier = Dictionary(uniqueKeysWithValues: voices.map { ($0.identifier, $0) }) + rebuildCatalogue() + + if let expandedProvider, + !languageSections.contains(where: { section in + section.localeIdentifier == expandedProvider.localeIdentifier + && section.providerSections.contains { + $0.provider == expandedProvider.provider + } + }) { + self.expandedProvider = nil + } + } + + private func rebuildCatalogue() { + languageSections = VoiceCatalogue( + voices: voicesByIdentifier.values.map { + VoiceCatalogueDescriptor( + identifier: $0.identifier, + name: $0.name, + localeIdentifier: $0.language + ) + }, + selectedVoiceIdentifier: selectedVoiceIdentifier, + defaultVoiceIdentifier: defaultVoiceIdentifier, + appLocale: currentLocale, + displayLocale: currentLocale + ).sections } private func voice(withIdentifier identifier: String) -> AVSpeechSynthesisVoice? { - (voicesInCurrentLanguage + voicesInOtherLanguages).first { $0.identifier == identifier } + voicesByIdentifier[identifier] } private func commitVoiceSelection(_ voice: AVSpeechSynthesisVoice) { @@ -312,6 +373,7 @@ private final class VoiceSettingsModel: ObservableObject { previewingVoiceIdentifier = voice.identifier selectedVoiceIdentifier = voice.identifier SettingsContext.shared.voiceId = voice.identifier + rebuildCatalogue() AppContext.shared.eventProcessor.hush() @@ -408,7 +470,7 @@ private struct VoiceSettingsSectionHeader: View { } private struct VoiceSettingsVoiceRow: View { - let voice: AVSpeechSynthesisVoice + let voice: VoiceCatalogueDescriptor let subtitle: String let isSelected: Bool let isPreviewing: Bool @@ -453,7 +515,7 @@ private struct VoiceSettingsVoiceRow: View { private var localizedVoiceName: AttributedString { var name = AttributedString(voice.name) - name.languageIdentifier = voice.language + name.languageIdentifier = voice.localeIdentifier return name } @@ -464,6 +526,52 @@ private struct VoiceSettingsVoiceRow: View { } } +private struct VoiceSettingsProviderRow: View { + let title: String + let selectedVoice: VoiceCatalogueDescriptor? + let isPreviewing: Bool + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .foregroundColor(.primaryForeground) + .accessibleTextFormat() + + if let selectedVoice { + Text(localizedName(selectedVoice)) + .font(.caption) + .foregroundColor(.secondaryForeground) + .accessibleTextFormat() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if isPreviewing { + ProgressView() + .progressViewStyle(.circular) + .tint(.primaryForeground) + .accessibilityHidden(true) + } + + if selectedVoice != nil { + Image(systemName: "checkmark") + .foregroundColor(.primaryForeground) + .accessibilityHidden(true) + } + } + .padding(.vertical, 6.0) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(selectedVoice == nil ? [] : .isSelected) + } + + private func localizedName(_ voice: VoiceCatalogueDescriptor) -> AttributedString { + var name = AttributedString(voice.name) + name.languageIdentifier = voice.localeIdentifier + return name + } +} + private extension View { @ViewBuilder func voiceSettingsListBackground() -> some View { diff --git a/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift b/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift new file mode 100644 index 000000000..8afb95498 --- /dev/null +++ b/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift @@ -0,0 +1,176 @@ +// +// VoiceCatalogueTests.swift +// Soundscape +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import XCTest +@testable import Soundscape + +final class VoiceCatalogueTests: XCTestCase { + private let displayLocale = Locale(identifier: "en-US") + + func testProviderClassification() { + XCTAssertNil(group(for: "com.apple.voice.standard")) + XCTAssertEqual(group(for: "com.apple.eloquence.reed"), .eloquence) + XCTAssertEqual(group(for: "com.apple.speech.synthesis.voice.eSpeak.en"), .eSpeak) + XCTAssertEqual(group(for: "org.example.espeak.voice"), .eSpeak) + XCTAssertEqual(group(for: "org.example.voice"), .other) + } + + func testGroupsByExactLocaleIncludingDialectAndScript() { + let catalogue = makeCatalogue(voices: [ + voice("gb", locale: "en-GB"), + voice("us", locale: "en-US"), + voice("traditional", locale: "zh-Hant"), + voice("simplified", locale: "zh-Hans") + ]) + + XCTAssertEqual( + Set(catalogue.sections.map(\.localeIdentifier)), + Set(["en-GB", "en-US", "zh-Hant", "zh-Hans"]) + ) + } + + func testSelectedLocaleIsFirstAndReordersAfterSelection() { + let voices = [ + voice("gb", locale: "en-GB"), + voice("us", locale: "en-US") + ] + + XCTAssertEqual( + makeCatalogue(voices: voices, selected: "gb").sections.first?.localeIdentifier, + "en-GB" + ) + XCTAssertEqual( + makeCatalogue(voices: voices, selected: "us").sections.first?.localeIdentifier, + "en-US" + ) + } + + func testMissingSelectionFallsBackToDefaultThenAppLocale() { + let voices = [ + voice("fr", locale: "fr-FR"), + voice("us", locale: "en-US") + ] + + XCTAssertEqual( + makeCatalogue( + voices: voices, + selected: "missing", + defaultVoice: "fr", + appLocale: Locale(identifier: "en-US") + ).sections.first?.localeIdentifier, + "fr-FR" + ) + XCTAssertEqual( + makeCatalogue( + voices: voices, + selected: "missing", + defaultVoice: "also-missing", + appLocale: Locale(identifier: "en-US") + ).sections.first?.localeIdentifier, + "en-US" + ) + } + + func testLocalesUseLocalizedAlphabeticalOrderWithoutPriorityMatch() { + let catalogue = makeCatalogue( + voices: [ + voice("zulu", locale: "zu-ZA"), + voice("french", locale: "fr-FR") + ], + appLocale: Locale(identifier: "en-US") + ) + + XCTAssertEqual(catalogue.sections.map(\.localeIdentifier), ["fr-FR", "zu-ZA"]) + } + + func testVoicesSortByLocalizedNameAndIdentifierTieBreak() { + let catalogue = makeCatalogue(voices: [ + voice("com.apple.z", name: "Zoe"), + voice("com.apple.b", name: "Alex"), + voice("com.apple.a", name: "Alex") + ]) + + XCTAssertEqual( + catalogue.sections[0].standardVoices.map(\.identifier), + ["com.apple.a", "com.apple.b", "com.apple.z"] + ) + } + + func testProviderSectionsHaveDeterministicOrderAndAggregateUnknownProviders() { + let catalogue = makeCatalogue(voices: [ + voice("org.first.voice"), + voice("com.apple.speech.voice.espeak.en"), + voice("com.apple.eloquence.reed"), + voice("net.second.voice") + ]) + let section = catalogue.sections[0] + + XCTAssertTrue(section.standardVoices.isEmpty) + XCTAssertEqual(section.providerSections.map(\.provider), [.eloquence, .eSpeak, .other]) + XCTAssertEqual( + section.providerSections.last?.voices.map(\.identifier), + ["net.second.voice", "org.first.voice"] + ) + } + + func testSelectedProviderSummaryFindsSelectedVoice() { + let selected = voice("org.provider.selected", name: "Selected") + let section = makeCatalogue(voices: [ + selected, + voice("org.provider.other", name: "Other") + ]).sections[0].providerSections[0] + + XCTAssertEqual( + section.selectedVoice(identifier: selected.identifier), + selected + ) + XCTAssertNil(section.selectedVoice(identifier: "missing")) + } + + func testCatalogueCanContainOnlyProviderVoices() { + let catalogue = makeCatalogue(voices: [ + voice("com.apple.eloquence.reed"), + voice("org.provider.voice") + ]) + + XCTAssertEqual(catalogue.sections.count, 1) + XCTAssertTrue(catalogue.sections[0].standardVoices.isEmpty) + XCTAssertEqual(catalogue.sections[0].allVoices.count, 2) + } + + private func group(for identifier: String) -> VoiceCatalogueProviderGroup? { + VoiceCatalogueProviderGroup.classify(identifier: identifier) + } + + private func voice( + _ identifier: String, + name: String? = nil, + locale: String = "en-US" + ) -> VoiceCatalogueDescriptor { + VoiceCatalogueDescriptor( + identifier: identifier, + name: name ?? identifier, + localeIdentifier: locale + ) + } + + private func makeCatalogue( + voices: [VoiceCatalogueDescriptor], + selected: String? = nil, + defaultVoice: String? = nil, + appLocale: Locale = Locale(identifier: "de-DE") + ) -> VoiceCatalogue { + VoiceCatalogue( + voices: voices, + selectedVoiceIdentifier: selected, + defaultVoiceIdentifier: defaultVoice, + appLocale: appLocale, + displayLocale: displayLocale + ) + } +} From b744d8801125111ae399e7cc3797eeba106091a6 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Mon, 27 Jul 2026 21:05:07 +0100 Subject: [PATCH 3/7] Group novelty voices and preserve locale order --- .../en-GB.lproj/Localizable.strings | 3 + .../en-US.lproj/Localizable.strings | 3 + .../AVSpeehSynthesisVoice+Extensions.swift | 27 ++++++ .../Views/Settings/Voice/VoiceCatalogue.swift | 63 ++++++++++++-- .../Settings/Voice/VoiceSettingsView.swift | 73 ++++++++++++---- .../UnitTests/Audio/VoiceCatalogueTests.swift | 85 ++++++++++++++++++- 6 files changed, 226 insertions(+), 28 deletions(-) diff --git a/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings b/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings index b5e7674a7..8cdba471f 100644 --- a/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings +++ b/apps/ios/GuideDogs/Assets/Localization/en-GB.lproj/Localizable.strings @@ -511,6 +511,9 @@ /* */ "voice.settings.other_voices" = "Other Voices"; +/* */ +"voice.settings.novelty" = "Novelty"; + /* */ "voice.voices" = "Voices"; diff --git a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings index b191dce1e..1beaf3ca0 100644 --- a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings +++ b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings @@ -455,6 +455,9 @@ /* Label for a collapsed group containing voices from providers other than Eloquence and eSpeak */ "voice.settings.other_voices" = "Other Voices"; +/* Label for the collapsed group containing novelty speech voices */ +"voice.settings.novelty" = "Novelty"; + /* Screen title to select a speech voice to for use. */ "voice.voices" = "Voices"; diff --git a/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift b/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift index 3806b484a..20f79900b 100644 --- a/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift +++ b/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift @@ -3,12 +3,39 @@ // Soundscape // // Copyright (c) Microsoft Corporation. +// Copyright (c) Soundscape Community Contributors. // Licensed under the MIT License. // import AVFoundation extension AVSpeechSynthesisVoice { + static let knownNoveltyVoiceIdentifiers: Set = [ + "com.apple.speech.synthesis.voice.Albert", + "com.apple.speech.synthesis.voice.BadNews", + "com.apple.speech.synthesis.voice.Bahh", + "com.apple.speech.synthesis.voice.Bells", + "com.apple.speech.synthesis.voice.Boing", + "com.apple.speech.synthesis.voice.Bubbles", + "com.apple.speech.synthesis.voice.Cellos", + "com.apple.speech.synthesis.voice.Deranged", + "com.apple.speech.synthesis.voice.GoodNews", + "com.apple.speech.synthesis.voice.Hysterical", + "com.apple.speech.synthesis.voice.Organ", + "com.apple.speech.synthesis.voice.Princess", + "com.apple.speech.synthesis.voice.Trinoids", + "com.apple.speech.synthesis.voice.Whisper", + "com.apple.speech.synthesis.voice.Zarvox" + ] + + var isNoveltyVoice: Bool { + if #available(iOS 17.0, *) { + return voiceTraits.contains(.isNoveltyVoice) + } + + return Self.knownNoveltyVoiceIdentifiers.contains(identifier) + } + /// Checks if there is an enhanced version of this voice available. If this voice is /// already is an enhanced voice, this method will return false. /// diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift index 5f290c952..cfb99ef56 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceCatalogue.swift @@ -12,6 +12,19 @@ struct VoiceCatalogueDescriptor: Equatable, Identifiable { let identifier: String let name: String let localeIdentifier: String + let isNovelty: Bool + + init( + identifier: String, + name: String, + localeIdentifier: String, + isNovelty: Bool = false + ) { + self.identifier = identifier + self.name = name + self.localeIdentifier = localeIdentifier + self.isNovelty = isNovelty + } var id: String { identifier @@ -71,6 +84,7 @@ struct VoiceCatalogueLanguageSection: Equatable, Identifiable { let localeIdentifier: String let title: String let standardVoices: [VoiceCatalogueDescriptor] + let noveltyVoices: [VoiceCatalogueDescriptor] let providerSections: [VoiceCatalogueProviderSection] var id: String { @@ -78,13 +92,18 @@ struct VoiceCatalogueLanguageSection: Equatable, Identifiable { } var allVoices: [VoiceCatalogueDescriptor] { - standardVoices + providerSections.flatMap(\.voices) + standardVoices + noveltyVoices + providerSections.flatMap(\.voices) } } -struct VoiceCatalogueProviderExpansion: Equatable { +enum VoiceCatalogueCollapsedGroup: Equatable { + case novelty + case provider(VoiceCatalogueProviderGroup) +} + +struct VoiceCatalogueExpansion: Equatable { let localeIdentifier: String - let provider: VoiceCatalogueProviderGroup + let group: VoiceCatalogueCollapsedGroup } struct VoiceCatalogue { @@ -95,7 +114,8 @@ struct VoiceCatalogue { selectedVoiceIdentifier: String?, defaultVoiceIdentifier: String?, appLocale: Locale, - displayLocale: Locale + displayLocale: Locale, + preservedLocaleOrder: [String]? = nil ) { let groupedVoices = Dictionary(grouping: voices) { Self.canonicalLocaleIdentifier($0.localeIdentifier) @@ -107,12 +127,20 @@ struct VoiceCatalogue { appLocale: appLocale ) + let localeOrder = preservedLocaleOrder.map { + Dictionary(uniqueKeysWithValues: $0.enumerated().map { ($1, $0) }) + } + sections = groupedVoices.map { localeIdentifier, voices in let sortedVoices = voices.sorted { Self.voiceIsOrderedBefore($0, $1, locale: displayLocale) } + let separatesNoveltyVoices = localeIdentifier == "en-US" let providerSections = VoiceCatalogueProviderGroup.allCases.compactMap { provider in - let providerVoices = sortedVoices.filter { $0.providerGroup == provider } + let providerVoices = sortedVoices.filter { + $0.providerGroup == provider + && (!separatesNoveltyVoices || !$0.isNovelty) + } return providerVoices.isEmpty ? nil : VoiceCatalogueProviderSection(provider: provider, voices: providerVoices) @@ -122,11 +150,34 @@ struct VoiceCatalogue { localeIdentifier: localeIdentifier, title: displayLocale.localizedString(forIdentifier: localeIdentifier) ?? Locale(identifier: localeIdentifier).localizedDescription(with: displayLocale), - standardVoices: sortedVoices.filter { $0.providerGroup == nil }, + standardVoices: sortedVoices.filter { + $0.providerGroup == nil + && (!separatesNoveltyVoices || !$0.isNovelty) + }, + noveltyVoices: separatesNoveltyVoices + ? sortedVoices.filter(\.isNovelty) + : [], providerSections: providerSections ) } .sorted { + if let localeOrder { + let lhsIndex = localeOrder[$0.localeIdentifier] + let rhsIndex = localeOrder[$1.localeIdentifier] + + if let lhsIndex, let rhsIndex, lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + + if lhsIndex != nil, rhsIndex == nil { + return true + } + + if lhsIndex == nil, rhsIndex != nil { + return false + } + } + if $0.localeIdentifier == selectedLocaleIdentifier { return true } diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift index d68b65d76..f5ae4a9fa 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -82,18 +82,44 @@ struct VoiceSettingsView: View { voiceButton(voice) } + if !section.noveltyVoices.isEmpty { + DisclosureGroup( + isExpanded: model.isExpandedBinding( + localeIdentifier: section.localeIdentifier, + group: .novelty + ) + ) { + ForEach(section.noveltyVoices) { voice in + voiceButton(voice) + } + } label: { + VoiceSettingsCategoryRow( + title: GDLocalizedString("voice.settings.novelty"), + selectedVoice: section.noveltyVoices.first { + $0.identifier == model.selectedVoiceIdentifier + }, + isPreviewing: section.noveltyVoices.contains { + $0.identifier == model.previewingVoiceIdentifier + } + ) + } + .tint(.primaryForeground) + .listRowBackground(Color.primaryBackground) + .listRowSeparatorTint(Color.secondaryBackground) + } + ForEach(section.providerSections) { providerSection in DisclosureGroup( isExpanded: model.isExpandedBinding( localeIdentifier: section.localeIdentifier, - provider: providerSection.provider + group: .provider(providerSection.provider) ) ) { ForEach(providerSection.voices) { voice in voiceButton(voice) } } label: { - VoiceSettingsProviderRow( + VoiceSettingsCategoryRow( title: model.title(for: providerSection.provider), selectedVoice: providerSection.selectedVoice( identifier: model.selectedVoiceIdentifier @@ -158,7 +184,7 @@ private final class VoiceSettingsModel: ObservableObject { @Published private(set) var previewingVoiceIdentifier: String? @Published private(set) var pendingEnhancedVoiceIdentifier: String? @Published private(set) var voiceToFocus: String? - @Published private(set) var expandedProvider: VoiceCatalogueProviderExpansion? + @Published private(set) var expandedCategory: VoiceCatalogueExpansion? @Published var speakingRate: Float private let currentLocale = LocalizationContext.currentAppLocale @@ -168,6 +194,7 @@ private final class VoiceSettingsModel: ObservableObject { private var isScreenVisible = false private var previewTask: Task? private var rateAnnouncementTask: Task? + private var displayedLocaleOrder: [String]? var additionalVoicesGuidance: String { "\(GDLocalizedString("voice.apple.additional")) \(GDLocalizedString("voice.apple.no_siri"))" @@ -298,17 +325,17 @@ private final class VoiceSettingsModel: ObservableObject { func isExpandedBinding( localeIdentifier: String, - provider: VoiceCatalogueProviderGroup + group: VoiceCatalogueCollapsedGroup ) -> Binding { - let expansion = VoiceCatalogueProviderExpansion( + let expansion = VoiceCatalogueExpansion( localeIdentifier: localeIdentifier, - provider: provider + group: group ) return Binding( - get: { self.expandedProvider == expansion }, + get: { self.expandedCategory == expansion }, set: { isExpanded in - self.expandedProvider = isExpanded ? expansion : nil + self.expandedCategory = isExpanded ? expansion : nil } ) } @@ -334,31 +361,41 @@ private final class VoiceSettingsModel: ObservableObject { voicesByIdentifier = Dictionary(uniqueKeysWithValues: voices.map { ($0.identifier, $0) }) rebuildCatalogue() - if let expandedProvider, + if let expandedCategory, !languageSections.contains(where: { section in - section.localeIdentifier == expandedProvider.localeIdentifier - && section.providerSections.contains { - $0.provider == expandedProvider.provider - } + guard section.localeIdentifier == expandedCategory.localeIdentifier else { + return false + } + + switch expandedCategory.group { + case .novelty: + return !section.noveltyVoices.isEmpty + case let .provider(provider): + return section.providerSections.contains { $0.provider == provider } + } }) { - self.expandedProvider = nil + self.expandedCategory = nil } } private func rebuildCatalogue() { - languageSections = VoiceCatalogue( + let sections = VoiceCatalogue( voices: voicesByIdentifier.values.map { VoiceCatalogueDescriptor( identifier: $0.identifier, name: $0.name, - localeIdentifier: $0.language + localeIdentifier: $0.language, + isNovelty: $0.isNoveltyVoice ) }, selectedVoiceIdentifier: selectedVoiceIdentifier, defaultVoiceIdentifier: defaultVoiceIdentifier, appLocale: currentLocale, - displayLocale: currentLocale + displayLocale: currentLocale, + preservedLocaleOrder: displayedLocaleOrder ).sections + languageSections = sections + displayedLocaleOrder = sections.map(\.localeIdentifier) } private func voice(withIdentifier identifier: String) -> AVSpeechSynthesisVoice? { @@ -526,7 +563,7 @@ private struct VoiceSettingsVoiceRow: View { } } -private struct VoiceSettingsProviderRow: View { +private struct VoiceSettingsCategoryRow: View { let title: String let selectedVoice: VoiceCatalogueDescriptor? let isPreviewing: Bool diff --git a/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift b/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift index 8afb95498..865dfd220 100644 --- a/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift +++ b/apps/ios/UnitTests/Audio/VoiceCatalogueTests.swift @@ -6,6 +6,7 @@ // Licensed under the MIT License. // +import AVFoundation import XCTest @testable import Soundscape @@ -50,6 +51,24 @@ final class VoiceCatalogueTests: XCTestCase { ) } + func testPreservedLocaleOrderOverridesSelectionChanges() { + let voices = [ + voice("gb", locale: "en-GB"), + voice("us", locale: "en-US"), + voice("fr", locale: "fr-FR") + ] + let initialCatalogue = makeCatalogue(voices: voices, selected: "gb") + let initialOrder = initialCatalogue.sections.map(\.localeIdentifier) + let updatedCatalogue = makeCatalogue( + voices: voices, + selected: "fr", + preservedLocaleOrder: initialOrder + ) + + XCTAssertEqual(initialOrder, ["en-GB", "en-US", "fr-FR"]) + XCTAssertEqual(updatedCatalogue.sections.map(\.localeIdentifier), initialOrder) + } + func testMissingSelectionFallsBackToDefaultThenAppLocale() { let voices = [ voice("fr", locale: "fr-FR"), @@ -143,6 +162,60 @@ final class VoiceCatalogueTests: XCTestCase { XCTAssertEqual(catalogue.sections[0].allVoices.count, 2) } + func testEnUSNoveltyVoicesHaveExclusiveCategory() { + let catalogue = makeCatalogue(voices: [ + voice("com.apple.standard", name: "Standard"), + voice("com.apple.novelty", name: "Novelty", isNovelty: true), + voice("org.provider.novelty", name: "Provider Novelty", isNovelty: true), + voice("org.provider.standard", name: "Provider Standard") + ]) + let section = catalogue.sections[0] + + XCTAssertEqual(section.standardVoices.map(\.identifier), ["com.apple.standard"]) + XCTAssertEqual( + section.noveltyVoices.map(\.identifier), + ["com.apple.novelty", "org.provider.novelty"] + ) + XCTAssertEqual( + section.providerSections.flatMap(\.voices).map(\.identifier), + ["org.provider.standard"] + ) + XCTAssertEqual(section.allVoices.count, 4) + } + + func testNoveltyVoicesOutsideEnUSKeepExistingPlacement() { + let catalogue = makeCatalogue(voices: [ + voice("com.apple.novelty", locale: "en-GB", isNovelty: true), + voice("org.provider.novelty", locale: "en-GB", isNovelty: true) + ]) + let section = catalogue.sections[0] + + XCTAssertTrue(section.noveltyVoices.isEmpty) + XCTAssertEqual(section.standardVoices.map(\.identifier), ["com.apple.novelty"]) + XCTAssertEqual( + section.providerSections.flatMap(\.voices).map(\.identifier), + ["org.provider.novelty"] + ) + } + + func testKnownNoveltyIdentifiersSupportOlderIOSVersions() { + XCTAssertTrue( + AVSpeechSynthesisVoice.knownNoveltyVoiceIdentifiers.contains( + "com.apple.speech.synthesis.voice.Albert" + ) + ) + XCTAssertTrue( + AVSpeechSynthesisVoice.knownNoveltyVoiceIdentifiers.contains( + "com.apple.speech.synthesis.voice.Zarvox" + ) + ) + XCTAssertFalse( + AVSpeechSynthesisVoice.knownNoveltyVoiceIdentifiers.contains( + "com.apple.speech.synthesis.voice.Junior" + ) + ) + } + private func group(for identifier: String) -> VoiceCatalogueProviderGroup? { VoiceCatalogueProviderGroup.classify(identifier: identifier) } @@ -150,12 +223,14 @@ final class VoiceCatalogueTests: XCTestCase { private func voice( _ identifier: String, name: String? = nil, - locale: String = "en-US" + locale: String = "en-US", + isNovelty: Bool = false ) -> VoiceCatalogueDescriptor { VoiceCatalogueDescriptor( identifier: identifier, name: name ?? identifier, - localeIdentifier: locale + localeIdentifier: locale, + isNovelty: isNovelty ) } @@ -163,14 +238,16 @@ final class VoiceCatalogueTests: XCTestCase { voices: [VoiceCatalogueDescriptor], selected: String? = nil, defaultVoice: String? = nil, - appLocale: Locale = Locale(identifier: "de-DE") + appLocale: Locale = Locale(identifier: "de-DE"), + preservedLocaleOrder: [String]? = nil ) -> VoiceCatalogue { VoiceCatalogue( voices: voices, selectedVoiceIdentifier: selected, defaultVoiceIdentifier: defaultVoice, appLocale: appLocale, - displayLocale: displayLocale + displayLocale: displayLocale, + preservedLocaleOrder: preservedLocaleOrder ) } } From f5f7bf2d9d85d25796d677fa2627cf26a735e983 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Mon, 27 Jul 2026 21:13:15 +0100 Subject: [PATCH 4/7] Keep compact voices in the catalogue --- .../Audio/Text To Speech/TTSConfigHelper.swift | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift b/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift index f66f76e12..6ebf78897 100644 --- a/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift +++ b/apps/ios/GuideDogs/Code/Audio/Text To Speech/TTSConfigHelper.swift @@ -38,19 +38,8 @@ struct TTSConfigHelper { } static func loadVoices() -> [AVSpeechSynthesisVoice] { - let availableVoices = AVSpeechSynthesisVoice.speechVoices().filter({ !self.disallowedVoices.contains($0.identifier) }) - let enhancedVoiceIdStubs: [String] = availableVoices.compactMap { $0.quality == .enhanced ? $0.identifier.replacingOccurrences(of: "premium", with: "") : nil } - let currentIdentifier = SettingsContext.shared.voiceId - - // Condense the list of voices such that we keep only the enhanced version if both an enhanced and default version exist - return availableVoices.filter { (voice) -> Bool in - // Keep all enhanced quality voices - guard voice.quality == .default, voice.identifier != currentIdentifier else { - return true - } - - // Remove any default quality voices which matching enhanced voices - return !enhancedVoiceIdStubs.contains(voice.identifier.replacingOccurrences(of: "compact", with: "")) + AVSpeechSynthesisVoice.speechVoices().filter { + !disallowedVoices.contains($0.identifier) } } } From e56ed72e96fa059106d9d0903eb4176f1376bcc3 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Mon, 27 Jul 2026 21:20:21 +0100 Subject: [PATCH 5/7] Simplify voice selection --- .../AVSpeehSynthesisVoice+Extensions.swift | 23 ------- .../Settings/Voice/VoiceSettingsView.swift | 62 ------------------- 2 files changed, 85 deletions(-) diff --git a/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift b/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift index 20f79900b..bef57f988 100644 --- a/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift +++ b/apps/ios/GuideDogs/Code/App/Framework Extensions/AVSpeech Extensions/AVSpeehSynthesisVoice+Extensions.swift @@ -35,27 +35,4 @@ extension AVSpeechSynthesisVoice { return Self.knownNoveltyVoiceIdentifiers.contains(identifier) } - - /// Checks if there is an enhanced version of this voice available. If this voice is - /// already is an enhanced voice, this method will return false. - /// - /// - Returns: True if there is an enhanced voice available, False otherwise. - func hasEnhancedVersion() -> Bool { - guard identifier.contains("com.apple.ttsbundle"), identifier.contains("compact"), !identifier.contains("siri") else { - return false - } - - return true - } - - /// Checks if this voice should have an enhanced version and if that enhanced version is already downloaded - /// - /// - Returns: True if this is a compact voice and there is an available voice with the premium version of this voice's identifier - func hasEnhancedVersionDownloaded() -> Bool { - guard hasEnhancedVersion() else { - return false - } - - return AVSpeechSynthesisVoice.speechVoices().contains { $0.identifier == self.identifier.replacingOccurrences(of: "compact", with: "premium")} - } } diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift index f5ae4a9fa..7dd0d21e4 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -11,7 +11,6 @@ import SwiftUI struct VoiceSettingsView: View { @StateObject private var model = VoiceSettingsModel() - @AccessibilityFocusState private var focusedVoiceIdentifier: String? var body: some View { List { @@ -46,34 +45,6 @@ struct VoiceSettingsView: View { .onReceive(NotificationCenter.default.publisher(for: .appWillEnterForeground)) { _ in model.appWillEnterForeground() } - .onChange(of: model.voiceToFocus) { voiceIdentifier in - focusedVoiceIdentifier = voiceIdentifier - } - .alert( - GDLocalizedString("voice.settings.enhanced_available.title"), - isPresented: isShowingEnhancedVoiceAlert - ) { - Button(GDLocalizedString("voice.settings.enhanced_available.button")) { - model.confirmEnhancedVoiceSelection() - } - - Button(GDLocalizedString("general.alert.cancel"), role: .cancel) { - model.cancelEnhancedVoiceSelection() - } - } message: { - Text(GDLocalizedString("voice.settings.enhanced_available")) - } - } - - private var isShowingEnhancedVoiceAlert: Binding { - Binding( - get: { model.pendingEnhancedVoiceIdentifier != nil }, - set: { isPresented in - if !isPresented { - model.cancelEnhancedVoiceSelection() - } - } - ) } private func voiceSection(_ section: VoiceCatalogueLanguageSection) -> some View { @@ -148,7 +119,6 @@ struct VoiceSettingsView: View { ) } .buttonStyle(.plain) - .accessibilityFocused($focusedVoiceIdentifier, equals: voice.identifier) .listRowBackground(Color.primaryBackground) .listRowSeparatorTint(Color.secondaryBackground) } @@ -182,8 +152,6 @@ private final class VoiceSettingsModel: ObservableObject { @Published private(set) var selectedVoiceIdentifier: String? @Published private(set) var defaultVoiceIdentifier: String? @Published private(set) var previewingVoiceIdentifier: String? - @Published private(set) var pendingEnhancedVoiceIdentifier: String? - @Published private(set) var voiceToFocus: String? @Published private(set) var expandedCategory: VoiceCatalogueExpansion? @Published var speakingRate: Float @@ -258,34 +226,9 @@ private final class VoiceSettingsModel: ObservableObject { return } - guard voice.hasEnhancedVersion(), selectedVoiceIdentifier != voice.identifier else { - commitVoiceSelection(voice) - return - } - - guard !voice.hasEnhancedVersionDownloaded() else { - commitVoiceSelection(voice) - return - } - - pendingEnhancedVoiceIdentifier = voice.identifier - } - - func confirmEnhancedVoiceSelection() { - guard let identifier = pendingEnhancedVoiceIdentifier, - let voice = voice(withIdentifier: identifier) else { - pendingEnhancedVoiceIdentifier = nil - return - } - - pendingEnhancedVoiceIdentifier = nil commitVoiceSelection(voice) } - func cancelEnhancedVoiceSelection() { - pendingEnhancedVoiceIdentifier = nil - } - func speakingRateEditingChanged(_ isEditing: Bool) { guard !isEditing else { return @@ -405,7 +348,6 @@ private final class VoiceSettingsModel: ObservableObject { private func commitVoiceSelection(_ voice: AVSpeechSynthesisVoice) { previewTask?.cancel() rateAnnouncementTask?.cancel() - voiceToFocus = nil defaultVoiceIdentifier = nil previewingVoiceIdentifier = voice.identifier selectedVoiceIdentifier = voice.identifier @@ -446,8 +388,6 @@ private final class VoiceSettingsModel: ObservableObject { } previewingVoiceIdentifier = nil - voiceToFocus = identifier - GDLogAppVerbose("Updated VO focus on selected voice") } private func announceSpeakingRateTest() { @@ -484,8 +424,6 @@ private final class VoiceSettingsModel: ObservableObject { previewTask = nil rateAnnouncementTask?.cancel() rateAnnouncementTask = nil - pendingEnhancedVoiceIdentifier = nil - voiceToFocus = nil if previewingVoiceIdentifier != nil { previewingVoiceIdentifier = nil From 09e681d5c0482055372a1ec917c356718d1e8b43 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Mon, 27 Jul 2026 21:30:37 +0100 Subject: [PATCH 6/7] Handle duplicate voice identifiers --- .../Visual UI/Views/Settings/Voice/VoiceSettingsView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift index 7dd0d21e4..783f7631a 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -301,7 +301,10 @@ private final class VoiceSettingsModel: ObservableObject { private func reloadVoices() { let voices = TTSConfigHelper.loadVoices() - voicesByIdentifier = Dictionary(uniqueKeysWithValues: voices.map { ($0.identifier, $0) }) + voicesByIdentifier = Dictionary( + voices.map { ($0.identifier, $0) }, + uniquingKeysWith: { first, _ in first } + ) rebuildCatalogue() if let expandedCategory, From fe3d16ddb34b6601b33355345af5bd36564aeaa3 Mon Sep 17 00:00:00 2001 From: Robbie Murray Date: Mon, 27 Jul 2026 21:45:12 +0100 Subject: [PATCH 7/7] Fix settings list background on iOS 15 --- .../Language/LanguageSettingsView.swift | 71 +----------------- .../Settings/SettingsListBackground.swift | 73 +++++++++++++++++++ .../Settings/Voice/VoiceSettingsView.swift | 13 +--- 3 files changed, 75 insertions(+), 82 deletions(-) create mode 100644 apps/ios/GuideDogs/Code/Visual UI/Views/Settings/SettingsListBackground.swift diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Language/LanguageSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Language/LanguageSettingsView.swift index 58525e3d5..e8ee70bad 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Language/LanguageSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Language/LanguageSettingsView.swift @@ -66,9 +66,8 @@ struct LanguageSettingsView: View { } } } - .languageSettingsListBackground() + .settingsListBackground() .background(Color.quaternaryBackground.ignoresSafeArea()) - .background(LanguageSettingsListBackgroundConfigurator()) .listStyle(PlainListStyle()) .tint(.primaryForeground) .navigationTitle(GDLocalizedString("settings.language.screen_title.2")) @@ -237,74 +236,6 @@ private struct LanguageSettingsLocaleRow: View { } } -private extension View { - @ViewBuilder - func languageSettingsListBackground() -> some View { - if #available(iOS 16.0, *) { - scrollContentBackground(.hidden) - } else { - self - } - } -} - -private struct LanguageSettingsListBackgroundConfigurator: UIViewRepresentable { - func makeUIView(context: Context) -> UIView { - let view = UIView(frame: .zero) - view.isHidden = true - clearTableBackground(from: view) - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - clearTableBackground(from: uiView) - } - - private func clearTableBackground(from view: UIView) { - guard #unavailable(iOS 16.0) else { - return - } - - DispatchQueue.main.async { - view.enclosingTableView()?.backgroundColor = .clear - } - } -} - -private extension UIView { - func enclosingTableView() -> UITableView? { - var currentView: UIView? = self - - while let view = currentView { - if let tableView = view as? UITableView { - return tableView - } - - if let tableView = view.firstSubview(of: UITableView.self) { - return tableView - } - - currentView = view.superview - } - - return nil - } - - func firstSubview(of type: T.Type) -> T? { - for subview in subviews { - if let matchingSubview = subview as? T { - return matchingSubview - } - - if let matchingSubview = subview.firstSubview(of: type) { - return matchingSubview - } - } - - return nil - } -} - struct LanguageSettingsView_Previews: PreviewProvider { static let locales = [ Locale(identifier: "en_US"), diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/SettingsListBackground.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/SettingsListBackground.swift new file mode 100644 index 000000000..93221768e --- /dev/null +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/SettingsListBackground.swift @@ -0,0 +1,73 @@ +// +// SettingsListBackground.swift +// Soundscape +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +extension View { + @ViewBuilder + func settingsListBackground() -> some View { + if #available(iOS 16.0, *) { + scrollContentBackground(.hidden) + } else { + background(SettingsListBackgroundConfigurator()) + } + } +} + +private struct SettingsListBackgroundConfigurator: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isHidden = true + clearTableBackground(from: view) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + clearTableBackground(from: uiView) + } + + private func clearTableBackground(from view: UIView) { + DispatchQueue.main.async { + view.enclosingTableView()?.backgroundColor = .clear + } + } +} + +private extension UIView { + func enclosingTableView() -> UITableView? { + var currentView: UIView? = self + + while let view = currentView { + if let tableView = view as? UITableView { + return tableView + } + + if let tableView = view.firstSubview(of: UITableView.self) { + return tableView + } + + currentView = view.superview + } + + return nil + } + + func firstSubview(of type: T.Type) -> T? { + for subview in subviews { + if let matchingSubview = subview as? T { + return matchingSubview + } + + if let matchingSubview = subview.firstSubview(of: type) { + return matchingSubview + } + } + + return nil + } +} diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift index 783f7631a..d0250036b 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Settings/Voice/VoiceSettingsView.swift @@ -34,7 +34,7 @@ struct VoiceSettingsView: View { voiceSection(section) } } - .voiceSettingsListBackground() + .settingsListBackground() .background(Color.quaternaryBackground.ignoresSafeArea()) .listStyle(PlainListStyle()) .tint(.primaryForeground) @@ -549,14 +549,3 @@ private struct VoiceSettingsCategoryRow: View { return name } } - -private extension View { - @ViewBuilder - func voiceSettingsListBackground() -> some View { - if #available(iOS 16.0, *) { - scrollContentBackground(.hidden) - } else { - self - } - } -}