Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
|
||||
/// ISO octave centres for the 10-band graphic EQ.
|
||||
let EQ_BANDS: [Float] = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
||||
let EQ_LABELS = ["31", "62", "125", "250", "500", "1K", "2K", "4K", "8K", "16K"]
|
||||
let EQ_MAX_DB: Float = 12
|
||||
let EQ_Q: Float = 1.41 // one octave
|
||||
|
||||
/// Equalizer APO's filter types; [apo] is the name its config files use.
|
||||
enum FilterType: String, Codable, CaseIterable {
|
||||
case Peak, LowShelf, HighShelf, LowPass, HighPass, BandPass, Notch, AllPass
|
||||
|
||||
var apo: String {
|
||||
switch self {
|
||||
case .Peak: "PK"
|
||||
case .LowShelf: "LSC"
|
||||
case .HighShelf: "HSC"
|
||||
case .LowPass: "LPQ"
|
||||
case .HighPass: "HPQ"
|
||||
case .BandPass: "BP"
|
||||
case .Notch: "NO"
|
||||
case .AllPass: "AP"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .Peak: "Peak"
|
||||
case .LowShelf: "Low Shelf"
|
||||
case .HighShelf: "High Shelf"
|
||||
case .LowPass: "Low Pass"
|
||||
case .HighPass: "High Pass"
|
||||
case .BandPass: "Band Pass"
|
||||
case .Notch: "Notch"
|
||||
case .AllPass: "All Pass"
|
||||
}
|
||||
}
|
||||
|
||||
var hasGain: Bool { self == .Peak || self == .LowShelf || self == .HighShelf }
|
||||
var hasSlope: Bool { self == .LowPass || self == .HighPass }
|
||||
}
|
||||
|
||||
enum EqChannel: String, Codable, CaseIterable {
|
||||
case Both, Left, Right
|
||||
var label: String { rawValue }
|
||||
}
|
||||
|
||||
/// Slopes offered for low and high pass, in dB per octave. 12 uses the filter's Q; steeper ones are Butterworth.
|
||||
let EQ_SLOPES = [6, 12, 24, 36, 48]
|
||||
|
||||
/// One parametric filter, as in Equalizer APO. Gain only matters for peaks and shelves.
|
||||
struct EqFilter: Codable, Hashable, Identifiable {
|
||||
var id: String
|
||||
var type: FilterType = .Peak
|
||||
var freq: Float = 1000
|
||||
var gain: Float = 0
|
||||
var q: Float = 0.707
|
||||
var slope = 12
|
||||
var channel: EqChannel = .Both
|
||||
var on = true
|
||||
|
||||
init(id: String, type: FilterType = .Peak, freq: Float = 1000, gain: Float = 0, q: Float = 0.707,
|
||||
slope: Int = 12, channel: EqChannel = .Both, on: Bool = true) {
|
||||
self.id = id; self.type = type; self.freq = freq; self.gain = gain; self.q = q
|
||||
self.slope = slope; self.channel = channel; self.on = on
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("id") ?? UUID().uuidString
|
||||
type = c.str("type").flatMap(FilterType.init(rawValue:)) ?? .Peak
|
||||
freq = Float(c.double("freq") ?? 1000)
|
||||
gain = Float(c.double("gain") ?? 0)
|
||||
q = Float(c.double("q") ?? 0.707)
|
||||
slope = c.int("slope") ?? 12
|
||||
channel = c.str("channel").flatMap(EqChannel.init(rawValue:)) ?? .Both
|
||||
on = c.bool("on") ?? true
|
||||
}
|
||||
|
||||
/// Whether it changes the sound at all (a 0 dB peak or shelf doesn't).
|
||||
var active: Bool { on && (!type.hasGain || abs(gain) >= 0.01) }
|
||||
|
||||
/// Its biquad sections at [fs]: [b0, b1, b2, a1, a2] each, normalised by a0.
|
||||
func sections(_ fs: Double) -> [[Double]] {
|
||||
guard active else { return [] }
|
||||
let f0 = min(max(Double(freq), 1), fs * 0.49)
|
||||
let q = min(max(Double(self.q), 0.025), 100)
|
||||
let g = Double(gain)
|
||||
switch type {
|
||||
case .LowPass, .HighPass:
|
||||
let lp = type == .LowPass
|
||||
if slope <= 6 { return [firstOrder(fs, f0, lp)] }
|
||||
if slope <= 12 { return [Biquad.design(type, fs, f0, 0, q)] }
|
||||
// Butterworth of order slope/6, as cascaded sections (plus a first-order one when odd).
|
||||
let n = slope / 6
|
||||
var out = (1...(n / 2)).map { k in Biquad.design(type, fs, f0, 0, 1 / (2 * cos(Double(2 * k - 1) * .pi / Double(2 * n)))) }
|
||||
if n % 2 == 1 { out.append(firstOrder(fs, f0, lp)) }
|
||||
return out
|
||||
default:
|
||||
return [Biquad.design(type, fs, f0, g, q)]
|
||||
}
|
||||
}
|
||||
|
||||
private func firstOrder(_ fs: Double, _ f0: Double, _ lowPass: Bool) -> [Double] {
|
||||
let k = tan(.pi * f0 / fs)
|
||||
let a1 = (k - 1) / (k + 1)
|
||||
return lowPass ? [k / (k + 1), k / (k + 1), 0, a1, 0] : [1 / (k + 1), -1 / (k + 1), 0, a1, 0]
|
||||
}
|
||||
|
||||
func applies(left: Bool) -> Bool { channel == .Both || (channel == .Left) == left }
|
||||
|
||||
/// The 10-band graphic EQ as peaking filters, ahead of the parametric ones.
|
||||
static func graphic(_ gains: [Float]) -> [EqFilter] {
|
||||
gains.enumerated().compactMap { i, g in
|
||||
abs(g) < 0.01 || i >= EQ_BANDS.count ? nil : EqFilter(id: "band\(i)", type: .Peak, freq: EQ_BANDS[i], gain: g, q: EQ_Q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine needs from an EQ profile; nil means bypass.
|
||||
struct EqParams: Equatable {
|
||||
var filters: [EqFilter]
|
||||
var preampDb: Float
|
||||
}
|
||||
|
||||
/// A biquad section, transposed direct form II, double state per channel.
|
||||
final class Biquad {
|
||||
private var b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0
|
||||
private var zl1 = 0.0, zl2 = 0.0, zr1 = 0.0, zr2 = 0.0
|
||||
|
||||
func set(_ c: [Double]) {
|
||||
b0 = c[0]; b1 = c[1]; b2 = c[2]; a1 = c[3]; a2 = c[4]
|
||||
}
|
||||
|
||||
func reset() { zl1 = 0; zl2 = 0; zr1 = 0; zr2 = 0 }
|
||||
|
||||
/// [buf] is interleaved stereo.
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int, left: Bool = true, right: Bool = true) {
|
||||
var l1 = zl1, l2 = zl2, r1 = zr1, r2 = zr2
|
||||
for i in 0..<frames {
|
||||
if left {
|
||||
let xl = Double(buf[i * 2])
|
||||
let yl = b0 * xl + l1
|
||||
l1 = b1 * xl - a1 * yl + l2
|
||||
l2 = b2 * xl - a2 * yl
|
||||
buf[i * 2] = Float(yl)
|
||||
}
|
||||
if right {
|
||||
let xr = Double(buf[i * 2 + 1])
|
||||
let yr = b0 * xr + r1
|
||||
r1 = b1 * xr - a1 * yr + r2
|
||||
r2 = b2 * xr - a2 * yr
|
||||
buf[i * 2 + 1] = Float(yr)
|
||||
}
|
||||
}
|
||||
// Flush denormals so a silent tail doesn't cost CPU.
|
||||
zl1 = abs(l1) < 1e-25 ? 0 : l1; zl2 = abs(l2) < 1e-25 ? 0 : l2
|
||||
zr1 = abs(r1) < 1e-25 ? 0 : r1; zr2 = abs(r2) < 1e-25 ? 0 : r2
|
||||
}
|
||||
|
||||
/// RBJ Audio EQ Cookbook coefficients, [b0, b1, b2, a1, a2] normalised by a0.
|
||||
static func design(_ type: FilterType, _ fs: Double, _ f0: Double, _ gainDb: Double, _ q: Double) -> [Double] {
|
||||
let a = pow(10, gainDb / 40)
|
||||
let w0 = 2 * .pi * min(f0, fs * 0.49) / fs
|
||||
let alpha = sin(w0) / (2 * q)
|
||||
let cw = cos(w0)
|
||||
let sa = 2 * a.squareRoot() * alpha
|
||||
let c: [Double]
|
||||
switch type {
|
||||
case .Peak: c = [1 + alpha * a, -2 * cw, 1 - alpha * a, 1 + alpha / a, -2 * cw, 1 - alpha / a]
|
||||
case .LowShelf: c = [
|
||||
a * ((a + 1) - (a - 1) * cw + sa), 2 * a * ((a - 1) - (a + 1) * cw), a * ((a + 1) - (a - 1) * cw - sa),
|
||||
(a + 1) + (a - 1) * cw + sa, -2 * ((a - 1) + (a + 1) * cw), (a + 1) + (a - 1) * cw - sa,
|
||||
]
|
||||
case .HighShelf: c = [
|
||||
a * ((a + 1) + (a - 1) * cw + sa), -2 * a * ((a - 1) + (a + 1) * cw), a * ((a + 1) + (a - 1) * cw - sa),
|
||||
(a + 1) - (a - 1) * cw + sa, 2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - sa,
|
||||
]
|
||||
case .LowPass: c = [(1 - cw) / 2, 1 - cw, (1 - cw) / 2, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .HighPass: c = [(1 + cw) / 2, -(1 + cw), (1 + cw) / 2, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .BandPass: c = [alpha, 0, -alpha, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .Notch: c = [1, -2 * cw, 1, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .AllPass: c = [1 - alpha, -2 * cw, 1 + alpha, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
}
|
||||
let a0 = c[3]
|
||||
return [c[0] / a0, c[1] / a0, c[2] / a0, c[4] / a0, c[5] / a0]
|
||||
}
|
||||
|
||||
/// |H| in dB at frequency f.
|
||||
static func magnitudeDb(_ c: [Double], _ fs: Double, _ f: Double) -> Double {
|
||||
let w = 2 * .pi * f / fs
|
||||
let cw = cos(w), sw = sin(w), c2 = cos(2 * w), s2 = sin(2 * w)
|
||||
let nr = c[0] + c[1] * cw + c[2] * c2
|
||||
let ni = -(c[1] * sw + c[2] * s2)
|
||||
let dr = 1 + c[3] * cw + c[4] * c2
|
||||
let di = -(c[3] * sw + c[4] * s2)
|
||||
return 10 * log10(max(1e-30, nr * nr + ni * ni) / max(1e-30, dr * dr + di * di))
|
||||
}
|
||||
}
|
||||
|
||||
enum EqMath {
|
||||
/// Combined response of [filters] on one channel, in dB at each of [freqs].
|
||||
static func response(_ filters: [EqFilter], _ freqs: [Double], left: Bool = true, fs: Double = 48000) -> [Double] {
|
||||
let coeffs = filters.filter { $0.applies(left: left) }.flatMap { $0.sections(fs) }
|
||||
return freqs.map { f in coeffs.reduce(0) { $0 + Biquad.magnitudeDb($1, fs, f) } }
|
||||
}
|
||||
|
||||
static func logFreqs(_ n: Int, lo: Double = 20, hi: Double = 20000) -> [Double] {
|
||||
(0..<n).map { lo * pow(hi / lo, Double($0) / (Double(n) - 1)) }
|
||||
}
|
||||
|
||||
/// Anti-clipping preamp: the negative of the curve's highest point (manual preamp included,
|
||||
/// louder channel), so a full-scale signal at the loudest frequency still peaks at 0 dBFS.
|
||||
static func autoPreampDb(_ filters: [EqFilter], manualDb: Float = 0) -> Float {
|
||||
let f = logFreqs(512)
|
||||
let peak = max(response(filters, f, left: true).max() ?? 0, response(filters, f, left: false).max() ?? 0)
|
||||
return -Float(max(0, peak + Double(manualDb)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs on the render thread; [configure] is called there too, between buffers.
|
||||
final class Equalizer {
|
||||
private struct Stage { let filter: Biquad; let left: Bool; let right: Bool }
|
||||
private struct Shape: Equatable { let id: String; let channel: EqChannel; let type: FilterType }
|
||||
|
||||
private var stages: [Stage] = []
|
||||
private var shape: [Shape] = []
|
||||
private var fs = 0
|
||||
private var params: EqParams?
|
||||
private var preamp: Float = 1
|
||||
private(set) var enabled = false
|
||||
|
||||
func configure(_ p: EqParams?, sampleRate: Int) {
|
||||
enabled = p != nil
|
||||
guard let p, p != params || sampleRate != fs else { return }
|
||||
let rateChanged = sampleRate != fs
|
||||
fs = sampleRate
|
||||
params = p
|
||||
preamp = dbToGain(p.preampDb)
|
||||
let designed = p.filters.flatMap { f in f.sections(Double(fs)).map { ($0, f) } }
|
||||
// Coefficient changes (a slider drag) keep the filters' state; a different layout starts fresh.
|
||||
let newShape = designed.map { Shape(id: $0.1.id, channel: $0.1.channel, type: $0.1.type) }
|
||||
if rateChanged || newShape != shape {
|
||||
stages = designed.map { Stage(filter: Biquad(), left: $0.1.channel != .Right, right: $0.1.channel != .Left) }
|
||||
shape = newShape
|
||||
}
|
||||
for (i, d) in designed.enumerated() { stages[i].filter.set(d.0) }
|
||||
}
|
||||
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int) {
|
||||
guard enabled else { return }
|
||||
if preamp != 1 { for i in 0..<(frames * 2) { buf[i] *= preamp } }
|
||||
for s in stages { s.filter.process(buf, frames, left: s.left, right: s.right) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Peak limiter on the master bus: stems pushed past unity or an EQ boost never clip.
|
||||
/// Instant attack, ~120 ms release, ceiling just under full scale.
|
||||
final class Limiter {
|
||||
private let ceiling: Float
|
||||
private var gain: Float = 1
|
||||
private var release: Float = 0
|
||||
|
||||
init(ceiling: Float = 0.985) { self.ceiling = ceiling }
|
||||
|
||||
func setSampleRate(_ fs: Int) { release = 1 - exp(-1 / (0.12 * Float(fs))) }
|
||||
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int) {
|
||||
var g = gain
|
||||
for i in 0..<frames {
|
||||
let l = buf[i * 2], r = buf[i * 2 + 1]
|
||||
let peak = max(abs(l), abs(r))
|
||||
let need: Float = peak * g > ceiling ? ceiling / peak : 1
|
||||
g = need < g ? need : g + (1 - g) * release
|
||||
if peak * g > ceiling { g = ceiling / peak }
|
||||
buf[i * 2] = l * g
|
||||
buf[i * 2 + 1] = r * g
|
||||
}
|
||||
gain = g
|
||||
}
|
||||
}
|
||||
|
||||
func dbToGain(_ db: Float) -> Float { pow(10, db / 20) }
|
||||
func gainToDb(_ g: Float) -> Float { g <= 1e-6 ? -120 : 20 * log10(g) }
|
||||
|
||||
/// Master slider taper (same cubic curve as the desktop app).
|
||||
func masterTaper(_ v: Float) -> Float { let x = min(max(v, 0), 1); return x * x * x }
|
||||
|
||||
/// Stem slider: 0..1 with unity at 0.8 and +3.9 dB at the top.
|
||||
func stemTaper(_ v: Float) -> Float { let x = min(max(v, 0), 1) / 0.8; return x * x }
|
||||
func stemUntaper(_ g: Float) -> Float { min(max(max(g, 0).squareRoot() * 0.8, 0), 1) }
|
||||
@@ -0,0 +1,379 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// os_unfair_lock in a stable heap slot. Short critical sections only; the render thread takes it.
|
||||
final class UnfairLock: @unchecked Sendable {
|
||||
private let p: UnsafeMutablePointer<os_unfair_lock>
|
||||
init() {
|
||||
p = .allocate(capacity: 1)
|
||||
p.initialize(to: os_unfair_lock())
|
||||
}
|
||||
deinit { p.deallocate() }
|
||||
@inline(__always) func lock() { os_unfair_lock_lock(p) }
|
||||
@inline(__always) func unlock() { os_unfair_lock_unlock(p) }
|
||||
@inline(__always) func with<T>(_ f: () throws -> T) rethrows -> T {
|
||||
os_unfair_lock_lock(p); defer { os_unfair_lock_unlock(p) }
|
||||
return try f()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-producer single-consumer ring of interleaved stereo float frames.
|
||||
final class FloatRing: @unchecked Sendable {
|
||||
private let buf: UnsafeMutablePointer<Float>
|
||||
private let cap: Int
|
||||
private var readPos: Int64 = 0
|
||||
private var writePos: Int64 = 0
|
||||
private let lock = UnfairLock()
|
||||
private let notFull = DispatchSemaphore(value: 0)
|
||||
private var eosFlag = false
|
||||
|
||||
init(capacityFrames: Int) {
|
||||
cap = capacityFrames * 2
|
||||
buf = .allocate(capacity: cap)
|
||||
buf.initialize(repeating: 0, count: cap)
|
||||
}
|
||||
deinit { buf.deallocate() }
|
||||
|
||||
var eos: Bool {
|
||||
get { lock.with { eosFlag } }
|
||||
set { lock.with { eosFlag = newValue } }
|
||||
}
|
||||
|
||||
var availableFrames: Int { lock.with { Int((writePos - readPos) / 2) } }
|
||||
var freeFrames: Int { lock.with { Int((Int64(cap) - (writePos - readPos)) / 2) } }
|
||||
|
||||
/// Blocks while full; returns false if [abort] became true first. [len] counts floats.
|
||||
func write(_ src: UnsafePointer<Float>, _ len: Int, abort: () -> Bool) -> Bool {
|
||||
var done = 0
|
||||
while done < len {
|
||||
lock.lock()
|
||||
let free = Int(Int64(cap) - (writePos - readPos))
|
||||
if free <= 0 {
|
||||
lock.unlock()
|
||||
if abort() { return false }
|
||||
_ = notFull.wait(timeout: .now() + .milliseconds(20))
|
||||
continue
|
||||
}
|
||||
lock.unlock()
|
||||
if abort() { return false }
|
||||
lock.lock()
|
||||
let n = min(Int(Int64(cap) - (writePos - readPos)), len - done)
|
||||
let w = Int(writePos % Int64(cap))
|
||||
let first = min(n, cap - w)
|
||||
(buf + w).update(from: src + done, count: first)
|
||||
if n > first { buf.update(from: src + done + first, count: n - first) }
|
||||
writePos += Int64(n)
|
||||
done += n
|
||||
lock.unlock()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Copies up to [frames] frames into dst; returns frames copied. Never blocks on data.
|
||||
func read(_ dst: UnsafeMutablePointer<Float>, _ frames: Int) -> Int {
|
||||
lock.lock()
|
||||
let n = Int(min(Int64(frames * 2), writePos - readPos))
|
||||
let r = Int(readPos % Int64(cap))
|
||||
let first = min(n, cap - r)
|
||||
dst.update(from: buf + r, count: first)
|
||||
if n > first { (dst + first).update(from: buf, count: n - first) }
|
||||
readPos += Int64(n)
|
||||
lock.unlock()
|
||||
if n > 0 { notFull.signal() }
|
||||
return n / 2
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lock.with {
|
||||
readPos = writePos
|
||||
eosFlag = false
|
||||
}
|
||||
notFull.signal()
|
||||
}
|
||||
|
||||
func wake() { notFull.signal() }
|
||||
}
|
||||
|
||||
/// The decoder's view of a cached resource. Retries brief network trouble before giving up.
|
||||
final class ResourceDataSource: @unchecked Sendable {
|
||||
private let res: CachedResource
|
||||
private let onClose: () -> Void
|
||||
private let lock = NSLock()
|
||||
private var failure: Error?
|
||||
private var isClosed = false
|
||||
|
||||
init(_ res: CachedResource, onClose: @escaping () -> Void = {}) {
|
||||
self.res = res
|
||||
self.onClose = onClose
|
||||
}
|
||||
|
||||
var failed: Error? { lock.withLock { failure } }
|
||||
var closed: Bool { lock.withLock { isClosed } }
|
||||
|
||||
/// Bytes read, 0 at end of file.
|
||||
func readAt(_ position: Int64, _ buffer: UnsafeMutableRawPointer, _ size: Int) throws -> Int {
|
||||
var attempt = 0
|
||||
while true {
|
||||
do {
|
||||
let n = try res.readAt(position, buffer, size)
|
||||
lock.withLock { failure = nil }
|
||||
if n > 0 && attempt == 0 { res.prefetch() }
|
||||
return max(n, 0)
|
||||
} catch {
|
||||
attempt += 1
|
||||
if closed || attempt > 8 {
|
||||
lock.withLock { failure = error }
|
||||
throw error
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.25 * Double(attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func size() -> Int64 {
|
||||
do { return try res.size() } catch {
|
||||
lock.withLock { failure = error }
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
let first = lock.withLock { () -> Bool in
|
||||
if isClosed { return false }
|
||||
isClosed = true
|
||||
return true
|
||||
}
|
||||
if first { onClose() }
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodeError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Decodes one stem on its own thread into [ring], as stereo float at the file's own rate.
|
||||
/// FLAC, Ogg Opus and whatever else Core Audio reads all go through ExtAudioFile, whose seeks
|
||||
/// are sample-accurate, so every stem of a track resumes on exactly the same frame.
|
||||
final class StemDecoder: @unchecked Sendable {
|
||||
let name: String
|
||||
let ring: FloatRing
|
||||
private let source: ResourceDataSource
|
||||
private let typeHint: AudioFileTypeID
|
||||
private let prepared = NSCondition()
|
||||
private var isPrepared = false
|
||||
private let state = UnfairLock()
|
||||
private var _sampleRate = 0
|
||||
private var _durationUs: Int64 = 0
|
||||
private var _error: Error?
|
||||
private var _readyGen = -1
|
||||
|
||||
var sampleRate: Int { state.with { _sampleRate } }
|
||||
var durationUs: Int64 { state.with { _durationUs } }
|
||||
var error: Error? { state.with { _error } }
|
||||
/// Generation whose data is in the ring (or that hit EOS).
|
||||
var readyGen: Int { state.with { _readyGen } }
|
||||
|
||||
private let seekLock = NSCondition()
|
||||
private var pendingGen = -1
|
||||
private var pendingFrame: Int64 = 0
|
||||
private var releasedFlag = false
|
||||
private var released: Bool { seekLock.withLock { releasedFlag } }
|
||||
private var thread: Thread?
|
||||
|
||||
/// [hint] is the file's extension or format ("flac", "opus", "mp3", ...), so Core Audio
|
||||
/// doesn't have to guess from the bytes.
|
||||
init(name: String, source: ResourceDataSource, ringFrames: Int, hint: String) {
|
||||
self.name = name
|
||||
self.source = source
|
||||
ring = FloatRing(capacityFrames: ringFrames)
|
||||
typeHint = Self.fileType(hint)
|
||||
}
|
||||
|
||||
static func fileType(_ hint: String) -> AudioFileTypeID {
|
||||
switch hint.lowercased() {
|
||||
case "flac": return kAudioFileFLACType
|
||||
case "opus", "ogg", "oga": return AudioFileTypeID(0x4F67_6773) // 'Oggs'
|
||||
case "mp3": return kAudioFileMP3Type
|
||||
case "m4a", "mp4", "alac": return kAudioFileM4AType
|
||||
case "aac": return kAudioFileAAC_ADTSType
|
||||
case "wav": return kAudioFileWAVEType
|
||||
case "aif", "aiff": return kAudioFileAIFFType
|
||||
case "caf": return kAudioFileCAFType
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func start(gen: Int, frame: Int64) {
|
||||
seek(gen: gen, frame: frame)
|
||||
let t = Thread { [self] in run() }
|
||||
t.name = "stem-\(name)"
|
||||
t.qualityOfService = .userInteractive
|
||||
thread = t
|
||||
t.start()
|
||||
}
|
||||
|
||||
/// Blocks until the stream's format is known. False on error or timeout.
|
||||
func awaitPrepared(timeoutMs: Int) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
|
||||
prepared.lock()
|
||||
while !isPrepared { if !prepared.wait(until: deadline) { break } }
|
||||
let ok = isPrepared
|
||||
prepared.unlock()
|
||||
return ok && error == nil
|
||||
}
|
||||
|
||||
private func markPrepared() {
|
||||
prepared.lock()
|
||||
isPrepared = true
|
||||
prepared.broadcast()
|
||||
prepared.unlock()
|
||||
}
|
||||
|
||||
func seek(gen: Int, frame: Int64) {
|
||||
seekLock.lock()
|
||||
pendingGen = gen
|
||||
pendingFrame = frame
|
||||
seekLock.broadcast()
|
||||
seekLock.unlock()
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private func seekPending(_ current: Int) -> Bool { seekLock.withLock { pendingGen != current || releasedFlag } }
|
||||
|
||||
func release() {
|
||||
seekLock.lock()
|
||||
releasedFlag = true
|
||||
seekLock.broadcast()
|
||||
seekLock.unlock()
|
||||
source.close()
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private static let readProc: AudioFile_ReadProc = { ud, pos, req, buf, actual in
|
||||
let src = Unmanaged<ResourceDataSource>.fromOpaque(ud).takeUnretainedValue()
|
||||
do {
|
||||
actual.pointee = UInt32(try src.readAt(pos, buf, Int(req)))
|
||||
return noErr
|
||||
} catch {
|
||||
actual.pointee = 0
|
||||
return kAudioFileUnspecifiedError
|
||||
}
|
||||
}
|
||||
|
||||
private static let sizeProc: AudioFile_GetSizeProc = { ud in
|
||||
max(0, Unmanaged<ResourceDataSource>.fromOpaque(ud).takeUnretainedValue().size())
|
||||
}
|
||||
|
||||
private func run() {
|
||||
var fileId: AudioFileID?
|
||||
var ext: ExtAudioFileRef?
|
||||
let srcRef = Unmanaged.passRetained(source)
|
||||
defer {
|
||||
if let ext { ExtAudioFileDispose(ext) }
|
||||
if let fileId { AudioFileClose(fileId) }
|
||||
srcRef.release()
|
||||
markPrepared()
|
||||
}
|
||||
do {
|
||||
var st = AudioFileOpenWithCallbacks(srcRef.toOpaque(), Self.readProc, nil, Self.sizeProc, nil, typeHint, &fileId)
|
||||
if st != noErr || fileId == nil {
|
||||
throw source.failed ?? DecodeError("Can't read \(name) (\(st))")
|
||||
}
|
||||
st = ExtAudioFileWrapAudioFileID(fileId!, false, &ext)
|
||||
guard st == noErr, let ext else { throw DecodeError("Can't decode \(name) (\(st))") }
|
||||
|
||||
var fmt = AudioStreamBasicDescription()
|
||||
var sz = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||
st = ExtAudioFileGetProperty(ext, kExtAudioFileProperty_FileDataFormat, &sz, &fmt)
|
||||
guard st == noErr, fmt.mSampleRate > 0 else { throw DecodeError("No audio in \(name)") }
|
||||
let rate = Int(fmt.mSampleRate.rounded())
|
||||
var frames: Int64 = 0
|
||||
sz = UInt32(MemoryLayout<Int64>.size)
|
||||
ExtAudioFileGetProperty(ext, kExtAudioFileProperty_FileLengthFrames, &sz, &frames)
|
||||
// Asking for stereo makes the converter upmix mono and drop channels past the
|
||||
// first two, the same as Android's toStereo.
|
||||
var client = AudioStreamBasicDescription(
|
||||
mSampleRate: fmt.mSampleRate, mFormatID: kAudioFormatLinearPCM,
|
||||
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
|
||||
mBytesPerPacket: 8, mFramesPerPacket: 1, mBytesPerFrame: 8,
|
||||
mChannelsPerFrame: 2, mBitsPerChannel: 32, mReserved: 0)
|
||||
st = ExtAudioFileSetProperty(ext, kExtAudioFileProperty_ClientDataFormat, UInt32(MemoryLayout.size(ofValue: client)), &client)
|
||||
guard st == noErr else { throw DecodeError("Can't convert \(name) (\(st))") }
|
||||
if fmt.mChannelsPerFrame > 2 {
|
||||
// Keep L/R instead of letting the converter downmix surround.
|
||||
var conv: AudioConverterRef?
|
||||
sz = UInt32(MemoryLayout<AudioConverterRef?>.size)
|
||||
if ExtAudioFileGetProperty(ext, kExtAudioFileProperty_AudioConverter, &sz, &conv) == noErr, let conv {
|
||||
var map: [Int32] = [0, 1]
|
||||
AudioConverterSetProperty(conv, kAudioConverterChannelMap, UInt32(MemoryLayout<Int32>.size * 2), &map)
|
||||
var nul: Unmanaged<CFPropertyList>?
|
||||
ExtAudioFileSetProperty(ext, kExtAudioFileProperty_ConverterConfig, UInt32(MemoryLayout<Unmanaged<CFPropertyList>?>.size), &nul)
|
||||
}
|
||||
}
|
||||
state.with {
|
||||
_sampleRate = rate
|
||||
_durationUs = frames > 0 ? frames * 1_000_000 / Int64(rate) : 0
|
||||
}
|
||||
markPrepared()
|
||||
|
||||
let chunk = 4096
|
||||
let scratch = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
defer { scratch.deallocate() }
|
||||
var gen = -1
|
||||
var outputDone = false
|
||||
var pos: Int64 = 0
|
||||
|
||||
while !released {
|
||||
seekLock.lock()
|
||||
if pendingGen != gen {
|
||||
gen = pendingGen
|
||||
let target = pendingFrame
|
||||
seekLock.unlock()
|
||||
ring.clear()
|
||||
st = ExtAudioFileSeek(ext, target)
|
||||
if st != noErr { throw source.failed ?? DecodeError("Seek failed in \(name) (\(st))") }
|
||||
pos = target
|
||||
outputDone = false
|
||||
} else if outputDone {
|
||||
ring.eos = true
|
||||
state.with { _readyGen = gen }
|
||||
_ = seekLock.wait(until: Date().addingTimeInterval(0.1))
|
||||
seekLock.unlock()
|
||||
continue
|
||||
} else {
|
||||
seekLock.unlock()
|
||||
}
|
||||
if released { break }
|
||||
|
||||
var n = UInt32(chunk)
|
||||
var abl = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 2, mDataByteSize: UInt32(chunk * 8), mData: scratch))
|
||||
st = ExtAudioFileRead(ext, &n, &abl)
|
||||
if st != noErr {
|
||||
if released { break }
|
||||
throw source.failed ?? DecodeError("Decode failed in \(name) (\(st))")
|
||||
}
|
||||
if n == 0 {
|
||||
// A short file is only an error if the source gave up well before the end.
|
||||
if let f = source.failed, frames > 0, pos < frames - Int64(rate) * 2 { throw f }
|
||||
outputDone = true
|
||||
continue
|
||||
}
|
||||
pos += Int64(n)
|
||||
let myGen = gen
|
||||
if ring.write(scratch, Int(n) * 2, abort: { seekPending(myGen) }) {
|
||||
state.with { _readyGen = gen }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if !released {
|
||||
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
|
||||
state.with { _error = error }
|
||||
}
|
||||
ring.eos = true
|
||||
state.with { _readyGen = Int.max }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@inline(__always) func monoNanos() -> Int64 { Int64(DispatchTime.now().uptimeNanoseconds) }
|
||||
|
||||
/// One track's stems, decoding in sync. Built off the main thread, then handed to the engine.
|
||||
final class TrackSession: @unchecked Sendable {
|
||||
let token: Int64
|
||||
let names: [String]
|
||||
let decoders: [StemDecoder]
|
||||
var sampleRate: Int { decoders.first?.sampleRate ?? 0 }
|
||||
var durationFrames: Int64 { (decoders.map(\.durationUs).max() ?? 0) * Int64(sampleRate) / 1_000_000 }
|
||||
fileprivate var gen = 0
|
||||
fileprivate var framePos: Int64 = 0
|
||||
fileprivate var target: [Float]
|
||||
fileprivate var current: [Float]
|
||||
fileprivate var mixVersion = -1
|
||||
|
||||
init(token: Int64, names: [String], decoders: [StemDecoder]) {
|
||||
self.token = token
|
||||
self.names = names
|
||||
self.decoders = decoders
|
||||
target = Array(repeating: 1, count: names.count)
|
||||
current = Array(repeating: 1, count: names.count)
|
||||
}
|
||||
|
||||
func error() -> Error? { decoders.lazy.compactMap(\.error).first }
|
||||
|
||||
fileprivate func ready(_ need: Int) -> Bool {
|
||||
decoders.allSatisfy { d in
|
||||
d.error != nil || (d.readyGen == gen || d.readyGen == Int.max) && (d.ring.eos || d.ring.availableFrames >= need)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func finished() -> Bool {
|
||||
decoders.allSatisfy { d in
|
||||
d.error != nil || (d.readyGen == gen || d.readyGen == Int.max) && d.ring.eos && d.ring.availableFrames == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-targets decoders; only valid before the session is handed to the engine.
|
||||
func startAt(_ frame: Int64) {
|
||||
gen += 1
|
||||
framePos = frame
|
||||
decoders.forEach { $0.seek(gen: gen, frame: frame) }
|
||||
}
|
||||
|
||||
func release() { decoders.forEach { $0.release() } }
|
||||
}
|
||||
|
||||
/// Per-stem RMS of short blocks as they're mixed, stamped with their track frame, so meters can
|
||||
/// show what's coming out of the speaker now rather than what was just mixed.
|
||||
final class MeterHistory: @unchecked Sendable {
|
||||
static let block = 256
|
||||
static let maxStems = 8
|
||||
/// ~5 s at 48 kHz: well past the output buffer.
|
||||
private static let cap = 1024
|
||||
private static let staleFrames: Int64 = 8192
|
||||
|
||||
private var tokens = [Int64](repeating: 0, count: cap)
|
||||
private var starts = [Int64](repeating: 0, count: cap)
|
||||
private var values = [Float](repeating: 0, count: cap * maxStems)
|
||||
private var head = 0
|
||||
private var count = 0
|
||||
private let lock = UnfairLock()
|
||||
|
||||
func clear() { lock.with { count = 0 } }
|
||||
|
||||
func add(_ token: Int64, _ start: Int64, _ rms: UnsafePointer<Float>, _ stems: Int) {
|
||||
lock.with {
|
||||
tokens[head] = token
|
||||
starts[head] = start
|
||||
let base = head * Self.maxStems
|
||||
for k in 0..<Self.maxStems { values[base + k] = k < stems ? rms[k] : 0 }
|
||||
head = (head + 1) % Self.cap
|
||||
count = min(count + 1, Self.cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills [out] with the levels of [token]'s block playing at [frame]; false if there's none.
|
||||
func read(_ token: Int64, _ frame: Int64, _ out: inout [Float]) -> Bool {
|
||||
lock.with {
|
||||
guard count > 0 else { return false }
|
||||
for k in 1...count {
|
||||
let i = (head - k + Self.cap) % Self.cap
|
||||
if tokens[i] != token || starts[i] > frame { continue }
|
||||
if frame - starts[i] > Self.staleFrames { return false }
|
||||
for j in 0..<min(out.count, Self.maxStems) { out[j] = values[i * Self.maxStems + j] }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The far end of the output ring: the render callback takes mixed frames from it and counts
|
||||
/// what it took, so the engine knows what has reached the speaker.
|
||||
private final class OutputTap: @unchecked Sendable {
|
||||
let ring: FloatRing
|
||||
let rate: Int
|
||||
private let lock = UnfairLock()
|
||||
private var consumed: Int64 = 0
|
||||
private var lastStart: Int64 = 0
|
||||
private var lastNanos: Int64 = 0
|
||||
private var runningFlag = false
|
||||
private var latencyNanos: Int64 = 0
|
||||
private let scratch: UnsafeMutablePointer<Float>
|
||||
private static let scratchFrames = 4096
|
||||
|
||||
init(rate: Int, frames: Int) {
|
||||
self.rate = rate
|
||||
ring = FloatRing(capacityFrames: frames)
|
||||
scratch = .allocate(capacity: Self.scratchFrames * 2)
|
||||
}
|
||||
deinit { scratch.deallocate() }
|
||||
|
||||
var running: Bool {
|
||||
get { lock.with { runningFlag } }
|
||||
set { lock.with { runningFlag = newValue } }
|
||||
}
|
||||
|
||||
func setLatency(_ seconds: Double) { lock.with { latencyNanos = Int64(seconds * 1e9) } }
|
||||
|
||||
func flush() {
|
||||
lock.with {
|
||||
ring.clear()
|
||||
consumed = 0
|
||||
lastStart = 0
|
||||
lastNanos = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames heard so far (since the last flush), never more than [written].
|
||||
func presented(_ now: Int64, _ written: Int64) -> Int64 {
|
||||
lock.with {
|
||||
if lastNanos == 0 { return 0 }
|
||||
let elapsed = runningFlag ? max(0, now - lastNanos) : 0
|
||||
let est = lastStart + elapsed * Int64(rate) / 1_000_000_000 - latencyNanos * Int64(rate) / 1_000_000_000
|
||||
return min(max(0, min(est, consumed)), written)
|
||||
}
|
||||
}
|
||||
|
||||
func render(_ silence: UnsafeMutablePointer<ObjCBool>, _ ts: UnsafePointer<AudioTimeStamp>, _ frames: AVAudioFrameCount, _ abl: UnsafeMutablePointer<AudioBufferList>) -> OSStatus {
|
||||
let list = UnsafeMutableAudioBufferListPointer(abl)
|
||||
let n = Int(frames)
|
||||
guard list.count >= 2, let l = list[0].mData?.assumingMemoryBound(to: Float.self),
|
||||
let r = list[1].mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
||||
lock.lock()
|
||||
if !runningFlag {
|
||||
lock.unlock()
|
||||
l.update(repeating: 0, count: n)
|
||||
r.update(repeating: 0, count: n)
|
||||
silence.pointee = true
|
||||
return noErr
|
||||
}
|
||||
let host = ts.pointee.mFlags.contains(.hostTimeValid) ? Int64(AVAudioTime.seconds(forHostTime: ts.pointee.mHostTime) * 1e9) : monoNanos()
|
||||
lastStart = consumed
|
||||
lastNanos = host
|
||||
var done = 0
|
||||
while done < n {
|
||||
let want = min(n - done, Self.scratchFrames)
|
||||
let got = ring.read(scratch, want)
|
||||
for i in 0..<got {
|
||||
l[done + i] = scratch[i * 2]
|
||||
r[done + i] = scratch[i * 2 + 1]
|
||||
}
|
||||
consumed += Int64(got)
|
||||
done += got
|
||||
if got < want { break }
|
||||
}
|
||||
lock.unlock()
|
||||
if done < n {
|
||||
(l + done).update(repeating: 0, count: n - done)
|
||||
(r + done).update(repeating: 0, count: n - done)
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
}
|
||||
|
||||
protocol StemEngineListener: AnyObject {
|
||||
func onEngineState()
|
||||
/// [token]'s audio is now what's coming out of the speaker.
|
||||
func onTransition(_ token: Int64)
|
||||
func onEnded(_ token: Int64)
|
||||
func onError(_ token: Int64, _ message: String)
|
||||
}
|
||||
|
||||
/// Mixes a [TrackSession]'s stems into one output: per-stem gain (ramped), EQ with anti-clip
|
||||
/// preamp, master gain, then a peak limiter. All output calls happen on the engine thread; the
|
||||
/// public methods just queue commands.
|
||||
final class StemEngine: @unchecked Sendable {
|
||||
static let chunk = 1024
|
||||
/// Per stem, decoded ahead of the mixer.
|
||||
static let ringSeconds = 3
|
||||
/// Frames each stem must have before (re)starting output.
|
||||
static let prefill = 8192
|
||||
static let bufferMs = 120
|
||||
|
||||
/// Where playback is; position = frame + (now - nanos) while [running].
|
||||
struct Snapshot {
|
||||
var token: Int64
|
||||
var frame: Int64
|
||||
var rate: Int
|
||||
var nanos: Int64
|
||||
var running: Bool
|
||||
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
||||
if rate <= 0 { return 0 }
|
||||
let f = running ? frame + (now - nanos) * Int64(rate) / 1_000_000_000 : frame
|
||||
return f * 1000 / Int64(rate)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Segment { let writtenStart: Int64; let token: Int64; let frameStart: Int64 }
|
||||
|
||||
private weak var listener: StemEngineListener?
|
||||
private let cmdLock = NSCondition()
|
||||
private var commands: [() -> Void] = []
|
||||
private var released = false
|
||||
|
||||
private var session: TrackSession?
|
||||
private var next: TrackSession?
|
||||
private var wantPlay = false
|
||||
private let av = AVAudioEngine()
|
||||
private var node: AVAudioSourceNode?
|
||||
private var tap: OutputTap?
|
||||
private var trackRate = 0
|
||||
private var written: Int64 = 0
|
||||
private var segments: [Segment] = []
|
||||
private var drainUntil: Int64 = -1
|
||||
private var drainDeadline: Int64 = 0
|
||||
private var drainToken: Int64 = -1
|
||||
private var waitingSince: Int64 = 0
|
||||
private var errorReported: Int64 = -1
|
||||
private let eq = Equalizer()
|
||||
private let limiter = Limiter()
|
||||
private var masterCur: Float = 1
|
||||
private let out = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
private let tmp = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
private let meters = MeterHistory()
|
||||
private var meterSums = [Float](repeating: 0, count: chunk / MeterHistory.block * MeterHistory.maxStems)
|
||||
|
||||
private let shared = UnfairLock()
|
||||
private var _eqParams: EqParams?
|
||||
private var _masterGain: Float = 1
|
||||
private var _mix: [String: Float] = [:]
|
||||
private var _mixVersion = 0
|
||||
private var _snapshot = Snapshot(token: -1, frame: 0, rate: 44100, nanos: 0, running: false)
|
||||
private var _playing = false
|
||||
private var _buffering = false
|
||||
|
||||
var eqParams: EqParams? {
|
||||
get { shared.with { _eqParams } }
|
||||
set { shared.with { _eqParams = newValue } }
|
||||
}
|
||||
var masterGain: Float {
|
||||
get { shared.with { _masterGain } }
|
||||
set { shared.with { _masterGain = newValue } }
|
||||
}
|
||||
private(set) var snapshot: Snapshot {
|
||||
get { shared.with { _snapshot } }
|
||||
set { shared.with { _snapshot = newValue } }
|
||||
}
|
||||
var playing: Bool { shared.with { _playing } }
|
||||
var buffering: Bool { shared.with { _buffering } }
|
||||
|
||||
init(listener: StemEngineListener) {
|
||||
self.listener = listener
|
||||
out.initialize(repeating: 0, count: Self.chunk * 2)
|
||||
tmp.initialize(repeating: 0, count: Self.chunk * 2)
|
||||
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: av, queue: nil) { [weak self] _ in
|
||||
// The route or hardware format changed and the engine stopped; start it again.
|
||||
self?.post { self?.restartOutput() }
|
||||
}
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: nil) { [weak self] _ in
|
||||
self?.post { self?.tap?.setLatency(Self.outputLatency()) }
|
||||
}
|
||||
#endif
|
||||
let t = Thread { [weak self] in self?.loop() }
|
||||
t.name = "stem-engine"
|
||||
t.qualityOfService = .userInteractive
|
||||
t.start()
|
||||
}
|
||||
|
||||
// ---- commands (any thread) --------------------------------------------------------
|
||||
|
||||
private func post(_ cmd: @escaping () -> Void) {
|
||||
cmdLock.lock()
|
||||
commands.append(cmd)
|
||||
cmdLock.broadcast()
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
/// Replaces whatever is playing with [s], which must already be prepared.
|
||||
func load(_ s: TrackSession, play: Bool) {
|
||||
post { [self] in
|
||||
dropAll()
|
||||
session = s
|
||||
wantPlay = play
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The track to continue into gaplessly when the current one ends (or nil).
|
||||
func queueNext(_ s: TrackSession?) {
|
||||
post { [self] in
|
||||
if next !== s { next?.release() }
|
||||
next = s
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ play: Bool) {
|
||||
post { [self] in
|
||||
wantPlay = play
|
||||
if !play { pauseOutput() }
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func seekTo(_ ms: Int64) {
|
||||
post { [self] in
|
||||
guard let s = session else { return }
|
||||
let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1))
|
||||
s.gen += 1
|
||||
s.framePos = frame
|
||||
s.decoders.forEach { $0.seek(gen: s.gen, frame: frame) }
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
post { [self] in
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func setMix(_ gains: [String: Float]) {
|
||||
shared.with {
|
||||
_mix = gains
|
||||
_mixVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
cmdLock.lock()
|
||||
released = true
|
||||
cmdLock.broadcast()
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
/// The meters' levels for what's audible right now, into [out] (one per stem). False when
|
||||
/// nothing is playing, so the meters can fall.
|
||||
func levelsNow(_ out: inout [Float]) -> Bool {
|
||||
let snap = snapshot
|
||||
if !snap.running || snap.rate <= 0 { return false }
|
||||
let frame = snap.frame + (monoNanos() - snap.nanos) * Int64(snap.rate) / 1_000_000_000
|
||||
return meters.read(snap.token, frame, &out)
|
||||
}
|
||||
|
||||
// ---- engine thread ----------------------------------------------------------------
|
||||
|
||||
private var isReleased: Bool { cmdLock.withLock { released } }
|
||||
|
||||
private func dropAll() {
|
||||
session?.release()
|
||||
next?.release()
|
||||
session = nil
|
||||
next = nil
|
||||
drainUntil = -1
|
||||
pauseOutput()
|
||||
tap?.flush()
|
||||
written = 0
|
||||
segments.removeAll()
|
||||
}
|
||||
|
||||
private func resetOutput(_ s: TrackSession) {
|
||||
pauseOutput()
|
||||
tap?.flush()
|
||||
written = 0
|
||||
segments = [Segment(writtenStart: 0, token: s.token, frameStart: s.framePos)]
|
||||
drainUntil = -1
|
||||
waitingSince = 0
|
||||
meters.clear()
|
||||
errorReported = -1
|
||||
}
|
||||
|
||||
private func loop() {
|
||||
while !isReleased {
|
||||
while true {
|
||||
cmdLock.lock()
|
||||
let cmd = commands.isEmpty ? nil : commands.removeFirst()
|
||||
cmdLock.unlock()
|
||||
guard let cmd else { break }
|
||||
cmd()
|
||||
}
|
||||
do {
|
||||
try step()
|
||||
} catch {
|
||||
let tok = session?.token ?? -1
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
let msg = error.localizedDescription
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onError(tok, msg) }
|
||||
}
|
||||
}
|
||||
dropAll()
|
||||
av.stop()
|
||||
}
|
||||
|
||||
private func idle(_ ms: Int) {
|
||||
cmdLock.lock()
|
||||
if commands.isEmpty && !released { _ = cmdLock.wait(until: Date().addingTimeInterval(Double(ms) / 1000)) }
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
private func step() throws {
|
||||
guard let s = session, wantPlay else {
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
idle(100)
|
||||
return
|
||||
}
|
||||
if let e = s.error() {
|
||||
if errorReported != s.token {
|
||||
errorReported = s.token
|
||||
wantPlay = false
|
||||
pauseOutput()
|
||||
setFlags(false, false)
|
||||
let tok = s.token, msg = e.localizedDescription
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onError(tok, msg.isEmpty ? "Playback failed" : msg) }
|
||||
}
|
||||
idle(50)
|
||||
return
|
||||
}
|
||||
if drainUntil >= 0 {
|
||||
drain(s)
|
||||
return
|
||||
}
|
||||
let need = written == 0 || buffering ? Self.prefill : Self.chunk
|
||||
if !s.ready(need) && !s.finished() {
|
||||
let now = monoNanos()
|
||||
if waitingSince == 0 { waitingSince = now }
|
||||
if !buffering && (written == 0 || now - waitingSince > 60_000_000) {
|
||||
tap?.running = false
|
||||
setFlags(false, true)
|
||||
}
|
||||
publish()
|
||||
idle(5)
|
||||
return
|
||||
}
|
||||
waitingSince = 0
|
||||
try ensureOutput(s.sampleRate)
|
||||
try startOutput()
|
||||
setFlags(true, false)
|
||||
|
||||
guard let tap else { return }
|
||||
if tap.ring.freeFrames < Self.chunk {
|
||||
publish()
|
||||
idle(5)
|
||||
return
|
||||
}
|
||||
let n = mixChunk(s)
|
||||
if n == 0 {
|
||||
if s.finished() { endOfTrack(s) } else { idle(2) }
|
||||
return
|
||||
}
|
||||
eq.configure(eqParams, sampleRate: s.sampleRate)
|
||||
eq.process(out, n)
|
||||
let m = masterGain
|
||||
rampGain(out, n, masterCur, m)
|
||||
masterCur = m
|
||||
limiter.process(out, n)
|
||||
_ = tap.ring.write(out, n * 2, abort: { false })
|
||||
written += Int64(n)
|
||||
publish()
|
||||
}
|
||||
|
||||
private func endOfTrack(_ s: TrackSession) {
|
||||
if let nx = next, nx.sampleRate == s.sampleRate, nx.error() == nil {
|
||||
// Gapless: keep writing into the same output.
|
||||
segments.append(Segment(writtenStart: written, token: nx.token, frameStart: nx.framePos))
|
||||
session = nx
|
||||
next = nil
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
// Let the output play out, then hand over.
|
||||
drainUntil = written
|
||||
drainToken = s.token
|
||||
drainDeadline = monoNanos() + written * 1_000_000_000 / Int64(max(1, trackRate)) + 1_000_000_000
|
||||
}
|
||||
|
||||
private func drain(_ s: TrackSession) {
|
||||
let (presented, _) = self.presented()
|
||||
publish()
|
||||
if presented < drainUntil - 64 && monoNanos() < drainDeadline {
|
||||
idle(10)
|
||||
return
|
||||
}
|
||||
drainUntil = -1
|
||||
if let nx = next, nx.error() == nil {
|
||||
session = nx
|
||||
next = nil
|
||||
s.release()
|
||||
resetOutput(nx)
|
||||
let tok = nx.token
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onTransition(tok) }
|
||||
} else {
|
||||
let tok = drainToken
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onEnded(tok) }
|
||||
}
|
||||
}
|
||||
|
||||
private func mixChunk(_ s: TrackSession) -> Int {
|
||||
if s.finished() { return 0 }
|
||||
var n = Self.chunk
|
||||
for d in s.decoders {
|
||||
if d.error != nil || d.ring.eos && d.ring.availableFrames == 0 { continue }
|
||||
n = min(n, d.ring.availableFrames)
|
||||
}
|
||||
if n <= 0 { return 0 }
|
||||
let (gains, version) = shared.with { (_mix, _mixVersion) }
|
||||
if s.mixVersion != version {
|
||||
for i in s.names.indices { s.target[i] = gains[s.names[i]] ?? 1 }
|
||||
s.mixVersion = version
|
||||
}
|
||||
out.update(repeating: 0, count: n * 2)
|
||||
let stride = MeterHistory.maxStems
|
||||
let block = MeterHistory.block
|
||||
let blocks = (n + block - 1) / block
|
||||
if meterSums.count < blocks * stride { meterSums = [Float](repeating: 0, count: blocks * stride) }
|
||||
for k in meterSums.indices { meterSums[k] = 0 }
|
||||
for (i, d) in s.decoders.enumerated() {
|
||||
let got = d.ring.read(tmp, n)
|
||||
if got < n { (tmp + got * 2).update(repeating: 0, count: (n - got) * 2) }
|
||||
let g0 = s.current[i]
|
||||
let g1 = s.target[i]
|
||||
s.current[i] = g1
|
||||
if g0 == 0 && g1 == 0 { continue }
|
||||
let step = (g1 - g0) / Float(n)
|
||||
var g = g0
|
||||
for b in 0..<blocks {
|
||||
let end = min(n, (b + 1) * block)
|
||||
var sum: Float = 0
|
||||
for f in (b * block)..<end {
|
||||
let l = tmp[f * 2] * g
|
||||
let r = tmp[f * 2 + 1] * g
|
||||
out[f * 2] += l
|
||||
out[f * 2 + 1] += r
|
||||
sum += l * l + r * r
|
||||
g += step
|
||||
}
|
||||
if i < stride { meterSums[b * stride + i] = (sum / Float(2 * (end - b * block))).squareRoot() }
|
||||
}
|
||||
}
|
||||
meterSums.withUnsafeBufferPointer { p in
|
||||
for b in 0..<blocks { meters.add(s.token, s.framePos + Int64(b * block), p.baseAddress! + b * stride, s.names.count) }
|
||||
}
|
||||
s.framePos += Int64(n)
|
||||
return n
|
||||
}
|
||||
|
||||
private func rampGain(_ buf: UnsafeMutablePointer<Float>, _ n: Int, _ from: Float, _ to: Float) {
|
||||
if from == 1 && to == 1 { return }
|
||||
let step = (to - from) / Float(n)
|
||||
var g = from
|
||||
for f in 0..<n {
|
||||
buf[f * 2] *= g
|
||||
buf[f * 2 + 1] *= g
|
||||
g += step
|
||||
}
|
||||
}
|
||||
|
||||
private static func outputLatency() -> Double {
|
||||
#if os(iOS)
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
return s.outputLatency + s.ioBufferDuration
|
||||
#else
|
||||
return 0.01
|
||||
#endif
|
||||
}
|
||||
|
||||
private func ensureOutput(_ rate: Int) throws {
|
||||
if node != nil && trackRate == rate { return }
|
||||
av.stop()
|
||||
if let node { av.detach(node) }
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setPreferredSampleRate(Double(rate))
|
||||
#endif
|
||||
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
||||
t.setLatency(Self.outputLatency())
|
||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
||||
throw DecodeError("Unsupported sample rate \(rate)")
|
||||
}
|
||||
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
|
||||
av.attach(n)
|
||||
av.connect(n, to: av.mainMixerNode, format: fmt)
|
||||
av.prepare()
|
||||
node = n
|
||||
tap = t
|
||||
trackRate = rate
|
||||
limiter.setSampleRate(rate)
|
||||
}
|
||||
|
||||
private func startOutput() throws {
|
||||
guard let tap else { return }
|
||||
if !av.isRunning {
|
||||
#if os(iOS)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
try av.start()
|
||||
tap.setLatency(Self.outputLatency())
|
||||
}
|
||||
tap.running = true
|
||||
}
|
||||
|
||||
private func pauseOutput() {
|
||||
tap?.running = false
|
||||
if av.isRunning { av.pause() }
|
||||
}
|
||||
|
||||
private func restartOutput() {
|
||||
tap?.setLatency(Self.outputLatency())
|
||||
// A paused engine also gets stopped by a route change; step() starts it again when it plays.
|
||||
if wantPlay, tap?.running == true, !av.isRunning { try? av.start() }
|
||||
}
|
||||
|
||||
/// (frames presented since the last flush, at monoNanos).
|
||||
private func presented() -> (Int64, Int64) {
|
||||
let now = monoNanos()
|
||||
guard let tap else { return (0, now) }
|
||||
return (tap.presented(now, written), now)
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
let (p, nanos) = presented()
|
||||
while segments.count > 1 && segments[1].writtenStart <= p {
|
||||
segments.removeFirst()
|
||||
let tok = segments[0].token
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onTransition(tok) }
|
||||
}
|
||||
let s = session
|
||||
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
|
||||
if let seg = segments.first {
|
||||
let running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil
|
||||
snapshot = Snapshot(token: seg.token, frame: seg.frameStart + max(0, p - seg.writtenStart), rate: rate, nanos: nanos, running: running)
|
||||
} else {
|
||||
snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func setFlags(_ playing: Bool, _ buffering: Bool) {
|
||||
let changed = shared.with { () -> Bool in
|
||||
if playing == _playing && buffering == _buffering { return false }
|
||||
_playing = playing
|
||||
_buffering = buffering
|
||||
return true
|
||||
}
|
||||
if changed { DispatchQueue.main.async { [weak self] in self?.listener?.onEngineState() } }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user