From 64939f13a547d3d286d6c415d4d89c7dad9d712d Mon Sep 17 00:00:00 2001 From: Dennis Bartels Date: Thu, 29 Oct 2026 18:06:12 +0100 Subject: [PATCH] fix(AudioPlayer): process a seek requested while paused on resume seek(to:) schedules processSource, which returns early while the player is paused. resume() never scheduled it again, so unless new data arrived the seek stayed pending: progress reported the target and playback did not move. Schedule processSource on resume, as pause() already does. --- .../Streaming/AudioPlayer/AudioPlayer.swift | 4 ++ .../Streaming/SeekWhilePausedTests.swift | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 AudioStreamingTests/Streaming/SeekWhilePausedTests.swift diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift index 13fba34..84bc37c 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift @@ -461,6 +461,10 @@ open class AudioPlayer { } startPlayer(resetBuffers: false) } + // A seek requested while paused was skipped by processSource; pick it up now. + sourceQueue.async { [weak self] in + self?.processSource() + } } /// Seeks the audio to the specified time. diff --git a/AudioStreamingTests/Streaming/SeekWhilePausedTests.swift b/AudioStreamingTests/Streaming/SeekWhilePausedTests.swift new file mode 100644 index 0000000..1c215a5 --- /dev/null +++ b/AudioStreamingTests/Streaming/SeekWhilePausedTests.swift @@ -0,0 +1,59 @@ +import AVFoundation +import XCTest +@testable import AudioStreaming + +final class SeekWhilePausedTests: XCTestCase { + private func makeToneFile(seconds: Double) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("seek-while-paused-\(UUID().uuidString).wav") + let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! + let file = try AVAudioFile(forWriting: url, settings: [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + ]) + let frames = AVAudioFrameCount(44100 * seconds) + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)! + buffer.frameLength = frames + for channel in 0 ..< 2 { + let samples = buffer.floatChannelData![channel] + for frame in 0 ..< Int(frames) { + samples[frame] = 0.01 * sin(Float(frame) * 2 * .pi * 440 / 44100) + } + } + try file.write(from: buffer) + return url + } + + private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return condition() + } + + func testSeekWhilePausedLandsAfterResume() throws { + let target = 5.0 + let url = try makeToneFile(seconds: 8) + defer { try? FileManager.default.removeItem(at: url) } + let player = AudioPlayer() + defer { player.stop() } + + player.play(url: url) + XCTAssertTrue(waitUntil(timeout: 5) { player.state == .playing && player.progress > 0.2 }) + + player.pause() + XCTAssertTrue(waitUntil(timeout: 2) { player.state == .paused }) + player.seek(to: target) + // Stay paused long enough for the seek's own processSource pass to run and bail out. + _ = waitUntil(timeout: 1) { false } + player.resume() + + XCTAssertTrue(waitUntil(timeout: 3) { player.progress > target + 0.3 }, + "progress stuck at \(player.progress), state \(player.state)") + } +}