moved from TheNoiseClock
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
ff8691ef81
commit
df8e097a0f
33
Package.swift
Normal file
33
Package.swift
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "AudioPlaybackKit",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
.tvOS(.v17)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "AudioPlaybackKit",
|
||||||
|
targets: ["AudioPlaybackKit"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
// .package(url: /* package url */, from: "1.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "AudioPlaybackKit",
|
||||||
|
dependencies: []
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "AudioPlaybackKitTests",
|
||||||
|
dependencies: ["AudioPlaybackKit"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
158
README.md
Normal file
158
README.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# AudioPlaybackKit
|
||||||
|
|
||||||
|
A Swift package for audio playback functionality, specifically designed for white noise and ambient sound applications.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Audio Playback**: High-quality audio playback with background support
|
||||||
|
- **Sound Management**: Load and manage audio files from bundles
|
||||||
|
- **Wake Lock**: Prevent device from sleeping during audio playback
|
||||||
|
- **Interruption Handling**: Automatic handling of audio interruptions (calls, etc.)
|
||||||
|
- **Configuration**: JSON-based sound configuration system
|
||||||
|
- **Preview Support**: Built-in audio preview functionality
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- iOS 16.0+ / macOS 13.0+
|
||||||
|
- Swift 5.9+
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Swift Package Manager
|
||||||
|
|
||||||
|
Add the following to your `Package.swift` file:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/yourusername/AudioPlaybackKit.git", from: "1.0.0")
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add it through Xcode:
|
||||||
|
1. File → Add Package Dependencies
|
||||||
|
2. Enter the repository URL
|
||||||
|
3. Select the version and add to your target
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Audio Playback
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import AudioPlaybackKit
|
||||||
|
|
||||||
|
// Get the shared player instance
|
||||||
|
let player = NoisePlayer.shared
|
||||||
|
|
||||||
|
// Create a sound
|
||||||
|
let sound = Sound(
|
||||||
|
name: "White Noise",
|
||||||
|
fileName: "white-noise.mp3",
|
||||||
|
category: "ambient",
|
||||||
|
description: "Classic white noise"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play the sound
|
||||||
|
player.playSound(sound)
|
||||||
|
|
||||||
|
// Stop the sound
|
||||||
|
player.stopSound()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the ViewModel
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import AudioPlaybackKit
|
||||||
|
|
||||||
|
// Create a view model
|
||||||
|
let viewModel = NoiseViewModel()
|
||||||
|
|
||||||
|
// Get available sounds
|
||||||
|
let sounds = viewModel.availableSounds
|
||||||
|
|
||||||
|
// Play a sound
|
||||||
|
viewModel.playSound(sounds.first!)
|
||||||
|
|
||||||
|
// Preview a sound (3 seconds)
|
||||||
|
viewModel.previewSound(sounds.first!)
|
||||||
|
|
||||||
|
// Stop playback
|
||||||
|
viewModel.stopSound()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sound Configuration
|
||||||
|
|
||||||
|
Create a `sounds.json` file in your app bundle:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sounds": [
|
||||||
|
{
|
||||||
|
"id": "white-noise",
|
||||||
|
"name": "White Noise",
|
||||||
|
"fileName": "white-noise.mp3",
|
||||||
|
"category": "ambient",
|
||||||
|
"description": "Classic white noise for focus and relaxation",
|
||||||
|
"bundleName": "Ambient"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "ambient",
|
||||||
|
"name": "Ambient",
|
||||||
|
"description": "General ambient sounds",
|
||||||
|
"bundleName": "Ambient"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"defaultVolume": 0.8,
|
||||||
|
"defaultLoopCount": -1,
|
||||||
|
"preloadSounds": true,
|
||||||
|
"preloadStrategy": "category",
|
||||||
|
"audioSessionCategory": "playback",
|
||||||
|
"audioSessionMode": "default",
|
||||||
|
"audioSessionOptions": ["mixWithOthers"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wake Lock Service
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import AudioPlaybackKit
|
||||||
|
|
||||||
|
let wakeLock = WakeLockService.shared
|
||||||
|
|
||||||
|
// Enable wake lock (prevents device from sleeping)
|
||||||
|
wakeLock.enableWakeLock()
|
||||||
|
|
||||||
|
// Disable wake lock
|
||||||
|
wakeLock.disableWakeLock()
|
||||||
|
|
||||||
|
// Check if active
|
||||||
|
if wakeLock.isActive {
|
||||||
|
print("Wake lock is active")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
- **NoisePlayer**: Main audio playback service
|
||||||
|
- **SoundConfigurationService**: Manages sound configuration and loading
|
||||||
|
- **WakeLockService**: Prevents device from sleeping during playback
|
||||||
|
- **NoiseViewModel**: SwiftUI-friendly view model for audio playback
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
- **Sound**: Represents an audio file with metadata
|
||||||
|
- **SoundConfiguration**: JSON configuration structure
|
||||||
|
- **AudioSettings**: Audio session and playback settings
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This package is available under the MIT license. See the LICENSE file for more info.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
33
Sources/AudioPlaybackKit/Constants/AudioConstants.swift
Normal file
33
Sources/AudioPlaybackKit/Constants/AudioConstants.swift
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// AudioConstants.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFAudio
|
||||||
|
|
||||||
|
/// Audio-related constants and configuration
|
||||||
|
public enum AudioConstants {
|
||||||
|
|
||||||
|
// MARK: - Audio Session Configuration
|
||||||
|
public enum AudioSession {
|
||||||
|
public static let category = AVAudioSession.Category.playback
|
||||||
|
public static let mode = AVAudioSession.Mode.default
|
||||||
|
public static let options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Settings
|
||||||
|
public enum Playback {
|
||||||
|
public static let numberOfLoops = -1 // Infinite loop
|
||||||
|
public static let prepareToPlay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Volume
|
||||||
|
public enum Volume {
|
||||||
|
public static let min: Float = 0.0
|
||||||
|
public static let max: Float = 1.0
|
||||||
|
public static let `default`: Float = 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Sources/AudioPlaybackKit/Models/Sound.swift
Normal file
67
Sources/AudioPlaybackKit/Models/Sound.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// Sound.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Sound data model for audio files
|
||||||
|
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(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
155
Sources/AudioPlaybackKit/Models/SoundConfiguration.swift
Normal file
155
Sources/AudioPlaybackKit/Models/SoundConfiguration.swift
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// SoundConfiguration.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration model for sound system loaded from JSON
|
||||||
|
public struct SoundConfiguration: Codable {
|
||||||
|
public let sounds: [Sound]
|
||||||
|
public let settings: AudioSettings
|
||||||
|
|
||||||
|
public init(sounds: [Sound], settings: AudioSettings) {
|
||||||
|
self.sounds = sounds
|
||||||
|
self.settings = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple struct for loading just the sounds array from category JSON files
|
||||||
|
public struct SoundsOnly: Codable {
|
||||||
|
public let sounds: [Sound]
|
||||||
|
|
||||||
|
public init(sounds: [Sound]) {
|
||||||
|
self.sounds = sounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Audio settings configuration
|
||||||
|
public struct AudioSettings: Codable {
|
||||||
|
public let defaultVolume: Float
|
||||||
|
public let defaultLoopCount: Int
|
||||||
|
public let preloadSounds: Bool
|
||||||
|
public let preloadStrategy: String // "all", "category", "none"
|
||||||
|
public let audioSessionCategory: String
|
||||||
|
public let audioSessionMode: String
|
||||||
|
public let audioSessionOptions: [String]
|
||||||
|
|
||||||
|
public init(defaultVolume: Float, defaultLoopCount: Int, preloadSounds: Bool, preloadStrategy: String, audioSessionCategory: String, audioSessionMode: String, audioSessionOptions: [String]) {
|
||||||
|
self.defaultVolume = defaultVolume
|
||||||
|
self.defaultLoopCount = defaultLoopCount
|
||||||
|
self.preloadSounds = preloadSounds
|
||||||
|
self.preloadStrategy = preloadStrategy
|
||||||
|
self.audioSessionCategory = audioSessionCategory
|
||||||
|
self.audioSessionMode = audioSessionMode
|
||||||
|
self.audioSessionOptions = audioSessionOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for loading and managing sound configuration
|
||||||
|
public class SoundConfigurationService {
|
||||||
|
public static let shared = SoundConfigurationService()
|
||||||
|
|
||||||
|
private var configuration: SoundConfiguration?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
|
||||||
|
/// Load audio settings from SoundsSettings.json
|
||||||
|
private func loadAudioSettings(from bundle: Bundle = .main) -> AudioSettings {
|
||||||
|
guard let url = bundle.url(forResource: "SoundsSettings", withExtension: "json") else {
|
||||||
|
print("⚠️ Warning: SoundsSettings.json not found, using default settings")
|
||||||
|
return AudioSettings(
|
||||||
|
defaultVolume: 0.8,
|
||||||
|
defaultLoopCount: -1,
|
||||||
|
preloadSounds: true,
|
||||||
|
preloadStrategy: "category",
|
||||||
|
audioSessionCategory: "playback",
|
||||||
|
audioSessionMode: "default",
|
||||||
|
audioSessionOptions: ["mixWithOthers"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||||
|
print("✅ Loaded audio settings from SoundsSettings.json")
|
||||||
|
return settings
|
||||||
|
} catch {
|
||||||
|
print("⚠️ Warning: Error loading audio settings, using defaults: \(error)")
|
||||||
|
return AudioSettings(
|
||||||
|
defaultVolume: 0.8,
|
||||||
|
defaultLoopCount: -1,
|
||||||
|
preloadSounds: true,
|
||||||
|
preloadStrategy: "category",
|
||||||
|
audioSessionCategory: "playback",
|
||||||
|
audioSessionMode: "default",
|
||||||
|
audioSessionOptions: ["mixWithOthers"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load sound configuration from multiple category-specific JSON files
|
||||||
|
public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration {
|
||||||
|
// Include AlarmSounds bundle for alarm sound preview functionality
|
||||||
|
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"]
|
||||||
|
var allSounds: [Sound] = []
|
||||||
|
|
||||||
|
for bundleName in bundleNames {
|
||||||
|
guard let bundleURL = bundle.url(forResource: bundleName, withExtension: "bundle"),
|
||||||
|
let categoryBundle = Bundle(url: bundleURL),
|
||||||
|
let url = categoryBundle.url(forResource: "sounds", withExtension: "json") else {
|
||||||
|
print("⚠️ Warning: Could not find sounds.json in \(bundleName).bundle")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
||||||
|
allSounds.append(contentsOf: soundsOnly.sounds)
|
||||||
|
|
||||||
|
print("✅ Loaded \(soundsOnly.sounds.count) sounds from \(bundleName).bundle")
|
||||||
|
} catch {
|
||||||
|
print("⚠️ Warning: Error loading sounds from \(bundleName).bundle: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load settings from separate file
|
||||||
|
let settings = loadAudioSettings(from: bundle)
|
||||||
|
|
||||||
|
let config = SoundConfiguration(sounds: allSounds, settings: settings)
|
||||||
|
self.configuration = config
|
||||||
|
print("✅ Loaded combined sound configuration with \(allSounds.count) sounds from \(bundleNames.count) bundles")
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current configuration
|
||||||
|
public func getConfiguration() -> SoundConfiguration {
|
||||||
|
if configuration == nil {
|
||||||
|
return loadConfigurationFromBundles()
|
||||||
|
}
|
||||||
|
return configuration!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all available sounds
|
||||||
|
public func getAvailableSounds() -> [Sound] {
|
||||||
|
return getConfiguration().sounds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sounds by category
|
||||||
|
public func getSoundsByCategory(_ categoryId: String) -> [Sound] {
|
||||||
|
return getConfiguration().sounds
|
||||||
|
.filter { $0.category == categoryId }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Get audio settings
|
||||||
|
public func getAudioSettings() -> AudioSettings {
|
||||||
|
return getConfiguration().settings
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
321
Sources/AudioPlaybackKit/Services/SoundPlayer.swift
Normal file
321
Sources/AudioPlaybackKit/Services/SoundPlayer.swift
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
//
|
||||||
|
// SoundPlayer.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Audio playback service for sounds and ambient audio
|
||||||
|
@available(iOS 17.0, tvOS 17.0, *)
|
||||||
|
@Observable
|
||||||
|
public class SoundPlayer {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
public static let shared = SoundPlayer()
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private var players: [String: AVAudioPlayer] = [:]
|
||||||
|
private let playersLock = NSLock()
|
||||||
|
private var currentPlayer: AVAudioPlayer?
|
||||||
|
public private(set) var currentSound: Sound?
|
||||||
|
private var shouldResumeAfterInterruption = false
|
||||||
|
private let wakeLockService = WakeLockService.shared
|
||||||
|
private let soundConfigurationService = SoundConfigurationService.shared
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
private init() {
|
||||||
|
setupAudioSession()
|
||||||
|
setupAudioInterruptionHandling()
|
||||||
|
// Preload sounds off the main thread to avoid blocking UI during app launch
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.preloadSounds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stopAllSounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
public var isPlaying: Bool {
|
||||||
|
return currentPlayer?.isPlaying ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playSound(_ sound: Sound) {
|
||||||
|
playSound(sound, volumeOverride: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playSound(_ sound: Sound, volume: Float) {
|
||||||
|
playSound(sound, volumeOverride: volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playSound(_ sound: Sound, volumeOverride: Float?) {
|
||||||
|
print("🎵 Attempting to play: \(sound.name)")
|
||||||
|
|
||||||
|
// Stop current sound if playing
|
||||||
|
stopSound()
|
||||||
|
|
||||||
|
// Store current sound for interruption handling
|
||||||
|
currentSound = sound
|
||||||
|
|
||||||
|
// Get or create player for this sound
|
||||||
|
playersLock.lock()
|
||||||
|
let player = players[sound.fileName]
|
||||||
|
playersLock.unlock()
|
||||||
|
|
||||||
|
guard let player else {
|
||||||
|
playersLock.lock()
|
||||||
|
let availableKeys = Array(players.keys)
|
||||||
|
playersLock.unlock()
|
||||||
|
print("❌ Sound not preloaded: \(sound.fileName)")
|
||||||
|
print("📁 Available sounds: \(availableKeys)")
|
||||||
|
|
||||||
|
// Try to load the sound dynamically as fallback
|
||||||
|
guard let fileUrl = getURL(for: sound) else {
|
||||||
|
print("❌ Sound file not found: \(sound.fileName)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
||||||
|
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||||
|
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||||
|
newPlayer.prepareToPlay()
|
||||||
|
playersLock.lock()
|
||||||
|
players[sound.fileName] = newPlayer
|
||||||
|
playersLock.unlock()
|
||||||
|
currentPlayer = newPlayer
|
||||||
|
let success = newPlayer.play()
|
||||||
|
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
print("❌ Error creating fallback player: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPlayer = player
|
||||||
|
if let volumeOverride {
|
||||||
|
player.volume = volumeOverride
|
||||||
|
}
|
||||||
|
let success = player.play()
|
||||||
|
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
|
print("🔊 Player isPlaying: \(player.isPlaying)")
|
||||||
|
print("🔊 Player volume: \(player.volume)")
|
||||||
|
|
||||||
|
// Enable wake lock when playing audio to prevent device sleep
|
||||||
|
if success {
|
||||||
|
wakeLockService.enableWakeLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopSound() {
|
||||||
|
currentPlayer?.stop()
|
||||||
|
currentPlayer = nil
|
||||||
|
shouldResumeAfterInterruption = false
|
||||||
|
|
||||||
|
// Disable wake lock when stopping audio
|
||||||
|
wakeLockService.disableWakeLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearCurrentSound() {
|
||||||
|
stopSound()
|
||||||
|
currentSound = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Helper method to get URL for sound file, handling bundles and direct paths
|
||||||
|
private func getURL(for sound: Sound) -> URL? {
|
||||||
|
// If sound has a bundle name, look in that bundle first
|
||||||
|
if let bundleName = sound.bundleName {
|
||||||
|
if let bundleURL = Bundle.main.url(forResource: bundleName, withExtension: "bundle"),
|
||||||
|
let bundle = Bundle(url: bundleURL) {
|
||||||
|
return bundle.url(forResource: sound.fileName, withExtension: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct file path
|
||||||
|
if sound.fileName.contains("/") {
|
||||||
|
// Path includes subfolder (e.g., "Sounds/white-noise.mp3")
|
||||||
|
let components = sound.fileName.components(separatedBy: "/")
|
||||||
|
let fileName = components.last!
|
||||||
|
let subfolder = components.dropLast().joined(separator: "/")
|
||||||
|
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
||||||
|
} else {
|
||||||
|
// Direct file path (fallback)
|
||||||
|
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// Alarm sounds live in a subdirectory; try that next
|
||||||
|
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupAudioSession() {
|
||||||
|
do {
|
||||||
|
let settings = soundConfigurationService.getAudioSettings()
|
||||||
|
|
||||||
|
// Use configuration settings or fall back to constants
|
||||||
|
let category = settings.audioSessionCategory == "playback" ?
|
||||||
|
AVAudioSession.Category.playback : AudioConstants.AudioSession.category
|
||||||
|
let mode = settings.audioSessionMode == "default" ?
|
||||||
|
AVAudioSession.Mode.default : AudioConstants.AudioSession.mode
|
||||||
|
let options: AVAudioSession.CategoryOptions = settings.audioSessionOptions.contains("mixWithOthers") == true ?
|
||||||
|
[.mixWithOthers] : AudioConstants.AudioSession.options
|
||||||
|
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
|
||||||
|
// Configure for background audio playback
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
|
||||||
|
print("🔊 Audio session configured for background playback")
|
||||||
|
} catch {
|
||||||
|
print("Error setting up audio session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preloadSounds() {
|
||||||
|
print("📁 Preloading audio files...")
|
||||||
|
|
||||||
|
// Get sound configuration
|
||||||
|
let sounds = soundConfigurationService.getAvailableSounds()
|
||||||
|
let settings = soundConfigurationService.getAudioSettings()
|
||||||
|
|
||||||
|
for sound in sounds {
|
||||||
|
guard let fileUrl = getURL(for: sound) else {
|
||||||
|
print("❌ Sound file not found: \(sound.fileName)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: fileUrl)
|
||||||
|
player.numberOfLoops = settings.defaultLoopCount
|
||||||
|
player.volume = settings.defaultVolume
|
||||||
|
if settings.preloadSounds {
|
||||||
|
player.prepareToPlay()
|
||||||
|
}
|
||||||
|
playersLock.lock()
|
||||||
|
players[sound.fileName] = player
|
||||||
|
playersLock.unlock()
|
||||||
|
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
|
||||||
|
} catch {
|
||||||
|
print("❌ Error preloading sound \(sound.fileName): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playersLock.lock()
|
||||||
|
let count = players.count
|
||||||
|
playersLock.unlock()
|
||||||
|
print("📁 Preloading complete. Loaded \(count) sounds.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAllSounds() {
|
||||||
|
playersLock.lock()
|
||||||
|
for player in players.values {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
players.removeAll()
|
||||||
|
playersLock.unlock()
|
||||||
|
currentPlayer = nil
|
||||||
|
currentSound = nil
|
||||||
|
shouldResumeAfterInterruption = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up audio interruption handling to maintain playback
|
||||||
|
private func setupAudioInterruptionHandling() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAudioInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRouteChange),
|
||||||
|
name: AVAudioSession.routeChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAudioInterruption(notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
// Audio was interrupted (e.g., phone call)
|
||||||
|
shouldResumeAfterInterruption = isPlaying
|
||||||
|
if isPlaying {
|
||||||
|
currentPlayer?.pause()
|
||||||
|
print("🔇 Audio interrupted - will resume after interruption ends")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
// Audio interruption ended
|
||||||
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) && shouldResumeAfterInterruption {
|
||||||
|
// Resume playback
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.resumePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRouteChange(notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch reason {
|
||||||
|
case .oldDeviceUnavailable:
|
||||||
|
// Headphones were unplugged, etc.
|
||||||
|
if isPlaying {
|
||||||
|
shouldResumeAfterInterruption = true
|
||||||
|
currentPlayer?.pause()
|
||||||
|
print("🔇 Audio route changed - will resume when new route available")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .newDeviceAvailable:
|
||||||
|
// New audio device available
|
||||||
|
if shouldResumeAfterInterruption {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.resumePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume playback after interruption
|
||||||
|
private func resumePlayback() {
|
||||||
|
guard shouldResumeAfterInterruption, let sound = currentSound else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
playSound(sound)
|
||||||
|
shouldResumeAfterInterruption = false
|
||||||
|
print("🔊 Audio playback resumed after interruption")
|
||||||
|
} catch {
|
||||||
|
print("❌ Error resuming audio playback: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Sources/AudioPlaybackKit/Services/WakeLockService.swift
Normal file
86
Sources/AudioPlaybackKit/Services/WakeLockService.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// WakeLockService.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Service to manage screen wake lock and prevent device from sleeping
|
||||||
|
@available(iOS 17.0, tvOS 17.0, *)
|
||||||
|
@Observable
|
||||||
|
public class WakeLockService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
public static let shared = WakeLockService()
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
public private(set) var isWakeLockActive = false
|
||||||
|
private var wakeLockTimer: Timer?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
disableWakeLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
|
||||||
|
/// Enable wake lock to prevent device from sleeping
|
||||||
|
public func enableWakeLock() {
|
||||||
|
guard !isWakeLockActive else { return }
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
// Prevent device from sleeping
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
|
||||||
|
// Set up periodic timer to maintain wake lock
|
||||||
|
wakeLockTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
|
||||||
|
// Keep the app active by briefly enabling/disabling idle timer
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
isWakeLockActive = true
|
||||||
|
print("🔒 Wake lock enabled - device will not sleep")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable wake lock and allow device to sleep normally
|
||||||
|
public func disableWakeLock() {
|
||||||
|
guard isWakeLockActive else { return }
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
// Allow device to sleep normally
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Stop the maintenance timer
|
||||||
|
wakeLockTimer?.invalidate()
|
||||||
|
wakeLockTimer = nil
|
||||||
|
|
||||||
|
isWakeLockActive = false
|
||||||
|
print("🔓 Wake lock disabled - device can sleep normally")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle wake lock state
|
||||||
|
public func toggleWakeLock() {
|
||||||
|
if isWakeLockActive {
|
||||||
|
disableWakeLock()
|
||||||
|
} else {
|
||||||
|
enableWakeLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if wake lock is currently active
|
||||||
|
public var isActive: Bool {
|
||||||
|
return isWakeLockActive
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift
Normal file
83
Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// SoundViewModel.swift
|
||||||
|
// AudioPlaybackKit
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// ViewModel for sound/audio playback
|
||||||
|
@available(iOS 17.0, tvOS 17.0, *)
|
||||||
|
@Observable
|
||||||
|
public class SoundViewModel {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private let soundPlayer: SoundPlayer
|
||||||
|
private let soundConfigurationService: SoundConfigurationService
|
||||||
|
public var isPreviewing: Bool = false
|
||||||
|
public var previewSound: Sound?
|
||||||
|
|
||||||
|
public var selectedSound: Sound?
|
||||||
|
|
||||||
|
public var isPlaying: Bool {
|
||||||
|
soundPlayer.isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
public var availableSounds: [Sound] {
|
||||||
|
return soundConfigurationService.getAvailableSounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
public init(soundPlayer: SoundPlayer = SoundPlayer.shared, soundConfigurationService: SoundConfigurationService = SoundConfigurationService.shared) {
|
||||||
|
self.soundPlayer = soundPlayer
|
||||||
|
self.soundConfigurationService = soundConfigurationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
public func playSound(_ sound: Sound) {
|
||||||
|
soundPlayer.playSound(sound)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopSound() {
|
||||||
|
soundPlayer.stopSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectSound(_ sound: Sound) {
|
||||||
|
// Stop any current playback when selecting a new sound
|
||||||
|
if isPlaying {
|
||||||
|
stopSound()
|
||||||
|
}
|
||||||
|
// Stop any preview
|
||||||
|
stopPreview()
|
||||||
|
|
||||||
|
selectedSound = sound
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview Functionality
|
||||||
|
public func previewSound(_ sound: Sound) {
|
||||||
|
// Stop any current preview
|
||||||
|
stopPreview()
|
||||||
|
|
||||||
|
// Set preview state
|
||||||
|
previewSound = sound
|
||||||
|
isPreviewing = true
|
||||||
|
|
||||||
|
// Play preview (3 seconds)
|
||||||
|
soundPlayer.playSound(sound)
|
||||||
|
|
||||||
|
// Auto-stop preview after 3 seconds
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||||
|
self.stopPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopPreview() {
|
||||||
|
if isPreviewing {
|
||||||
|
soundPlayer.stopSound()
|
||||||
|
isPreviewing = false
|
||||||
|
previewSound = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Tests/AudioPlaybackKitTests/AudioPlaybackKitTests.swift
Normal file
48
Tests/AudioPlaybackKitTests/AudioPlaybackKitTests.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// AudioPlaybackKitTests.swift
|
||||||
|
// AudioPlaybackKitTests
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import AudioPlaybackKit
|
||||||
|
|
||||||
|
final class AudioPlaybackKitTests: XCTestCase {
|
||||||
|
|
||||||
|
func testSoundModel() throws {
|
||||||
|
let sound = Sound(
|
||||||
|
name: "Test Sound",
|
||||||
|
fileName: "test.mp3",
|
||||||
|
category: "test",
|
||||||
|
description: "Test description"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(sound.name, "Test Sound")
|
||||||
|
XCTAssertEqual(sound.fileName, "test.mp3")
|
||||||
|
XCTAssertEqual(sound.category, "test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSoundConfigurationService() throws {
|
||||||
|
let service = SoundConfigurationService.shared
|
||||||
|
let sounds = service.getAvailableSounds()
|
||||||
|
|
||||||
|
// Should have fallback sounds if no configuration is loaded
|
||||||
|
XCTAssertFalse(sounds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWakeLockService() throws {
|
||||||
|
let service = WakeLockService.shared
|
||||||
|
|
||||||
|
// Initially should not be active
|
||||||
|
XCTAssertFalse(service.isActive)
|
||||||
|
|
||||||
|
// Enable wake lock
|
||||||
|
service.enableWakeLock()
|
||||||
|
XCTAssertTrue(service.isActive)
|
||||||
|
|
||||||
|
// Disable wake lock
|
||||||
|
service.disableWakeLock()
|
||||||
|
XCTAssertFalse(service.isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user