Commit 9fcff285 authored by Kourser's avatar Kourser
Browse files

feat(playback): quieter nights and honest failures

Four related quality improvements to the playback stack:

- Perceptual volume curve: the player slider position is squared before
  reaching the engine, giving fine-grained control at the quiet end.
- Sleep timer fade-out: volume ramps down over the last 20 seconds
  instead of pausing abruptly, then is restored for the next playback.
- Playback errors surface as an alert (new engineDidFail delegate path
  wired up to AppModel.errorMessage, previously never displayed) instead
  of silently advancing the queue when a downloaded file is unreadable.
- Effect toggles defer their engine re-route by one main-actor turn so
  the new-episode setup sequence no longer reloads the previous file.

Co-Authored-By: Claude (RCA)
parent 3d059b66
Loading
Loading
Loading
Loading
+8 −1
Original line number Diff line number Diff line
@@ -10,12 +10,19 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
### Ajouté
- **Volume de l'application** : curseur dédié dans le lecteur, appliqué en plus
  du volume système — pour écouter plus bas que le premier cran matériel
  (streaming et épisodes téléchargés).
  (streaming et épisodes téléchargés). Réponse perceptuelle : la moitié basse
  du curseur offre un réglage fin des faibles volumes.
- **Fondu du minuteur de sommeil** : le volume décroît progressivement sur les
  20 dernières secondes au lieu d'une coupure nette, puis est restauré pour la
  lecture suivante.

### 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).
- Les **erreurs de lecture** (fichier téléchargé illisible, moteur audio en
  échec) affichent désormais une alerte au lieu de passer silencieusement à
  l'épisode suivant de la file.

### À venir
- Intégrations système : Widget, CarPlay, Apple Watch, Siri / Raccourcis.
+8 −0
Original line number Diff line number Diff line
@@ -36,4 +36,12 @@ public protocol AudioPlayerEngineDelegate: AnyObject {
    func engineDidUpdateTime(_ time: TimeInterval)
    func engineDidLoadDuration(_ duration: TimeInterval)
    func engineDidFinishPlaying()
    /// Reports a failure to load or start playback. Distinct from
    /// ``engineDidFinishPlaying()`` so an unreadable file doesn't silently
    /// advance the queue (default no-op).
    func engineDidFail(_ error: Error)
}

