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:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+291
View File
@@ -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) }
+379
View File
@@ -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 }
}
}
}
+670
View File
@@ -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() } }
}
}