iOS 1.0 RC 2: Plex streams under upload caps, shuffle and album radio stations, Recently Added list, photo covers, crossfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-06 08:40:37 -08:00
1 parent c0e33b5e3e
commit 2fb8040388
17 files changed
+412 -74

No files matched your search

+101 -10
View File
@@ -231,6 +231,13 @@ final class StemEngine: @unchecked Sendable {
private var session: TrackSession?
private var next: TrackSession?
/// Crossfade length into [next], in ms (0 = gapless).
private var nextFadeMs = 0
/// The track fading in over the end of [session], and how far through the fade we are.
private var xf: TrackSession?
private var xfLen: Int64 = 0
private var xfDone: Int64 = 0
private var env = [Float](repeating: 0, count: 0)
private var wantPlay = false
private let av = AVAudioEngine()
private var node: AVAudioSourceNode?
@@ -319,11 +326,20 @@ final class StemEngine: @unchecked Sendable {
}
}
/// The track to continue into gaplessly when the current one ends (or nil).
func queueNext(_ s: TrackSession?) {
/// The track to continue into when the current one ends (or nil): gaplessly, or crossfading
/// over the last [fadeMs] of the current track.
func queueNext(_ s: TrackSession?, fadeMs: Int = 0) {
post { [self] in
if next !== s { next?.release() }
next = s
nextFadeMs = s == nil ? 0 : max(0, fadeMs)
}
}
/// Changes the fade into the queued track, if it hasn't started.
func setNextFade(_ fadeMs: Int) {
post { [self] in
if next != nil { nextFadeMs = max(0, fadeMs) }
}
}
@@ -348,6 +364,21 @@ final class StemEngine: @unchecked Sendable {
func seekTo(_ ms: Int64) {
post { [self] in
if let x = xf, let s = session {
xf = nil
if segments.count > 1 {
// The fade hasn't reached the speakers yet, so the seek is in the old track:
// put the new one back as next, from its start, to fade in again later.
x.gen += 1
x.framePos = 0
x.decoders.forEach { $0.seek(gen: x.gen, frame: 0) }
next?.release()
next = x
} else {
s.release()
session = x
}
}
guard let s = session else { return }
let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1))
s.gen += 1
@@ -396,8 +427,10 @@ final class StemEngine: @unchecked Sendable {
private func dropAll() {
session?.release()
next?.release()
xf?.release()
session = nil
next = nil
xf = nil
drainUntil = -1
pauseOutput()
tap?.flush()
@@ -497,9 +530,10 @@ final class StemEngine: @unchecked Sendable {
idle(max(5, Int(ms)))
return
}
let n = mixChunk(s)
startFade(s)
let n = xf != nil ? mixFade(s) : mixChunk(s)
if n == 0 {
if s.finished() { endOfTrack(s) } else { idle(2) }
if xf == nil, let cur = session, cur.finished() { endOfTrack(cur) } else { idle(2) }
return
}
eq.configure(eqParams, sampleRate: s.sampleRate)
@@ -553,20 +587,77 @@ final class StemEngine: @unchecked Sendable {
}
}
private func mixChunk(_ s: TrackSession) -> Int {
if s.finished() { return 0 }
/// Starts the crossfade into [next] once [s] is within the fade length of its end and the next
/// track has buffered. Different sample rates can't share the output, so those stay gapless.
private func startFade(_ s: TrackSession) {
guard xf == nil, nextFadeMs > 0, let nx = next, nx.sampleRate == s.sampleRate, nx.error() == nil else { return }
let dur = s.durationFrames
let len = Int64(nextFadeMs) * Int64(s.sampleRate) / 1000
guard dur > 0, len > 0, s.framePos >= dur - len, !s.finished(), nx.ready(Self.prefill) || nx.finished() else { return }
xf = nx
next = nil
nextFadeMs = 0
xfLen = max(Int64(Self.chunk), min(len, dur - s.framePos))
xfDone = 0
segments.append(Segment(writtenStart: written, token: nx.token, frameStart: nx.framePos))
}
/// One chunk of the crossfade: [s] fading out under [xf] fading in (equal power). When the fade
/// is done, or the old track runs out, the new one becomes the session.
private func mixFade(_ s: TrackSession) -> Int {
guard let x = xf else { return 0 }
let outDone = s.finished()
if x.finished() || outDone && xfDone >= xfLen { return endFade(s, x) }
var n = available(x)
if !outDone { n = min(n, available(s)) }
if n <= 0 { return 0 }
if env.count < n { env = [Float](repeating: 0, count: n) }
out.update(repeating: 0, count: n * 2)
let len = Double(xfLen)
if !outDone {
for f in 0..<n { env[f] = Float(cos(min(1, Double(xfDone + Int64(f)) / len) * .pi / 2)) }
env.withUnsafeBufferPointer { mix(s, n, $0.baseAddress!) }
}
for f in 0..<n { env[f] = Float(sin(min(1, Double(xfDone + Int64(f)) / len) * .pi / 2)) }
env.withUnsafeBufferPointer { mix(x, n, $0.baseAddress!) }
xfDone += Int64(n)
if xfDone >= xfLen { _ = endFade(s, x) }
return n
}
private func endFade(_ s: TrackSession, _ x: TrackSession) -> Int {
s.release()
session = x
xf = nil
return 0
}
/// Frames every live decoder of [s] can give right now, up to a chunk.
private func available(_ s: TrackSession) -> Int {
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)
}
return n
}
private func mixChunk(_ s: TrackSession) -> Int {
if s.finished() { return 0 }
let n = available(s)
if n <= 0 { return 0 }
out.update(repeating: 0, count: n * 2)
mix(s, n, nil)
return n
}
/// Adds [n] frames of [s]'s stems into [out], each at its mix gain (ramped), times [env] when given.
private func mix(_ s: TrackSession, _ n: Int, _ env: UnsafePointer<Float>?) {
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
@@ -585,8 +676,9 @@ final class StemEngine: @unchecked Sendable {
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
let e = env.map { g * $0[f] } ?? g
let l = tmp[f * 2] * e
let r = tmp[f * 2 + 1] * e
out[f * 2] += l
out[f * 2 + 1] += r
sum += l * l + r * r
@@ -599,7 +691,6 @@ final class StemEngine: @unchecked Sendable {
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) {