Files

140 lines
5.2 KiB
Swift

// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AVFoundation
import Localization
public enum RestSound: String, CaseIterable, Identifiable, Sendable {
case off, white, pink, rain
public var id: String { rawValue }
var title: String {
switch self {
case .off: tr("关闭")
case .white: tr("白噪音")
case .pink: tr("粉红噪音")
case .rain: tr("雨声")
}
}
}
/// 实时生成采样,无文件、下载或麦克风访问。render block 内不分配内存,也不访问 UI 状态。
@MainActor
final class RestSoundPlayer {
private var engine: AVAudioEngine?
private(set) var playing: RestSound = .off
/// 启动失败后短暂冷却;调用方每秒同步一次,不能每秒重建引擎。
private var failed: RestSound?
private var retryAfter: UInt64?
private var configurationObserver: NSObjectProtocol?
private let startEngine: (AVAudioEngine) throws -> Void
private let clock: () -> UInt64
init(startEngine: @escaping (AVAudioEngine) throws -> Void = { try $0.start() },
clock: @escaping () -> UInt64 = { RestClock.now() }) {
self.startEngine = startEngine
self.clock = clock
}
func play(_ sound: RestSound) {
guard sound != playing else { return }
if sound == failed, let retryAfter, clock() < retryAfter { return }
stop()
guard sound != .off else { return }
let engine = AVAudioEngine()
let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 2)!
let node = makeNoiseSourceNode(format: format, mode: sound)
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
engine.mainMixerNode.outputVolume = 0.16
do {
try startEngine(engine)
self.engine = engine
playing = sound
// 切换耳机等输出设备时系统会停止引擎;按新配置重建,否则本次休息剩余时间都没有声音。
configurationObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
let current = self.playing
self.stop()
self.play(current)
}
}
} catch {
engine.stop()
failed = sound
retryAfter = clock() + 10_000_000_000
}
}
func stop() {
if let configurationObserver { NotificationCenter.default.removeObserver(configurationObserver) }
configurationObserver = nil
engine?.stop()
engine = nil
playing = .off
failed = nil
retryAfter = nil
}
}
/// AVAudioSourceNode 在音频线程调用此闭包;在非隔离函数中创建,避免继承播放器的 MainActor。
nonisolated private func makeNoiseSourceNode(format: AVAudioFormat, mode: RestSound) -> AVAudioSourceNode {
let generator = NoiseGenerator(mode: mode)
return AVAudioSourceNode(format: format) { _, _, frameCount, audioBufferList in
let buffers = UnsafeMutableAudioBufferListPointer(audioBufferList)
for frame in 0..<Int(frameCount) {
let sample = generator.next()
for buffer in buffers {
buffer.mData!.assumingMemoryBound(to: Float.self)[frame] = sample
}
}
return noErr
}
}
/// 单个 source node 的渲染回调独占采样状态,不向其他线程暴露。
nonisolated private final class NoiseGenerator {
let mode: RestSound
private var seed: UInt64 = 0x9E3779B97F4A7C15
private var lowA: Float = 0
private var pink0: Float = 0
private var pink1: Float = 0
private var pink2: Float = 0
private var pink3: Float = 0
private var pink4: Float = 0
private var pink5: Float = 0
private var pink6: Float = 0
private var drops: Float = 0
init(mode: RestSound) { self.mode = mode }
func next() -> Float {
seed ^= seed << 13
seed ^= seed >> 7
seed ^= seed << 17
let white = Float(seed & 0xFFFF) / 32767.5 - 1
switch mode {
case .off: return 0
case .white: return white * 0.35
case .pink:
// Paul Kellett 的 pink-noise 滤波系数;多极点近似 1/f 频谱。
// https://www.musicdsp.org/en/latest/Filters/76-pink-noise-filter.html
pink0 = 0.99886 * pink0 + white * 0.0555179
pink1 = 0.99332 * pink1 + white * 0.0750759
pink2 = 0.96900 * pink2 + white * 0.1538520
pink3 = 0.86650 * pink3 + white * 0.3104856
pink4 = 0.55000 * pink4 + white * 0.5329522
pink5 = -0.7616 * pink5 - white * 0.0168980
let sample = pink0 + pink1 + pink2 + pink3 + pink4 + pink5 + pink6 + white * 0.5362
pink6 = white * 0.115926
return sample * 0.11
case .rain:
lowA += 0.035 * (white - lowA)
if seed & 0x1FFF == 0 { drops = 0.75 }
drops *= 0.998
return lowA * 0.7 + white * drops * 0.25
}
}
}