diff --git a/CLAUDE.md b/CLAUDE.md index 938fbb8..c7d7346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,6 @@ - Build example app for simulator: `cd PlayolaPlayerExample && xcodebuild -project PlayolaPlayerExample.xcodeproj -scheme PlayolaPlayerExample -destination 'platform=iOS Simulator,id=F8C70EF6-2658-40DE-ABB2-13B4EA4CFBD3' build -quiet` - Run all tests: `swift test` - Run specific test: `swift test --filter PlayolaPlayerTests/testName` -- Run specific test suite: `swift test --filter AudioNormalizationCalculatorTests` - Generate Xcode project: `swift package generate-xcodeproj` ## Code Style Guidelines diff --git a/README.md b/README.md index 31bfa02..e12f71f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ PlayolaPlayer is a Swift Package Manager library that handles audio streaming, s - Stream audio from Playola radio stations - Schedule audio files to play at specific times - Cache files locally with automatic cleanup -- Normalize audio volume levels - Smooth transitions and crossfades between audio files - Error reporting and logging - Swift Concurrency (async/await) @@ -592,7 +591,6 @@ File management ensures smooth playback: Built on AVAudioEngine for audio processing: - Real-time mixing of multiple audio sources - Volume control and fading between tracks -- Audio normalization for consistent volume levels - Session management for handling interruptions and route changes ### How Separate Files Become Continuous Radio @@ -1137,7 +1135,7 @@ swift package generate-xcodeproj swift test # Run specific test suite -swift test --filter AudioNormalizationCalculatorTests +swift test --filter PlayolaStationPlayerTests # Run specific test swift test --filter PlayolaPlayerTests/testSpecificFunction diff --git a/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift b/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift deleted file mode 100644 index 9cdd811..0000000 --- a/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// AudioNormalizationCalculator.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 2/7/25. -// -import AVFoundation - -struct AudioNormalizationCalculator { - enum AudioError: Error { - case fileConversion - case bufferConversion - } - - let file: AVAudioFile - var amplitude: Float? - - public func playerVolume(_ adjustedVolume: Float) -> Float { - return (amplitude ?? 1.0) * adjustedVolume - } - - /// Creates an AudioNormalizationCalculator with amplitude calculated on a background thread - static func create(_ file: AVAudioFile) async -> AudioNormalizationCalculator { - return await withCheckedContinuation { continuation in - Task.detached(priority: .userInitiated) { - var calculator = AudioNormalizationCalculator(file: file, calculateNow: false) - do { - let samples = try calculator.getSamples() - calculator.amplitude = calculator.getAmplitude(samples) - } catch { - // Report error on main thread - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get audio samples for normalization | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description) | Length: \(file.length)", - level: .warning) - } - continuation.resume(returning: calculator) - } - } - } - - /// Synchronous initializer for backwards compatibility (e.g., tests) - init(_ file: AVAudioFile) { - self.file = file - do { - let samples = try getSamples() - self.amplitude = getAmplitude(samples) - } catch { - // Replace print with proper error reporting - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get audio samples for normalization | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description) | Length: \(file.length)", - level: .warning) - } - } - } - - /// Private initializer used by async factory - private init(file: AVAudioFile, calculateNow: Bool) { - self.file = file - if calculateNow { - do { - let samples = try getSamples() - self.amplitude = getAmplitude(samples) - } catch { - // Error will be handled by caller - } - } - } - - func getSamples() throws -> [Float] { - guard - let buffer = AVAudioPCMBuffer( - pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length)) - else { - let detailedError = AudioError.bufferConversion - Task { - await PlayolaErrorReporter.shared.reportError( - detailedError, - context: - "Failed to create PCM buffer | File format: \(file.processingFormat.description) | " - + "Frame capacity: \(file.length)", - level: .error) - } - throw detailedError - } - - do { - try file.read(into: buffer) - } catch { - // Enhance the error with context before throwing - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to read audio file into buffer | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description)", - level: .error) - } - throw error - } - - guard let channelData = buffer.floatChannelData?.pointee else { - let error = AudioError.bufferConversion - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get float channel data from buffer | Buffer length: \(buffer.frameLength)", - level: .error) - } - throw error - } - - let floatArray = Array(UnsafeBufferPointer(start: channelData, count: Int(buffer.frameLength))) - return floatArray - } - - func getAmplitude(_ samples: [Float]) -> Float? { - let absArray = samples.map { abs($0) } - return absArray.max() - } -} - -// MARK: - Loudness helpers -extension AudioNormalizationCalculator { - /// Compute the dB offset required to reach target loudness from a given peak amplitude. - /// If `amplitude` is nil or non‑positive, returns 0 dB. - /// Formula: gain(dB) = -20 * log10(amplitude) - static func requiredDbOffsetDb(forAmplitude amplitude: Float?) -> Float { - guard let amplitude, amplitude > 0 else { return 0 } - return Float(-20.0 * log10(Double(amplitude))) - } - - /// Instance convenience accessor that uses the calculator's measured amplitude. - var requiredDbOffsetDb: Float { - Self.requiredDbOffsetDb(forAmplitude: amplitude) - } -} diff --git a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift index 8f9c24b..d87bdcf 100644 --- a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift +++ b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift @@ -34,7 +34,7 @@ public actor FileDownloaderAsync { public func download(from url: URL, to destinationURL: URL) async throws -> DownloadResult { guard !isCancelled else { throw URLError(.cancelled) } - let configuration = URLSessionConfiguration.default + let configuration = makeTLS12Configuration() configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 300 configuration.waitsForConnectivity = true @@ -84,7 +84,7 @@ public actor FileDownloaderAsync { logger: logger ) - let configuration = URLSessionConfiguration.default + let configuration = makeTLS12Configuration() configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 300 configuration.waitsForConnectivity = true diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index 1362e0d..ea3c9cc 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -66,7 +66,7 @@ public class ListeningSessionReporter { init( stationPlayer: PlayolaStationPlayer, authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = URLSession.shared, + urlSession: URLSessionProtocol = tls12Session, baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = stationPlayer @@ -307,7 +307,7 @@ public class ListeningSessionReporter { #if DEBUG internal init( authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = URLSession.shared, + urlSession: URLSessionProtocol = tls12Session, baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = nil diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index ebf5a50..8386e2f 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -5,7 +5,7 @@ // Created by Brian D Keane on 1/6/25. // -import AVFoundation +@preconcurrency import AVFoundation import Foundation import PlayolaCore import os.log @@ -138,22 +138,35 @@ open class PlayolaMainMixer: NSObject { } extension PlayolaMainMixer { + /// Starts the audio engine off the main thread to avoid blocking on + /// AUIOClient_StartIO during cold hardware initialization (e.g. resuming + /// from a phone-call interruption). AVAudioEngine.start() is thread-safe; + /// only the start call itself is dispatched off main. @MainActor - public func start() throws { + public func start() async throws { + let engine = self.engine do { - try engine.start() - } catch { - Task { - await errorReporter.reportError( - error, context: "Failed to start audio engine", - level: .critical) + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + try engine.start() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } } + } catch { + await errorReporter.reportError( + error, context: "Failed to start audio engine", + level: .critical) throw error } } @MainActor - public func restartEngine() throws { + public func restartEngine() async throws { os_log("Restarting audio engine", log: PlayolaMainMixer.logger, type: .info) if engine.isRunning { @@ -161,7 +174,7 @@ extension PlayolaMainMixer { } engine.prepare() - try start() + try await start() } public var isEngineRunning: Bool { diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 437ec14..2f893b3 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -59,7 +59,7 @@ public enum StationPlayerError: Error, LocalizedError { /// ``` @MainActor final public class PlayolaStationPlayer: ObservableObject { - var baseUrl = URL(string: "https://admin-api.playola.fm/v1")! + var baseUrl = URL(string: "https://admin-api.playola.fm")! @Published public var stationId: String? private var interruptedStationId: String? var currentSchedule: Schedule? @@ -402,7 +402,7 @@ final public class PlayolaStationPlayer: ObservableObject { let url = createScheduleURL(for: stationId) do { - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await tls12Session.data(from: url) let httpResponse = try validateHTTPResponse(response, url: url) try await validateStatusCode(httpResponse, data: data, stationId: stationId) @@ -739,7 +739,7 @@ final public class PlayolaStationPlayer: ObservableObject { Task { @MainActor in do { try await PlayolaMainMixer.shared.audioSessionManager.activate() - try PlayolaMainMixer.shared.restartEngine() + try await PlayolaMainMixer.shared.restartEngine() try await self.play(stationId: stationToResume) } catch { os_log( diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 88d1680..dae02f7 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -21,7 +21,6 @@ import os.log /// with support for: /// - Precise scheduling at specific timestamps /// - Volume fading and crossfading -/// - Audio normalization /// - Notifying delegates of playback events @MainActor public class SpinPlayer { @@ -97,9 +96,6 @@ public class SpinPlayer { private let engine: AVAudioEngine! = PlayolaMainMixer.shared.engine /// The node responsible for playing the audio file private let playerNode = AVAudioPlayerNode() - /// Per-file loudness normalization stage. We use an EQ solely for its `globalGain` which - /// supports boosts up to +24 dB (unlike AVAudioMixerNode which tops out at 1.0 and cannot boost). - private let normalizationEQ = AVAudioUnitEQ(numberOfBands: 0) // use only globalGain /// Insert a per-player track mixer so we can automate volume on the audio thread private var trackMixer = AVAudioMixerNode() @@ -114,7 +110,6 @@ public class SpinPlayer { private var didCaptureStart = false // MARK: - File Management - private var normalizationCalculator: AudioNormalizationCalculator? /// The currently playing audio file private var currentFile: AVAudioFile? { didSet { @@ -152,17 +147,11 @@ public class SpinPlayer { /// Make connections engine.attach(playerNode) - engine.attach(normalizationEQ) engine.attach(trackMixer) - // Graph: playerNode -> normalizationEQ (globalGain) -> per-track mixer -> main mixer + // Graph: playerNode -> per-track mixer -> main mixer engine.connect( playerNode, - to: normalizationEQ, - format: TapProperties.default.format - ) - engine.connect( - normalizationEQ, to: trackMixer, format: TapProperties.default.format ) @@ -173,7 +162,6 @@ public class SpinPlayer { ) // Baselines - normalizationEQ.globalGain = 0.0 // dB; set per-file on load when normalization is applied trackMixer.outputVolume = 1.0 // Always keep the player node at unity gain playerNode.volume = 1.0 @@ -231,9 +219,9 @@ public class SpinPlayer { trackMixer = newMixer engine.attach(newMixer) - // Reconnect normalizationEQ -> trackMixer -> main mixer + // Reconnect playerNode -> trackMixer -> main mixer engine.connect( - normalizationEQ, + playerNode, to: newMixer, format: TapProperties.default.format ) @@ -369,7 +357,9 @@ public class SpinPlayer { "Audio session must be configured before calling playNow — call ensureAudioSessionConfigured() first" ) playolaMainMixer.configureAudioSession() - try engine.start() + if !engine.isRunning { + try engine.start() + } guard let audioFile = validateAndGetCurrentFile() else { return } scheduleAndPlaySegment(audioFile: audioFile, from: from) @@ -520,11 +510,14 @@ public class SpinPlayer { Task { @MainActor in await self.loadFile(with: localUrl) - // Ensure audio session is configured before starting the engine + // Ensure audio session is configured and engine is started before playback. + // Starting the engine here (off-main via playolaMainMixer.start) avoids the + // AUIOClient_StartIO main-thread hang on cold first-play. do { try await self.playolaMainMixer.ensureAudioSessionConfigured() + try await self.playolaMainMixer.start() } catch { - // ensureAudioSessionConfigured already reported to Sentry; skip playback + // ensureAudioSessionConfigured / start already reported to Sentry; skip playback self.clear() continuation.resume(returning: .failure(error)) return @@ -871,7 +864,9 @@ public class SpinPlayer { "Audio session must be configured before calling schedulePlay — call ensureAudioSessionConfigured() first" ) playolaMainMixer.configureAudioSession() - try engine.start() + if !engine.isRunning { + try engine.start() + } } private func validateAudioFile() throws { @@ -1028,16 +1023,6 @@ public class SpinPlayer { private func createAudioFileFromValidatedURL(_ url: URL, fileSize: Int) async throws { do { currentFile = try AVAudioFile(forReading: url) - normalizationCalculator = await AudioNormalizationCalculator.create(currentFile!) - // Use calculator's dB helper; clamp to a practical range for safety - let rawDb = Double(self.normalizationCalculator?.requiredDbOffsetDb ?? 0) - let gainDb = min(24.0, max(-24.0, rawDb)) - self.normalizationEQ.globalGain = Float(gainDb) - os_log( - "🎚️ Set normalizationEQ.globalGain = %.2f dB for %@", - log: SpinPlayer.logger, type: .info, - gainDb, url.lastPathComponent - ) logSuccessfulLoad(url) } catch let audioError as NSError { await handleAudioFileCreationError(audioError, url: url, fileSize: fileSize) diff --git a/Sources/PlayolaPlayer/Player/TLS12Session.swift b/Sources/PlayolaPlayer/Player/TLS12Session.swift new file mode 100644 index 0000000..445c70b --- /dev/null +++ b/Sources/PlayolaPlayer/Player/TLS12Session.swift @@ -0,0 +1,26 @@ +import Foundation + +// TEMPORARY: Global TLS 1.2 cap. +// +// On iOS 26, URLSession sends a TLS 1.3 ClientHello that includes the X25519MLKEM768 +// post-quantum hybrid key share (~1.6 KB). Some users sit behind middleboxes +// (antivirus SSL inspection, parental-control routers, captive portals, etc.) that +// drop the larger ClientHello and surface as NSURLErrorSecureConnectionFailed (-1200). +// Capping URLSession to TLS 1.2 keeps the ClientHello small enough for these +// middleboxes to pass through. +// +// Every URLSession in this library is routed through `tls12Session` (or a +// configuration derived from `makeTLS12Configuration()` when a delegate is needed) +// so audio downloads, schedule fetches, and listening-session reports all succeed +// on these networks. +// +// REVERT WHEN: Apple ships an iOS 26 fix (watch 26.5+ release notes) OR exposes +// a supported opt-out for the post-quantum hybrid key share so we can keep TLS 1.3. +internal func makeTLS12Configuration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + configuration.tlsMinimumSupportedProtocolVersion = .TLSv12 + configuration.tlsMaximumSupportedProtocolVersion = .TLSv12 + return configuration +} + +internal let tls12Session: URLSession = URLSession(configuration: makeTLS12Configuration()) diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift deleted file mode 100644 index 7d42106..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// AudioNormalizationCalculatorTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// - -import AVFoundation -import Testing -import XCTest - -@testable import PlayolaPlayer - -// Create a protocol that both AVAudioFile and our mock implement -protocol AudioFileReadable { - var length: AVAudioFramePosition { get } - var processingFormat: AVAudioFormat { get } - var url: URL { get } - func read(into buffer: AVAudioPCMBuffer) throws -} - -// Make AVAudioFile conform to our protocol -extension AVAudioFile: AudioFileReadable {} - -// Make our mock conform to the protocol -extension AVAudioFileMock: AudioFileReadable {} - -struct AudioNormalizationCalculatorTests { - - // Test with silence (all zero values) - @Test("Calculator handles silence (zero amplitude)") - func testSilenceHandling() throws { - // Create an audio file mock with all zeros (silence) - let silenceSamples = Array(repeating: Float(0), count: 1000) - let mockAudioFile = AVAudioFileMock(samples: silenceSamples) - - // Create a property to simulate the calculator's behavior with zero amplitude - let amplitude: Float = 0.0 - - // Test volume adjustments with zero amplitude - let adjustedVolume = 1.0 / max(amplitude, 1.0) - #expect(adjustedVolume == 1.0) - - let playerVolume = amplitude * 0.5 - #expect(playerVolume == 0.0) - } - - // Test with normal audio (mixed positive and negative values) - @Test("Calculator detects correct amplitude from audio samples") - func testNormalAudio() throws { - // Create an audio file mock with samples containing mixed amplitudes - // Max value is 0.8, which should be detected as the amplitude - let mixedSamples: [Float] = [0.1, 0.5, -0.3, 0.8, -0.6, 0.2, -0.8, 0.3] - let mockAudioFile = AVAudioFileMock(samples: mixedSamples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.8 - - // Test volume adjustments - // For a file with amplitude 0.8, adjustedVolume should divide by 0.8 - // and playerVolume should multiply by 0.8 - let adjustedVolume = 0.64 / expectedAmplitude - #expect(abs(adjustedVolume - 0.8) < 0.001) - - let playerVolume = expectedAmplitude * 1.0 - #expect(abs(playerVolume - 0.8) < 0.001) - } - - // Test with loud audio (values close to or at the maximum) - @Test("Calculator handles loud audio correctly") - func testLoudAudio() throws { - // Create samples with values close to the maximum (1.0) - let loudSamples: [Float] = [0.95, 0.98, -0.92, 0.99, -0.97] - let mockAudioFile = AVAudioFileMock(samples: loudSamples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.99 - - // Test volume adjustments for loud audio - let adjustedVolume = 0.5 / expectedAmplitude - let expectedAdjustedVolume = 0.5 / 0.99 - #expect(abs(Double(adjustedVolume) - expectedAdjustedVolume) < 0.001) - - let playerVolume = expectedAmplitude * 0.5 - let expectedPlayerVolume = 0.5 * 0.99 - #expect(abs(Double(playerVolume) - expectedPlayerVolume) < 0.001) - } - - // Test the relationship between adjustedVolume and playerVolume - @Test("adjustedVolume and playerVolume are inverse operations") - func testVolumeAdjustmentInverse() throws { - // Create samples with a known amplitude - let samples: [Float] = [0.0, 0.7, -0.4, 0.2] - let mockAudioFile = AVAudioFileMock(samples: samples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.7 - - // Test inverse relationship - let originalVolume: Float = 0.5 - let adjusted = originalVolume / expectedAmplitude - let restored = adjusted * expectedAmplitude - - // The restored value should be very close to the original - #expect(abs(restored - originalVolume) < 0.001) - } - - @Test("requiredDbOffsetDb returns +6.02 dB for amplitude 0.5") - func testRequiredDbOffsetDbHalf() throws { - let db = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: 0.5) - #expect(abs(Double(db) - 6.0206) < 0.01) - } - - @Test("requiredDbOffsetDb returns 0 dB for nil/zero amplitude") - func testRequiredDbOffsetDbZeroOrNil() throws { - let dbNil = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: nil) - let dbZero = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: 0.0) - #expect(dbNil == 0) - #expect(dbZero == 0) - } -} diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift deleted file mode 100644 index 81f90a5..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// AudioNormalizationErrorTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. - -import AVFoundation -import Testing -import XCTest - -@testable import PlayolaPlayer - -struct AudioNormalizationErrorTests { - - @Test("Calculator handles read errors gracefully") - func testReadErrorHandling() throws { - // Create a custom audio file mock that will fail on read - let mockAudioFile = AVAudioFileMock(samples: [0.5, 0.6, 0.7]) - mockAudioFile.shouldThrowReadError = true - - // For testing purpose, check that our mock behaves as expected - // Let's try a simpler approach - just verify an error is thrown by trying to read - // and catching the error - var didThrow = false - do { - let buffer = AVAudioPCMBuffer(pcmFormat: mockAudioFile.processingFormat, frameCapacity: 100)! - try mockAudioFile.read(into: buffer) - } catch { - didThrow = true - } - #expect(didThrow) - - // Since we can't directly test the AudioNormalizationCalculator with our mock due to - // the inheritance issues, we'll test the core functionality: - // 1. When read() fails, getSamples() should throw an error - // 2. When getSamples() throws, amplitude should be nil - // 3. When amplitude is nil, adjustedVolume and playerVolume should return default values - - // Simulate the expected behavior: - let amplitude: Float? = nil // Would be nil after a read error - - // When amplitude is nil, adjustedVolume should return the input value - let inputVolume: Float = 0.7 - let expectedAdjustedVolume = inputVolume / (amplitude ?? 1.0) - #expect(expectedAdjustedVolume == inputVolume) - - // When amplitude is nil, playerVolume should return the input value - let expectedPlayerVolume = (amplitude ?? 1.0) * inputVolume - #expect(expectedPlayerVolume == inputVolume) - } -} - -// Enhanced mock that can provide more detailed error information -class EnhancedAudioFileMock: AVAudioFileMock { - enum FailureMode { - case none - case readError - case bufferCreationError - case channelDataError - } - - var failureMode: FailureMode = .none - var errorMessage: String = "" - - init(samples: [Float], failureMode: FailureMode = .none, errorMessage: String = "") { - super.init(samples: samples) - self.failureMode = failureMode - self.errorMessage = errorMessage - } - - override func read(into buffer: AVAudioPCMBuffer) throws { - switch failureMode { - case .none: - // Normal operation - try super.read(into: buffer) - - case .readError: - // Simulate a read error - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -1, - userInfo: [NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Read error" : errorMessage] - ) - - case .bufferCreationError: - // Simulate a buffer error after successful read - try super.read(into: buffer) - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -2, - userInfo: [ - NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Buffer creation error" : errorMessage - ] - ) - - case .channelDataError: - // Simulate channel data access error - try super.read(into: buffer) - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -3, - userInfo: [ - NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Channel data error" : errorMessage - ] - ) - } - } -} diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift deleted file mode 100644 index 1b5f2bb..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -import AVFoundation -import Testing -// -// AudioNormalizationWaveformTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// -import XCTest - -@testable import PlayolaPlayer - -struct AudioNormalizationWaveformTests { - - // For each test we'll directly test the math that the AudioNormalizationCalculator would perform - - @Test("Testing with a sine wave") - func testSineWave() throws { - // Generate a sine wave with amplitude 0.8 - let sineWaveSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .sine, - count: 1000, - amplitude: 0.8 - ) - - // Calculate the actual maximum amplitude of the samples - let actualMaxAmplitude = sineWaveSamples.map { abs($0) }.max() ?? 0 - - // Typically a sine wave won't hit exactly the requested amplitude due to discrete sampling - #expect(actualMaxAmplitude <= 0.8) - #expect(actualMaxAmplitude > 0.7) - - // Test the volume adjustment equations directly - let inputVolume: Float = 0.5 - let adjustedVolume = inputVolume / actualMaxAmplitude - - // Verify that adjustedVolume * amplitude = original input - #expect(abs(adjustedVolume * actualMaxAmplitude - inputVolume) < 0.001) - } - - @Test("Testing with a square wave") - func testSquareWave() throws { - // Generate a square wave with amplitude 0.6 - let squareWaveSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .square, - count: 1000, - amplitude: 0.6 - ) - - // Square waves have consistent amplitude - let actualMaxAmplitude = squareWaveSamples.map { abs($0) }.max() ?? 0 - #expect(abs(actualMaxAmplitude - 0.6) < 0.001) - - // Test the volume adjustment math - let playerVolume = actualMaxAmplitude * 0.8 // 0.8 is the requested volume - #expect(abs(playerVolume - 0.48) < 0.001) - } - - @Test("Testing with a ramp wave") - func testRampWave() throws { - // Generate a ramp from -0.7 to 0.7 - let rampSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .ramp, - count: 1000, - amplitude: 0.7 - ) - - // Calculate the actual maximum amplitude - let actualMaxAmplitude = rampSamples.map { abs($0) }.max() ?? 0 - #expect(abs(actualMaxAmplitude - 0.7) < 0.001) - - // The adjusted volume should be the input volume divided by the amplitude - let adjustedVolume = 0.5 / actualMaxAmplitude - #expect(abs(adjustedVolume - (0.5 / 0.7)) < 0.001) - } - - @Test("Testing with random noise") - func testNoise() throws { - // Generate random noise with max amplitude 0.5 - let noiseSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .noise, - count: 1000, - amplitude: 0.5 - ) - - // Find the actual maximum amplitude in the noise - let actualMaxAmplitude = noiseSamples.map { abs($0) }.max() ?? 0 - #expect(actualMaxAmplitude <= 0.5) - - // The player volume at a requested level of 1.0 should be the same as the amplitude - let playerVolume = actualMaxAmplitude * 1.0 - #expect(playerVolume == actualMaxAmplitude) - } - - @Test("Testing consistent volume normalization across waveforms") - func testConsistentNormalization() throws { - // Generate different waveforms with different amplitudes - let sineWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .sine, count: 1000, amplitude: 0.9) - let squareWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .square, count: 1000, amplitude: 0.7) - let rampWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .ramp, count: 1000, amplitude: 0.5) - let noiseWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .noise, count: 1000, amplitude: 0.3) - - // Find the maximum amplitude of each waveform - let sineAmplitude = sineWave.map { abs($0) }.max() ?? 0 - let squareAmplitude = squareWave.map { abs($0) }.max() ?? 0 - let rampAmplitude = rampWave.map { abs($0) }.max() ?? 0 - let noiseAmplitude = noiseWave.map { abs($0) }.max() ?? 0 - - // Target volume we want for all waveforms - let targetVolume: Float = 0.8 - - // Calculate the adjusted input volume needed for each waveform to reach the target - let sineAdjusted = targetVolume / sineAmplitude - let squareAdjusted = targetVolume / squareAmplitude - let rampAdjusted = targetVolume / rampAmplitude - let noiseAdjusted = targetVolume / noiseAmplitude - - // Verify that with these adjusted volumes, all waveforms would play at the same volume - let sineResult = sineAmplitude * sineAdjusted - let squareResult = squareAmplitude * squareAdjusted - let rampResult = rampAmplitude * rampAdjusted - let noiseResult = noiseAmplitude * noiseAdjusted - - #expect(abs(sineResult - targetVolume) < 0.001) - #expect(abs(squareResult - targetVolume) < 0.001) - #expect(abs(rampResult - targetVolume) < 0.001) - #expect(abs(noiseResult - targetVolume) < 0.001) - } -} diff --git a/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift b/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift deleted file mode 100644 index fae3b1d..0000000 --- a/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift +++ /dev/null @@ -1,157 +0,0 @@ -import AVFoundation -// -// AudioBufferTestUtilities.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// -import Foundation - -@testable import PlayolaPlayer - -/// Provides test utilities for working with audio data in tests -class AudioBufferTestUtilities { - - /// Creates a PCM buffer with the given samples - /// - Parameters: - /// - samples: Array of sample values to include in the buffer - /// - format: The audio format to use (defaults to stereo 44.1kHz float32) - /// - Returns: An initialized AVAudioPCMBuffer or nil if creation fails - static func createBuffer( - samples: [Float], - format: AVAudioFormat? = nil - ) -> AVAudioPCMBuffer? { - let audioFormat = format ?? AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! - - guard - let buffer = AVAudioPCMBuffer( - pcmFormat: audioFormat, - frameCapacity: AVAudioFrameCount(samples.count)) - else { - return nil - } - - // Copy samples to the buffer - if let channelData = buffer.floatChannelData { - for i in 0.. [Float] { - guard let channelData = buffer.floatChannelData, - channel < buffer.format.channelCount - else { - return [] - } - - var samples = [Float]() - let channelDataPtr = channelData[channel] - - for i in 0.. [Float] { - switch pattern { - case .sine: - return generateSineWave(sampleCount: count, amplitude: amplitude) - case .ramp: - return generateRamp(sampleCount: count, amplitude: amplitude) - case .square: - return generateSquareWave(sampleCount: count, amplitude: amplitude) - case .noise: - return generateNoise(sampleCount: count, amplitude: amplitude) - } - } - - // MARK: - Sample Generation Patterns - - enum SamplePattern { - case sine // Sine wave - case ramp // Linear ramp up - case square // Square wave - case noise // Random noise - } - - private static func generateSineWave(sampleCount: Int, amplitude: Float) -> [Float] { - var samples = [Float]() - let frequency: Float = 440.0 // A4 note - let sampleRate: Float = 44100.0 - - for i in 0.. [Float] { - var samples = [Float]() - - for i in 0.. [Float] { - var samples = [Float]() - let frequency: Float = 440.0 // A4 note - let sampleRate: Float = 44100.0 - let period = Int(sampleRate / frequency) - - for i in 0.. [Float] { - var samples = [Float]() - - for _ in 0..