197 lines
5.4 KiB
Swift
Executable File
197 lines
5.4 KiB
Swift
Executable File
//
|
|
// SoundEffect.swift
|
|
// Hex
|
|
//
|
|
// Created by Kit Langton on 1/26/25.
|
|
//
|
|
|
|
import AVFoundation
|
|
import ComposableArchitecture
|
|
import Dependencies
|
|
import DependenciesMacros
|
|
import Foundation
|
|
import HexCore
|
|
import SwiftUI
|
|
|
|
// Thank you. Never mind then.What a beautiful idea.
|
|
public enum SoundEffect: String, CaseIterable {
|
|
case pasteTranscript
|
|
case startRecording
|
|
case stopRecording
|
|
case cancel
|
|
|
|
public var fileName: String {
|
|
self.rawValue
|
|
}
|
|
|
|
var fileExtension: String {
|
|
"mp3"
|
|
}
|
|
}
|
|
|
|
@DependencyClient
|
|
public struct SoundEffectsClient {
|
|
public var play: @Sendable (SoundEffect) -> Void
|
|
public var stop: @Sendable (SoundEffect) -> Void
|
|
public var stopAll: @Sendable () -> Void
|
|
public var preloadSounds: @Sendable () async -> Void
|
|
public var setEnabled: @Sendable (Bool) async -> Void
|
|
}
|
|
|
|
extension SoundEffectsClient: DependencyKey {
|
|
public static var liveValue: SoundEffectsClient {
|
|
let live = SoundEffectsClientLive()
|
|
return SoundEffectsClient(
|
|
play: { soundEffect in
|
|
Task { await live.play(soundEffect) }
|
|
},
|
|
stop: { soundEffect in
|
|
Task { await live.stop(soundEffect) }
|
|
},
|
|
stopAll: {
|
|
Task { await live.stopAll() }
|
|
},
|
|
preloadSounds: {
|
|
await live.preloadSounds()
|
|
},
|
|
setEnabled: { enabled in
|
|
await live.setEnabled(enabled)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
public extension DependencyValues {
|
|
var soundEffects: SoundEffectsClient {
|
|
get { self[SoundEffectsClient.self] }
|
|
set { self[SoundEffectsClient.self] = newValue }
|
|
}
|
|
}
|
|
|
|
actor SoundEffectsClientLive {
|
|
private let logger = HexLog.sound
|
|
private let baselineVolume = HexSettings.baseSoundEffectsVolume
|
|
|
|
private let engine = AVAudioEngine()
|
|
@Shared(.hexSettings) var hexSettings: HexSettings
|
|
private var playerNodes: [SoundEffect: AVAudioPlayerNode] = [:]
|
|
private var audioBuffers: [SoundEffect: AVAudioPCMBuffer] = [:]
|
|
private var idleShutdownTask: Task<Void, Never>?
|
|
/// Comfortably longer than any sound effect, short enough that an idle Hex doesn't keep
|
|
/// an output IOProc (and coreaudiod) running around the clock (#209).
|
|
private static let idleShutdownDelay: Duration = .seconds(10)
|
|
|
|
func play(_ soundEffect: SoundEffect) {
|
|
guard hexSettings.soundEffectsEnabled else { return }
|
|
guard let player = playerNodes[soundEffect], let buffer = audioBuffers[soundEffect] else {
|
|
logger.error("Requested sound \(soundEffect.rawValue) not preloaded")
|
|
return
|
|
}
|
|
prepareEngineIfNeeded()
|
|
let clampedVolume = min(max(hexSettings.soundEffectsVolume, 0), baselineVolume)
|
|
player.volume = Float(clampedVolume)
|
|
player.stop()
|
|
player.scheduleBuffer(buffer, at: nil, options: [], completionHandler: nil)
|
|
player.play()
|
|
scheduleIdleShutdown()
|
|
}
|
|
|
|
/// Stops the output engine shortly after playback so it doesn't run while idle.
|
|
/// Restarting it on the next play costs only a few milliseconds.
|
|
private func scheduleIdleShutdown() {
|
|
idleShutdownTask?.cancel()
|
|
idleShutdownTask = Task {
|
|
try? await Task.sleep(for: Self.idleShutdownDelay)
|
|
guard !Task.isCancelled else { return }
|
|
stopEngineIfNeeded()
|
|
}
|
|
}
|
|
|
|
func stop(_ soundEffect: SoundEffect) {
|
|
playerNodes[soundEffect]?.stop()
|
|
}
|
|
|
|
func stopAll() {
|
|
playerNodes.values.forEach { $0.stop() }
|
|
}
|
|
|
|
func preloadSounds() async {
|
|
guard !isSetup else { return }
|
|
|
|
for soundEffect in SoundEffect.allCases {
|
|
loadSound(soundEffect)
|
|
}
|
|
|
|
isSetup = true
|
|
}
|
|
|
|
func setEnabled(_: Bool) async {
|
|
await preloadSounds()
|
|
|
|
// No prewarm on enable: play() starts the engine lazily, and an idle prewarm would
|
|
// just be shut down again by the idle timer.
|
|
if !hexSettings.soundEffectsEnabled {
|
|
stopAll()
|
|
idleShutdownTask?.cancel()
|
|
stopEngineIfNeeded()
|
|
}
|
|
}
|
|
|
|
private var isSetup = false
|
|
|
|
private func loadSound(_ soundEffect: SoundEffect) {
|
|
guard let url = Bundle.main.url(
|
|
forResource: soundEffect.fileName,
|
|
withExtension: soundEffect.fileExtension
|
|
) else {
|
|
logger.error("Missing sound resource \(soundEffect.fileName).\(soundEffect.fileExtension)")
|
|
return
|
|
}
|
|
|
|
do {
|
|
let file = try AVAudioFile(forReading: url)
|
|
let frameCount = AVAudioFrameCount(file.length)
|
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else {
|
|
logger.error("Failed to allocate buffer for \(soundEffect.rawValue)")
|
|
return
|
|
}
|
|
try file.read(into: buffer)
|
|
audioBuffers[soundEffect] = buffer
|
|
|
|
let player = AVAudioPlayerNode()
|
|
engine.attach(player)
|
|
engine.connect(player, to: engine.mainMixerNode, format: buffer.format)
|
|
playerNodes[soundEffect] = player
|
|
} catch {
|
|
logger.error("Failed to load sound \(soundEffect.rawValue): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func prepareEngineIfNeeded() {
|
|
guard !engine.isRunning else { return }
|
|
engine.prepare()
|
|
if #available(macOS 13.0, *) {
|
|
engine.isAutoShutdownEnabled = false
|
|
}
|
|
do {
|
|
try engine.start()
|
|
} catch {
|
|
logger.error("Failed to start AVAudioEngine: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func stopEngineIfNeeded() {
|
|
guard engine.isRunning else { return }
|
|
engine.stop()
|
|
logger.debug("Sound effects engine stopped")
|
|
}
|
|
|
|
deinit {
|
|
playerNodes.values.forEach {
|
|
$0.stop()
|
|
engine.detach($0)
|
|
}
|
|
engine.stop()
|
|
}
|
|
}
|