From 290453633488d4c0423c1907ed016f7f54e694ba Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 8 Sep 2025 16:53:23 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../AudioPlaybackKit/Models/Sound.swift | 40 ++++++++- .../Models/SoundConfiguration.swift | 88 +++---------------- .../Services/NoisePlayer.swift | 12 +-- TheNoiseClock/Resources/alarm-sounds.json | 65 ++++++++++++++ TheNoiseClock/Resources/sounds.json | 46 ---------- .../Services/AlarmSoundService.swift | 76 ++++++++++++++++ TheNoiseClock/Views/Alarms/AddAlarmView.swift | 6 +- .../Components/SoundSelectionView.swift | 2 +- .../Views/Alarms/EditAlarmView.swift | 6 +- 9 files changed, 200 insertions(+), 141 deletions(-) create mode 100644 TheNoiseClock/Resources/alarm-sounds.json create mode 100644 TheNoiseClock/Services/AlarmSoundService.swift diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/Sound.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/Sound.swift index 05fe3d7..9568ca2 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/Sound.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/Sound.swift @@ -8,26 +8,60 @@ import Foundation /// Sound data model for audio files -public struct Sound: Identifiable, Hashable { +public struct Sound: Identifiable, Hashable, Codable { public let id: String public let name: String public let fileName: String public let category: String public let description: String public let bundleName: String? // Optional bundle name for organization + public let isDefault: Bool? // Optional - used for alarm sounds to mark default // MARK: - Initialization - public init(name: String, fileName: String, category: String, description: String, bundleName: String? = nil) { - self.id = fileName // Use fileName as stable identifier + public init(id: String? = nil, name: String, fileName: String, category: String, description: String, bundleName: String? = nil, isDefault: Bool? = nil) { + self.id = id ?? UUID().uuidString // Use provided id or generate GUID self.name = name self.fileName = fileName self.category = category self.description = description self.bundleName = bundleName + self.isDefault = isDefault + } + + // MARK: - Codable Custom Implementation + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Generate a new GUID for each sound loaded from JSON + self.id = UUID().uuidString + + self.name = try container.decode(String.self, forKey: .name) + self.fileName = try container.decode(String.self, forKey: .fileName) + self.category = try container.decode(String.self, forKey: .category) + self.description = try container.decode(String.self, forKey: .description) + self.bundleName = try container.decodeIfPresent(String.self, forKey: .bundleName) + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(fileName, forKey: .fileName) + try container.encode(category, forKey: .category) + try container.encode(description, forKey: .description) + try container.encodeIfPresent(bundleName, forKey: .bundleName) + try container.encodeIfPresent(isDefault, forKey: .isDefault) + } + + private enum CodingKeys: String, CodingKey { + case id, name, fileName, category, description, bundleName, isDefault } // MARK: - Hashable public func hash(into hasher: inout Hasher) { hasher.combine(id) } + } diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift index b9dc258..0eeb554 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift @@ -9,40 +9,17 @@ import Foundation /// Configuration model for sound system loaded from JSON public struct SoundConfiguration: Codable { - public let sounds: [SoundConfig] + public let sounds: [Sound] public let categories: [SoundCategory] public let settings: AudioSettings - public init(sounds: [SoundConfig], categories: [SoundCategory], settings: AudioSettings) { + public init(sounds: [Sound], categories: [SoundCategory], settings: AudioSettings) { self.sounds = sounds self.categories = categories self.settings = settings } } -/// Individual sound configuration -public struct SoundConfig: Codable, Identifiable { - public let id: String - public let name: String - public let fileName: String - public let category: String - public let description: String - public let bundleName: String? // Optional bundle name for organization - - public init(id: String, name: String, fileName: String, category: String, description: String, bundleName: String? = nil) { - self.id = id - self.name = name - self.fileName = fileName - self.category = category - self.description = description - self.bundleName = bundleName - } - - /// Convert to Sound model for compatibility - public func toSound() -> Sound { - return Sound(name: name, fileName: fileName, category: category, description: description, bundleName: bundleName) - } -} /// Sound category configuration public struct SoundCategory: Codable, Identifiable { @@ -89,10 +66,9 @@ public class SoundConfigurationService { private init() {} /// Load sound configuration from JSON file - public func loadConfiguration(from bundle: Bundle = .main, fileName: String = "sounds") -> SoundConfiguration? { + public func loadConfiguration(from bundle: Bundle = .main, fileName: String = "sounds") -> SoundConfiguration { guard let url = bundle.url(forResource: fileName, withExtension: "json") else { - print("❌ \(fileName).json not found in bundle") - return nil + fatalError("❌ \(fileName).json not found in bundle. Ensure the file exists in your app bundle.") } do { @@ -102,81 +78,43 @@ public class SoundConfigurationService { print("✅ Loaded sound configuration with \(config.sounds.count) sounds") return config } catch { - print("❌ Error loading sound configuration: \(error)") - return nil + fatalError("❌ Error loading sound configuration: \(error)") } } /// Get current configuration - public func getConfiguration() -> SoundConfiguration? { + public func getConfiguration() -> SoundConfiguration { if configuration == nil { return loadConfiguration() } - return configuration + return configuration! } /// Get all available sounds public func getAvailableSounds() -> [Sound] { - guard let config = getConfiguration() else { - print("⚠️ No configuration available, falling back to constants") - return getFallbackSounds() - } - - return config.sounds.map { $0.toSound() } + return getConfiguration().sounds } /// Get sounds by category public func getSoundsByCategory(_ categoryId: String) -> [Sound] { - guard let config = getConfiguration() else { - return [] - } - - return config.sounds + return getConfiguration().sounds .filter { $0.category == categoryId } - .map { $0.toSound() } } /// Get sounds by bundle name public func getSoundsByBundle(_ bundleName: String) -> [Sound] { - guard let config = getConfiguration() else { - return [] - } - - return config.sounds + return getConfiguration().sounds .filter { $0.bundleName == bundleName } - .map { $0.toSound() } - } - - /// Get alarm sounds specifically - public func getAlarmSounds() -> [Sound] { - return getSoundsByCategory("alarm") } /// Get available categories public func getAvailableCategories() -> [SoundCategory] { - return getConfiguration()?.categories ?? [] + return getConfiguration().categories } /// Get audio settings - public func getAudioSettings() -> AudioSettings? { - return getConfiguration()?.settings + public func getAudioSettings() -> AudioSettings { + return getConfiguration().settings } - /// Fallback sounds if JSON loading fails - private func getFallbackSounds() -> [Sound] { - return [ - // White noise sounds - Sound(name: "White Noise", fileName: "white-noise.mp3", category: "ambient", description: "Classic white noise for focus and relaxation", bundleName: "Ambient"), - Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3", category: "nature", description: "Heavy rainfall sounds for peaceful sleep", bundleName: "Nature"), - Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3", category: "mechanical", description: "Fan and heater sounds for consistent background noise", bundleName: "Mechanical"), - - // Alarm sounds - Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Classic digital alarm sound", bundleName: "AlarmSounds"), - Sound(name: "iPhone Alarm", fileName: "iphone-alarm.mp3", category: "alarm", description: "iPhone-style alarm sound", bundleName: "AlarmSounds"), - Sound(name: "Classic Alarm", fileName: "classic-alarm.mp3", category: "alarm", description: "Traditional alarm sound", bundleName: "AlarmSounds"), - Sound(name: "Beep Alarm", fileName: "beep-alarm.mp3", category: "alarm", description: "Short beep alarm sound", bundleName: "AlarmSounds"), - Sound(name: "Siren Alarm", fileName: "siren-alarm.mp3", category: "alarm", description: "Emergency siren alarm for heavy sleepers", bundleName: "AlarmSounds"), - Sound(name: "Voice Wake Up", fileName: "voice-wakeup.mp3", category: "alarm", description: "Voice-based wake up sound", bundleName: "AlarmSounds") - ] - } } diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/NoisePlayer.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/NoisePlayer.swift index 309c4b2..d804207 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/NoisePlayer.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/NoisePlayer.swift @@ -128,11 +128,11 @@ public class NoisePlayer { let settings = soundConfigurationService.getAudioSettings() // Use configuration settings or fall back to constants - let category = settings?.audioSessionCategory == "playback" ? + let category = settings.audioSessionCategory == "playback" ? AVAudioSession.Category.playback : AudioConstants.AudioSession.category - let mode = settings?.audioSessionMode == "default" ? + let mode = settings.audioSessionMode == "default" ? AVAudioSession.Mode.default : AudioConstants.AudioSession.mode - let options: AVAudioSession.CategoryOptions = settings?.audioSessionOptions.contains("mixWithOthers") == true ? + let options: AVAudioSession.CategoryOptions = settings.audioSessionOptions.contains("mixWithOthers") == true ? [.mixWithOthers] : AudioConstants.AudioSession.options try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options) @@ -163,9 +163,9 @@ public class NoisePlayer { do { let player = try AVAudioPlayer(contentsOf: fileUrl) - player.numberOfLoops = settings?.defaultLoopCount ?? AudioConstants.Playback.numberOfLoops - player.volume = settings?.defaultVolume ?? AudioConstants.Volume.default - if settings?.preloadSounds ?? AudioConstants.Playback.prepareToPlay { + player.numberOfLoops = settings.defaultLoopCount + player.volume = settings.defaultVolume + if settings.preloadSounds { player.prepareToPlay() } players[sound.fileName] = player diff --git a/TheNoiseClock/Resources/alarm-sounds.json b/TheNoiseClock/Resources/alarm-sounds.json new file mode 100644 index 0000000..6c0bc11 --- /dev/null +++ b/TheNoiseClock/Resources/alarm-sounds.json @@ -0,0 +1,65 @@ +{ + "sounds": [ + { + "id": "digital-alarm", + "name": "Digital Alarm", + "fileName": "digital-alarm.caf", + "description": "Classic digital alarm sound", + "category": "alarm", + "bundleName": null, + "isDefault": true + }, + { + "id": "buzzing-alarm", + "name": "Buzzing Alarm", + "fileName": "buzzing-alarm.caf", + "description": "Buzzing sound for gentle wake-up", + "category": "alarm", + "bundleName": null + }, + { + "id": "classic-alarm", + "name": "Classic Alarm", + "fileName": "classic-alarm.caf", + "description": "Traditional alarm sound", + "category": "alarm", + "bundleName": null + }, + { + "id": "beep-alarm", + "name": "Beep Alarm", + "fileName": "beep-alarm.caf", + "description": "Short beep alarm sound", + "category": "alarm", + "bundleName": null + }, + { + "id": "siren-alarm", + "name": "Siren Alarm", + "fileName": "siren-alarm.caf", + "description": "Emergency siren alarm for heavy sleepers", + "category": "alarm", + "bundleName": null + } + ], + "categories": [ + { + "id": "alarm", + "name": "Alarm Sounds", + "description": "Wake-up and notification alarm sounds", + "bundleName": null + } + ], + "settings": { + "defaultVolume": 1.0, + "defaultLoopCount": -1, + "preloadSounds": true, + "preloadStrategy": "category", + "audioSessionCategory": "playback", + "audioSessionMode": "default", + "audioSessionOptions": ["mixWithOthers"], + "defaultSound": "digital-alarm.caf", + "previewDuration": 3.0, + "fadeInDuration": 0.5 + } +} diff --git a/TheNoiseClock/Resources/sounds.json b/TheNoiseClock/Resources/sounds.json index 7789856..e10dddd 100644 --- a/TheNoiseClock/Resources/sounds.json +++ b/TheNoiseClock/Resources/sounds.json @@ -23,46 +23,6 @@ "category": "mechanical", "description": "Fan and heater sounds for consistent background noise", "bundleName": "Mechanical" - }, - { - "id": "digital-alarm", - "name": "Digital Alarm", - "fileName": "digital-alarm.caf", - "category": "alarm", - "description": "Classic digital alarm sound", - "bundleName": null - }, - { - "id": "iphone-alarm", - "name": "Buzzing Alarm", - "fileName": "buzzing-alarm.caf", - "category": "alarm", - "description": "Buzzing sound", - "bundleName": null - }, - { - "id": "classic-alarm", - "name": "Classic Alarm", - "fileName": "classic-alarm.caf", - "category": "alarm", - "description": "Traditional alarm sound", - "bundleName": null - }, - { - "id": "beep-alarm", - "name": "Beep Alarm", - "fileName": "beep-alarm.caf", - "category": "alarm", - "description": "Short beep alarm sound", - "bundleName": null - }, - { - "id": "siren-alarm", - "name": "Siren Alarm", - "fileName": "siren-alarm.caf", - "category": "alarm", - "description": "Emergency siren alarm for heavy sleepers", - "bundleName": null } ], "categories": [ @@ -83,12 +43,6 @@ "name": "Mechanical", "description": "Mechanical and electronic sounds", "bundleName": "Mechanical" - }, - { - "id": "alarm", - "name": "Alarm Sounds", - "description": "Wake-up and notification alarm sounds", - "bundleName": "AlarmSounds" } ], "settings": { diff --git a/TheNoiseClock/Services/AlarmSoundService.swift b/TheNoiseClock/Services/AlarmSoundService.swift new file mode 100644 index 0000000..5de9d3c --- /dev/null +++ b/TheNoiseClock/Services/AlarmSoundService.swift @@ -0,0 +1,76 @@ +// +// AlarmSoundService.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/8/25. +// + +import Foundation +import AudioPlaybackKit + +/// Extension service for alarm-specific sound functionality +class AlarmSoundService { + static let shared = AlarmSoundService() + + // MARK: - Constants + /// The category ID for alarm sounds as defined in alarm-sounds.json + static let alarmCategoryId = "alarm" + + private init() {} + + /// Load alarm sound configuration from alarm-sounds.json + private func loadAlarmConfiguration() -> SoundConfiguration { + guard let url = Bundle.main.url(forResource: "alarm-sounds", withExtension: "json") else { + fatalError("❌ alarm-sounds.json not found in bundle. Ensure the file exists in your app bundle.") + } + + do { + let data = try Data(contentsOf: url) + let config = try JSONDecoder().decode(SoundConfiguration.self, from: data) + return config + } catch { + fatalError("❌ Error loading alarm sound configuration: \(error)") + } + } + + /// Get all available alarm sounds + func getAlarmSounds() -> [Sound] { + return loadAlarmConfiguration().sounds + } + + /// Get alarm sounds by category + func getAlarmSoundsByCategory(_ categoryId: String) -> [Sound] { + return loadAlarmConfiguration().sounds + .filter { $0.category == categoryId } + } + + /// Get alarm sounds for the default alarm category + func getAlarmSoundsForDefaultCategory() -> [Sound] { + return getAlarmSoundsByCategory(Self.alarmCategoryId) + } + + /// Get default alarm sound + func getDefaultAlarmSound() -> Sound? { + let sounds = loadAlarmConfiguration().sounds + return sounds.first { $0.isDefault == true } ?? sounds.first + } + + /// Get alarm sound categories + func getAlarmSoundCategories() -> [SoundCategory] { + return loadAlarmConfiguration().categories + } + + /// Get alarm sound settings + func getAlarmSoundSettings() -> AudioSettings { + return loadAlarmConfiguration().settings + } + + /// Get sound display name by filename + func getSoundDisplayName(_ fileName: String) -> String { + let alarmSounds = getAlarmSounds() + if let sound = alarmSounds.first(where: { $0.fileName == fileName }) { + return sound.name + } + return fileName.replacingOccurrences(of: ".caf", with: "").capitalized + } +} diff --git a/TheNoiseClock/Views/Alarms/AddAlarmView.swift b/TheNoiseClock/Views/Alarms/AddAlarmView.swift index e4e6f05..0be6443 100644 --- a/TheNoiseClock/Views/Alarms/AddAlarmView.swift +++ b/TheNoiseClock/Views/Alarms/AddAlarmView.swift @@ -125,10 +125,6 @@ struct AddAlarmView: View { // MARK: - Helper Methods private func getSoundDisplayName(_ fileName: String) -> String { - let alarmSounds = SoundConfigurationService.shared.getAlarmSounds() - if let sound = alarmSounds.first(where: { $0.fileName == fileName }) { - return sound.name - } - return fileName.replacingOccurrences(of: ".mp3", with: "").capitalized + return AlarmSoundService.shared.getSoundDisplayName(fileName) } } \ No newline at end of file diff --git a/TheNoiseClock/Views/Alarms/Components/SoundSelectionView.swift b/TheNoiseClock/Views/Alarms/Components/SoundSelectionView.swift index a3c3129..36fc71f 100644 --- a/TheNoiseClock/Views/Alarms/Components/SoundSelectionView.swift +++ b/TheNoiseClock/Views/Alarms/Components/SoundSelectionView.swift @@ -15,7 +15,7 @@ struct SoundSelectionView: View { // Use shared player instance to avoid audio conflicts private let noisePlayer = NoisePlayer.shared - private let alarmSounds = SoundConfigurationService.shared.getAlarmSounds().sorted { $0.name < $1.name } + private let alarmSounds = AlarmSoundService.shared.getAlarmSounds().sorted { $0.name < $1.name } @State private var isPlaying = false @State private var currentlyPlayingSound: String? = nil diff --git a/TheNoiseClock/Views/Alarms/EditAlarmView.swift b/TheNoiseClock/Views/Alarms/EditAlarmView.swift index 07989a4..c3b685e 100644 --- a/TheNoiseClock/Views/Alarms/EditAlarmView.swift +++ b/TheNoiseClock/Views/Alarms/EditAlarmView.swift @@ -145,11 +145,7 @@ struct EditAlarmView: View { // MARK: - Helper Methods private func getSoundDisplayName(_ fileName: String) -> String { - let alarmSounds = SoundConfigurationService.shared.getAlarmSounds() - if let sound = alarmSounds.first(where: { $0.fileName == fileName }) { - return sound.name - } - return fileName.replacingOccurrences(of: ".mp3", with: "").capitalized + return AlarmSoundService.shared.getSoundDisplayName(fileName) } }