Commit 8b636677 authored by Kourser's avatar Kourser
Browse files

feat: equalizer (downloaded episodes) and podcast preview before subscribing



5-band parametric AVAudioUnitEQ in Settings (presets + per-band sliders), applied to the AVAudioEngine path for downloaded episodes with live updates. Podcast preview (metadata + recent episodes) before subscribing, from add-by-URL and search results. New UI strings localized into all 18 languages.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 2919b1a4
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -16,10 +16,14 @@ public protocol AudioPlayerEngine: AnyObject {
    /// Hints whether the next ``load(url:startAt:)`` should skip silence.
    /// Engines that don't support it ignore this (default no-op).
    func setSkipSilence(_ enabled: Bool)
    /// Applies per-band equalizer gains (dB), or `nil` to bypass. Engines that
    /// don't support it ignore this (default no-op).
    func setEqualizer(_ gains: [Float]?)
}

public extension AudioPlayerEngine {
    func setSkipSilence(_ enabled: Bool) {}
    func setEqualizer(_ gains: [Float]?) {}
}

@MainActor
+8 −1
Original line number Diff line number Diff line
@@ -55,7 +55,8 @@ public final class PlaybackController {
        startAt: TimeInterval = 0,
        rate: Float? = nil,
        skipEnd: TimeInterval = 0,
        skipSilence: Bool = false
        skipSilence: Bool = false,
        equalizer: [Float]? = nil
    ) {
        guard let source = url ?? episode.enclosureURL else { return }
        if let rate { playbackRate = rate }
@@ -65,6 +66,7 @@ public final class PlaybackController {
        currentTime = startAt
        duration = episode.duration ?? 0
        engine.setSkipSilence(skipSilence)
        engine.setEqualizer(equalizer)
        engine.load(url: source, startAt: startAt)
        engine.setRate(playbackRate)
        engine.play()
@@ -156,6 +158,11 @@ public final class PlaybackController {
        notify()
    }

    /// Updates the equalizer for the current playback (live, downloaded files).
    public func setEqualizer(_ gains: [Float]?) {
        engine.setEqualizer(gains)
    }

    private func clamp(_ time: TimeInterval) -> TimeInterval {
        let lower = max(0, time)
        return duration > 0 ? min(lower, duration) : lower
+46 −9
Original line number Diff line number Diff line
import Foundation
import AVFoundation

/// An ``AudioPlayerEngine`` that skips silence by analysing the decoded audio
/// and scheduling only the voiced segments of a **local** file. Decoding runs
/// off the main actor; scheduling and time bookkeeping stay on the main actor.
///
/// Only suitable for local files (`AVAudioFile` cannot stream). Engine
/// selection (local + enabled) is handled by ``SwitchingAudioEngine``.
/// An ``AudioPlayerEngine`` for **local** files (`AVAudioFile` can't stream),
/// built on `AVAudioEngine`. It optionally skips silence (analysing the decoded
/// audio and scheduling only the voiced segments) and applies a parametric
/// equalizer. Decoding runs off the main actor; scheduling and time bookkeeping
/// stay on the main actor. Engine selection is handled by ``SwitchingAudioEngine``.
@MainActor
public final class SilenceSkippingEngine: AudioPlayerEngine {
    public weak var delegate: AudioPlayerEngineDelegate?

    /// Centre frequencies of the equalizer bands (Hz). Source of truth shared
    /// with the app's equalizer settings.
    public static let equalizerBandFrequencies: [Float] = [60, 250, 1000, 4000, 12000]

    // Tunables (silence threshold is linear RMS; chunk ≈ 93 ms at 44.1 kHz).
    private static let chunkFrames: AVAudioFrameCount = 4096
    private static let windowChunks = 32
@@ -20,11 +23,14 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
    private let timePitch = AVAudioUnitTimePitch()
    private let eq = AVAudioUnitEQ(numberOfBands: SilenceSkippingEngine.equalizerBandFrequencies.count)

    /// File used by the player node to read scheduled segments (main actor only).
    private var scheduleFile: AVAudioFile?
    private var sampleRate: Double = 44_100
    private var desiredRate: Float = 1.0
    private var skipSilenceEnabled = true
    private var eqGains: [Float]?

    private var timeline = PlaybackTimeline()
    private var analysisTask: Task<Void, Never>?
@@ -35,7 +41,17 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {

    public init() {
        engine.attach(player)
        engine.attach(eq)
        engine.attach(timePitch)
        for (index, frequency) in Self.equalizerBandFrequencies.enumerated() {
            let band = eq.bands[index]
            band.filterType = .parametric
            band.frequency = frequency
            band.bandwidth = 1.0
            band.gain = 0
            band.bypass = false
        }
        eq.bypass = true
    }

    // MARK: AudioPlayerEngine
@@ -50,9 +66,11 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
        sampleRate = format.sampleRate
        scheduleFile = file

        engine.connect(player, to: timePitch, format: format)
        engine.connect(player, to: eq, format: format)
        engine.connect(eq, to: timePitch, format: format)
        engine.connect(timePitch, to: engine.mainMixerNode, format: format)
        timePitch.rate = desiredRate
        applyEqualizer()

        let duration = Double(file.length) / sampleRate
        if duration > 0 { delegate?.engineDidLoadDuration(duration) }
@@ -95,11 +113,30 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
        timePitch.rate = max(1.0 / 32, min(32, rate))
    }

    public func setSkipSilence(_ enabled: Bool) {
        skipSilenceEnabled = enabled
    }

    public func setEqualizer(_ gains: [Float]?) {
        eqGains = gains
        applyEqualizer()
    }

    /// Applies the stored gains to the EQ bands, or bypasses the unit when off.
    private func applyEqualizer() {
        guard let gains = eqGains else { eq.bypass = true; return }
        eq.bypass = false
        for (index, band) in eq.bands.enumerated() {
            band.gain = index < gains.count ? gains[index] : 0
        }
    }

    // MARK: Analysis (decode off the main actor, schedule on it)

    private func startAnalysis(url: URL, fromFrame: AVAudioFramePosition) {
        analysisTask?.cancel()
        let session = generation
        let skip = skipSilenceEnabled
        analysisTask = Task.detached(priority: .userInitiated) { [weak self] in
            guard let analysisFile = try? AVAudioFile(forReading: url) else { return }
            let format = analysisFile.processingFormat
@@ -118,7 +155,7 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
                    do { try analysisFile.read(into: buffer, frameCount: chunk) } catch { break }
                    let read = buffer.frameLength
                    if read == 0 { break }
                    flags.append(SilenceAnalysis.isSilent(buffer, frames: read,
                    flags.append(skip && SilenceAnalysis.isSilent(buffer, frames: read,
                                                                  threshold: SilenceSkippingEngine.silenceThreshold))
                    frame += AVAudioFramePosition(read)
                }
+19 −8
Original line number Diff line number Diff line
import Foundation

/// Routes playback to the right backend: the silence-skipping `AVAudioEngine`
/// when skip-silence is enabled **and** the source is a local file, 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.
/// Routes playback to the right backend: the `AVAudioEngine` processor when a
/// 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).
@MainActor
public final class SwitchingAudioEngine: AudioPlayerEngine {
    public weak var delegate: AudioPlayerEngineDelegate? {
@@ -12,14 +12,15 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
    }

    private let streaming: AudioPlayerEngine
    private let silenceSkipping: AudioPlayerEngine
    private let processing: AudioPlayerEngine
    private var active: AudioPlayerEngine
    private var skipSilence = false
    private var eqGains: [Float]?

    public init() {
        let streaming = AVAudioPlayerEngine()
        self.streaming = streaming
        self.silenceSkipping = SilenceSkippingEngine()
        self.processing = SilenceSkippingEngine()
        self.active = streaming
    }

@@ -27,14 +28,24 @@ public final class SwitchingAudioEngine: AudioPlayerEngine {
        skipSilence = enabled
    }

    public func setEqualizer(_ gains: [Float]?) {
        eqGains = gains
        // Apply live when the processing engine is active; the streaming engine
        // ignores it (a stream can't be re-routed mid-playback).
        active.setEqualizer(gains)
    }

    public func load(url: URL, startAt seconds: TimeInterval) {
        let next: AudioPlayerEngine = (skipSilence && url.isFileURL) ? silenceSkipping : streaming
        let needsProcessing = url.isFileURL && (skipSilence || eqGains != nil)
        let next = needsProcessing ? processing : streaming
        if next !== active {
            active.pause()
            active.delegate = nil
            active = next
            active.delegate = delegate
        }
        active.setSkipSilence(skipSilence)
        active.setEqualizer(eqGains)
        active.load(url: url, startAt: seconds)
    }

+22 −15
Original line number Diff line number Diff line
@@ -4,6 +4,9 @@ struct AddPodcastView: View {
    @Environment(AppModel.self) private var model
    @Environment(\.dismiss) private var dismiss
    @State private var urlString = ""
    @State private var previewURL: URL?
    @State private var showPreview = false
    @State private var invalid = false

    var body: some View {
        NavigationStack {
@@ -11,12 +14,12 @@ struct AddPodcastView: View {
                Section("URL du flux RSS") {
                    TextField("https://exemple.com/feed.xml", text: $urlString)
                        .urlFieldStyle()
                        .submitLabel(.done)
                        .onSubmit(add)
                        .submitLabel(.go)
                        .onSubmit(preview)
                }
                if let error = model.errorMessage {
                if invalid {
                    Section {
                        Text(error).foregroundStyle(.red)
                        Text("URL invalide.").foregroundStyle(.red)
                    }
                }
            }
@@ -27,22 +30,26 @@ struct AddPodcastView: View {
                    Button("Annuler") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    if model.isAdding {
                        ProgressView()
                    } else {
                        Button("Ajouter", action: add)
                    Button("Prévisualiser", action: preview)
                        .disabled(urlString.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
            .navigationDestination(isPresented: $showPreview) {
                if let previewURL {
                    PodcastPreviewView(feedURL: previewURL) { dismiss() }
                }
            }
        }

    private func add() {
        Task {
            if await model.subscribe(urlString: urlString) {
                dismiss()
    }

    private func preview() {
        guard !urlString.trimmingCharacters(in: .whitespaces).isEmpty else { return }
        if let url = model.normalizedFeedURL(urlString) {
            invalid = false
            previewURL = url
            showPreview = true
        } else {
            invalid = true
        }
    }
}
Loading