Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-08 16:53:23 -05:00
parent 6fd0562a41
commit 2904536334
9 changed files with 200 additions and 141 deletions

View File

@ -8,26 +8,60 @@
import Foundation import Foundation
/// Sound data model for audio files /// Sound data model for audio files
public struct Sound: Identifiable, Hashable { public struct Sound: Identifiable, Hashable, Codable {
public let id: String public let id: String
public let name: String public let name: String
public let fileName: String public let fileName: String
public let category: String public let category: String
public let description: String public let description: String
public let bundleName: String? // Optional bundle name for organization public let bundleName: String? // Optional bundle name for organization
public let isDefault: Bool? // Optional - used for alarm sounds to mark default
// MARK: - Initialization // MARK: - Initialization
public init(name: String, fileName: String, category: String, description: String, bundleName: String? = nil) { public init(id: String? = nil, name: String, fileName: String, category: String, description: String, bundleName: String? = nil, isDefault: Bool? = nil) {
self.id = fileName // Use fileName as stable identifier self.id = id ?? UUID().uuidString // Use provided id or generate GUID
self.name = name self.name = name
self.fileName = fileName self.fileName = fileName
self.category = category self.category = category
self.description = description self.description = description
self.bundleName = bundleName 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 // MARK: - Hashable
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
} }

View File

@ -9,40 +9,17 @@ import Foundation
/// Configuration model for sound system loaded from JSON /// Configuration model for sound system loaded from JSON
public struct SoundConfiguration: Codable { public struct SoundConfiguration: Codable {
public let sounds: [SoundConfig] public let sounds: [Sound]
public let categories: [SoundCategory] public let categories: [SoundCategory]
public let settings: AudioSettings public let settings: AudioSettings
public init(sounds: [SoundConfig], categories: [SoundCategory], settings: AudioSettings) { public init(sounds: [Sound], categories: [SoundCategory], settings: AudioSettings) {
self.sounds = sounds self.sounds = sounds
self.categories = categories self.categories = categories
self.settings = settings 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 /// Sound category configuration
public struct SoundCategory: Codable, Identifiable { public struct SoundCategory: Codable, Identifiable {
@ -89,10 +66,9 @@ public class SoundConfigurationService {
private init() {} private init() {}
/// Load sound configuration from JSON file /// 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 { guard let url = bundle.url(forResource: fileName, withExtension: "json") else {
print("\(fileName).json not found in bundle") fatalError("\(fileName).json not found in bundle. Ensure the file exists in your app bundle.")
return nil
} }
do { do {
@ -102,81 +78,43 @@ public class SoundConfigurationService {
print("✅ Loaded sound configuration with \(config.sounds.count) sounds") print("✅ Loaded sound configuration with \(config.sounds.count) sounds")
return config return config
} catch { } catch {
print("❌ Error loading sound configuration: \(error)") fatalError("❌ Error loading sound configuration: \(error)")
return nil
} }
} }
/// Get current configuration /// Get current configuration
public func getConfiguration() -> SoundConfiguration? { public func getConfiguration() -> SoundConfiguration {
if configuration == nil { if configuration == nil {
return loadConfiguration() return loadConfiguration()
} }
return configuration return configuration!
} }
/// Get all available sounds /// Get all available sounds
public func getAvailableSounds() -> [Sound] { public func getAvailableSounds() -> [Sound] {
guard let config = getConfiguration() else { return getConfiguration().sounds
print("⚠️ No configuration available, falling back to constants")
return getFallbackSounds()
}
return config.sounds.map { $0.toSound() }
} }
/// Get sounds by category /// Get sounds by category
public func getSoundsByCategory(_ categoryId: String) -> [Sound] { public func getSoundsByCategory(_ categoryId: String) -> [Sound] {
guard let config = getConfiguration() else { return getConfiguration().sounds
return []
}
return config.sounds
.filter { $0.category == categoryId } .filter { $0.category == categoryId }
.map { $0.toSound() }
} }
/// Get sounds by bundle name /// Get sounds by bundle name
public func getSoundsByBundle(_ bundleName: String) -> [Sound] { public func getSoundsByBundle(_ bundleName: String) -> [Sound] {
guard let config = getConfiguration() else { return getConfiguration().sounds
return []
}
return config.sounds
.filter { $0.bundleName == bundleName } .filter { $0.bundleName == bundleName }
.map { $0.toSound() }
}
/// Get alarm sounds specifically
public func getAlarmSounds() -> [Sound] {
return getSoundsByCategory("alarm")
} }
/// Get available categories /// Get available categories
public func getAvailableCategories() -> [SoundCategory] { public func getAvailableCategories() -> [SoundCategory] {
return getConfiguration()?.categories ?? [] return getConfiguration().categories
} }
/// Get audio settings /// Get audio settings
public func getAudioSettings() -> AudioSettings? { public func getAudioSettings() -> AudioSettings {
return getConfiguration()?.settings 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")
]
}
} }

View File

@ -128,11 +128,11 @@ public class NoisePlayer {
let settings = soundConfigurationService.getAudioSettings() let settings = soundConfigurationService.getAudioSettings()
// Use configuration settings or fall back to constants // Use configuration settings or fall back to constants
let category = settings?.audioSessionCategory == "playback" ? let category = settings.audioSessionCategory == "playback" ?
AVAudioSession.Category.playback : AudioConstants.AudioSession.category AVAudioSession.Category.playback : AudioConstants.AudioSession.category
let mode = settings?.audioSessionMode == "default" ? let mode = settings.audioSessionMode == "default" ?
AVAudioSession.Mode.default : AudioConstants.AudioSession.mode 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 [.mixWithOthers] : AudioConstants.AudioSession.options
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options) try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
@ -163,9 +163,9 @@ public class NoisePlayer {
do { do {
let player = try AVAudioPlayer(contentsOf: fileUrl) let player = try AVAudioPlayer(contentsOf: fileUrl)
player.numberOfLoops = settings?.defaultLoopCount ?? AudioConstants.Playback.numberOfLoops player.numberOfLoops = settings.defaultLoopCount
player.volume = settings?.defaultVolume ?? AudioConstants.Volume.default player.volume = settings.defaultVolume
if settings?.preloadSounds ?? AudioConstants.Playback.prepareToPlay { if settings.preloadSounds {
player.prepareToPlay() player.prepareToPlay()
} }
players[sound.fileName] = player players[sound.fileName] = player

View File

@ -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
}
}

View File

@ -23,46 +23,6 @@
"category": "mechanical", "category": "mechanical",
"description": "Fan and heater sounds for consistent background noise", "description": "Fan and heater sounds for consistent background noise",
"bundleName": "Mechanical" "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": [ "categories": [
@ -83,12 +43,6 @@
"name": "Mechanical", "name": "Mechanical",
"description": "Mechanical and electronic sounds", "description": "Mechanical and electronic sounds",
"bundleName": "Mechanical" "bundleName": "Mechanical"
},
{
"id": "alarm",
"name": "Alarm Sounds",
"description": "Wake-up and notification alarm sounds",
"bundleName": "AlarmSounds"
} }
], ],
"settings": { "settings": {

View File

@ -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
}
}

View File

@ -125,10 +125,6 @@ struct AddAlarmView: View {
// MARK: - Helper Methods // MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
let alarmSounds = SoundConfigurationService.shared.getAlarmSounds() return AlarmSoundService.shared.getSoundDisplayName(fileName)
if let sound = alarmSounds.first(where: { $0.fileName == fileName }) {
return sound.name
}
return fileName.replacingOccurrences(of: ".mp3", with: "").capitalized
} }
} }

View File

@ -15,7 +15,7 @@ struct SoundSelectionView: View {
// Use shared player instance to avoid audio conflicts // Use shared player instance to avoid audio conflicts
private let noisePlayer = NoisePlayer.shared 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 isPlaying = false
@State private var currentlyPlayingSound: String? = nil @State private var currentlyPlayingSound: String? = nil

View File

@ -145,11 +145,7 @@ struct EditAlarmView: View {
// MARK: - Helper Methods // MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
let alarmSounds = SoundConfigurationService.shared.getAlarmSounds() return AlarmSoundService.shared.getSoundDisplayName(fileName)
if let sound = alarmSounds.first(where: { $0.fileName == fileName }) {
return sound.name
}
return fileName.replacingOccurrences(of: ".mp3", with: "").capitalized
} }
} }