Commit 3d059b66 authored by Kourser's avatar Kourser
Browse files

fix(playback): apply equalizer toggles live during local playback

SwitchingAudioEngine only chose its backend at load time, so enabling
the equalizer (or silence skipping) while a downloaded episode was
playing had no effect until the next episode. The router now mirrors
the playback state (URL, position, rate, play/pause) and re-routes on
the spot, reloading the file at the current position; a silence-skip
toggle on the processing engine re-analyses in place via seek.

Co-Authored-By: Claude (RCA)
parent d72262d7
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -12,6 +12,11 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  du volume système — pour écouter plus bas que le premier cran matériel
  (streaming et épisodes téléchargés).

### Corrigé
- L'égaliseur (et le saut de silence) activé **pendant** la lecture d'un épisode
  téléchargé prend maintenant effet immédiatement, au lieu d'attendre l'épisode
  suivant (bascule à chaud du moteur audio, position et vitesse conservées).

### À venir
- Intégrations système : Widget, CarPlay, Apple Watch, Siri / Raccourcis.
- Langues de l'UE manquantes (maltais, irlandais, letton, lituanien, estonien) —
+94 −20
Original line number Diff line number Diff line
@@ -4,12 +4,12 @@ import Foundation
/// local file needs silence-skipping **or** the equalizer, otherwise the
/// streaming `AVPlayer` engine. This keeps streaming and normal playback on the
/// proven path and only engages the heavier engine when it can actually work
/// (both effects require decoded local audio).
/// (both effects require decoded local audio). Toggling an effect during local
/// playback re-routes on the spot, resuming at the current position with the
/// same rate and play/pause state.
@MainActor
public final class SwitchingAudioEngine: AudioPlayerEngine {
    public weak var delegate: AudioPlayerEngineDelegate? {
        didSet { active.delegate = delegate }
    }
    public weak var delegate: AudioPlayerEngineDelegate?

    private let streaming: AudioPlayerEngine
    private let processing: AudioPlayerEngine
@@ -17,22 +17,42 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
    private var skipSilence = false
    private var eqGains: [Float]?

