mirror of
https://github.com/EmergeTools/Pow.git
synced 2026-03-25 08:55:50 +00:00
Co-authored-by: Robert Böhnke <robb@robb.is> Co-authored-by: Kasper Lahti <kasper@lahti.email>
479 lines
13 KiB
Swift
479 lines
13 KiB
Swift
#if os(iOS)
|
|
import SwiftUI
|
|
import CoreHaptics
|
|
import UniformTypeIdentifiers
|
|
import AVFoundation
|
|
|
|
public extension AnyChangeEffect {
|
|
/// Triggers sound effect as feedback whenever a value changes.
|
|
///
|
|
/// - Parameter soundEffect: The sound effect to trigger.
|
|
static func feedback(_ soundEffect: SoundEffect) -> AnyChangeEffect {
|
|
.simulation { change in
|
|
SoundEffectModifier(audio: soundEffect, impulseCount: change)
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct SoundEffect: Hashable, Sendable {
|
|
/// The audio session used to play sound effects or `nil` to get the default audio behavior.
|
|
///
|
|
/// Provide an `AVAudioSession` instance if your app is already using an audio session and you want the audio behavior of the sound effects to match other audio in your app.
|
|
///
|
|
/// - Note: Sound effects have a different audio behavior when running in a simulator. If you don't set an audio session, Pow activates the shared audio session when needed.
|
|
/// If you only want to change the audio behavior for the simulator you can check the target environment before setting the session.
|
|
/// ```
|
|
/// #if targetEnvironment(simulator)
|
|
/// SoundEffect.audioSession = AVAudioSession.sharedInstance()
|
|
/// #endif
|
|
/// ```
|
|
static var audioSession: AVAudioSession?
|
|
|
|
var urls: [URL]
|
|
|
|
var url: URL? {
|
|
return urls.first
|
|
}
|
|
|
|
var volume: Double = 1.0
|
|
|
|
/// Creates a sound effect.
|
|
///
|
|
/// If more than one name is given the sound effect will rotate between playing one of those sounds.
|
|
///
|
|
/// So for example `SoundEffect("Pop1", "Pop2", "Pop3")` will play a different pop sound every time.
|
|
///
|
|
/// - Parameters:
|
|
/// - names: One or more names of the sound resource to lookup.
|
|
/// - type: The type of the sound resource to lookup. Defaults to `.audio`.
|
|
/// - bundle: The bundle to search for the sound resource. Defaults to the main `Bundle`.
|
|
public init(_ names: String..., type: UTType = .audio, bundle: Bundle = .main) {
|
|
let types: [UTType]
|
|
if type == .audio {
|
|
types = [type, .aiff, .wav, UTType(filenameExtension: "caf")!, .mpeg4Audio, UTType(filenameExtension: "m4a")!]
|
|
} else {
|
|
types = [type]
|
|
}
|
|
|
|
self.urls = []
|
|
|
|
for type in types {
|
|
let fileExtensions = type.tags[.filenameExtension] ?? []
|
|
for fileExtension in fileExtensions {
|
|
for name in names {
|
|
if let url = bundle.url(forResource: name, withExtension: fileExtension) {
|
|
self.urls.append(url)
|
|
}
|
|
}
|
|
if urls.count > 0 {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
print("No sound resource named \(names.map({ "'\($0)'" }).formatted(.list(type: .and))) with type '\(type)' found in bundle \(bundle)")
|
|
}
|
|
|
|
/// Create a sound effect from the specified URL.
|
|
///
|
|
/// - Parameter url: The URL of the sound to play.
|
|
public init(url: URL) {
|
|
self.urls = [url]
|
|
}
|
|
|
|
/// Sets the volume of this sound.
|
|
///
|
|
/// - Parameter value: A value between 0.0 (silent) and 1.0 (maximum volume).
|
|
public func volume(_ value: Double) -> Self {
|
|
var copy = self
|
|
copy.volume = value
|
|
return copy
|
|
}
|
|
}
|
|
|
|
private struct SoundEffectModifier: ViewModifier, Simulative {
|
|
var impulseCount: Int = 0
|
|
|
|
// TODO: Remove from protocol
|
|
var initialVelocity: CGFloat = 0
|
|
|
|
var audio: SoundEffect
|
|
|
|
init(audio: SoundEffect, impulseCount: Int) {
|
|
self.audio = audio
|
|
self.impulseCount = impulseCount
|
|
}
|
|
|
|
let engine: AnySoundEffectPlayer = .shared
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: impulseCount) { _ in
|
|
Task(priority: .userInitiated) {
|
|
try await engine.register(audio)
|
|
try await engine.play(audio)
|
|
try await engine.unregister(audio)
|
|
}
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
try await engine.register(audio)
|
|
}
|
|
}
|
|
.onChange(of: audio) { [oldValue = audio] newValue in
|
|
guard oldValue != newValue else { return }
|
|
|
|
Task {
|
|
try await engine.unregister(oldValue)
|
|
try await engine.register(newValue)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
Task {
|
|
try await engine.unregister(audio)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private protocol SoundEffectPlayer {
|
|
func register(_ audio: SoundEffect) async throws
|
|
func unregister(_ audio: SoundEffect) async throws
|
|
func play(_ audio: SoundEffect) async throws
|
|
}
|
|
|
|
private actor AnySoundEffectPlayer: SoundEffectPlayer {
|
|
static var shared = AnySoundEffectPlayer()
|
|
|
|
let player: any SoundEffectPlayer
|
|
|
|
init() {
|
|
#if targetEnvironment(simulator)
|
|
self.player = AVSoundEffectPlayer()
|
|
#else
|
|
if CHHapticEngine.capabilitiesForHardware().supportsAudio {
|
|
self.player = HapticEngineSoundEffectPlayer()
|
|
} else {
|
|
self.player = EmptySoundEffectPlayer()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func register(_ audio: SoundEffect) async throws {
|
|
try await player.register(audio)
|
|
}
|
|
|
|
func unregister(_ audio: SoundEffect) async throws {
|
|
try await player.unregister(audio)
|
|
}
|
|
|
|
func play(_ audio: SoundEffect) async throws {
|
|
try await player.play(audio)
|
|
}
|
|
}
|
|
|
|
private actor EmptySoundEffectPlayer: SoundEffectPlayer {
|
|
func register(_ audio: SoundEffect) async throws {
|
|
|
|
}
|
|
|
|
func unregister(_ audio: SoundEffect) async throws {
|
|
|
|
}
|
|
|
|
func play(_ audio: SoundEffect) async throws {
|
|
|
|
}
|
|
}
|
|
|
|
private actor HapticEngineSoundEffectPlayer: SoundEffectPlayer {
|
|
private var engine: CHHapticEngine?
|
|
|
|
private struct SoundEffectReference {
|
|
let resourceID: CHHapticAudioResourceID
|
|
|
|
var count: Int = 1
|
|
}
|
|
|
|
private var registeredSounds: [URL: SoundEffectReference] = [:]
|
|
|
|
private var didSetUp = false
|
|
|
|
init() {
|
|
|
|
}
|
|
|
|
private func setUp() throws {
|
|
if didSetUp { return }
|
|
defer { didSetUp = true }
|
|
|
|
if let audioSession = SoundEffect.audioSession {
|
|
engine = try? CHHapticEngine(audioSession: audioSession)
|
|
} else {
|
|
engine = try? CHHapticEngine()
|
|
}
|
|
|
|
guard let engine else { return }
|
|
|
|
if #available(iOS 16.0, *) {
|
|
engine.playsAudioOnly = true
|
|
}
|
|
|
|
engine.isAutoShutdownEnabled = false
|
|
|
|
engine.resetHandler = {
|
|
try? engine.start()
|
|
}
|
|
}
|
|
|
|
private func tearDown() async throws {
|
|
guard let engine else { return }
|
|
|
|
do {
|
|
#if DEBUG
|
|
print("Stopping engine")
|
|
#endif
|
|
|
|
try await engine.stop()
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func register(_ audio: SoundEffect) throws {
|
|
try setUp()
|
|
|
|
guard let engine else { return }
|
|
|
|
for url in audio.urls {
|
|
if var newRegisteredSound = registeredSounds[url] {
|
|
newRegisteredSound.count += 1
|
|
registeredSounds[url] = newRegisteredSound
|
|
} else {
|
|
#if DEBUG
|
|
print("Registering \(audio)")
|
|
#endif
|
|
let resourceID = try engine.registerAudioResource(url)
|
|
|
|
let reference = SoundEffectReference(resourceID: resourceID)
|
|
registeredSounds[url] = reference
|
|
}
|
|
}
|
|
}
|
|
|
|
func unregister(_ audio: SoundEffect) async throws {
|
|
guard let engine else { return }
|
|
|
|
for url in audio.urls {
|
|
registeredSounds[url]?.count -= 1
|
|
|
|
if registeredSounds[url]?.count == 0 {
|
|
#if DEBUG
|
|
print("Unregistering \(audio)")
|
|
#endif
|
|
|
|
if let resourceID = registeredSounds[url]?.resourceID {
|
|
try engine.unregisterAudioResource(resourceID)
|
|
}
|
|
|
|
registeredSounds[url] = nil
|
|
}
|
|
}
|
|
|
|
if registeredSounds.isEmpty {
|
|
try await tearDown()
|
|
}
|
|
}
|
|
|
|
func play(_ audio: SoundEffect) async throws {
|
|
guard let engine else { return }
|
|
|
|
try await engine.start()
|
|
|
|
// TODO: Avoid playing the same sound twice if there are more variations.
|
|
guard let url = audio.urls.randomElement() else { return }
|
|
|
|
guard let resourceID = registeredSounds[url]?.resourceID else { return }
|
|
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
let event = CHHapticEvent(
|
|
audioResourceID: resourceID,
|
|
parameters: [.init(parameterID: .audioVolume, value: Float(audio.volume))],
|
|
relativeTime: CHHapticTimeImmediate
|
|
)
|
|
|
|
do {
|
|
let pattern = try CHHapticPattern(events: [event], parameters: [])
|
|
let player = try engine.makeAdvancedPlayer(with: pattern)
|
|
|
|
player.completionHandler = { x in
|
|
continuation.resume()
|
|
}
|
|
|
|
try player.start(atTime: CHHapticTimeImmediate)
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private actor AVSoundEffectPlayer: SoundEffectPlayer {
|
|
private struct SoundEffectReference {
|
|
let id: UUID
|
|
|
|
var count: Int
|
|
}
|
|
|
|
private var registeredSounds: [SoundEffect: SoundEffectReference] = [:]
|
|
|
|
private var shouldDeactivateAudioSession = true
|
|
|
|
private var didSetUp = false
|
|
|
|
init() {
|
|
|
|
}
|
|
|
|
private func setUp() throws {
|
|
if didSetUp { return }
|
|
defer { didSetUp = true }
|
|
guard SoundEffect.audioSession == nil else { return }
|
|
|
|
let audioSession = AVAudioSession.sharedInstance()
|
|
#if targetEnvironment(simulator)
|
|
try audioSession.setCategory(.playback, mode: .default)
|
|
#else
|
|
try audioSession.setCategory(.ambient, mode: .default)
|
|
#endif
|
|
try audioSession.setActive(true)
|
|
|
|
shouldDeactivateAudioSession = true
|
|
}
|
|
|
|
private func tearDown() throws {
|
|
#if DEBUG
|
|
print("Stopping engine")
|
|
#endif
|
|
|
|
guard shouldDeactivateAudioSession else { return }
|
|
|
|
let audioSession = AVAudioSession.sharedInstance()
|
|
|
|
try audioSession.setActive(false)
|
|
}
|
|
|
|
func register(_ audio: SoundEffect) {
|
|
try? setUp()
|
|
|
|
if var updatedSound = registeredSounds[audio] {
|
|
updatedSound.count += 1
|
|
registeredSounds[audio] = updatedSound
|
|
} else {
|
|
#if DEBUG
|
|
print("Registering \(audio)")
|
|
#endif
|
|
let id = UUID()
|
|
let sound = SoundEffectReference(id: id, count: 1)
|
|
registeredSounds[audio] = sound
|
|
}
|
|
}
|
|
|
|
func unregister(_ audio: SoundEffect) {
|
|
guard var registeredSound = registeredSounds[audio] else { return }
|
|
|
|
registeredSound.count -= 1
|
|
|
|
if registeredSound.count == 0 {
|
|
#if DEBUG
|
|
print("Unregistering \(audio)")
|
|
#endif
|
|
|
|
registeredSounds[audio] = nil
|
|
} else {
|
|
registeredSounds[audio] = registeredSound
|
|
}
|
|
|
|
if registeredSounds.count == 0 {
|
|
try? tearDown()
|
|
}
|
|
}
|
|
|
|
func play(_ audio: SoundEffect) async throws {
|
|
// TODO: Avoid playing the same sound twice if there are more variations.
|
|
guard let url = audio.urls.randomElement() else {
|
|
return
|
|
}
|
|
|
|
let player = AVAudioPlayerWithCompletionHandler(url: url, volume: audio.volume)
|
|
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
player.play { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private class AVAudioPlayerWithCompletionHandler: NSObject, AVAudioPlayerDelegate {
|
|
let url: URL
|
|
|
|
let volume: Double
|
|
|
|
var completion: (Result<Void, Error>) -> Void
|
|
|
|
var player: AVAudioPlayer?
|
|
|
|
init(url: URL, volume: Double) {
|
|
self.url = url
|
|
self.volume = volume
|
|
self.completion = { _ in }
|
|
self.player = nil
|
|
}
|
|
|
|
func play(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
self.completion = completion
|
|
|
|
do {
|
|
let player = try AVAudioPlayer(contentsOf: url)
|
|
player.prepareToPlay()
|
|
player.delegate = self
|
|
player.volume = Float(volume)
|
|
player.play()
|
|
self.player = player
|
|
} catch {
|
|
completion(.failure(error))
|
|
tearDown()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
player?.stop()
|
|
tearDown()
|
|
}
|
|
|
|
func tearDown() {
|
|
player = nil
|
|
completion = { _ in }
|
|
}
|
|
|
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
if flag {
|
|
completion(.success(()))
|
|
} else {
|
|
completion(.failure(AVError(.unknown)))
|
|
}
|
|
tearDown()
|
|
}
|
|
|
|
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
|
|
if let error {
|
|
completion(.failure(error))
|
|
} else {
|
|
completion(.failure(AVError(.unknown)))
|
|
}
|
|
tearDown()
|
|
}
|
|
}
|
|
#endif
|