ios 1.0.10: shuffle/repeat on Now Playing, playback speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b767cacc2
commit
b0dba777f9
6 files changed
+96
-8
No files matched your search
@@ -189,6 +189,10 @@ final class Settings: @unchecked Sendable {
|
||||
/// How far down Soft karaoke puts the vocals, in dB.
|
||||
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var playbackSpeed: Double {
|
||||
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }
|
||||
set { write(\.playbackSpeed, "playbackSpeed", newValue, 1.0) }
|
||||
}
|
||||
var masterVolume: Float {
|
||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||
set { write(\.masterVolume, "masterVolume", newValue, 0.85) }
|
||||
|
||||
@@ -104,6 +104,7 @@ private final class OutputTap: @unchecked Sendable {
|
||||
private var lastNanos: Int64 = 0
|
||||
private var runningFlag = false
|
||||
private var latencyNanos: Int64 = 0
|
||||
private var speedFactor = 1.0
|
||||
private let scratch: UnsafeMutablePointer<Float>
|
||||
private static let scratchFrames = 4096
|
||||
|
||||
@@ -121,6 +122,12 @@ private final class OutputTap: @unchecked Sendable {
|
||||
|
||||
func setLatency(_ seconds: Double) { lock.with { latencyNanos = Int64(seconds * 1e9) } }
|
||||
|
||||
/// Playback speed; the time-pitch unit pulls frames this much faster than real time.
|
||||
var speed: Double {
|
||||
get { lock.with { speedFactor } }
|
||||
set { lock.with { speedFactor = newValue } }
|
||||
}
|
||||
|
||||
func flush() {
|
||||
lock.with {
|
||||
ring.clear()
|
||||
@@ -135,7 +142,7 @@ private final class OutputTap: @unchecked Sendable {
|
||||
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
|
||||
let est = lastStart + Int64(Double((elapsed - latencyNanos) * Int64(rate)) * speedFactor) / 1_000_000_000
|
||||
return min(max(0, min(est, consumed)), written)
|
||||
}
|
||||
}
|
||||
@@ -203,9 +210,10 @@ final class StemEngine: @unchecked Sendable {
|
||||
var rate: Int
|
||||
var nanos: Int64
|
||||
var running: Bool
|
||||
var speed = 1.0
|
||||
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
||||
if rate <= 0 { return 0 }
|
||||
let f = running ? frame + (now - nanos) * Int64(rate) / 1_000_000_000 : frame
|
||||
let f = running ? frame + Int64(Double((now - nanos) * Int64(rate)) * speed) / 1_000_000_000 : frame
|
||||
return f * 1000 / Int64(rate)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +231,8 @@ final class StemEngine: @unchecked Sendable {
|
||||
private let av = AVAudioEngine()
|
||||
private var node: AVAudioSourceNode?
|
||||
private var tap: OutputTap?
|
||||
private let pitch: AVAudioUnitTimePitch = { let p = AVAudioUnitTimePitch(); p.bypass = true; return p }()
|
||||
private var speed = 1.0
|
||||
private var trackRate = 0
|
||||
private var written: Int64 = 0
|
||||
private var segments: [Segment] = []
|
||||
@@ -321,6 +331,17 @@ final class StemEngine: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback speed, 0.5 to 2. Pitch stays the same.
|
||||
func setSpeed(_ v: Double) {
|
||||
post { [self] in
|
||||
speed = min(max(v, 0.5), 2)
|
||||
pitch.rate = Float(speed)
|
||||
pitch.bypass = speed == 1
|
||||
tap?.speed = speed
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func seekTo(_ ms: Int64) {
|
||||
post { [self] in
|
||||
guard let s = session else { return }
|
||||
@@ -601,12 +622,15 @@ final class StemEngine: @unchecked Sendable {
|
||||
#endif
|
||||
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
||||
t.setLatency(Self.outputLatency())
|
||||
t.speed = speed
|
||||
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)
|
||||
if pitch.engine == nil { av.attach(pitch) }
|
||||
av.connect(n, to: pitch, format: fmt)
|
||||
av.connect(pitch, to: av.mainMixerNode, format: fmt)
|
||||
av.prepare()
|
||||
node = n
|
||||
tap = t
|
||||
@@ -655,9 +679,9 @@ final class StemEngine: @unchecked Sendable {
|
||||
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)
|
||||
snapshot = Snapshot(token: seg.token, frame: seg.frameStart + max(0, p - seg.writtenStart), rate: rate, nanos: nanos, running: running, speed: speed)
|
||||
} else {
|
||||
snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false)
|
||||
snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false, speed: speed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ final class NowPlaying: @unchecked Sendable {
|
||||
MPMediaItemPropertyAlbumTitle: t.album,
|
||||
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? 1.0 : 0.0,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? pm.speed : 0.0,
|
||||
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
|
||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
|
||||
|
||||
@@ -123,6 +123,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
private(set) var stemNames: [String] = []
|
||||
private(set) var mix: [String: StemSetting] = [:]
|
||||
private(set) var master: Float
|
||||
private(set) var speed = 1.0
|
||||
private(set) var waveform: Waveform?
|
||||
private(set) var durationMs: Int64 = 0
|
||||
private(set) var message: AppMessage?
|
||||
@@ -165,6 +166,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||
engine.eqParams = eq.currentParams()
|
||||
engine.masterGain = masterTaper(master)
|
||||
speed = settings.playbackSpeed
|
||||
engine.setSpeed(speed)
|
||||
restore()
|
||||
observeSession()
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -329,6 +332,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
|
||||
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
|
||||
|
||||
static let speeds: [Double] = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2]
|
||||
|
||||
func setSpeed(_ v: Double) {
|
||||
speed = min(max(v, 0.5), 2)
|
||||
engine.setSpeed(speed)
|
||||
settings.playbackSpeed = speed
|
||||
changed()
|
||||
}
|
||||
|
||||
// ---- transport ----------------------------------------------------------------------
|
||||
|
||||
func play() {
|
||||
|
||||
@@ -153,6 +153,7 @@ struct NowPlayingScreen: View {
|
||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||
.padding(.top, panel == .Art ? 34 : 18)
|
||||
HStack {
|
||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
Spacer()
|
||||
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
||||
Spacer()
|
||||
@@ -169,10 +170,12 @@ struct NowPlayingScreen: View {
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
Spacer()
|
||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
}
|
||||
.frame(height: 84)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||
@@ -246,6 +249,51 @@ private struct TransportButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shuffle and repeat, either side of the transport. A dot underneath when on.
|
||||
private struct SideToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 19, weight: .semibold)).contentTransition(.symbolEffect(.replace))
|
||||
Circle().frame(width: 4, height: 4).opacity(on ? 1 : 0)
|
||||
}
|
||||
.foregroundStyle(on ? p.label : p.label3)
|
||||
.frame(width: 40, height: 56)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.sensoryFeedback(.selection, trigger: symbol + "\(on)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback speed picker; shows the current speed.
|
||||
private struct SpeedMenu: View {
|
||||
private var g: AppGraph { .shared }
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
Menu {
|
||||
Picker("Speed", selection: Binding(get: { pm.speed }, set: { pm.setSpeed($0) })) {
|
||||
ForEach(PlaybackManager.speeds, id: \.self) { Text(Self.label($0)).tag($0) }
|
||||
}
|
||||
} label: {
|
||||
Text(Self.label(pm.speed)).font(.system(size: 14, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(pm.speed == 1 ? p.label2 : p.bg)
|
||||
.frame(minWidth: 40, minHeight: 40)
|
||||
.padding(.horizontal, 2)
|
||||
.background(pm.speed == 1 ? .clear : p.label, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.menuOrder(.fixed)
|
||||
}
|
||||
static func label(_ v: Double) -> String {
|
||||
(v == v.rounded() ? String(Int(v)) : String(format: "%g", v)) + "×"
|
||||
}
|
||||
}
|
||||
|
||||
private struct PanelToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.9"
|
||||
CURRENT_PROJECT_VERSION: "10"
|
||||
MARKETING_VERSION: "1.0.10"
|
||||
CURRENT_PROJECT_VERSION: "11"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
|
||||
Reference in new issue
Block a user