diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index a77585c..c7a5c1b 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -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) } diff --git a/ios/Stemify/Engine/StemEngine.swift b/ios/Stemify/Engine/StemEngine.swift index 79d2db8..3fbbb14 100644 --- a/ios/Stemify/Engine/StemEngine.swift +++ b/ios/Stemify/Engine/StemEngine.swift @@ -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 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) } } diff --git a/ios/Stemify/Playback/NowPlaying.swift b/ios/Stemify/Playback/NowPlaying.swift index 578688c..a9e823c 100644 --- a/ios/Stemify/Playback/NowPlaying.swift +++ b/ios/Stemify/Playback/NowPlaying.swift @@ -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), diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index 83f33c9..ca8e145 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -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() { diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 486b9fe..17d2b0d 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -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 diff --git a/ios/project.yml b/ios/project.yml index 26ee8bc..35442fa 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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