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>
380 lines
14 KiB
Swift
380 lines
14 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|