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.
|
/// 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 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 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 {
|
var masterVolume: Float {
|
||||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||||
set { write(\.masterVolume, "masterVolume", newValue, 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 lastNanos: Int64 = 0
|
||||||
private var runningFlag = false
|
private var runningFlag = false
|
||||||
private var latencyNanos: Int64 = 0
|
private var latencyNanos: Int64 = 0
|
||||||
|
private var speedFactor = 1.0
|
||||||
private let scratch: UnsafeMutablePointer<Float>
|
private let scratch: UnsafeMutablePointer<Float>
|
||||||
private static let scratchFrames = 4096
|
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) } }
|
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() {
|
func flush() {
|
||||||
lock.with {
|
lock.with {
|
||||||
ring.clear()
|
ring.clear()
|
||||||
@@ -135,7 +142,7 @@ private final class OutputTap: @unchecked Sendable {
|
|||||||
lock.with {
|
lock.with {
|
||||||
if lastNanos == 0 { return 0 }
|
if lastNanos == 0 { return 0 }
|
||||||
let elapsed = runningFlag ? max(0, now - lastNanos) : 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)
|
return min(max(0, min(est, consumed)), written)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,9 +210,10 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
var rate: Int
|
var rate: Int
|
||||||
var nanos: Int64
|
var nanos: Int64
|
||||||
var running: Bool
|
var running: Bool
|
||||||
|
var speed = 1.0
|
||||||
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
||||||
if rate <= 0 { return 0 }
|
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)
|
return f * 1000 / Int64(rate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +231,8 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
private let av = AVAudioEngine()
|
private let av = AVAudioEngine()
|
||||||
private var node: AVAudioSourceNode?
|
private var node: AVAudioSourceNode?
|
||||||
private var tap: OutputTap?
|
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 trackRate = 0
|
||||||
private var written: Int64 = 0
|
private var written: Int64 = 0
|
||||||
private var segments: [Segment] = []
|
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) {
|
func seekTo(_ ms: Int64) {
|
||||||
post { [self] in
|
post { [self] in
|
||||||
guard let s = session else { return }
|
guard let s = session else { return }
|
||||||
@@ -601,12 +622,15 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
#endif
|
#endif
|
||||||
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
||||||
t.setLatency(Self.outputLatency())
|
t.setLatency(Self.outputLatency())
|
||||||
|
t.speed = speed
|
||||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
||||||
throw DecodeError("Unsupported sample rate \(rate)")
|
throw DecodeError("Unsupported sample rate \(rate)")
|
||||||
}
|
}
|
||||||
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
|
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
|
||||||
av.attach(n)
|
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()
|
av.prepare()
|
||||||
node = n
|
node = n
|
||||||
tap = t
|
tap = t
|
||||||
@@ -655,9 +679,9 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
|
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
|
||||||
if let seg = segments.first {
|
if let seg = segments.first {
|
||||||
let running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil
|
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 {
|
} 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,
|
MPMediaItemPropertyAlbumTitle: t.album,
|
||||||
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
|
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
|
||||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 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,
|
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
|
||||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
|
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
private(set) var stemNames: [String] = []
|
private(set) var stemNames: [String] = []
|
||||||
private(set) var mix: [String: StemSetting] = [:]
|
private(set) var mix: [String: StemSetting] = [:]
|
||||||
private(set) var master: Float
|
private(set) var master: Float
|
||||||
|
private(set) var speed = 1.0
|
||||||
private(set) var waveform: Waveform?
|
private(set) var waveform: Waveform?
|
||||||
private(set) var durationMs: Int64 = 0
|
private(set) var durationMs: Int64 = 0
|
||||||
private(set) var message: AppMessage?
|
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 }
|
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||||
engine.eqParams = eq.currentParams()
|
engine.eqParams = eq.currentParams()
|
||||||
engine.masterGain = masterTaper(master)
|
engine.masterGain = masterTaper(master)
|
||||||
|
speed = settings.playbackSpeed
|
||||||
|
engine.setSpeed(speed)
|
||||||
restore()
|
restore()
|
||||||
observeSession()
|
observeSession()
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -329,6 +332,15 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
|
|
||||||
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
|
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 ----------------------------------------------------------------------
|
// ---- transport ----------------------------------------------------------------------
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ struct NowPlayingScreen: View {
|
|||||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||||
.padding(.top, panel == .Art ? 34 : 18)
|
.padding(.top, panel == .Art ? 34 : 18)
|
||||||
HStack {
|
HStack {
|
||||||
|
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||||
Spacer()
|
Spacer()
|
||||||
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -169,10 +170,12 @@ struct NowPlayingScreen: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||||
}
|
}
|
||||||
.frame(height: 84)
|
.frame(height: 84)
|
||||||
HStack {
|
HStack {
|
||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
|
SpeedMenu()
|
||||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
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 {
|
private struct PanelToggle: View {
|
||||||
let symbol: String
|
let symbol: String
|
||||||
let on: Bool
|
let on: Bool
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.9"
|
MARKETING_VERSION: "1.0.10"
|
||||||
CURRENT_PROJECT_VERSION: "10"
|
CURRENT_PROJECT_VERSION: "11"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
|
|||||||
Reference in new issue
Block a user