public extension AudioPlayerEngineDelegate {
    func engineDidFail(_ error: Error) {}
}
+36 −4
Original line number Diff line number Diff line
@@ -33,9 +33,16 @@ public final class PlaybackController {
    @ObservationIgnored private var skipEndSeconds: TimeInterval = 0
    @ObservationIgnored private var didCutEnd = false
    @ObservationIgnored private var sleepTask: Task<Void, Never>?
    /// App-level volume (0...1) the fade multiplies into; restored afterwards.
    @ObservationIgnored private var baseVolume: Float = 1
    @ObservationIgnored public weak var observer: PlaybackObserver?
    /// Called when the current item plays to its end (used for queue advance).
    @ObservationIgnored public var onFinish: (@MainActor () -> Void)?
    /// Called when the engine fails to load or play (no queue advance).
    @ObservationIgnored public var onError: (@MainActor (Error) -> Void)?

    /// Seconds over which the sleep timer fades the volume before pausing.
    static let sleepFadeSeconds: TimeInterval = 20

    public init(engine: AudioPlayerEngine) {
        self.engine = engine
@@ -100,10 +107,16 @@ public final class PlaybackController {

    // MARK: Sleep timer

    /// Pauses playback after `minutes`. Replaces any running timer.
    /// Pauses playback after `minutes`, fading the volume out over the last
    /// ``sleepFadeSeconds``. Replaces any running timer.
    public func startSleepTimer(minutes: Int) {
        startSleepTimer(seconds: TimeInterval(max(1, minutes) * 60))
    }

    /// Seconds-level granularity, internal for tests.
    func startSleepTimer(seconds: TimeInterval) {
        cancelSleepTimer()
        sleepRemaining = TimeInterval(max(1, minutes) * 60)
        sleepRemaining = seconds
        sleepTask = Task { [weak self] in
            while let remaining = self?.sleepRemaining, remaining > 0, !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
@@ -116,20 +129,32 @@ public final class PlaybackController {
    public func cancelSleepTimer() {
        sleepTask?.cancel()
        sleepTask = nil
        let wasFading = sleepFadeFactor < 1
        sleepRemaining = nil
        if wasFading { engine.setVolume(baseVolume) }
    }

    private func tickSleepTimer() {
    /// Internal for tests (production ticking is driven by ``startSleepTimer``).
    func tickSleepTimer() {
        guard let remaining = sleepRemaining else { return }
        if remaining <= 1 {
            sleepRemaining = nil
            sleepTask = nil
            pause()
            engine.setVolume(baseVolume) // undo the fade for the next playback
        } else {
            sleepRemaining = remaining - 1
            let fade = sleepFadeFactor
            if fade < 1 { engine.setVolume(baseVolume * fade) }
        }
    }

    /// 1 outside the fade window, falling linearly to 0 at timer expiry.
    private var sleepFadeFactor: Float {
        guard let remaining = sleepRemaining, remaining < Self.sleepFadeSeconds else { return 1 }
        return Float(remaining / Self.sleepFadeSeconds)
    }

    public func resume() {
        guard currentEpisode != nil else { return }
        engine.setRate(playbackRate)
@@ -166,7 +191,8 @@ public final class PlaybackController {
    /// Sets the app-level playback volume (0...1), applied on top of the
    /// system volume.
    public func setVolume(_ volume: Float) {
        engine.setVolume(volume)
        baseVolume = volume
        engine.setVolume(volume * sleepFadeFactor)
    }

    private func clamp(_ time: TimeInterval) -> TimeInterval {
@@ -206,4 +232,10 @@ extension PlaybackController: AudioPlayerEngineDelegate {
        notify()
        onFinish?()
    }

    public func engineDidFail(_ error: Error) {
        isPlaying = false
        notify()
        onError?(error)
    }
}
+19 −4
Original line number Diff line number Diff line
@@ -58,8 +58,11 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {

    public func load(url: URL, startAt seconds: TimeInterval) {
        teardown()
        guard let file = try? AVAudioFile(forReading: url) else {
            delegate?.engineDidFinishPlaying()
        let file: AVAudioFile
        do {
            file = try AVAudioFile(forReading: url)
        } catch {
            delegate?.engineDidFail(error)
            return
        }
        let format = file.processingFormat
@@ -79,13 +82,25 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
        timeline = PlaybackTimeline()
        analysisComplete = false
        finished = false
        try? engine.start()
        do {
            try engine.start()
        } catch {
            delegate?.engineDidFail(error)
            return
        }

        startAnalysis(url: url, fromFrame: Self.frame(for: seconds, sampleRate: sampleRate))
    }

    public func play() {
        if !engine.isRunning { try? engine.start() }
        if !engine.isRunning {
            do {
                try engine.start()
            } catch {
                delegate?.engineDidFail(error)
                return
            }
        }
        player.play()
        startTicker()
    }
+34 −10
Original line number Diff line number Diff line
@@ -22,6 +22,8 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
    private var currentTime: TimeInterval = 0
    private var rate: Float = 1.0
    private var isPlaying = false
    private var pendingRouting: Task<Void, Never>?
    private var needsReanalysis = false

    public convenience init() {
        self.init(streaming: AVAudioPlayerEngine(), processing: SilenceSkippingEngine())
@@ -41,9 +43,8 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
        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)
        }
        needsReanalysis = true
        scheduleRoutingUpdate()
    }

    public func setEqualizer(_ gains: [Float]?) {
@@ -52,7 +53,7 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
        // 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() }
        if changed { scheduleRoutingUpdate() }
    }

    public func setVolume(_ volume: Float) {
@@ -62,6 +63,9 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
    }

    public func load(url: URL, startAt seconds: TimeInterval) {
        pendingRouting?.cancel()
        pendingRouting = nil
        needsReanalysis = false
        currentURL = url
        currentTime = seconds
        let next = engine(for: url)
@@ -95,20 +99,35 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
        url.isFileURL && (skipSilence || eqGains != nil) ? processing : streaming
    }

    /// Defers the routing update by one main-actor turn, so the effect toggles
    /// issued right before a `load(url:startAt:)` (new-episode setup) coalesce
    /// into that load instead of pointlessly reloading the previous file.
    private func scheduleRoutingUpdate() {
        pendingRouting?.cancel()
        pendingRouting = Task { [weak self] in
            guard let self, !Task.isCancelled else { return }
            self.pendingRouting = nil
            self.applyRoutingUpdate()
        }
    }

    /// 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 }
    /// restoring position, rate and play state; a silence-skip change that
    /// keeps the processing engine re-analyses in place instead.
    private func applyRoutingUpdate() {
        defer { needsReanalysis = false }
        guard let url = currentURL else { return }
        let next = engine(for: url)
        guard next !== active else { return false }
        if next !== active {
            switchActive(to: next)
            active.setSkipSilence(skipSilence)
            active.setEqualizer(eqGains)
            active.load(url: url, startAt: currentTime)
            active.setRate(rate)
            if isPlaying { active.play() }
        return true
        } else if needsReanalysis, active === processing {
            active.seek(to: currentTime)
        }
    }

    private func switchActive(to next: AudioPlayerEngine) {
@@ -133,4 +152,9 @@ extension SwitchingAudioEngine: AudioPlayerEngineDelegate {
        isPlaying = false
        delegate?.engineDidFinishPlaying()
    }

    public func engineDidFail(_ error: Error) {
        isPlaying = false
        delegate?.engineDidFail(error)
    }
}
Loading