    public init() {
        let streaming = AVAudioPlayerEngine()
    // Playback state mirrored here so a live re-route can restore it.
    private var currentURL: URL?
    private var currentTime: TimeInterval = 0
    private var rate: Float = 1.0
    private var isPlaying = false

    public convenience init() {
        self.init(streaming: AVAudioPlayerEngine(), processing: SilenceSkippingEngine())
    }

    /// Injectable backends, for tests.
    init(streaming: AudioPlayerEngine, processing: AudioPlayerEngine) {
        self.streaming = streaming
        self.processing = SilenceSkippingEngine()
        self.processing = processing
        self.active = streaming
        streaming.delegate = self
    }

    public func setSkipSilence(_ enabled: Bool) {
        guard enabled != skipSilence else { return }
        skipSilence = enabled
        active.setSkipSilence(enabled)
        // Taking effect requires re-analysing the file: switch engines when the
        // routing changes, otherwise re-schedule in place (seek re-analyses).
        if !reroute(), active === processing {
            active.seek(to: currentTime)
        }
    }

    public func setEqualizer(_ gains: [Float]?) {
        let changed = gains != eqGains
        eqGains = gains
        // Apply live when the processing engine is active; the streaming engine
        // ignores it (a stream can't be re-routed mid-playback).
        // The EQ unit applies live on the processing engine; the streaming
        // engine ignores it (a stream can't be processed).
        active.setEqualizer(gains)
        if changed { reroute() }
    }

    public func setVolume(_ volume: Float) {
@@ -42,21 +62,75 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
    }

    public func load(url: URL, startAt seconds: TimeInterval) {
        let needsProcessing = url.isFileURL && (skipSilence || eqGains != nil)
        let next = needsProcessing ? processing : streaming
        if next !== active {
        currentURL = url
        currentTime = seconds
        let next = engine(for: url)
        if next !== active { switchActive(to: next) }
        active.setSkipSilence(skipSilence)
        active.setEqualizer(eqGains)
        active.load(url: url, startAt: seconds)
    }

    public func play() {
        isPlaying = true
        active.play()
    }

    public func pause() {
        isPlaying = false
        active.pause()
            active.delegate = nil
            active = next
            active.delegate = delegate
    }

    public func seek(to seconds: TimeInterval) {
        currentTime = seconds
        active.seek(to: seconds)
    }

    public func setRate(_ rate: Float) {
        self.rate = rate
        active.setRate(rate)
    }

    private func engine(for url: URL) -> AudioPlayerEngine {
        url.isFileURL && (skipSilence || eqGains != nil) ? processing : streaming
    }

    /// Switches backends mid-playback if the current source now demands it,
    /// restoring position, rate and play state; returns whether it switched.
    @discardableResult
    private func reroute() -> Bool {
        guard let url = currentURL else { return false }
        let next = engine(for: url)
        guard next !== active else { return false }
        switchActive(to: next)
        active.setSkipSilence(skipSilence)
        active.setEqualizer(eqGains)
        active.load(url: url, startAt: seconds)
        active.load(url: url, startAt: currentTime)
        active.setRate(rate)
        if isPlaying { active.play() }
        return true
    }

    private func switchActive(to next: AudioPlayerEngine) {
        active.pause()
        active.delegate = nil
        active = next
        active.delegate = self
    }
}

extension SwitchingAudioEngine: AudioPlayerEngineDelegate {
    public func engineDidUpdateTime(_ time: TimeInterval) {
        currentTime = time
        delegate?.engineDidUpdateTime(time)
    }

    public func engineDidLoadDuration(_ duration: TimeInterval) {
        delegate?.engineDidLoadDuration(duration)
    }

    public func play() { active.play() }
    public func pause() { active.pause() }
    public func seek(to seconds: TimeInterval) { active.seek(to: seconds) }
    public func setRate(_ rate: Float) { active.setRate(rate) }
    public func engineDidFinishPlaying() {
        isPlaying = false
        delegate?.engineDidFinishPlaying()
    }
}
+120 −0
Original line number Diff line number Diff line
import Foundation
import Testing
@testable import PlaybackKit

@MainActor
private final class SpyEngine: AudioPlayerEngine {
    weak var delegate: AudioPlayerEngineDelegate?

    private(set) var loads: [(url: URL, startAt: TimeInterval)] = []
    private(set) var seeks: [TimeInterval] = []
    private(set) var isPlaying = false
    private(set) var rate: Float = 0
    private(set) var skipSilence = false
    private(set) var equalizer: [Float]?

    func load(url: URL, startAt seconds: TimeInterval) { loads.append((url, seconds)) }
    func play() { isPlaying = true }
    func pause() { isPlaying = false }
    func seek(to seconds: TimeInterval) { seeks.append(seconds) }
    func setRate(_ rate: Float) { self.rate = rate }
    func setSkipSilence(_ enabled: Bool) { skipSilence = enabled }
    func setEqualizer(_ gains: [Float]?) { equalizer = gains }
}

private let localURL = URL(fileURLWithPath: "/tmp/episode.mp3")
private let remoteURL = URL(string: "https://example.com/episode.mp3")!

@MainActor
@Suite struct SwitchingAudioEngineTests {
    fileprivate let streaming = SpyEngine()
    fileprivate let processing = SpyEngine()
    var engine: SwitchingAudioEngine {
        SwitchingAudioEngine(streaming: streaming, processing: processing)
    }

    @Test func localFileWithoutEffectsUsesStreamingEngine() {
        let engine = engine
        engine.load(url: localURL, startAt: 0)
        engine.play()
        #expect(streaming.loads.count == 1)
        #expect(processing.loads.isEmpty)
    }

    @Test func enablingEqualizerMidPlaybackSwitchesAndRestoresState() {
        let engine = engine
        engine.load(url: localURL, startAt: 0)
        engine.setRate(1.5)
        engine.play()
        streaming.delegate?.engineDidUpdateTime(42)

        engine.setEqualizer([1, 2, 3, 4, 5])

        #expect(processing.loads.count == 1)
        #expect(processing.loads.last?.startAt == 42)
        #expect(processing.equalizer == [1, 2, 3, 4, 5])
        #expect(processing.rate == 1.5)
        #expect(processing.isPlaying)
        #expect(!streaming.isPlaying)
    }

    @Test func disablingEqualizerWhilePausedSwitchesBackWithoutPlaying() {
        let engine = engine
        engine.setEqualizer([1, 2, 3, 4, 5])
        engine.load(url: localURL, startAt: 0)
        engine.play()
        processing.delegate?.engineDidUpdateTime(10)
        engine.pause()

        engine.setEqualizer(nil)

        #expect(streaming.loads.count == 1)
        #expect(streaming.loads.last?.startAt == 10)
        #expect(!streaming.isPlaying)
    }

    @Test func equalizerToggleDoesNotRerouteRemoteStreams() {
        let engine = engine
        engine.load(url: remoteURL, startAt: 0)
        engine.play()

        engine.setEqualizer([1, 2, 3, 4, 5])

        #expect(processing.loads.isEmpty)
        #expect(streaming.isPlaying)
    }

    @Test func skipSilenceToggleReanalysesInPlaceOnProcessingEngine() {
        let engine = engine
        engine.setEqualizer([1, 2, 3, 4, 5])
        engine.load(url: localURL, startAt: 0)
        engine.play()
        processing.delegate?.engineDidUpdateTime(30)

        engine.setSkipSilence(true)

        #expect(processing.loads.count == 1) // no reload, re-analysis via seek
        #expect(processing.skipSilence)
        #expect(processing.seeks == [30])
    }

    @Test func delegateEventsForwardFromTheActiveEngineOnly() {
        @MainActor
        final class DelegateSpy: AudioPlayerEngineDelegate {
            private(set) var times: [TimeInterval] = []
            func engineDidUpdateTime(_ time: TimeInterval) { times.append(time) }
            func engineDidLoadDuration(_ duration: TimeInterval) {}
            func engineDidFinishPlaying() {}
        }
        let engine = engine
        let outer = DelegateSpy()
        engine.delegate = outer
        engine.load(url: localURL, startAt: 0)
        engine.play()
        engine.setEqualizer([1, 2, 3, 4, 5])

        #expect(streaming.delegate == nil)
        processing.delegate?.engineDidUpdateTime(5)
        #expect(outer.times == [5])
    }
}