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